feat(i18n): Complete localization of marketing frontend with react-i18next, prefixed URLs, JSON migrations, and automation
This commit is contained in:
@@ -28,11 +28,10 @@ class MarketingRegisterController extends Controller
|
|||||||
{
|
{
|
||||||
$package = $package_id ? Package::find($package_id) : null;
|
$package = $package_id ? Package::find($package_id) : null;
|
||||||
|
|
||||||
App::setLocale('de');
|
//App::setLocale('de');
|
||||||
|
|
||||||
return Inertia::render('Auth/Register', [
|
return Inertia::render('auth/register', [
|
||||||
'package' => $package,
|
'package' => $package,
|
||||||
'privacyHtml' => view('legal.datenschutz')->render(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,3 +156,4 @@ class MarketingRegisterController extends Controller
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ class MarketingController extends Controller
|
|||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$packages = [
|
$packages = Package::where('type', 'endcustomer')->orderBy('price')->get()->map(function ($p) {
|
||||||
['id' => 'basic', 'name' => 'Basic', 'events' => 1, 'price' => 0, 'description' => '1 Event, 100 Fotos, Grundfunktionen'],
|
return $p->append(['features', 'limits']);
|
||||||
['id' => 'standard', 'name' => 'Standard', 'events' => 10, 'price' => 99, 'description' => '10 Events, Unbegrenzt Fotos, Erweiterte Features'],
|
});
|
||||||
['id' => 'premium', 'name' => 'Premium', 'events' => 50, 'price' => 199, 'description' => '50 Events, Support & Custom, Alle Features'],
|
|
||||||
];
|
|
||||||
|
|
||||||
return Inertia::render('marketing/Home', compact('packages'));
|
return Inertia::render('marketing/Home', compact('packages'));
|
||||||
}
|
}
|
||||||
@@ -417,7 +415,7 @@ class MarketingController extends Controller
|
|||||||
|
|
||||||
public function occasionsType($locale, $type)
|
public function occasionsType($locale, $type)
|
||||||
{
|
{
|
||||||
$validTypes = ['weddings', 'birthdays', 'corporate-events', 'family-celebrations'];
|
$validTypes = ['hochzeit', 'geburtstag', 'firmenevent'];
|
||||||
if (!in_array($type, $validTypes)) {
|
if (!in_array($type, $validTypes)) {
|
||||||
abort(404, 'Invalid occasion type');
|
abort(404, 'Invalid occasion type');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,63 +4,43 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\Rules;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function index()
|
||||||
* Display the user's profile form.
|
|
||||||
*/
|
|
||||||
public function edit(Request $request): View
|
|
||||||
{
|
{
|
||||||
return view('profile.edit', [
|
$user = Auth::user()->load('purchases.packages');
|
||||||
'user' => $request->user(),
|
return Inertia::render('Profile/Index', [
|
||||||
|
'user' => $user,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function account()
|
||||||
* Update the user's profile information.
|
|
||||||
*/
|
|
||||||
public function update(Request $request, User $user): RedirectResponse
|
|
||||||
{
|
{
|
||||||
// Authorized via auth middleware
|
$user = Auth::user()->load('purchases.packages');
|
||||||
|
if (request()->isMethod('post')) {
|
||||||
$request->validate([
|
$validated = request()->validate([
|
||||||
'username' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:users,username,' . $user->id],
|
'name' => 'required|string|max:255',
|
||||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,' . $user->id],
|
'email' => 'required|email|unique:users,email,' . $user->id,
|
||||||
'first_name' => ['required', 'string', 'max:255'],
|
|
||||||
'last_name' => ['required', 'string', 'max:255'],
|
|
||||||
'address' => ['required', 'string'],
|
|
||||||
'phone' => ['required', 'string', 'max:20'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user->update($request->only([
|
$user->update($validated);
|
||||||
'username', 'email', 'first_name', 'last_name', 'address', 'phone'
|
|
||||||
]));
|
|
||||||
|
|
||||||
return back()->with('status', 'profile-updated');
|
return back()->with('success', 'Profil aktualisiert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return Inertia::render('Profile/Account', [
|
||||||
* Update the user's password.
|
'user' => $user,
|
||||||
*/
|
]);
|
||||||
public function updatePassword(Request $request, User $user): RedirectResponse
|
}
|
||||||
|
|
||||||
|
public function orders()
|
||||||
{
|
{
|
||||||
// Authorized via auth middleware
|
$user = Auth::user()->load('purchases.packages');
|
||||||
|
return Inertia::render('Profile/Orders', [
|
||||||
$request->validate([
|
'purchases' => $user->purchases,
|
||||||
'current_password' => ['required', 'current_password'],
|
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user->update([
|
|
||||||
'password' => Hash::make($request->password),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return back()->with('status', 'password-updated');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
70
app/Http/Kernel.php
Normal file
70
app/Http/Kernel.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||||
|
|
||||||
|
class Kernel extends HttpKernel
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The application's global HTTP middleware stack.
|
||||||
|
*
|
||||||
|
* These middleware are run during every request to your application.
|
||||||
|
*
|
||||||
|
* @var array<int, class-string|string>
|
||||||
|
*/
|
||||||
|
protected $middleware = [
|
||||||
|
// \App\Http\Middleware\TrustHosts::class,
|
||||||
|
\App\Http\Middleware\TrustProxies::class,
|
||||||
|
\Illuminate\Http\Middleware\HandleCors::class,
|
||||||
|
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||||
|
\App\Http\Middleware\TrimStrings::class,
|
||||||
|
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application's route middleware groups.
|
||||||
|
*
|
||||||
|
* @var array<string, array<int, class-string|string>>
|
||||||
|
*/
|
||||||
|
protected $middlewareGroups = [
|
||||||
|
'web' => [
|
||||||
|
\App\Http\Middleware\EncryptCookies::class,
|
||||||
|
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||||
|
\Illuminate\Session\Middleware\StartSession::class,
|
||||||
|
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
|
||||||
|
\App\Http\Middleware\VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||||
|
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'api' => [
|
||||||
|
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
|
||||||
|
'throttle:api',
|
||||||
|
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application's route middleware.
|
||||||
|
*
|
||||||
|
* These middleware may be assigned to groups or used individually.
|
||||||
|
*
|
||||||
|
* @var array<string, class-string|string>
|
||||||
|
*/
|
||||||
|
protected $routeMiddleware = [
|
||||||
|
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||||
|
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||||
|
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
|
||||||
|
'can' => \Illuminate\Auth\Middleware\Authorize::class,
|
||||||
|
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
|
||||||
|
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
|
||||||
|
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||||
|
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||||
|
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||||
|
'locale' => \App\Http\Middleware\SetLocale::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -61,6 +61,12 @@ class HandleInertiaRequests extends Middleware
|
|||||||
],
|
],
|
||||||
'supportedLocales' => $supportedLocales,
|
'supportedLocales' => $supportedLocales,
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'translations' => [
|
||||||
|
'marketing' => __('marketing'),
|
||||||
|
'auth' => __('auth'),
|
||||||
|
'profile' => __('profile'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class SetLocale
|
class SetLocale
|
||||||
@@ -15,16 +17,25 @@ class SetLocale
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
$locale = $request->segment(1);
|
$locale = $request->segment(1); // Erste URL-Segment als Locale (z.B. /de/packages -> 'de')
|
||||||
|
|
||||||
|
// Unterstützte Sprachen
|
||||||
$supportedLocales = ['de', 'en'];
|
$supportedLocales = ['de', 'en'];
|
||||||
|
|
||||||
if (in_array($locale, $supportedLocales)) {
|
if (in_array($locale, $supportedLocales)) {
|
||||||
app()->setLocale($locale);
|
// Locale setzen
|
||||||
session()->put('locale', $locale);
|
App::setLocale($locale);
|
||||||
|
Session::put('locale', $locale);
|
||||||
} else {
|
} else {
|
||||||
$locale = session('locale', config('app.locale', 'de'));
|
// Fallback zu 'de'
|
||||||
app()->setLocale($locale);
|
$defaultLocale = 'de';
|
||||||
|
App::setLocale($defaultLocale);
|
||||||
|
Session::put('locale', $defaultLocale);
|
||||||
|
|
||||||
|
// Redirect zu default Locale, wenn keine Locale in URL
|
||||||
|
if (!$locale) {
|
||||||
|
return redirect("/{$defaultLocale}" . $request->getRequestUri());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
|
|||||||
@@ -97,39 +97,36 @@ use Illuminate\\Database\\Migrations\\Migration;
|
|||||||
use Illuminate\\Database\\Schema\\Blueprint;
|
use Illuminate\\Database\\Schema\\Blueprint;
|
||||||
use Illuminate\\Support\\Facades\\Schema;
|
use Illuminate\\Support\\Facades\\Schema;
|
||||||
|
|
||||||
// Event types (global)
|
|
||||||
Schema::create('event_types', function (Blueprint $table) {
|
Schema::create('event_types', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->json('name');
|
$table->json('name'); // Translatable: { "de": "Hochzeit", "en": "Wedding" }
|
||||||
$table->string('slug', 100)->unique();
|
$table->string('slug', 100)->unique();
|
||||||
$table->string('icon', 64)->nullable();
|
$table->string('icon', 64)->nullable();
|
||||||
$table->json('settings')->nullable();
|
$table->json('settings')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Events (tenant-scoped)
|
|
||||||
Schema::create('events', function (Blueprint $table) {
|
Schema::create('events', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||||
$table->json('name');
|
$table->json('name'); // Translatable: { "de": "Event Name", "en": "Event Name" }
|
||||||
$table->date('date');
|
$table->date('date');
|
||||||
$table->string('slug');
|
$table->string('slug');
|
||||||
$table->json('description')->nullable();
|
$table->json('description')->nullable(); // Translatable
|
||||||
$table->json('settings')->nullable();
|
$table->json('settings')->nullable();
|
||||||
$table->foreignId('event_type_id')->constrained('event_types');
|
$table->foreignId('event_type_id')->constrained('event_types');
|
||||||
$table->boolean('is_active')->default(true);
|
$table->boolean('is_active')->default(true);
|
||||||
$table->string('default_locale', 5)->default('de');
|
$table->string('default_locale', 5)->default('de'); // For event-specific i18n fallback
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->unique(['tenant_id', 'slug']);
|
$table->unique(['tenant_id', 'slug']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emotions (global library)
|
|
||||||
Schema::create('emotions', function (Blueprint $table) {
|
Schema::create('emotions', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->json('name');
|
$table->json('name'); // Translatable: { "de": "Freude", "en": "Joy" }
|
||||||
$table->string('icon', 50);
|
$table->string('icon', 50);
|
||||||
$table->string('color', 7);
|
$table->string('color', 7);
|
||||||
$table->json('description')->nullable();
|
$table->json('description')->nullable(); // Translatable
|
||||||
$table->integer('sort_order')->default(0);
|
$table->integer('sort_order')->default(0);
|
||||||
$table->boolean('is_active')->default(true);
|
$table->boolean('is_active')->default(true);
|
||||||
});
|
});
|
||||||
@@ -141,22 +138,21 @@ Schema::create('emotion_event_type', function (Blueprint $table) {
|
|||||||
$table->primary(['emotion_id', 'event_type_id']);
|
$table->primary(['emotion_id', 'event_type_id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tasks (with optional tenant/event scoping)
|
|
||||||
Schema::create('tasks', function (Blueprint $table) {
|
Schema::create('tasks', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('emotion_id')->constrained('emotions');
|
$table->foreignId('emotion_id')->constrained('emotions');
|
||||||
$table->foreignId('event_type_id')->nullable()->constrained('event_types')->nullOnDelete();
|
$table->foreignId('event_type_id')->nullable()->constrained('event_types')->nullOnDelete();
|
||||||
$table->json('title');
|
$table->json('title'); // Translatable
|
||||||
$table->json('description');
|
$table->json('description'); // Translatable
|
||||||
$table->string('difficulty', 16)->default('easy'); // app enum
|
$table->string('difficulty', 16)->default('easy'); // app enum
|
||||||
$table->json('example_text')->nullable();
|
$table->json('example_text')->nullable(); // Translatable
|
||||||
$table->integer('sort_order')->default(0);
|
$table->integer('sort_order')->default(0);
|
||||||
$table->boolean('is_active')->default(true);
|
$table->boolean('is_active')->default(true);
|
||||||
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
|
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
|
||||||
$table->string('scope', 16)->default('global'); // global|tenant|event
|
$table->string('scope', 16)->default('global'); // global|tenant|event
|
||||||
$table->foreignId('event_id')->nullable()->constrained('events')->nullOnDelete();
|
$table->foreignId('event_id')->nullable()->constrained('events')->nullOnDelete();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
$table->unique(['tenant_id', 'emotion_id', 'title']);
|
$table->unique(['tenant_id', 'emotion_id', 'title->de']); // Example for de fallback; adjust for multi-locale
|
||||||
});
|
});
|
||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
@@ -196,8 +192,8 @@ Schema::create('legal_pages', function (Blueprint $table) {
|
|||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
|
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
|
||||||
$table->string('slug', 32); // imprint|privacy|terms|custom
|
$table->string('slug', 32); // imprint|privacy|terms|custom
|
||||||
$table->json('title');
|
$table->json('title'); // Translatable
|
||||||
$table->json('body_markdown');
|
$table->json('body_markdown'); // Translatable Markdown content per locale
|
||||||
$table->string('locale_fallback', 5)->default('de');
|
$table->string('locale_fallback', 5)->default('de');
|
||||||
$table->unsignedInteger('version')->default(1);
|
$table->unsignedInteger('version')->default(1);
|
||||||
$table->timestamp('effective_from')->nullable();
|
$table->timestamp('effective_from')->nullable();
|
||||||
@@ -211,3 +207,4 @@ Schema::create('legal_pages', function (Blueprint $table) {
|
|||||||
- Prefer app-level enums (string columns + validation) over DB `ENUM`.
|
- Prefer app-level enums (string columns + validation) over DB `ENUM`.
|
||||||
- Use `cascadeOnDelete()` only where child data must be removed with parent; otherwise `nullOnDelete()`.
|
- Use `cascadeOnDelete()` only where child data must be removed with parent; otherwise `nullOnDelete()`.
|
||||||
- Every tenant-owned table should include `tenant_id` and appropriate composite indexes.
|
- Every tenant-owned table should include `tenant_id` and appropriate composite indexes.
|
||||||
|
- **i18n Integration**: JSON fields (e.g., `name`, `description`) store locale-specific values as `{ "de": "Text", "en": "Text" }`. Use Laravel's `json` cast or spatie/laravel-translatable for access. Fallback to `default_locale` or global fallback ('de'). Update via Filament resources with locale selectors. Ensure indexes on JSON paths if querying (e.g., `->de` for German titles).
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ apps/guest-pwa/
|
|||||||
usePollStats.ts // polls /events/:slug/stats every 10s
|
usePollStats.ts // polls /events/:slug/stats every 10s
|
||||||
usePollGalleryDelta.ts // polls /events/:slug/photos?since=...
|
usePollGalleryDelta.ts // polls /events/:slug/photos?since=...
|
||||||
i18n/
|
i18n/
|
||||||
de.json
|
config.ts // i18next init with react-i18next, backend loadPath '/lang/{{lng}}/guest.json'
|
||||||
|
de.json // Namespace: guest (e.g., { "gallery": { "title": "Galerie" } })
|
||||||
en.json
|
en.json
|
||||||
main.tsx
|
main.tsx
|
||||||
App.tsx
|
App.tsx
|
||||||
@@ -145,6 +146,7 @@ State & Data
|
|||||||
- TanStack Query for server data (events, photos); optimistic updates for likes.
|
- TanStack Query for server data (events, photos); optimistic updates for likes.
|
||||||
- Zustand store for local-only state (profile, queue, banners).
|
- Zustand store for local-only state (profile, queue, banners).
|
||||||
- IndexedDB for upload queue; CacheStorage for shell/assets.
|
- IndexedDB for upload queue; CacheStorage for shell/assets.
|
||||||
|
- i18n: react-i18next; load 'guest' namespace JSON from /lang/{locale}/guest.json; path-based detection for /de/e/:slug, /en/e/:slug; useTranslation('guest') in components.
|
||||||
- Polling: focus-aware intervals (10s stats, 30s gallery); use document visibility to pause; backoff on failures.
|
- Polling: focus-aware intervals (10s stats, 30s gallery); use document visibility to pause; backoff on failures.
|
||||||
|
|
||||||
Accessibility & Performance
|
Accessibility & Performance
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ Technical Notes
|
|||||||
- Storage: IndexedDB for queue + cache; `CacheStorage` for shell/assets.
|
- Storage: IndexedDB for queue + cache; `CacheStorage` for shell/assets.
|
||||||
- Background Sync: use Background Sync API when available; fallback to retry on app open.
|
- Background Sync: use Background Sync API when available; fallback to retry on app open.
|
||||||
- Accessibility: large tap targets, high contrast, keyboard support, reduced motion.
|
- Accessibility: large tap targets, high contrast, keyboard support, reduced motion.
|
||||||
- i18n: default `de`, fallback `en`; all strings in locale files; RTL not in MVP.
|
- i18n: react-i18next with JSON files (`public/lang/{locale}/guest.json`); default `de`, fallback `en`; path-based detection (/de/, /en/); RTL not in MVP. Strings for UI (e.g., gallery, upload, tasks) extracted via i18next-scanner; integrate with prefixed routing and middleware.
|
||||||
- Media types: Photos only (no videos) — decision locked for MVP and v1.
|
- Media types: Photos only (no videos) — decision locked for MVP and v1.
|
||||||
- Realtime model: periodic polling (no WebSockets). Home counters every 10s; gallery delta every 30s with exponential backoff when tab hidden or offline.
|
- Realtime model: periodic polling (no WebSockets). Home counters every 10s; gallery delta every 30s with exponential backoff when tab hidden or offline.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,78 @@
|
|||||||
# 12 — Internationalization
|
# 12 — Internationalization (i18n)
|
||||||
|
|
||||||
- Default locale `de`, fallback `en`.
|
## Overview
|
||||||
- Translatable fields stored as JSON (`name`, `description`, `title`, `body_markdown`).
|
- **Default Locale**: `de` (Deutsch), fallback `en` (English).
|
||||||
- PWA copy managed via i18n files; glossary maintained centrally.
|
- **Supported Locales**: MVP focuses on `de` and `en`; expandable via config/app.php locales array.
|
||||||
- Date/number formatting per locale; right-to-left not in MVP.
|
- **Strategy**: Hybrid approach – Laravel PHP for backend/Blade views, react-i18next for React/Vite PWAs (Marketing, Tenant Admin, Guest).
|
||||||
|
- **Translatable Fields**: JSON columns in DB for dynamic content (e.g., `name`, `description`, `title`, `body_markdown` in models like Package, EventType, LegalPage).
|
||||||
|
- **Namespaces**: Organized by feature (e.g., `marketing`, `auth`, `guest`, `admin`) to avoid key collisions; glossary terms centralized in a shared namespace if needed.
|
||||||
|
- **Date/Number Formatting**: Locale-aware via Laravel's Carbon/Intl; PWAs use Intl API or date-fns with locale bundles.
|
||||||
|
- **RTL Support**: Not in MVP; future via CSS classes and i18next RTL plugin.
|
||||||
|
- **SEO**: Multilingual URLs with prefixes (/de/, /en/), hreflang tags, canonical links, translated meta (title, description, og:).
|
||||||
|
|
||||||
|
## Backend (Laravel/PHP)
|
||||||
|
- **Config**: `config/app.php` – `locale => 'de'`, `fallback_locale => 'en'`, `available_locales => ['de', 'en']`.
|
||||||
|
- **Translation Files**:
|
||||||
|
- PHP arrays: `resources/lang/{locale}/{group}.php` (e.g., `marketing.php`, `auth.php`, `legal.php`) for Blade and API responses.
|
||||||
|
- JSON for PWAs: `public/lang/{locale}/{namespace}.json` (e.g., `public/lang/de/marketing.json`) – migrated from PHP where possible; loaded via dedicated route.
|
||||||
|
- **Routing**:
|
||||||
|
- Prefixed groups: `Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('SetLocale')` in `routes/web.php`.
|
||||||
|
- Fallbacks: Non-prefixed routes redirect to `/de/{path}` (e.g., `/login` → `/de/login`).
|
||||||
|
- Auth routes (login, register, logout): Prefixed and named (e.g., `Route::get('/login', ...)->name('login')`).
|
||||||
|
- API routes: Locale from header/session; no URL prefix for `/api/v1`.
|
||||||
|
- **Middleware**: `SetLocale` – Extracts locale from URL segment(1), sets `App::setLocale()`, stores in session; defaults to 'de'.
|
||||||
|
- **JSON Loader Route**: `Route::get('/lang/{locale}/{namespace}.json', ...)` – Serves from `public_path('lang/{locale}/{namespace}.json')`; Vite proxy forwards requests.
|
||||||
|
- **DB Translations**: Use JSON fields with spatie/laravel-translatable or native casts; admin UI (Filament) for editing per locale.
|
||||||
|
- **Legal Pages**: Dynamic via LegalPage model; rendered with `__($key)` or JSON for PWAs.
|
||||||
|
|
||||||
|
## Frontend (React/Vite PWAs)
|
||||||
|
- **Library**: react-i18next with i18next-http-backend for async JSON loads.
|
||||||
|
- **Setup** (`resources/js/i18n.js`):
|
||||||
|
- Init: `i18n.use(Backend).use(LanguageDetector).init({ lng: 'de', fallbackLng: 'en', ns: ['marketing', 'auth'], backend: { loadPath: '/lang/{{lng}}/{{ns}}.json' } })`.
|
||||||
|
- Detection: Path-based (`order: ['path']`, `lookupFromPathIndex: 0`) for prefixed URLs; cookie/session fallback.
|
||||||
|
- Provider: Wrap `<App>` in `<I18nextProvider i18n={i18n}>` in `app.tsx`.
|
||||||
|
- **Usage**:
|
||||||
|
- Hook: `const { t } = useTranslation('namespace');` in components (e.g., `t('marketing.home.title')`).
|
||||||
|
- Interpolation: Placeholders `{count}`; pluralization via i18next rules.
|
||||||
|
- Dynamic Keys: Avoid; use namespaces for organization.
|
||||||
|
- **Inertia Integration**:
|
||||||
|
- Page Resolver: `resolvePageComponent(`./Pages/${name}.tsx`, import.meta.glob('./Pages/**/*.tsx'))` – Matches capital 'Pages' directory.
|
||||||
|
- Props: Pass `locale` from middleware to pages.
|
||||||
|
- Links: `<Link href={`/${locale}/path`}>` for prefixed navigation (e.g., Header.tsx).
|
||||||
|
- **Marketing Frontend**:
|
||||||
|
- Namespaces: `marketing` (Home, Packages, Blog, Features), `auth` (Login, Register).
|
||||||
|
- Components: All hard-coded strings replaced (e.g., Home.tsx: `t('marketing.hero.title')`); SEO meta via `Head` with `t()`.
|
||||||
|
- Header: Locale selector; dynamic links (e.g., `/${locale}/login` with `t('auth.header.login')`).
|
||||||
|
- **Guest/Tenant PWAs**:
|
||||||
|
- Similar setup; load JSON on app init.
|
||||||
|
- Guest: Anonymous, locale from URL or default 'de'; strings for UI (e.g., gallery, upload).
|
||||||
|
- Tenant Admin: User-preferred locale from profile; sync with backend.
|
||||||
|
- **Automation**:
|
||||||
|
- Extraction: i18next-scanner (npm script: `i18next-scanner --config i18next-scanner.config.js`) to scan TSX for `t('key')` and update JSON.
|
||||||
|
- Validation: Missing keys log warnings; dev mode strict.
|
||||||
|
|
||||||
|
## SEO & Accessibility
|
||||||
|
- **Multilingual URLs**: `/de/home`, `/en/home`; 301 redirects for non-prefixed.
|
||||||
|
- **Hreflang**: `<link rel="alternate" hreflang="de" href="/de/home">` in `<Head>`.
|
||||||
|
- **Canonical**: `<link rel="canonical" href={currentUrl}>` based on detected locale.
|
||||||
|
- **Meta**: Translated via `t('seo.title')`; og:locale='de_DE'.
|
||||||
|
- **Sitemap**: Generate with `de/` and `en/` variants; update `public/sitemap.xml`.
|
||||||
|
- **Robots.txt**: Allow both locales; noindex for dev.
|
||||||
|
- **Accessibility**: ARIA labels with `t()`; screen reader support for language switches.
|
||||||
|
|
||||||
|
## Migration from PHP to JSON
|
||||||
|
- Extract keys from `resources/lang/{locale}/marketing.php` to `public/lang/{locale}/marketing.json`.
|
||||||
|
- Consolidate: Remove duplicates; use nested objects (e.g., `{ "header": { "login": "Anmelden" } }`).
|
||||||
|
- Fallback: PHP arrays remain for backend; JSON for PWAs.
|
||||||
|
|
||||||
|
## Testing & Maintenance
|
||||||
|
- **Tests**: PHPUnit for backend (`__('key')`); Vitest/Jest for frontend (`t('key')` renders correctly).
|
||||||
|
- **Linting**: ESLint rule for missing translations; i18next-scanner in pre-commit.
|
||||||
|
- **Deployment**: JSON files in public; cache-bust via Vite hashes.
|
||||||
|
- **Expansion**: Add locales via config; migrate more namespaces (e.g., `guest`, `admin`).
|
||||||
|
|
||||||
|
## Decisions & Trade-offs
|
||||||
|
- Path-based detection over query params for SEO/clean URLs.
|
||||||
|
- JSON over PHP for PWAs: Faster async loads, no server roundtrips.
|
||||||
|
- No auto-redirect on locale mismatch in MVP; user chooses via selector.
|
||||||
|
- ADR: Use react-i18next over next-intl (Inertia compatibility).
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Die App ist API-first und interagiert ausschließlich über den Backend-API-Endp
|
|||||||
### Error Handling & UX
|
### Error Handling & UX
|
||||||
- **Rate Limits**: 429-Responses handhaben mit Retry-Logic und User-Feedback ("Zu viele Anfragen, versuche es später").
|
- **Rate Limits**: 429-Responses handhaben mit Retry-Logic und User-Feedback ("Zu viele Anfragen, versuche es später").
|
||||||
- **Offline Mode**: Degradiertes UI (Read-Only); Sync-Status-Indikator.
|
- **Offline Mode**: Degradiertes UI (Read-Only); Sync-Status-Indikator.
|
||||||
- **i18n**: Unterstützung für de/en; Locale aus User-Profile.
|
- **i18n**: react-i18next mit JSON (`public/lang/{locale}/admin.json`); de/en; Locale aus User-Profile oder URL-Prefix (/de/, /en/); Detection via LanguageDetector; RTL nicht in MVP.
|
||||||
|
|
||||||
## API-Integration
|
## API-Integration
|
||||||
Die App konsumiert den API-Contract aus docs/prp/03-api.md. Schlüssel-Endpunkte:
|
Die App konsumiert den API-Contract aus docs/prp/03-api.md. Schlüssel-Endpunkte:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
- **Navigation**: Tabbar unten (Dashboard, Events, Photos, Settings); Side-Menu für Profile/Logout.
|
- **Navigation**: Tabbar unten (Dashboard, Events, Photos, Settings); Side-Menu für Profile/Logout.
|
||||||
- **Offline-Indikator**: Banner oben ("Offline-Modus: Änderungen werden synchronisiert").
|
- **Offline-Indikator**: Banner oben ("Offline-Modus: Änderungen werden synchronisiert").
|
||||||
- **Loading**: Spinner für API-Calls; Skeleton-Screens für Listen.
|
- **Loading**: Spinner für API-Calls; Skeleton-Screens für Listen.
|
||||||
- **i18n**: Rechts-nach-Links für de/en; Icons von Framework7-Icons (Material).
|
- **i18n**: LTR für de/en (react-i18next); alle Strings via `t('admin.key')`; Icons von Lucide React (aktuell, nicht Framework7).
|
||||||
|
|
||||||
## Benötigte Seiten und Komponenten
|
## Benötigte Seiten und Komponenten
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
Diese Settings werden lokal in der App gespeichert (via Capacitor Preferences oder IndexedDB) und beeinflussen das Verhalten der App:
|
Diese Settings werden lokal in der App gespeichert (via Capacitor Preferences oder IndexedDB) und beeinflussen das Verhalten der App:
|
||||||
|
|
||||||
### Core App Settings
|
### Core App Settings
|
||||||
- **language**: String (default: 'de') – UI-Sprache; Sync mit User-Locale.
|
- **language**: String (default: 'de') – UI-Sprache; Sync mit User-Profile und i18next (react-i18next); Fallback 'en'; URL-Prefix (/de/, /en/) für persistente Wechsel.
|
||||||
- **themeMode**: String ('system' | 'light' | 'dark') – Dark Mode-Präferenz; Framework7-Theming.
|
- **themeMode**: String ('system' | 'light' | 'dark') – Dark Mode-Präferenz; Framework7-Theming.
|
||||||
- **offlineMode**: Boolean (default: true) – Aktiviert Offline-Caching und Background-Sync.
|
- **offlineMode**: Boolean (default: true) – Aktiviert Offline-Caching und Background-Sync.
|
||||||
- **pushNotifications**: Boolean (default: true) – Erlaubt Push-Registrierung.
|
- **pushNotifications**: Boolean (default: true) – Erlaubt Push-Registrierung.
|
||||||
|
|||||||
45
i18next-scanner.config.js
Normal file
45
i18next-scanner.config.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
module.exports = {
|
||||||
|
input: [
|
||||||
|
'resources/js/**/*.tsx',
|
||||||
|
'!resources/js/**/*.d.ts',
|
||||||
|
'!resources/js/i18n/**'
|
||||||
|
],
|
||||||
|
output: './',
|
||||||
|
options: {
|
||||||
|
debug: true,
|
||||||
|
removeUnusedKeys: true,
|
||||||
|
sort: true,
|
||||||
|
lineEnding: '\n',
|
||||||
|
attr: {
|
||||||
|
list: ['t'],
|
||||||
|
translateAttribute: 'i18nKey',
|
||||||
|
},
|
||||||
|
func: {
|
||||||
|
list: ['t', 'useTranslation'],
|
||||||
|
extensions: ['.tsx'],
|
||||||
|
},
|
||||||
|
trans: {
|
||||||
|
component: 'Trans',
|
||||||
|
i18nKey: 'i18nKey',
|
||||||
|
extensions: ['.tsx'],
|
||||||
|
fallbackKey: (ns, value) => value,
|
||||||
|
},
|
||||||
|
lngs: ['de', 'en'],
|
||||||
|
ns: ['marketing', 'auth', 'guest', 'admin'],
|
||||||
|
defaultLng: 'de',
|
||||||
|
defaultNs: 'marketing',
|
||||||
|
defaultValue: '__STRING_NOT_TRANSLATED__',
|
||||||
|
resource: {
|
||||||
|
loadPath: 'public/lang/{{lng}}/{{ns}}.json',
|
||||||
|
savePath: 'public/lang/{{lng}}/{{ns}}.json',
|
||||||
|
jsonIndent: 2,
|
||||||
|
lineEnding: '\n'
|
||||||
|
},
|
||||||
|
nsSeparators: ['.'],
|
||||||
|
keySeparator: false,
|
||||||
|
interpolation: {
|
||||||
|
prefix: '{{',
|
||||||
|
suffix: '}}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
994
package-lock.json
generated
994
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -5,6 +5,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build:ssr": "vite build && vite build --ssr",
|
"build:ssr": "vite build && vite build --ssr",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"extract:i18n": "i18next-scanner",
|
||||||
"format": "prettier --write resources/",
|
"format": "prettier --write resources/",
|
||||||
"format:check": "prettier --check resources/",
|
"format:check": "prettier --check resources/",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-react": "^7.37.3",
|
"eslint-plugin-react": "^7.37.3",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"i18next-scanner": "^4.6.0",
|
||||||
"playwright": "^1.55.1",
|
"playwright": "^1.55.1",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-organize-imports": "^4.1.0",
|
"prettier-plugin-organize-imports": "^4.1.0",
|
||||||
@@ -31,17 +33,18 @@
|
|||||||
"@inertiajs/react": "^2.1.0",
|
"@inertiajs/react": "^2.1.0",
|
||||||
"@playwright/mcp": "^0.0.37",
|
"@playwright/mcp": "^0.0.37",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-collapsible": "^1.1.3",
|
"@radix-ui/react-collapsible": "^1.1.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
@@ -54,15 +57,20 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel": "^8.6.0",
|
"embla-carousel": "^8.6.0",
|
||||||
"embla-carousel-autoplay": "^8.6.0",
|
"embla-carousel-autoplay": "^8.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"i18next": "^25.5.3",
|
||||||
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
|
"i18next-http-backend": "^3.0.2",
|
||||||
"laravel-vite-plugin": "^2.0",
|
"laravel-vite-plugin": "^2.0",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-i18next": "^16.0.0",
|
||||||
"react-router-dom": "^7.8.2",
|
"react-router-dom": "^7.8.2",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
|
|||||||
57
public/lang/de/auth.json
Normal file
57
public/lang/de/auth.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"failed": "Diese Anmeldedaten wurden nicht gefunden.",
|
||||||
|
"password": "Das Passwort ist falsch.",
|
||||||
|
"throttle": "Zu viele Login-Versuche. Bitte versuche es in :seconds Sekunden erneut.",
|
||||||
|
"login": {
|
||||||
|
"title": "Anmelden",
|
||||||
|
"description": "Geben Sie Ihre E-Mail und Ihr Passwort ein, um sich anzumelden.",
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"email_placeholder": "email@example.com",
|
||||||
|
"password": "Passwort",
|
||||||
|
"password_placeholder": "Passwort",
|
||||||
|
"remember": "Angemeldet bleiben",
|
||||||
|
"submit": "Anmelden",
|
||||||
|
"forgot": "Passwort vergessen?",
|
||||||
|
"no_account": "Kein Account?",
|
||||||
|
"sign_up": "Registrieren"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Registrieren",
|
||||||
|
"welcome": "Willkommen bei Fotospiel – Erstellen Sie Ihren Account",
|
||||||
|
"description": "Registrierung ermöglicht Zugriff auf Events, Galerien und personalisierte Features.",
|
||||||
|
"package_name": "Paket",
|
||||||
|
"package_description": "Beschreibung",
|
||||||
|
"package_price_free": "Kostenlos",
|
||||||
|
"package_price": "{{price}} €",
|
||||||
|
"first_name": "Vorname",
|
||||||
|
"first_name_placeholder": "Vorname",
|
||||||
|
"last_name": "Nachname",
|
||||||
|
"last_name_placeholder": "Nachname",
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"email_placeholder": "email@example.com",
|
||||||
|
"address": "Adresse",
|
||||||
|
"address_placeholder": "Adresse",
|
||||||
|
"phone": "Telefonnummer",
|
||||||
|
"phone_placeholder": "Telefonnummer",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"username_placeholder": "Benutzername",
|
||||||
|
"password": "Passwort",
|
||||||
|
"password_placeholder": "Passwort",
|
||||||
|
"confirm_password": "Passwort bestätigen",
|
||||||
|
"confirm_password_placeholder": "Passwort bestätigen",
|
||||||
|
"privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.",
|
||||||
|
"submit": "Account erstellen",
|
||||||
|
"has_account": "Bereits registriert?",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"errors_title": "Fehler bei der Registrierung:",
|
||||||
|
"privacy_policy": "Datenschutzerklärung"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"login": "Anmelden",
|
||||||
|
"register": "Registrieren"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.",
|
||||||
|
"resend": "E-Mail erneut senden"
|
||||||
|
}
|
||||||
|
}
|
||||||
266
public/lang/de/marketing.json
Normal file
266
public/lang/de/marketing.json
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
{
|
||||||
|
"home": {
|
||||||
|
"title": "Startseite - Fotospiel",
|
||||||
|
"hero_title": "Fotospiel",
|
||||||
|
"hero_description": "Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.",
|
||||||
|
"cta_explore": "Pakete entdecken",
|
||||||
|
"hero_image_alt": "Event-Fotos mit QR-Code",
|
||||||
|
"how_title": "So funktioniert es",
|
||||||
|
"step1_title": "Paket wählen",
|
||||||
|
"step1_desc": "Wähle das passende Paket für dein Event.",
|
||||||
|
"step2_title": "QR-Code teilen",
|
||||||
|
"step2_desc": "Teile den QR-Code mit deinen Gästen.",
|
||||||
|
"step3_title": "Fotos sammeln",
|
||||||
|
"step3_desc": "Gäste laden Fotos hoch – sicher und einfach.",
|
||||||
|
"features_title": "Warum Fotospiel?",
|
||||||
|
"feature1_title": "Sicher & Datenschutzkonform",
|
||||||
|
"feature1_desc": "GDPR-konform, keine PII-Speicherung.",
|
||||||
|
"feature2_title": "Mobil & PWA",
|
||||||
|
"feature2_desc": "Funktioniert offline, installierbar wie App.",
|
||||||
|
"feature3_title": "Einfach zu bedienen",
|
||||||
|
"feature3_desc": "Intuitive UI für Gäste und Organisatoren.",
|
||||||
|
"packages_title": "Unsere Pakete",
|
||||||
|
"view_details": "Details ansehen",
|
||||||
|
"all_packages": "Alle Pakete ansehen",
|
||||||
|
"contact_title": "Kontakt",
|
||||||
|
"name_label": "Name",
|
||||||
|
"email_label": "E-Mail",
|
||||||
|
"message_label": "Nachricht",
|
||||||
|
"sending": "Wird gesendet...",
|
||||||
|
"send": "Senden",
|
||||||
|
"testimonials_title": "Was unsere Kunden sagen",
|
||||||
|
"testimonial1": "Toll für Hochzeiten! Einfach und sicher.",
|
||||||
|
"testimonial2": "Beste App für Event-Fotos.",
|
||||||
|
"testimonial3": "Schnell und benutzerfreundlich.",
|
||||||
|
"faq_title": "Häufige Fragen",
|
||||||
|
"faq1_q": "Ist es kostenlos?",
|
||||||
|
"faq1_a": "Ja, es gibt ein kostenloses Paket für kleine Events.",
|
||||||
|
"faq2_q": "Wie funktioniert der QR-Code?",
|
||||||
|
"faq2_a": "Gäste scannen und laden Fotos hoch – einfach!"
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"title": "Unsere Packages",
|
||||||
|
"hero_title": "Entdecken Sie unsere flexiblen Packages",
|
||||||
|
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
||||||
|
"cta_explore": "Pakete entdecken",
|
||||||
|
"tab_endcustomer": "Endkunden",
|
||||||
|
"tab_reseller": "Reseller & Agenturen",
|
||||||
|
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
||||||
|
"section_reseller": "Packages für Reseller (Jährliches Abo)",
|
||||||
|
"free": "Kostenlos",
|
||||||
|
"one_time": "Einmalkauf",
|
||||||
|
"subscription": "Abo",
|
||||||
|
"year": "Jahr",
|
||||||
|
"max_photos": "Fotos",
|
||||||
|
"max_guests": "Gäste",
|
||||||
|
"gallery_days": "Tage Galerie",
|
||||||
|
"max_events_year": "Events/Jahr",
|
||||||
|
"buy_now": "Jetzt kaufen",
|
||||||
|
"subscribe_now": "Jetzt abonnieren",
|
||||||
|
"register_buy": "Registrieren und kaufen",
|
||||||
|
"register_subscribe": "Registrieren und abonnieren",
|
||||||
|
"faq_title": "Häufige Fragen zu Packages",
|
||||||
|
"faq_q1": "Was ist ein Package?",
|
||||||
|
"faq_a1": "Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.",
|
||||||
|
"faq_q2": "Kann ich upgraden?",
|
||||||
|
"faq_a2": "Ja, wählen Sie bei Event-Erstellung ein höheres Package oder upgraden Sie später.",
|
||||||
|
"faq_q3": "Was passiert bei Ablauf?",
|
||||||
|
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
|
||||||
|
"faq_q4": "Zahlungssicher?",
|
||||||
|
"faq_a4": "Ja, via Stripe oder PayPal – sicher und GDPR-konform.",
|
||||||
|
"final_cta": "Bereit für Ihr nächstes Event?",
|
||||||
|
"contact_us": "Kontaktieren Sie uns",
|
||||||
|
"feature_live_slideshow": "Live-Slideshow",
|
||||||
|
"feature_analytics": "Analytics",
|
||||||
|
"feature_watermark": "Wasserzeichen",
|
||||||
|
"feature_branding": "Branding",
|
||||||
|
"feature_support": "Support",
|
||||||
|
"feature_basic_uploads": "Basis-Uploads",
|
||||||
|
"feature_unlimited_sharing": "Unbegrenztes Teilen",
|
||||||
|
"feature_no_watermark": "Kein Wasserzeichen",
|
||||||
|
"feature_custom_tasks": "Benutzerdefinierte Tasks",
|
||||||
|
"feature_advanced_analytics": "Erweiterte Analytics",
|
||||||
|
"feature_priority_support": "Priorisierter Support",
|
||||||
|
"feature_limited_sharing": "Begrenztes Teilen",
|
||||||
|
"feature_no_branding": "Kein Branding",
|
||||||
|
"feature_0": "Basis-Feature",
|
||||||
|
"feature_reseller_dashboard": "Reseller-Dashboard",
|
||||||
|
"feature_custom_branding": "Benutzerdefiniertes Branding",
|
||||||
|
"feature_advanced_reporting": "Erweiterte Berichterstattung",
|
||||||
|
"for_endcustomers": "Für Endkunden",
|
||||||
|
"for_resellers": "Für Reseller",
|
||||||
|
"details_show": "Details anzeigen",
|
||||||
|
"comparison_title": "Packages vergleichen",
|
||||||
|
"price": "Preis",
|
||||||
|
"max_photos_label": "Max. Fotos",
|
||||||
|
"max_guests_label": "Max. Gäste",
|
||||||
|
"gallery_days_label": "Galerie-Tage",
|
||||||
|
"watermark_label": "Wasserzeichen",
|
||||||
|
"no_watermark": "Kein Wasserzeichen",
|
||||||
|
"custom_branding": "Benutzerdefiniertes Branding",
|
||||||
|
"max_tenants": "Max. Tenants",
|
||||||
|
"max_events": "Max. Events/Jahr",
|
||||||
|
"faq_free": "Was ist das Free Package?",
|
||||||
|
"faq_upgrade": "Kann ich upgraden?",
|
||||||
|
"faq_reseller": "Was für Reseller?",
|
||||||
|
"faq_payment": "Zahlung sicher?",
|
||||||
|
"faq_free_desc": "Das Free Package bietet grundlegende Features für kleine Events mit begrenzter Anzahl an Fotos und Gästen.",
|
||||||
|
"faq_upgrade_desc": "Ja, Sie können jederzeit upgraden, um mehr Features und Limits zu erhalten. Der Upgrade ist nahtlos und Ihre Daten bleiben erhalten.",
|
||||||
|
"faq_reseller_desc": "Reseller-Packages sind jährliche Abos für Agenturen, die mehrere Events verwalten. Inklusive Dashboard und Branding-Optionen.",
|
||||||
|
"faq_payment_desc": "Alle Zahlungen werden über sichere Provider wie Stripe oder PayPal abgewickelt. Ihre Daten sind GDPR-konform geschützt.",
|
||||||
|
"testimonials": {
|
||||||
|
"anna": "Fotospiel hat unsere Hochzeit perfekt gemacht! Die Gäste konnten einfach Fotos teilen, und die Galerie war ein Hit.",
|
||||||
|
"max": "Als Event-Organisator liebe ich die Analytics und das einfache Branding. Super für Firmenevents!",
|
||||||
|
"lisa": "Kostenloses Paket für Geburtstage – einfach und sicher. Kein Stress mit Apps!"
|
||||||
|
},
|
||||||
|
"what_customers_say": "Was unsere Kunden sagen",
|
||||||
|
"close": "Schließen",
|
||||||
|
"to_order": "Jetzt bestellen",
|
||||||
|
"currency": {
|
||||||
|
"euro": "€"
|
||||||
|
},
|
||||||
|
"view_details": "Details ansehen",
|
||||||
|
"feature": "Feature"
|
||||||
|
},
|
||||||
|
"blog": {
|
||||||
|
"title": "Fotospiel - Blog",
|
||||||
|
"hero_title": "Fotospiel Blog",
|
||||||
|
"hero_description": "Tipps, News und Guides für perfekte Event-Fotos mit QR-Codes, PWA und mehr. Bleiben Sie informiert!",
|
||||||
|
"hero_cta": "Mehr über Fotospiel",
|
||||||
|
"posts_title": "Aktuelle Blog-Beiträge",
|
||||||
|
"by": "Von",
|
||||||
|
"team": "Fotospiel Team",
|
||||||
|
"published_at": "Veröffentlicht am",
|
||||||
|
"read_more": "Lesen",
|
||||||
|
"back": "Zurück zum Blog",
|
||||||
|
"empty": "Noch keine Beiträge verfügbar. Bleiben Sie dran!",
|
||||||
|
"our_blog": "Unser Blog",
|
||||||
|
"latest_posts": "Neueste Beiträge",
|
||||||
|
"no_posts": "Keine Beiträge verfügbar.",
|
||||||
|
"read_more_link": "Mehr lesen"
|
||||||
|
},
|
||||||
|
"kontakt": {
|
||||||
|
"title": "Kontakt - Fotospiel",
|
||||||
|
"description": "Haben Sie Fragen? Schreiben Sie uns!",
|
||||||
|
"name": "Name",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"message": "Nachricht",
|
||||||
|
"sending": "Wird gesendet...",
|
||||||
|
"send": "Senden",
|
||||||
|
"back_home": "Zurück zur Startseite"
|
||||||
|
},
|
||||||
|
"occasions": {
|
||||||
|
"title": "Fotospiel für :type",
|
||||||
|
"hero_title": "Fotospiel für :type",
|
||||||
|
"hero_description": "Sammeln Sie unvergessliche Fotos von Ihren Gästen mit QR-Codes. Perfekt für :type – einfach, mobil und datenschutzkonform.",
|
||||||
|
"cta": "Paket wählen",
|
||||||
|
"weddings": {
|
||||||
|
"title": "Hochzeiten mit Fotospiel",
|
||||||
|
"description": "Fangen Sie romantische Momente ein: Gäste teilen Fotos via QR, wählen Emotionen wie 'Romantisch' oder 'Freudig'. Besser als traditionelle Fotoboxen.",
|
||||||
|
"benefits_title": "Vorteile für Hochzeiten",
|
||||||
|
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
|
||||||
|
"benefit2": "Emotion-Filter: Kategorisieren von Fotos (z.B. 'Tanz', 'Kuss').",
|
||||||
|
"benefit3": "Private Galerie: Nur genehmigte Fotos sichtbar.",
|
||||||
|
"benefit4": "Download: Hochauflösend für Album.",
|
||||||
|
"image_alt": "Hochzeitsfotos"
|
||||||
|
},
|
||||||
|
"birthdays": {
|
||||||
|
"title": "Geburtstage feiern",
|
||||||
|
"description": "Lassen Sie Freunde und Familie spontane Fotos teilen. QR auf der Torte – Spaß garantiert!",
|
||||||
|
"benefits_title": "Vorteile für Geburtstage",
|
||||||
|
"benefit1": "Schnelle Uploads: Kamera oder Galerie.",
|
||||||
|
"benefit2": "Likes & Shares: Beliebte Momente hervorheben.",
|
||||||
|
"benefit3": "Offline-fähig: PWA funktioniert ohne Internet.",
|
||||||
|
"benefit4": "Anonym: Keine Registrierung erforderlich.",
|
||||||
|
"image_alt": "Geburtstagsfotos"
|
||||||
|
},
|
||||||
|
"corporate": {
|
||||||
|
"title": "Firmenevents professionell",
|
||||||
|
"description": "Networking und Team-Building: Fotos zentral sammeln, Highlights intern teilen.",
|
||||||
|
"benefits_title": "Vorteile für Firmenevents",
|
||||||
|
"benefit1": "QR an Ständen: Gäste fotografieren sich selbst.",
|
||||||
|
"benefit2": "Kategorien: 'Team', 'Netzwerk', 'Präsentation'.",
|
||||||
|
"benefit3": "Export: Für Social Media oder Intranet.",
|
||||||
|
"benefit4": "GDPR-sicher: Keine PII gespeichert.",
|
||||||
|
"image_alt": "Firmenevent-Fotos"
|
||||||
|
},
|
||||||
|
"family": {
|
||||||
|
"title": "Familienfeiern",
|
||||||
|
"description": "Von Taufen bis Jubiläen: Erinnerungen von allen Verwandten sammeln.",
|
||||||
|
"benefits_title": "Vorteile für Familienfeiern",
|
||||||
|
"benefit1": "Einfach für alle Altersgruppen: Große Schrift, touch-freundlich.",
|
||||||
|
"benefit2": "Emotionen: 'Familie', 'Glück', 'Einheit'.",
|
||||||
|
"benefit3": "Teilen: Via Link oder QR für Nachfeier.",
|
||||||
|
"benefit4": "Unbegrenzt: Im Premium-Plan.",
|
||||||
|
"image_alt": "Familienfotos"
|
||||||
|
},
|
||||||
|
"not_found": "Anlass nicht gefunden.",
|
||||||
|
"hochzeit_title": "Hochzeit – Perfekte Gastfotos mit QR",
|
||||||
|
"hochzeit_desc": "Machen Sie Ihre Hochzeit unvergesslich mit Fotospiel. Gäste teilen Fotos einfach via QR-Code – sicher, privat und in Echtzeit. Von Zeremonie bis Party, alle Momente zentral gesammelt.",
|
||||||
|
"hochzeit_feature1": "Live-Slideshow für Gäste",
|
||||||
|
"hochzeit_feature2": "Emotion-basierte Foto-Filter",
|
||||||
|
"hochzeit_feature3": "Unbegrenzte Galerie für 30 Tage",
|
||||||
|
"hochzeit_cta": "Hochzeitspaket wählen",
|
||||||
|
"geburtstag_title": "Geburtstag – Feiern mit geteilten Erinnerungen",
|
||||||
|
"geburtstag_desc": "Feiern Sie Geburtstage mit Fotospiel! QR-Code für Gäste zum Hochladen von Fotos – von Kinder- bis Erwachsenen-Partys. Einfach teilen, liken und downloaden.",
|
||||||
|
"geburtstag_feature1": "Kostenloses Paket für kleine Partys",
|
||||||
|
"geburtstag_feature2": "Schnelle Uploads via PWA",
|
||||||
|
"geburtstag_feature3": "Privat und datenschutzkonform",
|
||||||
|
"geburtstag_cta": "Geburtstagspaket entdecken",
|
||||||
|
"firmenevent_title": "Firmenevent – Team-Events und Konferenzen",
|
||||||
|
"firmenevent_desc": "Für Firmenevents, Teambuildings und Konferenzen: Fotospiel sammelt alle Fotos zentral via QR. Branding, Analytics und sichere Galerie für Ihr Unternehmen.",
|
||||||
|
"firmenevent_feature1": "Benutzerdefiniertes Branding für Firmenlogo",
|
||||||
|
"firmenevent_feature2": "Erweiterte Analytics",
|
||||||
|
"firmenevent_feature3": "Priorisierter Support",
|
||||||
|
"firmenevent_cta": "Firmenpaket anfragen"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Erfolg",
|
||||||
|
"verify_email": "E-Mail verifizieren",
|
||||||
|
"check_email": "Überprüfen Sie Ihre E-Mail auf den Verifizierungslink.",
|
||||||
|
"redirecting": "Weiterleitung zum Admin-Bereich...",
|
||||||
|
"complete_purchase": "Kauf abschließen",
|
||||||
|
"login_to_continue": "Melden Sie sich an, um fortzufahren.",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"email_verify_title": "E-Mail verifizieren",
|
||||||
|
"email_verify_desc": "Bitte überprüfen Sie Ihre E-Mail und klicken Sie auf den Verifizierungslink.",
|
||||||
|
"resend_verification": "Verifizierung erneut senden",
|
||||||
|
"already_registered": "Bereits registriert? Anmelden",
|
||||||
|
"purchase_complete_title": "Kauf abschließen",
|
||||||
|
"purchase_complete_desc": "Melden Sie sich an, um fortzufahren.",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"no_account": "Kein Konto? Registrieren"
|
||||||
|
},
|
||||||
|
"blog_show": {
|
||||||
|
"title_suffix": " - Fotospiel Blog",
|
||||||
|
"by_author": "Von",
|
||||||
|
"published_on": "Veröffentlicht am",
|
||||||
|
"back_to_blog": "Zurück zum Blog"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"how_it_works": "So funktioniert es",
|
||||||
|
"features": "Features",
|
||||||
|
"occasions": "Anlässe",
|
||||||
|
"blog": "Blog",
|
||||||
|
"packages": "Pakete",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"discover_packages": "Pakete entdecken",
|
||||||
|
"privacy": "Datenschutz",
|
||||||
|
"impressum": "Impressum"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"company": "Fotospiel GmbH",
|
||||||
|
"rights_reserved": "Alle Rechte vorbehalten"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"free": "Kostenlos"
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"euro": "€"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"title": "Fotospiel - Sammle Gastfotos für Events mit QR-Codes",
|
||||||
|
"description": "Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform."
|
||||||
|
}
|
||||||
|
}
|
||||||
57
public/lang/en/auth.json
Normal file
57
public/lang/en/auth.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"failed": "These credentials do not match our records.",
|
||||||
|
"password": "The provided password is incorrect.",
|
||||||
|
"throttle": "Too many login attempts. Please try again in :seconds seconds.",
|
||||||
|
"login": {
|
||||||
|
"title": "Log in",
|
||||||
|
"description": "Enter your email and password below to log in.",
|
||||||
|
"email": "Email address",
|
||||||
|
"email_placeholder": "email@example.com",
|
||||||
|
"password": "Password",
|
||||||
|
"password_placeholder": "Password",
|
||||||
|
"remember": "Remember me",
|
||||||
|
"submit": "Log in",
|
||||||
|
"forgot": "Forgot password?",
|
||||||
|
"no_account": "Don't have an account?",
|
||||||
|
"sign_up": "Sign up"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"title": "Register",
|
||||||
|
"welcome": "Welcome to Fotospiel – Create your account",
|
||||||
|
"description": "Registration enables access to events, galleries, and personalized features.",
|
||||||
|
"package_name": "Package",
|
||||||
|
"package_description": "Description",
|
||||||
|
"package_price_free": "Free",
|
||||||
|
"package_price": "{{price}} €",
|
||||||
|
"first_name": "First name",
|
||||||
|
"first_name_placeholder": "First name",
|
||||||
|
"last_name": "Last name",
|
||||||
|
"last_name_placeholder": "Last name",
|
||||||
|
"email": "Email address",
|
||||||
|
"email_placeholder": "email@example.com",
|
||||||
|
"address": "Address",
|
||||||
|
"address_placeholder": "Address",
|
||||||
|
"phone": "Phone number",
|
||||||
|
"phone_placeholder": "Phone number",
|
||||||
|
"username": "Username",
|
||||||
|
"username_placeholder": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"password_placeholder": "Password",
|
||||||
|
"confirm_password": "Confirm password",
|
||||||
|
"confirm_password_placeholder": "Confirm password",
|
||||||
|
"privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.",
|
||||||
|
"submit": "Create account",
|
||||||
|
"has_account": "Already registered?",
|
||||||
|
"login": "Log in",
|
||||||
|
"errors_title": "Registration errors:",
|
||||||
|
"privacy_policy": "Privacy policy"
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"login": "Log in",
|
||||||
|
"register": "Register"
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"notice": "Please confirm your email address.",
|
||||||
|
"resend": "Resend email"
|
||||||
|
}
|
||||||
|
}
|
||||||
257
public/lang/en/marketing.json
Normal file
257
public/lang/en/marketing.json
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
{
|
||||||
|
"home": {
|
||||||
|
"title": "Home - Fotospiel",
|
||||||
|
"hero_title": "Fotospiel",
|
||||||
|
"hero_description": "Collect guest photos for events with QR codes. Our secure PWA platform for guests and organizers – simple, mobile, and privacy-compliant. Better than competitors, loved by thousands.",
|
||||||
|
"cta_explore": "Discover Packages",
|
||||||
|
"hero_image_alt": "Event photos with QR code",
|
||||||
|
"how_title": "How it works",
|
||||||
|
"step1_title": "Choose Package",
|
||||||
|
"step1_desc": "Choose the right package for your event.",
|
||||||
|
"step2_title": "Share QR Code",
|
||||||
|
"step2_desc": "Share the QR code with your guests.",
|
||||||
|
"step3_title": "Collect Photos",
|
||||||
|
"step3_desc": "Guests upload photos – secure and easy.",
|
||||||
|
"features_title": "Why Fotospiel?",
|
||||||
|
"feature1_title": "Secure & Privacy Compliant",
|
||||||
|
"feature1_desc": "GDPR compliant, no PII storage.",
|
||||||
|
"feature2_title": "Mobile & PWA",
|
||||||
|
"feature2_desc": "Works offline, installable like an app.",
|
||||||
|
"feature3_title": "Easy to Use",
|
||||||
|
"feature3_desc": "Intuitive UI for guests and organizers.",
|
||||||
|
"packages_title": "Our Packages",
|
||||||
|
"view_details": "View Details",
|
||||||
|
"all_packages": "View All Packages",
|
||||||
|
"contact_title": "Contact",
|
||||||
|
"name_label": "Name",
|
||||||
|
"email_label": "Email",
|
||||||
|
"message_label": "Message",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"send": "Send",
|
||||||
|
"testimonials_title": "What our customers say",
|
||||||
|
"testimonial1": "Great for weddings! Simple and secure.",
|
||||||
|
"testimonial2": "Best app for event photos.",
|
||||||
|
"testimonial3": "Fast and user-friendly.",
|
||||||
|
"faq_title": "Frequently Asked Questions",
|
||||||
|
"faq1_q": "Is it free?",
|
||||||
|
"faq1_a": "Yes, there is a free package for small events.",
|
||||||
|
"faq2_q": "How does the QR code work?",
|
||||||
|
"faq2_a": "Guests scan and upload photos – easy!"
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"title": "Our Packages",
|
||||||
|
"hero_title": "Discover our flexible Packages",
|
||||||
|
"hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure, and scalable.",
|
||||||
|
"cta_explore": "Discover Packages",
|
||||||
|
"tab_endcustomer": "End Customers",
|
||||||
|
"tab_reseller": "Resellers & Agencies",
|
||||||
|
"section_endcustomer": "Packages for End Customers (One-time purchase per event)",
|
||||||
|
"section_reseller": "Packages for Resellers (Annual Subscription)",
|
||||||
|
"free": "Free",
|
||||||
|
"one_time": "One-time purchase",
|
||||||
|
"subscription": "Subscription",
|
||||||
|
"year": "Year",
|
||||||
|
"max_photos": "Photos",
|
||||||
|
"max_guests": "Guests",
|
||||||
|
"gallery_days": "Gallery Days",
|
||||||
|
"max_events_year": "Events/Year",
|
||||||
|
"buy_now": "Buy Now",
|
||||||
|
"subscribe_now": "Subscribe Now",
|
||||||
|
"register_buy": "Register and Buy",
|
||||||
|
"register_subscribe": "Register and Subscribe",
|
||||||
|
"faq_title": "Frequently Asked Questions about Packages",
|
||||||
|
"faq_q1": "What is a Package?",
|
||||||
|
"faq_a1": "A Package defines limits and features for your event, e.g. number of photos and gallery duration.",
|
||||||
|
"faq_q2": "Can I upgrade?",
|
||||||
|
"faq_a2": "Yes, choose a higher package when creating the event or upgrade later.",
|
||||||
|
"faq_q3": "What happens when it expires?",
|
||||||
|
"faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend.",
|
||||||
|
"faq_q4": "Payment secure?",
|
||||||
|
"faq_a4": "Yes, via Stripe or PayPal – secure and GDPR compliant.",
|
||||||
|
"final_cta": "Ready for your next event?",
|
||||||
|
"contact_us": "Contact Us",
|
||||||
|
"feature_live_slideshow": "Live Slideshow",
|
||||||
|
"feature_analytics": "Analytics",
|
||||||
|
"feature_watermark": "Watermark",
|
||||||
|
"feature_branding": "Branding",
|
||||||
|
"feature_support": "Support",
|
||||||
|
"feature_basic_uploads": "Basic Uploads",
|
||||||
|
"feature_unlimited_sharing": "Unlimited Sharing",
|
||||||
|
"feature_no_watermark": "No Watermark",
|
||||||
|
"feature_custom_tasks": "Custom Tasks",
|
||||||
|
"feature_advanced_analytics": "Advanced Analytics",
|
||||||
|
"feature_priority_support": "Priority Support",
|
||||||
|
"feature_limited_sharing": "Limited Sharing",
|
||||||
|
"feature_no_branding": "No Branding",
|
||||||
|
"feature_0": "Basic Feature",
|
||||||
|
"feature_reseller_dashboard": "Reseller Dashboard",
|
||||||
|
"feature_custom_branding": "Custom Branding",
|
||||||
|
"feature_advanced_reporting": "Advanced Reporting",
|
||||||
|
"for_endcustomers": "For End Customers",
|
||||||
|
"for_resellers": "For Resellers",
|
||||||
|
"details_show": "Show Details",
|
||||||
|
"comparison_title": "Compare Packages",
|
||||||
|
"price": "Price",
|
||||||
|
"max_photos_label": "Max. Photos",
|
||||||
|
"max_guests_label": "Max. Guests",
|
||||||
|
"gallery_days_label": "Gallery Days",
|
||||||
|
"watermark_label": "Watermark",
|
||||||
|
"no_watermark": "No Watermark",
|
||||||
|
"custom_branding": "Custom Branding",
|
||||||
|
"max_tenants": "Max. Tenants",
|
||||||
|
"max_events": "Max. Events/Year",
|
||||||
|
"faq_free": "What is the Free Package?",
|
||||||
|
"faq_upgrade": "Can I upgrade?",
|
||||||
|
"faq_reseller": "What for Resellers?",
|
||||||
|
"faq_payment": "Payment secure?",
|
||||||
|
"testimonials": {
|
||||||
|
"anna": "Fotospiel made our wedding perfect! Guests could easily share photos, and the gallery was a hit.",
|
||||||
|
"max": "As an event organizer, I love the analytics and easy branding. Great for corporate events!",
|
||||||
|
"lisa": "Free package for birthdays – simple and secure. No app hassle!"
|
||||||
|
},
|
||||||
|
"what_customers_say": "What our customers say",
|
||||||
|
"close": "Close",
|
||||||
|
"to_order": "Order Now"
|
||||||
|
},
|
||||||
|
"blog": {
|
||||||
|
"title": "Fotospiel - Blog",
|
||||||
|
"hero_title": "Fotospiel Blog",
|
||||||
|
"hero_description": "Tips, news, and guides for perfect event photos with QR codes, PWA, and more. Stay informed!",
|
||||||
|
"hero_cta": "More about Fotospiel",
|
||||||
|
"posts_title": "Latest Blog Posts",
|
||||||
|
"by": "By",
|
||||||
|
"team": "Fotospiel Team",
|
||||||
|
"published_at": "Published on",
|
||||||
|
"read_more": "Read More",
|
||||||
|
"back": "Back to Blog",
|
||||||
|
"empty": "No posts available yet. Stay tuned!",
|
||||||
|
"our_blog": "Our Blog",
|
||||||
|
"latest_posts": "Latest Posts",
|
||||||
|
"no_posts": "No posts available.",
|
||||||
|
"read_more_link": "Read More"
|
||||||
|
},
|
||||||
|
"kontakt": {
|
||||||
|
"title": "Contact - Fotospiel",
|
||||||
|
"description": "Have questions? Write to us!",
|
||||||
|
"name": "Name",
|
||||||
|
"email": "Email",
|
||||||
|
"message": "Message",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"send": "Send",
|
||||||
|
"back_home": "Back to Home"
|
||||||
|
},
|
||||||
|
"occasions": {
|
||||||
|
"title": "Fotospiel for :type",
|
||||||
|
"hero_title": "Fotospiel for :type",
|
||||||
|
"hero_description": "Collect unforgettable photos from your guests with QR codes. Perfect for :type – simple, mobile, and privacy-compliant.",
|
||||||
|
"cta": "Choose Package",
|
||||||
|
"weddings": {
|
||||||
|
"title": "Weddings with Fotospiel",
|
||||||
|
"description": "Capture romantic moments: Guests share photos via QR, choose emotions like 'Romantic' or 'Joyful'. Better than traditional photo booths.",
|
||||||
|
"benefits_title": "Benefits for Weddings",
|
||||||
|
"benefit1": "QR Code for Guests: Easy sharing without app download.",
|
||||||
|
"benefit2": "Emotion Filter: Categorize photos (e.g. 'Dance', 'Kiss').",
|
||||||
|
"benefit3": "Private Gallery: Only approved photos visible.",
|
||||||
|
"benefit4": "Download: High-res for album.",
|
||||||
|
"image_alt": "Wedding photos"
|
||||||
|
},
|
||||||
|
"birthdays": {
|
||||||
|
"title": "Celebrate Birthdays",
|
||||||
|
"description": "Let friends and family share spontaneous photos. QR on the cake – fun guaranteed!",
|
||||||
|
"benefits_title": "Benefits for Birthdays",
|
||||||
|
"benefit1": "Quick Uploads: Camera or gallery.",
|
||||||
|
"benefit2": "Likes & Shares: Highlight popular moments.",
|
||||||
|
"benefit3": "Offline-capable: PWA works without internet.",
|
||||||
|
"benefit4": "Anonymous: No registration required.",
|
||||||
|
"image_alt": "Birthday photos"
|
||||||
|
},
|
||||||
|
"corporate": {
|
||||||
|
"title": "Corporate Events Professional",
|
||||||
|
"description": "Networking and team-building: Collect photos centrally, share highlights internally.",
|
||||||
|
"benefits_title": "Benefits for Corporate Events",
|
||||||
|
"benefit1": "QR at Booths: Guests photograph themselves.",
|
||||||
|
"benefit2": "Categories: 'Team', 'Network', 'Presentation'.",
|
||||||
|
"benefit3": "Export: For social media or intranet.",
|
||||||
|
"benefit4": "GDPR-secure: No PII stored.",
|
||||||
|
"image_alt": "Corporate event photos"
|
||||||
|
},
|
||||||
|
"family": {
|
||||||
|
"title": "Family Celebrations",
|
||||||
|
"description": "From baptisms to anniversaries: Collect memories from all relatives.",
|
||||||
|
"benefits_title": "Benefits for Family Celebrations",
|
||||||
|
"benefit1": "Easy for all ages: Large font, touch-friendly.",
|
||||||
|
"benefit2": "Emotions: 'Family', 'Happiness', 'Unity'.",
|
||||||
|
"benefit3": "Share: Via link or QR for after-party.",
|
||||||
|
"benefit4": "Unlimited: In premium plan.",
|
||||||
|
"image_alt": "Family photos"
|
||||||
|
},
|
||||||
|
"not_found": "Event type not found.",
|
||||||
|
"hochzeit_title": "Wedding – Perfect Guest Photos with QR",
|
||||||
|
"hochzeit_desc": "Make your wedding unforgettable with Fotospiel. Guests share photos easily via QR code – secure, private, and in real-time. From ceremony to party, all moments centrally collected.",
|
||||||
|
"hochzeit_feature1": "Live Slideshow for Guests",
|
||||||
|
"hochzeit_feature2": "Emotion-based Photo Filters",
|
||||||
|
"hochzeit_feature3": "Unlimited Gallery for 30 Days",
|
||||||
|
"hochzeit_cta": "Choose Wedding Package",
|
||||||
|
"geburtstag_title": "Birthday – Celebrate with Shared Memories",
|
||||||
|
"geburtstag_desc": "Celebrate birthdays with Fotospiel! QR code for guests to upload photos – from kids' to adult parties. Easy to share, like, and download.",
|
||||||
|
"geburtstag_feature1": "Free Package for Small Parties",
|
||||||
|
"geburtstag_feature2": "Quick Uploads via PWA",
|
||||||
|
"geburtstag_feature3": "Private and Privacy Compliant",
|
||||||
|
"geburtstag_cta": "Discover Birthday Package",
|
||||||
|
"firmenevent_title": "Corporate Event – Team Events and Conferences",
|
||||||
|
"firmenevent_desc": "For corporate events, team buildings, and conferences: Fotospiel collects all photos centrally via QR. Branding, analytics, and secure gallery for your company.",
|
||||||
|
"firmenevent_feature1": "Custom Branding for Company Logo",
|
||||||
|
"firmenevent_feature2": "Advanced Analytics",
|
||||||
|
"firmenevent_feature3": "Priority Support",
|
||||||
|
"firmenevent_cta": "Request Corporate Package"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Success",
|
||||||
|
"verify_email": "Verify Email",
|
||||||
|
"check_email": "Check your email for the verification link.",
|
||||||
|
"redirecting": "Redirecting to Admin Area...",
|
||||||
|
"complete_purchase": "Complete Purchase",
|
||||||
|
"login_to_continue": "Log in to continue.",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"email_verify_title": "Verify Email",
|
||||||
|
"email_verify_desc": "Please check your email and click the verification link.",
|
||||||
|
"resend_verification": "Resend Verification",
|
||||||
|
"already_registered": "Already registered? Log in",
|
||||||
|
"purchase_complete_title": "Complete Purchase",
|
||||||
|
"purchase_complete_desc": "Log in to continue.",
|
||||||
|
"login": "Log In",
|
||||||
|
"no_account": "No Account? Register"
|
||||||
|
},
|
||||||
|
"blog_show": {
|
||||||
|
"title_suffix": " - Fotospiel Blog",
|
||||||
|
"by_author": "By",
|
||||||
|
"published_on": "Published on",
|
||||||
|
"back_to_blog": "Back to Blog"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"how_it_works": "How it Works",
|
||||||
|
"features": "Features",
|
||||||
|
"occasions": "Occasions",
|
||||||
|
"blog": "Blog",
|
||||||
|
"packages": "Packages",
|
||||||
|
"contact": "Contact",
|
||||||
|
"discover_packages": "Discover Packages",
|
||||||
|
"privacy": "Privacy",
|
||||||
|
"impressum": "Imprint"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"company": "Fotospiel GmbH",
|
||||||
|
"rights_reserved": "All Rights Reserved"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"free": "Free"
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"euro": "€"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"title": "Fotospiel - Collect Guest Photos for Events with QR Codes",
|
||||||
|
"description": "Collect guest photos for events with QR codes. Our secure PWA platform for guests and organizers – simple, mobile, and privacy-compliant."
|
||||||
|
}
|
||||||
|
}
|
||||||
79
public/sitemap.xml
Normal file
79
public/sitemap.xml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<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: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>
|
||||||
|
<loc>https://fotospiel.app/de/</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>
|
||||||
|
<loc>https://fotospiel.app/en/</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>
|
||||||
|
<loc>https://fotospiel.app/de/packages</loc>
|
||||||
|
<lastmod>2025-10-02</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/packages" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/packages" />
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fotospiel.app/en/packages</loc>
|
||||||
|
<lastmod>2025-10-02</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/packages" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/packages" />
|
||||||
|
</url>
|
||||||
|
<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>
|
||||||
|
<loc>https://fotospiel.app/de/kontakt</loc>
|
||||||
|
<lastmod>2025-10-02</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/kontakt" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/kontakt" />
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://fotospiel.app/en/kontakt</loc>
|
||||||
|
<lastmod>2025-10-02</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.6</priority>
|
||||||
|
<xhtml:link rel="alternate" hreflang="de" href="https://fotospiel.app/de/kontakt" />
|
||||||
|
<xhtml:link rel="alternate" hreflang="en" href="https://fotospiel.app/en/kontakt" />
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
@@ -260,3 +260,9 @@
|
|||||||
background-size: 400% 400%, 400% 400%;
|
background-size: 400% 400%, 400% 400%;
|
||||||
animation: aurora 20s ease infinite;
|
animation: aurora 20s ease infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .bg-aurora-enhanced {
|
||||||
|
background: radial-gradient(circle at 20% 80%, #0f4c75 0%, #2a1b3d 50%, #1a1a2e 100%), linear-gradient(-45deg, #0f3460, #533483, #0b6b9f, #0f4c75);
|
||||||
|
background-size: 400% 400%, 400% 400%;
|
||||||
|
animation: aurora 20s ease infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,16 +4,32 @@ import { createInertiaApp } from '@inertiajs/react';
|
|||||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { initializeTheme } from './hooks/use-appearance';
|
import { initializeTheme } from './hooks/use-appearance';
|
||||||
|
import AppLayout from './Components/Layout/AppLayout';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import i18n from './i18n';
|
||||||
|
|
||||||
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(`./pages/${name}.tsx`, import.meta.glob('./pages/**/*.tsx')),
|
resolve: (name) => resolvePageComponent(
|
||||||
|
`./Pages/${name}.tsx`,
|
||||||
|
import.meta.glob('./Pages/**/*.tsx')
|
||||||
|
).then((page) => {
|
||||||
|
if (page) {
|
||||||
|
const PageComponent = (page as any).default;
|
||||||
|
return (props: any) => <AppLayout><PageComponent {...props} /></AppLayout>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
setup({ el, App, props }) {
|
setup({ el, App, props }) {
|
||||||
const root = createRoot(el);
|
const root = createRoot(el);
|
||||||
|
|
||||||
root.render(<App {...props} />);
|
root.render(
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<App {...props} />
|
||||||
|
</I18nextProvider>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
color: '#4B5563',
|
color: '#4B5563',
|
||||||
|
|||||||
23
resources/js/components/Layout/AppLayout.tsx
Normal file
23
resources/js/components/Layout/AppLayout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePage } from '@inertiajs/react';
|
||||||
|
import Header from './Header';
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
header?: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppLayout: React.FC<AppLayoutProps> = ({ children, header, footer }) => {
|
||||||
|
const { auth } = usePage().props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
{header || <Header />}
|
||||||
|
<main>{children}</main>
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
153
resources/js/components/Layout/Header.tsx
Normal file
153
resources/js/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePage } from '@inertiajs/react';
|
||||||
|
import { Link, router } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppearance } from '@/hooks/use-appearance';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||||
|
import { Sun, Moon } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Header: React.FC = () => {
|
||||||
|
const { auth, locale } = usePage().props as any;
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
|
const { appearance, updateAppearance } = useAppearance();
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newAppearance = appearance === 'dark' ? 'light' : 'dark';
|
||||||
|
updateAppearance(newAppearance);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLanguageChange = (value: string) => {
|
||||||
|
router.visit(`/${value}`, { preserveState: true, replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
router.post(`/${locale}/logout`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="fixed top-0 z-50 w-full bg-white dark:bg-gray-900 shadow-lg border-b-2 border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="container mx-auto px-4 py-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Link href={`/${locale}`} className="text-2xl font-bold text-gray-800 dark:text-gray-200">
|
||||||
|
Fotospiel
|
||||||
|
</Link>
|
||||||
|
<nav className="flex space-x-8">
|
||||||
|
<Link href={`/${locale}`} className="text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white font-sans-marketing text-lg">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
<Link href={`/${locale}/packages`} className="text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white font-sans-marketing text-lg">
|
||||||
|
Pakete
|
||||||
|
</Link>
|
||||||
|
<Link href={`/${locale}/blog`} className="text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white font-sans-marketing text-lg">
|
||||||
|
Blog
|
||||||
|
</Link>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white font-sans-marketing text-lg">
|
||||||
|
Anlässe
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${locale}/anlaesse/hochzeit`}>
|
||||||
|
Hochzeit
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${locale}/anlaesse/geburtstag`}>
|
||||||
|
Geburtstag
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/${locale}/anlaesse/firmenevent`}>
|
||||||
|
Firmenevent
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Link href={`/${locale}/kontakt`} className="text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white font-sans-marketing text-lg">
|
||||||
|
Kontakt
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="h-8 w-8"
|
||||||
|
>
|
||||||
|
<Sun className={cn("h-4 w-4", appearance === "dark" && "hidden")} />
|
||||||
|
<Moon className={cn("h-4 w-4", appearance !== "dark" && "hidden")} />
|
||||||
|
<span className="sr-only">Theme Toggle</span>
|
||||||
|
</Button>
|
||||||
|
<Select value={locale} onValueChange={handleLanguageChange}>
|
||||||
|
<SelectTrigger className="w-[70px] h-8">
|
||||||
|
<SelectValue placeholder="DE" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="de">DE</SelectItem>
|
||||||
|
<SelectItem value="en">EN</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{auth.user ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="relative h-8 w-8 rounded-full">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarImage src={auth.user.avatar} alt={auth.user.name} />
|
||||||
|
<AvatarFallback>{auth.user.name.charAt(0)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">{auth.user.name}</p>
|
||||||
|
<p className="text-xs leading-none text-muted-foreground">{auth.user.email}</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/profile">
|
||||||
|
Profil
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/profile/orders">
|
||||||
|
Bestellungen
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
|
Abmelden
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/login`}
|
||||||
|
className="text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{t('header.login')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/${locale}/register`}
|
||||||
|
className="bg-pink-500 text-white px-4 py-2 rounded hover:bg-pink-600 dark:bg-pink-600 dark:hover:bg-pink-700"
|
||||||
|
>
|
||||||
|
{t('header.register')}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
27
resources/js/components/ui/switch.tsx
Normal file
27
resources/js/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
68
resources/js/hooks/use-appearance.ts
Normal file
68
resources/js/hooks/use-appearance.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
type Appearance = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
export function useAppearance(): { appearance: Appearance; updateAppearance: (mode: Appearance) => void } {
|
||||||
|
const [appearance, setAppearance] = useState<Appearance>('system');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem('theme') as Appearance | null;
|
||||||
|
if (stored) {
|
||||||
|
setAppearance(stored);
|
||||||
|
} else {
|
||||||
|
setAppearance('system');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateAppearance = (mode: Appearance) => {
|
||||||
|
setAppearance(mode);
|
||||||
|
localStorage.setItem('theme', mode);
|
||||||
|
if (mode === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appearance === 'system') {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
if (mediaQuery.matches) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
const listener = (e: MediaQueryListEvent) => {
|
||||||
|
if (e.matches) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mediaQuery.addEventListener('change', listener);
|
||||||
|
return () => mediaQuery.removeEventListener('change', listener);
|
||||||
|
} else if (appearance === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, [appearance]);
|
||||||
|
|
||||||
|
return { appearance, updateAppearance };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeTheme() {
|
||||||
|
const stored = localStorage.getItem('theme') as Appearance | null;
|
||||||
|
if (stored) {
|
||||||
|
if (stored === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
if (mediaQuery.matches) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
resources/js/i18n.js
Normal file
29
resources/js/i18n.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import Backend from 'i18next-http-backend';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(Backend)
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
fallbackLng: 'de',
|
||||||
|
debug: process.env.NODE_ENV === 'development',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
backend: {
|
||||||
|
loadPath: '/lang/{{lng}}/{{ns}}.json',
|
||||||
|
},
|
||||||
|
ns: ['marketing', 'auth'],
|
||||||
|
defaultNS: 'marketing',
|
||||||
|
supportedLngs: ['de', 'en'],
|
||||||
|
detection: {
|
||||||
|
order: ['path', 'cookie', 'localStorage', 'htmlTag', 'subdomain'],
|
||||||
|
lookupFromPathIndex: 0,
|
||||||
|
caches: ['cookie'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
@@ -1,42 +1,63 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React from 'react';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head, Link, usePage } from '@inertiajs/react';
|
||||||
import MarketingHeader from '@/components/marketing/MarketingHeader';
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarketingFooter from '@/components/marketing/MarketingFooter';
|
|
||||||
|
|
||||||
interface MarketingLayoutProps {
|
interface MarketingLayoutProps {
|
||||||
children: ReactNode;
|
children: React.ReactNode;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MarketingLayout: React.FC<MarketingLayoutProps> = ({
|
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) => {
|
||||||
children,
|
const { t } = useTranslation('marketing');
|
||||||
title = 'Fotospiel - Event-Fotos einfach und sicher mit QR-Codes',
|
const { url } = usePage();
|
||||||
description = 'Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.'
|
|
||||||
}) => {
|
const { translations } = usePage().props as any;
|
||||||
|
const marketing = translations?.marketing || {};
|
||||||
|
|
||||||
|
const getString = (key: string, fallback: string) => {
|
||||||
|
const value = marketing[key];
|
||||||
|
return typeof value === 'string' ? value : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentLocale = url.startsWith('/en/') ? 'en' : 'de';
|
||||||
|
const alternateLocale = currentLocale === 'de' ? 'en' : 'de';
|
||||||
|
const path = url.replace(/^\/(de|en)?/, '');
|
||||||
|
const canonicalUrl = `https://fotospiel.app/${currentLocale}${path}`;
|
||||||
|
const alternateUrl = `https://fotospiel.app/${alternateLocale}${path}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>{title}</title>
|
<title>{title || t('meta.title', getString('title', 'Fotospiel'))}</title>
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))} />
|
||||||
<style>{`
|
<meta property="og:title" content={title || t('meta.title', getString('title', 'Fotospiel'))} />
|
||||||
@keyframes aurora {
|
<meta property="og:description" content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))} />
|
||||||
0%, 100% { background-position: 0% 50%; }
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
50% { background-position: 100% 50%; }
|
<link rel="canonical" href={canonicalUrl} />
|
||||||
}
|
<link rel="alternate" hrefLang="de" href={`https://fotospiel.app/de${path}`} />
|
||||||
.bg-aurora {
|
<link rel="alternate" hrefLang="en" href={`https://fotospiel.app/en${path}`} />
|
||||||
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
|
<link rel="alternate" hrefLang="x-default" href="https://fotospiel.app/de" />
|
||||||
background-size: 400% 400%;
|
|
||||||
animation: aurora 15s ease infinite;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</Head>
|
</Head>
|
||||||
<div className="bg-gray-50 text-gray-900 min-h-screen flex flex-col font-sans antialiased">
|
<div className="min-h-screen bg-white">
|
||||||
<MarketingHeader />
|
<main>
|
||||||
<main className="flex-grow">
|
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<MarketingFooter />
|
<footer className="bg-gray-800 text-white py-8">
|
||||||
|
<div className="container mx-auto px-4 text-center">
|
||||||
|
<p>© 2025 Fotospiel. Alle Rechte vorbehalten.</p>
|
||||||
|
<div className="mt-4 space-x-4">
|
||||||
|
<Link href="/datenschutz" className="hover:underline">
|
||||||
|
{t('nav.privacy', getString('nav.privacy', 'Datenschutz'))}
|
||||||
|
</Link>
|
||||||
|
<Link href="/impressum" className="hover:underline">
|
||||||
|
{t('nav.impressum', getString('nav.impressum', 'Impressum'))}
|
||||||
|
</Link>
|
||||||
|
<Link href="/kontakt" className="hover:underline">
|
||||||
|
{t('nav.contact', getString('nav.contact', 'Kontakt'))}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
45
resources/js/pages/Profile/Account.tsx
Normal file
45
resources/js/pages/Profile/Account.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useForm } from '@inertiajs/react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
const ProfileAccount = () => {
|
||||||
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
post('/profile/account');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account bearbeiten</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input id="name" value={data.name} onChange={(e) => setData('name', e.target.value)} />
|
||||||
|
{errors.name && <p className="text-red-500 text-sm">{errors.name}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input id="email" type="email" value={data.email} onChange={(e) => setData('email', e.target.value)} />
|
||||||
|
{errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={processing}>Speichern</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileAccount;
|
||||||
38
resources/js/pages/Profile/Index.tsx
Normal file
38
resources/js/pages/Profile/Index.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePage } from '@inertiajs/react';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import Account from './Account';
|
||||||
|
import Orders from './Orders';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
|
const ProfileIndex = () => {
|
||||||
|
const { user } = usePage().props as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Mein Profil</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p>Hallo, {user.name}!</p>
|
||||||
|
<p>Email: {user.email}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Tabs defaultValue="account" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="account">Account</TabsTrigger>
|
||||||
|
<TabsTrigger value="orders">Bestellungen</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="account">
|
||||||
|
<Account />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="orders">
|
||||||
|
<Orders />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileIndex;
|
||||||
61
resources/js/pages/Profile/Orders.tsx
Normal file
61
resources/js/pages/Profile/Orders.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { usePage } from '@inertiajs/react';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface Purchase {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
package: {
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfileOrders = () => {
|
||||||
|
const { purchases } = usePage().props as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bestellungen</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Paket</TableHead>
|
||||||
|
<TableHead>Preis</TableHead>
|
||||||
|
<TableHead>Datum</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{purchases.map((purchase) => (
|
||||||
|
<TableRow key={purchase.id}>
|
||||||
|
<TableCell>{purchase.package.name}</TableCell>
|
||||||
|
<TableCell>{purchase.package.price} €</TableCell>
|
||||||
|
<TableCell>{format(new Date(purchase.created_at), 'dd.MM.yyyy')}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={purchase.status === 'completed' ? 'default' : 'secondary'}>
|
||||||
|
{purchase.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{purchases.length === 0 && (
|
||||||
|
<p className="text-center text-muted-foreground py-8">Keine Bestellungen gefunden.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileOrders;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react';
|
import { FormEvent, useEffect, useState } from 'react';
|
||||||
import { Head, useForm } from '@inertiajs/react';
|
import { Head, useForm } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import InputError from '@/components/input-error';
|
import InputError from '@/components/input-error';
|
||||||
import TextLink from '@/components/text-link';
|
import TextLink from '@/components/text-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -18,6 +19,7 @@ interface LoginProps {
|
|||||||
|
|
||||||
export default function Login({ status, canResetPassword }: LoginProps) {
|
export default function Login({ status, canResetPassword }: LoginProps) {
|
||||||
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
|
|
||||||
const { data, setData, post, processing, errors, clearErrors } = useForm({
|
const { data, setData, post, processing, errors, clearErrors } = useForm({
|
||||||
email: '',
|
email: '',
|
||||||
@@ -52,13 +54,13 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
|||||||
}, [errors, hasTriedSubmit]);
|
}, [errors, hasTriedSubmit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout title="Log in to your account" description="Enter your email and password below to log in">
|
<AuthLayout title={t('login.title')} description={t('login.description')}>
|
||||||
<Head title="Log in" />
|
<Head title={t('login.title')} />
|
||||||
|
|
||||||
<form onSubmit={submit} className="flex flex-col gap-6">
|
<form onSubmit={submit} className="flex flex-col gap-6">
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">Email address</Label>
|
<Label htmlFor="email">{t('login.email')}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -67,7 +69,7 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
|||||||
autoFocus
|
autoFocus
|
||||||
tabIndex={1}
|
tabIndex={1}
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
placeholder="email@example.com"
|
placeholder={t('login.email_placeholder')}
|
||||||
value={data.email}
|
value={data.email}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setData('email', e.target.value);
|
setData('email', e.target.value);
|
||||||
@@ -81,10 +83,10 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
|||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">{t('login.password')}</Label>
|
||||||
{canResetPassword && (
|
{canResetPassword && (
|
||||||
<TextLink href={request()} className="ml-auto text-sm" tabIndex={5}>
|
<TextLink href={request()} className="ml-auto text-sm" tabIndex={5}>
|
||||||
Forgot password?
|
{t('login.forgot')}
|
||||||
</TextLink>
|
</TextLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +97,7 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
|||||||
required
|
required
|
||||||
tabIndex={2}
|
tabIndex={2}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
placeholder="Password"
|
placeholder={t('login.password_placeholder')}
|
||||||
value={data.password}
|
value={data.password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setData('password', e.target.value);
|
setData('password', e.target.value);
|
||||||
@@ -115,19 +117,19 @@ export default function Login({ status, canResetPassword }: LoginProps) {
|
|||||||
checked={data.remember}
|
checked={data.remember}
|
||||||
onCheckedChange={(checked) => setData('remember', Boolean(checked))}
|
onCheckedChange={(checked) => setData('remember', Boolean(checked))}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="remember">Remember me</Label>
|
<Label htmlFor="remember">{t('login.remember')}</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="mt-4 w-full" tabIndex={4} disabled={processing}>
|
<Button type="submit" className="mt-4 w-full" tabIndex={4} disabled={processing}>
|
||||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
{processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
|
||||||
Log in
|
{t('login.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
Don't have an account?{' '}
|
{t('login.no_account')}{' '}
|
||||||
<TextLink href={register()} tabIndex={5}>
|
<TextLink href={register()} tabIndex={5}>
|
||||||
Sign up
|
{t('login.sign_up')}
|
||||||
</TextLink>
|
</TextLink>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LoaderCircle, User, Mail, Phone, Lock, Home, MapPin } from 'lucide-react';
|
import { LoaderCircle, User, Mail, Phone, Lock, Home, MapPin } from 'lucide-react';
|
||||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
|||||||
export default function Register({ package: initialPackage, privacyHtml }: RegisterProps) {
|
export default function Register({ package: initialPackage, privacyHtml }: RegisterProps) {
|
||||||
const [privacyOpen, setPrivacyOpen] = useState(false);
|
const [privacyOpen, setPrivacyOpen] = useState(false);
|
||||||
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
|
|
||||||
const { data, setData, post, processing, errors, clearErrors } = useForm({
|
const { data, setData, post, processing, errors, clearErrors } = useForm({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -60,23 +62,23 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
}, [errors, hasTriedSubmit]);
|
}, [errors, hasTriedSubmit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title="Registrieren">
|
<MarketingLayout title={t('register.title')}>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-4xl w-full space-y-8">
|
<div className="max-w-4xl w-full space-y-8">
|
||||||
<div className="bg-white rounded-lg shadow-md p-8">
|
<div className="bg-white rounded-lg shadow-md p-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mt-6 text-center text-3xl font-bold text-gray-900 font-display">
|
<h2 className="mt-6 text-center text-3xl font-bold text-gray-900 font-display">
|
||||||
Willkommen bei Fotospiel – Erstellen Sie Ihren Account
|
{t('register.welcome')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-4 text-center text-gray-600 font-sans-marketing">
|
<p className="mt-4 text-center text-gray-600 font-sans-marketing">
|
||||||
Registrierung ermöglicht Zugriff auf Events, Galerien und personalisierte Features.
|
{t('register.description')}
|
||||||
</p>
|
</p>
|
||||||
{initialPackage && (
|
{initialPackage && (
|
||||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
<h3 className="text-lg font-semibold text-blue-900 mb-2">{initialPackage.name}</h3>
|
<h3 className="text-lg font-semibold text-blue-900 mb-2">{initialPackage.name}</h3>
|
||||||
<p className="text-blue-800 mb-2">{initialPackage.description}</p>
|
<p className="text-blue-800 mb-2">{initialPackage.description}</p>
|
||||||
<p className="text-sm text-blue-700">
|
<p className="text-sm text-blue-700">
|
||||||
{initialPackage.price === 0 ? 'Kostenlos' : `${initialPackage.price} €`}
|
{initialPackage.price === 0 ? t('register.package_price_free') : t('register.package_price', { price: initialPackage.price })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -85,7 +87,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Vorname *
|
{t('register.first_name')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
@@ -102,7 +104,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.first_name ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.first_name ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
placeholder="Vorname"
|
placeholder={t('register.first_name_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.first_name && <p key={`error-first_name`} className="text-sm text-red-600 mt-1">{errors.first_name}</p>}
|
{errors.first_name && <p key={`error-first_name`} className="text-sm text-red-600 mt-1">{errors.first_name}</p>}
|
||||||
@@ -110,7 +112,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
|
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Nachname *
|
{t('register.last_name')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
@@ -127,7 +129,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.last_name ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.last_name ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
placeholder="Nachname"
|
placeholder={t('register.last_name_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.last_name && <p key={`error-last_name`} className="text-sm text-red-600 mt-1">{errors.last_name}</p>}
|
{errors.last_name && <p key={`error-last_name`} className="text-sm text-red-600 mt-1">{errors.last_name}</p>}
|
||||||
@@ -135,7 +137,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
E-Mail-Adresse *
|
{t('register.email')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
@@ -152,7 +154,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
placeholder="email@example.com"
|
placeholder={t('register.email_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.email && <p key={`error-email`} className="text-sm text-red-600 mt-1">{errors.email}</p>}
|
{errors.email && <p key={`error-email`} className="text-sm text-red-600 mt-1">{errors.email}</p>}
|
||||||
@@ -160,7 +162,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
|
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Adresse *
|
{t('register.address')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
@@ -177,7 +179,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.address ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.address ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
placeholder="Adresse"
|
placeholder={t('register.address_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.address && <p key={`error-address`} className="text-sm text-red-600 mt-1">{errors.address}</p>}
|
{errors.address && <p key={`error-address`} className="text-sm text-red-600 mt-1">{errors.address}</p>}
|
||||||
@@ -185,7 +187,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
|
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Telefon *
|
{t('register.phone')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
@@ -202,7 +204,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.phone ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.phone ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
placeholder="Telefonnummer"
|
placeholder={t('register.phone_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.phone && <p key={`error-phone`} className="text-sm text-red-600 mt-1">{errors.phone}</p>}
|
{errors.phone && <p key={`error-phone`} className="text-sm text-red-600 mt-1">{errors.phone}</p>}
|
||||||
@@ -210,7 +212,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
|
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Benutzername *
|
{t('register.username')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
@@ -227,7 +229,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.username ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.username ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
placeholder="Benutzername"
|
placeholder={t('register.username_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.username && <p key={`error-username`} className="text-sm text-red-600 mt-1">{errors.username}</p>}
|
{errors.username && <p key={`error-username`} className="text-sm text-red-600 mt-1">{errors.username}</p>}
|
||||||
@@ -235,7 +237,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
|
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Passwort *
|
{t('register.password')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
@@ -255,7 +257,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
placeholder="Passwort"
|
placeholder={t('register.password_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.password && <p key={`error-password`} className="text-sm text-red-600 mt-1">{errors.password}</p>}
|
{errors.password && <p key={`error-password`} className="text-sm text-red-600 mt-1">{errors.password}</p>}
|
||||||
@@ -263,7 +265,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
|
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Passwort bestätigen *
|
{t('register.confirm_password')} *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
|
||||||
@@ -283,7 +285,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
|
className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.password_confirmation ? 'border-red-500' : 'border-gray-300'}`}
|
||||||
placeholder="Passwort bestätigen"
|
placeholder={t('register.confirm_password_placeholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.password_confirmation && <p key={`error-password_confirmation`} className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
|
{errors.password_confirmation && <p key={`error-password_confirmation`} className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
|
||||||
@@ -305,15 +307,14 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 rounded"
|
className="h-4 w-4 text-[#FFB6C1] focus:ring-[#FFB6C1] border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="privacy_consent" className="ml-2 block text-sm text-gray-900">
|
<label htmlFor="privacy_consent" className="ml-2 block text-sm text-gray-900">
|
||||||
Ich stimme der{' '}
|
{t('register.privacy_consent')}{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPrivacyOpen(true)}
|
onClick={() => setPrivacyOpen(true)}
|
||||||
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
|
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
|
||||||
>
|
>
|
||||||
Datenschutzerklärung
|
{t('register.privacy_policy')}
|
||||||
</button>{' '}
|
</button>.
|
||||||
zu.
|
|
||||||
</label>
|
</label>
|
||||||
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
|
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -321,7 +322,7 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
|
|
||||||
{Object.keys(errors).length > 0 && (
|
{Object.keys(errors).length > 0 && (
|
||||||
<div key={`general-errors-${Object.keys(errors).join('-')}`} className="p-4 bg-red-50 border border-red-200 rounded-md mb-6">
|
<div key={`general-errors-${Object.keys(errors).join('-')}`} className="p-4 bg-red-50 border border-red-200 rounded-md mb-6">
|
||||||
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler bei der Registrierung:</h4>
|
<h4 className="text-sm font-medium text-red-800 mb-2">{t('register.errors_title')}</h4>
|
||||||
<ul className="text-sm text-red-800 space-y-1">
|
<ul className="text-sm text-red-800 space-y-1">
|
||||||
{Object.entries(errors).map(([key, value]) => (
|
{Object.entries(errors).map(([key, value]) => (
|
||||||
<li key={key} className="flex items-start">
|
<li key={key} className="flex items-start">
|
||||||
@@ -338,14 +339,14 @@ export default function Register({ package: initialPackage, privacyHtml }: Regis
|
|||||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-[#FFB6C1] hover:bg-[#FF69B4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FFB6C1] transition duration-300 disabled:opacity-50"
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-[#FFB6C1] hover:bg-[#FF69B4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#FFB6C1] transition duration-300 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{processing && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
|
{processing && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
|
||||||
Account erstellen
|
{t('register.submit')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Bereits registriert?{' '}
|
{t('register.has_account')}{' '}
|
||||||
<a href="/login" className="font-medium text-[#FFB6C1] hover:text-[#FF69B4]">
|
<a href="/login" className="font-medium text-[#FFB6C1] hover:text-[#FF69B4]">
|
||||||
Anmelden
|
{t('register.login')}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Head, Link, usePage } from '@inertiajs/react';
|
import { Head, Link, usePage } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,6 +14,7 @@ interface Props {
|
|||||||
|
|
||||||
const Blog: React.FC<Props> = ({ posts }) => {
|
const Blog: React.FC<Props> = ({ posts }) => {
|
||||||
const { url } = usePage();
|
const { url } = usePage();
|
||||||
|
const { t } = useTranslation('marketing');
|
||||||
|
|
||||||
const renderPagination = () => {
|
const renderPagination = () => {
|
||||||
if (!posts.links || posts.links.length <= 3) return null;
|
if (!posts.links || posts.links.length <= 3) return null;
|
||||||
@@ -37,28 +39,28 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title="Blog - Fotospiel">
|
<MarketingLayout title={t('blog.title')}>
|
||||||
<Head title="Blog - Fotospiel" />
|
<Head title={t('blog.title')} />
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="bg-aurora-enhanced text-white py-20 px-4">
|
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4">
|
||||||
<div className="container mx-auto text-center">
|
<div className="container mx-auto text-center">
|
||||||
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">Unser Blog</h1>
|
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('blog.hero_title')}</h1>
|
||||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing">Tipps, Tricks und Inspiration für perfekte Event-Fotos.</p>
|
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing">{t('blog.hero_description')}</p>
|
||||||
<Link href="#posts" className="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing hover:bg-gray-100 transition">
|
<Link href="#posts" className="bg-white dark:bg-gray-800 text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing hover:bg-gray-100 dark:hover:bg-gray-700 transition">
|
||||||
Zum Blog
|
{t('blog.hero_cta')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Posts Section */}
|
{/* Posts Section */}
|
||||||
<section id="posts" className="py-20 px-4 bg-white">
|
<section id="posts" className="py-20 px-4 bg-white dark:bg-gray-900">
|
||||||
<div className="container mx-auto max-w-4xl">
|
<div className="container mx-auto max-w-4xl">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Neueste Beiträge</h2>
|
<h2 className="text-3xl font-bold text-center mb-12 font-display text-gray-900 dark:text-gray-100">{t('blog.posts_title')}</h2>
|
||||||
{posts.data.length > 0 ? (
|
{posts.data.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
{posts.data.map((post) => (
|
{posts.data.map((post) => (
|
||||||
<div key={post.id} className="bg-gray-50 p-6 rounded-lg">
|
<div key={post.id} className="bg-gray-50 dark:bg-gray-800 p-6 rounded-lg">
|
||||||
{post.featured_image && (
|
{post.featured_image && (
|
||||||
<img
|
<img
|
||||||
src={post.featured_image}
|
src={post.featured_image}
|
||||||
@@ -66,20 +68,20 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
|||||||
className="w-full h-48 object-cover rounded mb-4"
|
className="w-full h-48 object-cover rounded mb-4"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">
|
<h3 className="text-xl font-semibold mb-2 font-sans-marketing text-gray-900 dark:text-gray-100">
|
||||||
<Link href={`/blog/${post.slug}`} className="hover:text-[#FFB6C1]">
|
<Link href={`/blog/${post.slug}`} className="hover:text-[#FFB6C1]">
|
||||||
{post.title}
|
{post.title}
|
||||||
</Link>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-4 text-gray-700 font-serif-custom">{post.excerpt}</p>
|
<p className="mb-4 text-gray-700 dark:text-gray-300 font-serif-custom">{post.excerpt}</p>
|
||||||
<p className="text-sm text-gray-500 mb-4 font-sans-marketing">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 font-sans-marketing">
|
||||||
Veröffentlicht am {post.published_at}
|
{t('blog.by')} {post.author?.name || t('blog.team')} | {t('blog.published_at')} {post.published_at}
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href={`/blog/${post.slug}`}
|
href={`/blog/${post.slug}`}
|
||||||
className="text-[#FFB6C1] font-semibold font-sans-marketing hover:underline"
|
className="text-[#FFB6C1] font-semibold font-sans-marketing hover:underline"
|
||||||
>
|
>
|
||||||
Weiterlesen
|
{t('blog.read_more')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -87,7 +89,7 @@ const Blog: React.FC<Props> = ({ posts }) => {
|
|||||||
{renderPagination()}
|
{renderPagination()}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-center text-gray-600 font-serif-custom">Keine Beiträge verfügbar.</p>
|
<p className="text-center text-gray-600 dark:text-gray-400 font-serif-custom">{t('blog.empty')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Head, Link, usePage } from '@inertiajs/react';
|
import { Head, Link, usePage } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,15 +17,17 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BlogShow: React.FC<Props> = ({ post }) => {
|
const BlogShow: React.FC<Props> = ({ post }) => {
|
||||||
|
const { t } = useTranslation('blog_show');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title={`${post.title} - Fotospiel Blog`}>
|
<MarketingLayout title={`${post.title} ${t('title_suffix')}`}>
|
||||||
<Head title={`${post.title} - Fotospiel Blog`} />
|
<Head title={`${post.title} ${t('title_suffix')}`} />
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] text-white py-20 px-4">
|
<section className="bg-gradient-to-r from-[#FFB6C1] via-[#FFD700] to-[#87CEEB] text-white py-20 px-4">
|
||||||
<div className="container mx-auto text-center">
|
<div className="container mx-auto text-center">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.title}</h1>
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.title}</h1>
|
||||||
<p className="text-lg mb-8">
|
<p className="text-lg mb-8">
|
||||||
Von {post.author?.name || 'Dem Fotospiel Team'} | {new Date(post.published_at).toLocaleDateString('de-DE')}
|
{t('by_author')} {post.author?.name || t('team')} | {t('published_on')} {new Date(post.published_at).toLocaleDateString('de-DE')}
|
||||||
</p>
|
</p>
|
||||||
{post.featured_image && (
|
{post.featured_image && (
|
||||||
<img
|
<img
|
||||||
@@ -50,7 +53,7 @@ const BlogShow: React.FC<Props> = ({ post }) => {
|
|||||||
href="/blog"
|
href="/blog"
|
||||||
className="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#FF69B4] transition"
|
className="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#FF69B4] transition"
|
||||||
>
|
>
|
||||||
Zurück zum Blog
|
{t('back_to_blog')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Head, Link, useForm } from '@inertiajs/react';
|
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
||||||
import { Package } from '@/types'; // Annahme: Typ für Package
|
|
||||||
|
interface Package {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
packages: Package[];
|
packages: Package[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home: React.FC<Props> = ({ packages }) => {
|
const Home: React.FC<Props> = ({ packages }) => {
|
||||||
|
const { t } = useTranslation('marketing');
|
||||||
const { data, setData, post, processing, errors, reset } = useForm({
|
const { data, setData, post, processing, errors, reset } = useForm({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -21,237 +29,203 @@ const Home: React.FC<Props> = ({ packages }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [errors]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title="Home - Fotospiel">
|
<MarketingLayout title={t('home.title')}>
|
||||||
<Head title="Fotospiel - Event-Fotos einfach und sicher mit QR-Codes" />
|
<Head title={t('home.hero_title')} />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section id="hero" className="bg-aurora-enhanced text-white py-20 px-4">
|
<section id="hero" className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4">
|
||||||
<div className="container mx-auto flex flex-col md:flex-row items-center gap-8 max-w-6xl">
|
<div className="container mx-auto flex flex-col md:flex-row items-center gap-8 max-w-6xl">
|
||||||
<div className="md:w-1/2 text-center md:text-left">
|
<div className="md:w-1/2 text-center md:text-left">
|
||||||
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">Fotospiel</h1>
|
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('home.hero_title')}</h1>
|
||||||
<p className="text-xl md:text-2xl mb-8 font-sans-marketing">Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.</p>
|
<p className="text-xl md:text-2xl mb-8 font-sans-marketing">{t('home.hero_description')}</p>
|
||||||
<Link href="/packages" className="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition font-sans-marketing">
|
<Link
|
||||||
Jetzt starten – Kostenlos
|
href="/packages"
|
||||||
|
className="bg-white dark:bg-gray-800 text-[#FFB6C1] px-8 py-4 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-300 inline-block"
|
||||||
|
>
|
||||||
|
{t('home.cta_explore')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:w-1/2">
|
<div className="md:w-1/2">
|
||||||
<img
|
<img
|
||||||
src="https://images.unsplash.com/photo-1511285560929-80b456fea0bc?w=600&h=400&fit=crop"
|
src="/images/hero-image.jpg"
|
||||||
alt="Event-Fotos mit QR"
|
alt={t('home.hero_image_alt')}
|
||||||
className="rounded-lg shadow-lg w-full"
|
className="w-full h-auto rounded-lg shadow-lg"
|
||||||
style={{ filter: 'drop-shadow(0 10px 8px rgba(0,0,0,0.1))' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* How it works Section */}
|
{/* How it Works Section */}
|
||||||
<section id="how-it-works" className="py-20 px-4 bg-gray-50">
|
<section className="py-20 px-4 bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto max-w-6xl">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">So funktioniert es – in 4 einfachen Schritten mit QR-Codes</h2>
|
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.how_title')}</h2>
|
||||||
<div className="grid md:grid-cols-4 gap-8">
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<img
|
<div className="w-16 h-16 bg-[#FFB6C1] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
src="https://images.unsplash.com/photo-1558618047-3c8d6b4d3b0a?w=300&h=200&fit=crop"
|
<span className="text-2xl">1</span>
|
||||||
alt="QR-Code generieren"
|
</div>
|
||||||
className="w-12 h-12 mx-auto mb-4 rounded-full"
|
<h3 className="text-xl font-semibold mb-2">{t('home.step1_title')}</h3>
|
||||||
/>
|
<p>{t('home.step1_desc')}</p>
|
||||||
<h3 className="font-semibold mb-2 font-sans-marketing">Event erstellen & QR generieren</h3>
|
|
||||||
<p className="text-gray-600 font-serif-custom">Als Organisator: Registrieren, Event anlegen, QR-Code erstellen und drucken/teilen.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<img
|
<div className="w-16 h-16 bg-[#FFB6C1] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
src="https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=300&h=200&fit=crop"
|
<span className="text-2xl">2</span>
|
||||||
alt="Fotos hochladen"
|
</div>
|
||||||
className="w-12 h-12 mx-auto mb-4 rounded-full"
|
<h3 className="text-xl font-semibold mb-2">{t('home.step2_title')}</h3>
|
||||||
/>
|
<p>{t('home.step2_desc')}</p>
|
||||||
<h3 className="font-semibold mb-2 font-sans-marketing">Fotos hochladen via QR</h3>
|
|
||||||
<p className="text-gray-600 font-serif-custom">Gäste: QR scannen, PWA öffnen, Fotos via Kamera oder Galerie teilen.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<img
|
<div className="w-16 h-16 bg-[#FFB6C1] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
src="https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=300&h=200&fit=crop"
|
<span className="text-2xl">3</span>
|
||||||
alt="Freigaben & Likes"
|
|
||||||
className="w-12 h-12 mx-auto mb-4 rounded-full"
|
|
||||||
/>
|
|
||||||
<h3 className="font-semibold mb-2 font-sans-marketing">Freigaben & Likes</h3>
|
|
||||||
<p className="text-gray-600 font-serif-custom">Emotions auswählen, Fotos liken, Galerie browsen – alles anonym.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<h3 className="text-xl font-semibold mb-2">{t('home.step3_title')}</h3>
|
||||||
<img
|
<p>{t('home.step3_desc')}</p>
|
||||||
src="https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=300&h=200&fit=crop"
|
|
||||||
alt="Download & Teilen"
|
|
||||||
className="w-12 h-12 mx-auto mb-4 rounded-full"
|
|
||||||
/>
|
|
||||||
<h3 className="font-semibold mb-2 font-sans-marketing">Download & Teilen</h3>
|
|
||||||
<p className="text-gray-600 font-serif-custom">Freigegebene Fotos herunterladen, Event abschließen und QR archivieren.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<section id="features" className="py-20 px-4 bg-white">
|
<section className="py-20 px-4 dark:bg-gray-700">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto max-w-6xl">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Warum Fotospiel mit QR?</h2>
|
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.features_title')}</h2>
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
<div className="text-center p-6">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
<img
|
<h3 className="text-xl font-semibold mb-2">{t('home.feature1_title')}</h3>
|
||||||
src="https://images.unsplash.com/photo-1560472354-b33ff0c44a43?w=400&h=250&fit=crop"
|
<p>{t('home.feature1_desc')}</p>
|
||||||
alt="Sichere QR-Uploads"
|
|
||||||
className="w-16 h-16 mx-auto mb-4 rounded-full"
|
|
||||||
/>
|
|
||||||
<h3 className="text-xl font-semibold mb-2 font-display">Sichere QR-Uploads</h3>
|
|
||||||
<p className="text-gray-600 font-serif-custom">GDPR-konform, anonyme Sessions, QR-basierte Zugriffe ohne PII-Speicherung.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-6">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
<img
|
<h3 className="text-xl font-semibold mb-2">{t('home.feature2_title')}</h3>
|
||||||
src="https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=400&h=250&fit=crop"
|
<p>{t('home.feature2_desc')}</p>
|
||||||
alt="Mobile PWA & QR"
|
|
||||||
className="w-16 h-16 mx-auto mb-4 rounded-full"
|
|
||||||
/>
|
|
||||||
<h3 className="text-xl font-semibold mb-2 font-display">Mobile PWA & QR</h3>
|
|
||||||
<p className="text-gray-600 font-serif-custom">Offline-fähig, App-ähnlich für iOS/Android, QR-Scan für schnellen Einstieg.</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center p-6">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
<img
|
<h3 className="text-xl font-semibold mb-2">{t('home.feature3_title')}</h3>
|
||||||
src="https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=400&h=250&fit=crop"
|
<p>{t('home.feature3_desc')}</p>
|
||||||
alt="Schnell & Einfach"
|
|
||||||
className="w-16 h-16 mx-auto mb-4 rounded-full"
|
|
||||||
/>
|
|
||||||
<h3 className="text-xl font-semibold mb-2 font-display">Schnell & Einfach mit QR</h3>
|
|
||||||
<p className="text-gray-600 font-serif-custom">Automatische Thumbnails, Echtzeit-Updates, QR-Sharing für Gäste.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Packages Teaser Section */}
|
{/* Packages Teaser */}
|
||||||
<section id="pricing" className="py-20 px-4 bg-gray-50">
|
<section className="py-20 px-4 bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto max-w-6xl">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Unsere Packages</h2>
|
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.packages_title')}</h2>
|
||||||
<p className="text-center text-lg text-gray-600 mb-8 font-sans-marketing">Wählen Sie das passende Paket für Ihr Event – von kostenlos bis premium.</p>
|
<div className="grid md:grid-cols-2 gap-8 mb-8">
|
||||||
|
{packages.slice(0, 2).map((pkg) => (
|
||||||
|
<div key={pkg.id} className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md text-center">
|
||||||
|
<h3 className="text-2xl font-bold mb-2">{pkg.name}</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300 mb-4">{pkg.description}</p>
|
||||||
|
<p className="text-3xl font-bold text-[#FFB6C1]">{pkg.price} {t('home.currency.euro')}</p>
|
||||||
|
<Link href={`/packages/${pkg.id}`} className="mt-4 inline-block bg-[#FFB6C1] text-white px-6 py-2 rounded-full hover:bg-pink-600">
|
||||||
|
{t('home.view_details')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Link href="/packages" className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-semibold text-lg hover:bg-[#FF69B4] transition font-sans-marketing">
|
<Link href="/packages" className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
|
||||||
Alle Packages ansehen
|
{t('home.all_packages')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Contact Section */}
|
{/* Contact Section */}
|
||||||
<section id="contact" className="py-20 px-4 bg-white">
|
<section id="contact" className="py-20 px-4 dark:bg-gray-700">
|
||||||
<div className="container mx-auto max-w-2xl">
|
<div className="container mx-auto max-w-4xl">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Kontakt</h2>
|
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.contact_title')}</h2>
|
||||||
<form key={`home-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium mb-2 font-sans-marketing">Name</label>
|
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||||
|
{t('home.name_label')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
value={data.name}
|
value={data.name}
|
||||||
onChange={(e) => setData('name', e.target.value)}
|
onChange={(e) => setData('name', e.target.value)}
|
||||||
required
|
className="w-full p-3 border rounded-lg"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]"
|
|
||||||
/>
|
/>
|
||||||
{errors.name && <p key={`error-name`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.name}</p>}
|
{errors.name && <p className="text-red-500 text-sm">{errors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium mb-2 font-sans-marketing">E-Mail</label>
|
<label htmlFor="email" className="block text-sm font-medium mb-2">
|
||||||
|
{t('home.email_label')}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
value={data.email}
|
value={data.email}
|
||||||
onChange={(e) => setData('email', e.target.value)}
|
onChange={(e) => setData('email', e.target.value)}
|
||||||
required
|
className="w-full p-3 border rounded-lg"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]"
|
|
||||||
/>
|
/>
|
||||||
{errors.email && <p key={`error-email`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.email}</p>}
|
{errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="message" className="block text-sm font-medium mb-2 font-sans-marketing">Nachricht</label>
|
<label htmlFor="message" className="block text-sm font-medium mb-2">
|
||||||
|
{t('home.message_label')}
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
|
rows={4}
|
||||||
value={data.message}
|
value={data.message}
|
||||||
onChange={(e) => setData('message', e.target.value)}
|
onChange={(e) => setData('message', e.target.value)}
|
||||||
rows={4}
|
className="w-full p-3 border rounded-lg"
|
||||||
required
|
/>
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]"
|
{errors.message && <p className="text-red-500 text-sm">{errors.message}</p>}
|
||||||
></textarea>
|
|
||||||
{errors.message && <p key={`error-message`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.message}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={processing} className="w-full bg-[#FFB6C1] text-white py-3 rounded-md font-semibold hover:bg-[#FF69B4] transition disabled:opacity-50 font-sans-marketing">
|
<button
|
||||||
{processing ? 'Sendet...' : 'Senden'}
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-bold hover:bg-pink-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{processing ? t('home.sending') : t('home.send')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{Object.keys(errors).length === 0 && data.message && !processing && (
|
|
||||||
<p className="mt-4 text-green-600 text-center font-serif-custom">Nachricht gesendet!</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (Object.keys(errors).length > 0) {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, [errors]);
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Testimonials Section */}
|
{/* Testimonials Section */}
|
||||||
<section className="py-20 px-4 bg-gray-50">
|
<section className="py-20 px-4 bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto max-w-6xl">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Was unsere Kunden sagen</h2>
|
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.testimonials_title')}</h2>
|
||||||
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
<div className="bg-white p-6 rounded-lg">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
<p className="mb-4 font-serif-custom">"Perfekt für unsere Hochzeit! QR-Sharing war super einfach."</p>
|
<p className="italic mb-4">"{t('home.testimonial1')}"</p>
|
||||||
<p className="font-semibold font-sans-marketing">- Anna & Max</p>
|
<p className="font-semibold">- Anna M.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-6 rounded-lg">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
<p className="mb-4 font-serif-custom">"Großes Firmenevent – alle Fotos zentral via QR."</p>
|
<p className="italic mb-4">"{t('home.testimonial2')}"</p>
|
||||||
<p className="font-semibold font-sans-marketing">- Team XYZ GmbH</p>
|
<p className="font-semibold">- Max S.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
|
<p className="italic mb-4">"{t('home.testimonial3')}"</p>
|
||||||
|
<p className="font-semibold">- Lisa K.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ Section */}
|
{/* FAQ Section */}
|
||||||
<section className="py-20 px-4 bg-white">
|
<section className="py-20 px-4 dark:bg-gray-700">
|
||||||
<div className="container mx-auto max-w-3xl">
|
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Häufige Fragen</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold font-display">Ist es kostenlos?</h3>
|
|
||||||
<p className="font-serif-custom">Ja, der Basic-Tarif ist kostenlos für 1 Event mit QR. Upgrades ab 99€.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold font-display">Datenschutz?</h3>
|
|
||||||
<p className="font-serif-custom">100% GDPR-konform. Keine personenbezogenen Daten gespeichert. QR-Zugriffe anonym. Siehe <Link href="/datenschutz" className="text-[#FFB6C1]">Datenschutzerklärung</Link>.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-50 p-4 rounded-lg">
|
|
||||||
<h3 className="font-semibold font-display">Wie funktioniert QR-Sharing?</h3>
|
|
||||||
<p className="font-serif-custom">Generiere QR im Dashboard, teile es – Gäste scannen, laden Fotos hoch in der PWA.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Packages Section (aus aktuellem TSX, angepasst) */}
|
|
||||||
<section className="py-20 px-4 bg-white">
|
|
||||||
<div className="container mx-auto max-w-4xl">
|
<div className="container mx-auto max-w-4xl">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Unsere Pakete</h2>
|
<h2 className="text-3xl md:text-4xl font-bold text-center mb-12 font-display">{t('home.faq_title')}</h2>
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div className="space-y-4">
|
||||||
{packages.map((pkg) => (
|
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||||
<div key={pkg.id} className="bg-gray-50 p-6 rounded-lg text-center">
|
<h3 className="font-semibold">{t('home.faq1_q')}</h3>
|
||||||
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
|
<p>{t('home.faq1_a')}</p>
|
||||||
<p className="text-gray-600 mb-4 font-serif-custom">{pkg.description}</p>
|
</div>
|
||||||
<p className="text-2xl font-bold text-[#FFB6C1] mb-4 font-display">€{pkg.price}</p>
|
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||||
<Link
|
<h3 className="font-semibold">{t('home.faq2_q')}</h3>
|
||||||
href={`/marketing/buy/${pkg.id}`}
|
<p>{t('home.faq2_a')}</p>
|
||||||
className="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition font-sans-marketing"
|
|
||||||
>
|
|
||||||
Kaufen
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
import { Head, Link, useForm, usePage } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
||||||
|
|
||||||
const Kontakt: React.FC = () => {
|
const Kontakt: React.FC = () => {
|
||||||
@@ -10,6 +11,7 @@ const Kontakt: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { flash } = usePage().props as any;
|
const { flash } = usePage().props as any;
|
||||||
|
const { t } = useTranslation('marketing');
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -18,72 +20,72 @@ const Kontakt: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [errors]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title="Kontakt - Fotospiel">
|
<MarketingLayout title={t('kontakt.title')}>
|
||||||
<Head title="Kontakt - Fotospiel" />
|
<Head title={t('kontakt.title')} />
|
||||||
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<h1 className="text-3xl font-bold text-center mb-8 font-display">Kontakt</h1>
|
<h1 className="text-3xl font-bold text-center mb-8 font-display text-gray-900 dark:text-gray-100">{t('kontakt.title')}</h1>
|
||||||
<p className="text-center text-gray-600 mb-8 font-sans-marketing">Haben Sie Fragen? Schreiben Sie uns!</p>
|
<p className="text-center text-gray-600 dark:text-gray-300 mb-8 font-sans-marketing">{t('kontakt.description')}</p>
|
||||||
<form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
|
<form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2 font-sans-marketing">Name</label>
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-sans-marketing">{t('kontakt.name')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
value={data.name}
|
value={data.name}
|
||||||
onChange={(e) => setData('name', e.target.value)}
|
onChange={(e) => setData('name', e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
{errors.name && <p key={`error-name`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.name}</p>}
|
{errors.name && <p key={`error-name`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2 font-sans-marketing">E-Mail</label>
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-sans-marketing">{t('kontakt.email')}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
value={data.email}
|
value={data.email}
|
||||||
onChange={(e) => setData('email', e.target.value)}
|
onChange={(e) => setData('email', e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
{errors.email && <p key={`error-email`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.email}</p>}
|
{errors.email && <p key={`error-email`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2 font-sans-marketing">Nachricht</label>
|
<label htmlFor="message" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-sans-marketing">{t('kontakt.message')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
value={data.message}
|
value={data.message}
|
||||||
onChange={(e) => setData('message', e.target.value)}
|
onChange={(e) => setData('message', e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||||
></textarea>
|
></textarea>
|
||||||
{errors.message && <p key={`error-message`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.message}</p>}
|
{errors.message && <p key={`error-message`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={processing} className="w-full bg-[#FFB6C1] text-white py-3 rounded-md font-semibold hover:bg-[#FF69B4] transition disabled:opacity-50 font-sans-marketing">
|
<button type="submit" disabled={processing} className="w-full bg-[#FFB6C1] text-white py-3 rounded-md font-semibold hover:bg-[#FF69B4] transition disabled:opacity-50 font-sans-marketing">
|
||||||
{processing ? 'Sendet...' : 'Senden'}
|
{processing ? t('kontakt.sending') : t('kontakt.send')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{flash?.success && <p className="mt-4 text-green-600 text-center font-serif-custom">{flash.success}</p>}
|
{flash?.success && <p className="mt-4 text-green-600 dark:text-green-400 text-center font-serif-custom">{flash.success}</p>}
|
||||||
{Object.keys(errors).length > 0 && (
|
{Object.keys(errors).length > 0 && (
|
||||||
<div key={`general-errors-${Object.keys(errors).join('-')}`} className="mt-4 p-4 bg-red-100 border border-red-400 rounded-md">
|
<div key={`general-errors-${Object.keys(errors).join('-')}`} className="mt-4 p-4 bg-red-100 dark:bg-red-900/20 border border-red-400 dark:border-red-600 rounded-md">
|
||||||
<ul className="list-disc list-inside">
|
<ul className="list-disc list-inside">
|
||||||
{Object.values(errors).map((error, index) => (
|
{Object.values(errors).map((error, index) => (
|
||||||
<li key={`error-${index}`} className="font-serif-custom">{error}</li>
|
<li key={`error-${index}`} className="font-serif-custom text-red-700 dark:text-red-300">{error}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (Object.keys(errors).length > 0) {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, [errors]);
|
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<Link href="/" className="text-[#FFB6C1] hover:underline font-sans-marketing">Zurück zur Startseite</Link>
|
<Link href="/" className="text-[#FFB6C1] hover:underline font-sans-marketing">{t('kontakt.back_home')}</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,83 +1,77 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, usePage } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
||||||
|
|
||||||
interface Props {
|
interface OccasionsProps {
|
||||||
type: string;
|
type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Occasions: React.FC<Props> = ({ type }) => {
|
const Occasions: React.FC<OccasionsProps> = ({ type }) => {
|
||||||
const occasions = {
|
const { t } = useTranslation('marketing');
|
||||||
weddings: {
|
|
||||||
title: 'Hochzeiten',
|
const occasionsContent = {
|
||||||
description: 'Erfangen Sie die magischen Momente Ihrer Hochzeit mit professionellen Fotos.',
|
hochzeit: {
|
||||||
features: ['Unbegrenzte Fotos', 'Sofort-Download', 'Privat-Event-Code', 'Emotionen tracken'],
|
title: t('occasions.weddings.title'),
|
||||||
image: '/images/wedding-lights-background.svg' // Platzhalter
|
description: t('occasions.weddings.description'),
|
||||||
|
features: [
|
||||||
|
t('occasions.weddings.benefit1'),
|
||||||
|
t('occasions.weddings.benefit2'),
|
||||||
|
t('occasions.weddings.benefit3'),
|
||||||
|
t('occasions.weddings.benefit4'),
|
||||||
|
],
|
||||||
|
cta: t('occasions.cta'),
|
||||||
},
|
},
|
||||||
birthdays: {
|
geburtstag: {
|
||||||
title: 'Geburtstage',
|
title: t('occasions.birthdays.title'),
|
||||||
description: 'Feiern Sie Geburtstage unvergesslich mit unseren Event-Foto-Lösungen.',
|
description: t('occasions.birthdays.description'),
|
||||||
features: ['Schnelle Einrichtung', 'Gäste teilen Fotos', 'Themen-Filter', 'Druck-Optionen'],
|
features: [
|
||||||
image: '/images/birthday-placeholder.jpg'
|
t('occasions.birthdays.benefit1'),
|
||||||
|
t('occasions.birthdays.benefit2'),
|
||||||
|
t('occasions.birthdays.benefit3'),
|
||||||
|
t('occasions.birthdays.benefit4'),
|
||||||
|
],
|
||||||
|
cta: t('occasions.cta'),
|
||||||
},
|
},
|
||||||
'corporate-events': {
|
firmenevent: {
|
||||||
title: 'Firmenevents',
|
title: t('occasions.corporate.title'),
|
||||||
description: 'Professionelle Fotos für Teamevents, Konferenzen und Unternehmensfeiern.',
|
description: t('occasions.corporate.description'),
|
||||||
features: ['Branding-Integration', 'Sichere Cloud-Speicher', 'Analytics & Reports', 'Schnelle Bearbeitung'],
|
features: [
|
||||||
image: '/images/corporate-placeholder.jpg'
|
t('occasions.corporate.benefit1'),
|
||||||
|
t('occasions.corporate.benefit2'),
|
||||||
|
t('occasions.corporate.benefit3'),
|
||||||
|
t('occasions.corporate.benefit4'),
|
||||||
|
],
|
||||||
|
cta: t('occasions.cta'),
|
||||||
},
|
},
|
||||||
'family-celebrations': {
|
|
||||||
title: 'Familienfeiern',
|
|
||||||
description: 'Erinnerungen an Taufen, Jubiläen und Familienzusammenkünfte festhalten.',
|
|
||||||
features: ['Persönliche Alben', 'Gemeinsame Zugriffe', 'Einfache Bedienung', 'Hohe Qualität'],
|
|
||||||
image: '/images/family-placeholder.jpg'
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const occasion = occasions[type as keyof typeof occasions] || occasions.weddings;
|
const content = occasionsContent[type as keyof typeof occasionsContent] || occasionsContent.hochzeit;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title={`${occasion.title} - Fotospiel`}>
|
<MarketingLayout title={content.title}>
|
||||||
<Head title={`${occasion.title} - Fotospiel`} />
|
<Head title={content.title} />
|
||||||
{/* Hero Section */}
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-20 px-4">
|
||||||
<section className="bg-aurora-enhanced text-white py-20 px-4">
|
|
||||||
<div className="container mx-auto text-center">
|
|
||||||
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{occasion.title}</h1>
|
|
||||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing">{occasion.description}</p>
|
|
||||||
{occasion.image && (
|
|
||||||
<img
|
|
||||||
src={occasion.image}
|
|
||||||
alt={occasion.title}
|
|
||||||
className="mx-auto rounded-lg shadow-lg max-w-4xl w-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Features Section */}
|
|
||||||
<section className="py-20 px-4 bg-white">
|
|
||||||
<div className="container mx-auto max-w-4xl">
|
<div className="container mx-auto max-w-4xl">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Warum Fotospiel für {occasion.title}?</h2>
|
<div className="text-center mb-12">
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-gray-100 mb-6 font-display">{content.title}</h1>
|
||||||
{occasion.features.map((feature, index) => (
|
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 font-sans-marketing">{content.description}</p>
|
||||||
<div key={index} className="bg-gray-50 p-6 rounded-lg flex items-center">
|
<a href="/packages" className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
|
||||||
<div className="w-8 h-8 bg-[#FFB6C1] rounded-full flex items-center justify-center mr-4">
|
{content.cta}
|
||||||
<span className="text-white text-sm font-bold font-sans-marketing">✓</span>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-700 font-serif-custom">{feature}</p>
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
{content.features.map((feature, index) => (
|
||||||
|
<div key={index} className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md text-center">
|
||||||
|
<div className="w-12 h-12 bg-[#FFB6C1] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span className="text-white font-bold">{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2 text-gray-900 dark:text-gray-100">{feature}</h3>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center mt-12">
|
|
||||||
<Link
|
|
||||||
href="/marketing/packages"
|
|
||||||
className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing hover:bg-[#FF69B4] transition"
|
|
||||||
>
|
|
||||||
Passendes Paket wählen
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</MarketingLayout>
|
</MarketingLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Head, Link, usePage } from '@inertiajs/react';
|
import { Head, Link, usePage } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
|
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
@@ -40,11 +41,12 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
const [currentStep, setCurrentStep] = useState('step1');
|
const [currentStep, setCurrentStep] = useState('step1');
|
||||||
const { props } = usePage();
|
const { props } = usePage();
|
||||||
const { auth } = props as any;
|
const { auth } = props as any;
|
||||||
|
const { t } = useTranslation('marketing');
|
||||||
|
|
||||||
const testimonials = [
|
const testimonials = [
|
||||||
{ name: 'Anna M.', text: 'Das Starter-Paket war perfekt für unsere Hochzeit – einfach und günstig!', rating: 5 },
|
{ name: 'Anna M.', text: t('packages.testimonials.anna'), rating: 5 },
|
||||||
{ name: 'Max B.', text: 'Pro-Paket mit Analytics hat uns geholfen, die besten Momente zu finden.', rating: 5 },
|
{ name: 'Max B.', text: t('packages.testimonials.max'), rating: 5 },
|
||||||
{ name: 'Lisa K.', text: 'Als Reseller spare ich Zeit mit dem M-Paket – super Support!', rating: 5 },
|
{ name: 'Lisa K.', text: t('packages.testimonials.lisa'), rating: 5 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const allPackages = [...endcustomerPackages, ...resellerPackages];
|
const allPackages = [...endcustomerPackages, ...resellerPackages];
|
||||||
@@ -72,21 +74,21 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title="Packages">
|
<MarketingLayout title={t('packages.title')}>
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="bg-aurora-enhanced text-white py-20 px-4">
|
<section className="bg-aurora-enhanced text-gray-900 dark:text-gray-100 py-20 px-4">
|
||||||
<div className="container mx-auto text-center">
|
<div className="container mx-auto text-center">
|
||||||
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">Unsere Packages</h1>
|
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('packages.hero_title')}</h1>
|
||||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing">Wählen Sie das passende Paket für Ihr Event – von kostenlos bis premium.</p>
|
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto font-sans-marketing">{t('packages.hero_description')}</p>
|
||||||
<Link href="#endcustomer" className="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing hover:bg-gray-100 transition">
|
<Link href="#endcustomer" className="bg-white dark:bg-gray-800 text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg font-sans-marketing hover:bg-gray-100 dark:hover:bg-gray-700 transition">
|
||||||
Jetzt entdecken
|
{t('packages.cta_explore')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="endcustomer" className="py-20 px-4">
|
<section id="endcustomer" className="py-20 px-4 dark:bg-gray-600">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Für Endkunden</h2>
|
<h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_endcustomer')}</h2>
|
||||||
|
|
||||||
{/* Mobile Carousel for Endcustomer Packages */}
|
{/* Mobile Carousel for Endcustomer Packages */}
|
||||||
<div className="block md:hidden">
|
<div className="block md:hidden">
|
||||||
@@ -95,35 +97,35 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<CarouselItem key={pkg.id} className="pl-1 basis-full">
|
<CarouselItem key={pkg.id} className="pl-1 basis-full">
|
||||||
<div
|
<div
|
||||||
className="bg-white p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
|
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
|
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
|
||||||
<p className="text-gray-600 mb-4 font-serif-custom">{pkg.description}</p>
|
<p className="text-gray-600 dark:text-gray-300 mb-4 font-serif-custom">{pkg.description}</p>
|
||||||
<p className="text-2xl font-bold text-[#FFB6C1] mb-4 font-sans-marketing">
|
<p className="text-2xl font-bold text-[#FFB6C1] mb-4 font-sans-marketing">
|
||||||
{pkg.price === 0 ? 'Kostenlos' : `${pkg.price} €`}
|
{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-gray-600 mb-6 space-y-1 font-sans-marketing">
|
<ul className="text-sm text-gray-600 dark:text-gray-300 mb-6 space-y-1 font-sans-marketing">
|
||||||
<li>• {pkg.events} Events</li>
|
<li>• {pkg.events} {t('packages.one_time')}</li>
|
||||||
{pkg.features.map((feature, index) => (
|
{pkg.features.map((feature, index) => (
|
||||||
<li key={index} className="flex items-center">
|
<li key={index} className="flex items-center">
|
||||||
{getFeatureIcon(feature)} {feature}
|
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{pkg.limits?.max_photos && <li>• Max. {pkg.limits.max_photos} Fotos</li>}
|
{pkg.limits?.max_photos && <li>• {t('packages.max_photos')} {pkg.limits.max_photos}</li>}
|
||||||
{pkg.limits?.gallery_days && <li>• Galerie {pkg.limits.gallery_days} Tage</li>}
|
{pkg.limits?.gallery_days && <li>• {t('packages.gallery_days')} {pkg.limits.gallery_days}</li>}
|
||||||
{pkg.limits?.max_guests && <li>• Max. {pkg.limits.max_guests} Gäste</li>}
|
{pkg.limits?.max_guests && <li>• {t('packages.max_guests')} {pkg.limits.max_guests}</li>}
|
||||||
{pkg.watermark_allowed === false && <li><Badge variant="secondary">Kein Watermark</Badge></li>}
|
{pkg.watermark_allowed === false && <li><Badge variant="secondary">{t('packages.no_watermark')}</Badge></li>}
|
||||||
{pkg.branding_allowed && <li><Badge variant="secondary">Custom Branding</Badge></li>}
|
{pkg.branding_allowed && <li><Badge variant="secondary">{t('packages.custom_branding')}</Badge></li>}
|
||||||
</ul>
|
</ul>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleCardClick(pkg)}
|
onClick={() => handleCardClick(pkg)}
|
||||||
className="w-full mt-4 font-sans-marketing"
|
className="w-full mt-4 font-sans-marketing"
|
||||||
>
|
>
|
||||||
Details anzeigen
|
{t('packages.view_details')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
@@ -140,35 +142,35 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<div
|
<div
|
||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
className="bg-white p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
|
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
|
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
|
||||||
<p className="text-gray-600 mb-4 font-serif-custom">{pkg.description}</p>
|
<p className="text-gray-600 dark:text-gray-300 mb-4 font-serif-custom">{pkg.description}</p>
|
||||||
<p className="text-2xl font-bold text-[#FFB6C1] mb-4 font-sans-marketing">
|
<p className="text-2xl font-bold text-[#FFB6C1] mb-4 font-sans-marketing">
|
||||||
{pkg.price === 0 ? 'Kostenlos' : `${pkg.price} €`}
|
{pkg.price === 0 ? t('packages.free') : `${pkg.price} ${t('packages.currency.euro')}`}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-gray-600 mb-6 space-y-1 font-sans-marketing">
|
<ul className="text-sm text-gray-600 dark:text-gray-300 mb-6 space-y-1 font-sans-marketing">
|
||||||
<li>• {pkg.events} Events</li>
|
<li>• {pkg.events} {t('packages.one_time')}</li>
|
||||||
{pkg.features.map((feature, index) => (
|
{pkg.features.map((feature, index) => (
|
||||||
<li key={index} className="flex items-center">
|
<li key={index} className="flex items-center">
|
||||||
{getFeatureIcon(feature)} {feature}
|
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{pkg.limits?.max_photos && <li>• Max. {pkg.limits.max_photos} Fotos</li>}
|
{pkg.limits?.max_photos && <li>• {t('packages.max_photos')} {pkg.limits.max_photos}</li>}
|
||||||
{pkg.limits?.gallery_days && <li>• Galerie {pkg.limits.gallery_days} Tage</li>}
|
{pkg.limits?.gallery_days && <li>• {t('packages.gallery_days')} {pkg.limits.gallery_days}</li>}
|
||||||
{pkg.limits?.max_guests && <li>• Max. {pkg.limits.max_guests} Gäste</li>}
|
{pkg.limits?.max_guests && <li>• {t('packages.max_guests')} {pkg.limits.max_guests}</li>}
|
||||||
{pkg.watermark_allowed === false && <li><Badge variant="secondary">Kein Watermark</Badge></li>}
|
{pkg.watermark_allowed === false && <li><Badge variant="secondary">{t('packages.no_watermark')}</Badge></li>}
|
||||||
{pkg.branding_allowed && <li><Badge variant="secondary">Custom Branding</Badge></li>}
|
{pkg.branding_allowed && <li><Badge variant="secondary">{t('packages.custom_branding')}</Badge></li>}
|
||||||
</ul>
|
</ul>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleCardClick(pkg)}
|
onClick={() => handleCardClick(pkg)}
|
||||||
className="w-full mt-4 font-sans-marketing"
|
className="w-full mt-4 font-sans-marketing"
|
||||||
>
|
>
|
||||||
Details anzeigen
|
{t('packages.view_details')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -178,63 +180,63 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
|
|
||||||
{/* Comparison Section for Endcustomer */}
|
{/* Comparison Section for Endcustomer */}
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h3 className="text-2xl font-bold text-center mb-6 font-display">Endkunden-Pakete vergleichen</h3>
|
<h3 className="text-2xl font-bold text-center mb-6 font-display">{t('packages.comparison_title')}</h3>
|
||||||
<div className="block md:hidden">
|
<div className="block md:hidden">
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full">
|
||||||
<AccordionItem value="price">
|
<AccordionItem value="price">
|
||||||
<AccordionTrigger className="font-sans-marketing">Preis</AccordionTrigger>
|
<AccordionTrigger className="font-sans-marketing">{t('packages.price')}</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className="grid grid-cols-3 gap-4 p-4">
|
<div className="grid grid-cols-3 gap-4 p-4">
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<div key={pkg.id} className="text-center">
|
<div key={pkg.id} className="text-center">
|
||||||
<p className="font-bold">{pkg.name}</p>
|
<p className="font-bold">{pkg.name}</p>
|
||||||
<p>{pkg.price === 0 ? 'Kostenlos' : `${pkg.price} €`}</p>
|
<p>{pkg.price === 0 ? t('free') : `${pkg.price} ${t('currency.euro')}`}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
<AccordionItem value="max-photos">
|
<AccordionItem value="max-photos">
|
||||||
<AccordionTrigger className="font-sans-marketing">Max. Fotos {getFeatureIcon('max_photos')}</AccordionTrigger>
|
<AccordionTrigger className="font-sans-marketing">{t('packages.max_photos_label')} {getFeatureIcon('max_photos')}</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className="grid grid-cols-3 gap-4 p-4">
|
<div className="grid grid-cols-3 gap-4 p-4">
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<div key={pkg.id} className="text-center">
|
<div key={pkg.id} className="text-center">
|
||||||
<p className="font-bold">{pkg.name}</p>
|
<p className="font-bold">{pkg.name}</p>
|
||||||
<p>{pkg.limits?.max_photos || 'Unbegrenzt'}</p>
|
<p>{pkg.limits?.max_photos || t('unlimited')}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
<AccordionItem value="max-guests">
|
<AccordionItem value="max-guests">
|
||||||
<AccordionTrigger className="font-sans-marketing">Max. Gäste {getFeatureIcon('max_guests')}</AccordionTrigger>
|
<AccordionTrigger className="font-sans-marketing">{t('packages.max_guests_label')} {getFeatureIcon('max_guests')}</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className="grid grid-cols-3 gap-4 p-4">
|
<div className="grid grid-cols-3 gap-4 p-4">
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<div key={pkg.id} className="text-center">
|
<div key={pkg.id} className="text-center">
|
||||||
<p className="font-bold">{pkg.name}</p>
|
<p className="font-bold">{pkg.name}</p>
|
||||||
<p>{pkg.limits?.max_guests || 'Unbegrenzt'}</p>
|
<p>{pkg.limits?.max_guests || t('unlimited')}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
<AccordionItem value="gallery-days">
|
<AccordionItem value="gallery-days">
|
||||||
<AccordionTrigger className="font-sans-marketing">Galerie Tage {getFeatureIcon('gallery_days')}</AccordionTrigger>
|
<AccordionTrigger className="font-sans-marketing">{t('packages.gallery_days_label')} {getFeatureIcon('gallery_days')}</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className="grid grid-cols-3 gap-4 p-4">
|
<div className="grid grid-cols-3 gap-4 p-4">
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<div key={pkg.id} className="text-center">
|
<div key={pkg.id} className="text-center">
|
||||||
<p className="font-bold">{pkg.name}</p>
|
<p className="font-bold">{pkg.name}</p>
|
||||||
<p>{pkg.limits?.gallery_days || 'Unbegrenzt'}</p>
|
<p>{pkg.limits?.gallery_days || t('unlimited')}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
<AccordionItem value="watermark">
|
<AccordionItem value="watermark">
|
||||||
<AccordionTrigger className="font-sans-marketing">Watermark {getFeatureIcon('no_watermark')}</AccordionTrigger>
|
<AccordionTrigger className="font-sans-marketing">{t('packages.watermark_label')} {getFeatureIcon('no_watermark')}</AccordionTrigger>
|
||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className="grid grid-cols-3 gap-4 p-4">
|
<div className="grid grid-cols-3 gap-4 p-4">
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
@@ -252,7 +254,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Feature</TableHead>
|
<TableHead>{t('packages.feature')}</TableHead>
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<TableHead key={pkg.id} className="text-center">
|
<TableHead key={pkg.id} className="text-center">
|
||||||
{pkg.name}
|
{pkg.name}
|
||||||
@@ -262,39 +264,39 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-semibold">Preis</TableCell>
|
<TableCell className="font-semibold">{t('packages.price')}</TableCell>
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<TableCell key={pkg.id} className="text-center">
|
<TableCell key={pkg.id} className="text-center">
|
||||||
{pkg.price === 0 ? 'Kostenlos' : `${pkg.price} €`}
|
{pkg.price === 0 ? t('free') : `${pkg.price} ${t('currency.euro')}`}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-semibold">Max. Fotos {getFeatureIcon('max_photos')}</TableCell>
|
<TableCell className="font-semibold">{t('packages.max_photos_label')} {getFeatureIcon('max_photos')}</TableCell>
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<TableCell key={pkg.id} className="text-center">
|
<TableCell key={pkg.id} className="text-center">
|
||||||
{pkg.limits?.max_photos || 'Unbegrenzt'}
|
{pkg.limits?.max_photos || t('unlimited')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-semibold">Max. Gäste {getFeatureIcon('max_guests')}</TableCell>
|
<TableCell className="font-semibold">{t('packages.max_guests_label')} {getFeatureIcon('max_guests')}</TableCell>
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<TableCell key={pkg.id} className="text-center">
|
<TableCell key={pkg.id} className="text-center">
|
||||||
{pkg.limits?.max_guests || 'Unbegrenzt'}
|
{pkg.limits?.max_guests || t('unlimited')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-semibold">Galerie Tage {getFeatureIcon('gallery_days')}</TableCell>
|
<TableCell className="font-semibold">{t('packages.gallery_days_label')} {getFeatureIcon('gallery_days')}</TableCell>
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<TableCell key={pkg.id} className="text-center">
|
<TableCell key={pkg.id} className="text-center">
|
||||||
{pkg.limits?.gallery_days || 'Unbegrenzt'}
|
{pkg.limits?.gallery_days || t('unlimited')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-semibold">Watermark {getFeatureIcon('no_watermark')}</TableCell>
|
<TableCell className="font-semibold">{t('packages.watermark_label')} {getFeatureIcon('no_watermark')}</TableCell>
|
||||||
{endcustomerPackages.map((pkg) => (
|
{endcustomerPackages.map((pkg) => (
|
||||||
<TableCell key={pkg.id} className="text-center">
|
<TableCell key={pkg.id} className="text-center">
|
||||||
{pkg.watermark_allowed === false ? <Check className="w-4 h-4 text-green-500" /> : <X className="w-4 h-4 text-red-500" />}
|
{pkg.watermark_allowed === false ? <Check className="w-4 h-4 text-green-500" /> : <X className="w-4 h-4 text-red-500" />}
|
||||||
@@ -307,9 +309,9 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="py-20 px-4 bg-gray-50">
|
<section className="py-20 px-4 bg-gray-50 dark:bg-gray-900">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Für Reseller</h2>
|
<h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.section_reseller')}</h2>
|
||||||
|
|
||||||
{/* Mobile Carousel for Reseller Packages */}
|
{/* Mobile Carousel for Reseller Packages */}
|
||||||
<div className="block md:hidden">
|
<div className="block md:hidden">
|
||||||
@@ -318,32 +320,32 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
{resellerPackages.map((pkg) => (
|
{resellerPackages.map((pkg) => (
|
||||||
<CarouselItem key={pkg.id} className="pl-1 basis-full">
|
<CarouselItem key={pkg.id} className="pl-1 basis-full">
|
||||||
<div
|
<div
|
||||||
className="bg-white p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />
|
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
|
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
|
||||||
<p className="text-gray-600 mb-4 font-serif-custom">{pkg.description}</p>
|
<p className="text-gray-600 dark:text-gray-300 mb-4 font-serif-custom">{pkg.description}</p>
|
||||||
<p className="text-2xl font-bold text-[#FFD700] mb-4 font-sans-marketing">
|
<p className="text-2xl font-bold text-[#FFD700] mb-4 font-sans-marketing">
|
||||||
{pkg.price} € / Jahr
|
{pkg.price} {t('packages.currency.euro')} / {t('packages.year')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-gray-600 mb-6 space-y-1 font-sans-marketing">
|
<ul className="text-sm text-gray-600 dark:text-gray-300 mb-6 space-y-1 font-sans-marketing">
|
||||||
{pkg.features.map((feature, index) => (
|
{pkg.features.map((feature, index) => (
|
||||||
<li key={index} className="flex items-center">
|
<li key={index} className="flex items-center">
|
||||||
{getFeatureIcon(feature)} {feature}
|
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{pkg.limits?.max_tenants && <li>• Max. {pkg.limits.max_tenants} Tenants</li>}
|
{pkg.limits?.max_tenants && <li>• {t('packages.max_tenants')} {pkg.limits.max_tenants}</li>}
|
||||||
{pkg.limits?.max_events && <li>• Max. {pkg.limits.max_events} Events/Jahr</li>}
|
{pkg.limits?.max_events && <li>• {t('packages.max_events_year')} {pkg.limits.max_events}</li>}
|
||||||
{pkg.branding_allowed && <li><Badge variant="secondary">Custom Branding</Badge></li>}
|
{pkg.branding_allowed && <li><Badge variant="secondary">{t('packages.custom_branding')}</Badge></li>}
|
||||||
</ul>
|
</ul>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleCardClick(pkg)}
|
onClick={() => handleCardClick(pkg)}
|
||||||
className="w-full mt-4 font-sans-marketing"
|
className="w-full mt-4 font-sans-marketing"
|
||||||
>
|
>
|
||||||
Details anzeigen
|
{t('packages.view_details')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CarouselItem>
|
</CarouselItem>
|
||||||
@@ -360,32 +362,32 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
{resellerPackages.map((pkg) => (
|
{resellerPackages.map((pkg) => (
|
||||||
<div
|
<div
|
||||||
key={pkg.id}
|
key={pkg.id}
|
||||||
className="bg-white p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
|
||||||
>
|
>
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />
|
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
|
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">{pkg.name}</h3>
|
||||||
<p className="text-gray-600 mb-4 font-serif-custom">{pkg.description}</p>
|
<p className="text-gray-600 dark:text-gray-300 mb-4 font-serif-custom">{pkg.description}</p>
|
||||||
<p className="text-2xl font-bold text-[#FFD700] mb-4 font-sans-marketing">
|
<p className="text-2xl font-bold text-[#FFD700] mb-4 font-sans-marketing">
|
||||||
{pkg.price} € / Jahr
|
{pkg.price} {t('packages.currency.euro')} / {t('packages.year')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-gray-600 mb-6 space-y-1 font-sans-marketing">
|
<ul className="text-sm text-gray-600 dark:text-gray-300 mb-6 space-y-1 font-sans-marketing">
|
||||||
{pkg.features.map((feature, index) => (
|
{pkg.features.map((feature, index) => (
|
||||||
<li key={index} className="flex items-center">
|
<li key={index} className="flex items-center">
|
||||||
{getFeatureIcon(feature)} {feature}
|
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{pkg.limits?.max_tenants && <li>• Max. {pkg.limits.max_tenants} Tenants</li>}
|
{pkg.limits?.max_tenants && <li>• {t('packages.max_tenants')} {pkg.limits.max_tenants}</li>}
|
||||||
{pkg.limits?.max_events && <li>• Max. {pkg.limits.max_events} Events/Jahr</li>}
|
{pkg.limits?.max_events && <li>• {t('packages.max_events_year')} {pkg.limits.max_events}</li>}
|
||||||
{pkg.branding_allowed && <li><Badge variant="secondary">Custom Branding</Badge></li>}
|
{pkg.branding_allowed && <li><Badge variant="secondary">{t('packages.custom_branding')}</Badge></li>}
|
||||||
</ul>
|
</ul>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleCardClick(pkg)}
|
onClick={() => handleCardClick(pkg)}
|
||||||
className="w-full mt-4 font-sans-marketing"
|
className="w-full mt-4 font-sans-marketing"
|
||||||
>
|
>
|
||||||
Details anzeigen
|
{t('packages.view_details')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -395,25 +397,25 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ Section */}
|
{/* FAQ Section */}
|
||||||
<section className="py-20 px-4">
|
<section className="py-20 px-4 dark:bg-gray-700">
|
||||||
<div className="container mx-auto">
|
<div className="container mx-auto">
|
||||||
<h2 className="text-3xl font-bold text-center mb-12 font-display">Häufige Fragen</h2>
|
<h2 className="text-3xl font-bold text-center mb-12 font-display">{t('packages.faq_title')}</h2>
|
||||||
<div className="grid md:grid-cols-2 gap-8">
|
<div className="grid md:grid-cols-2 gap-8">
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
<h3 className="text-xl font-semibold mb-2 font-display">Was ist das Free-Paket?</h3>
|
<h3 className="text-xl font-semibold mb-2 font-display">{t('packages.faq_free')}</h3>
|
||||||
<p className="text-gray-600 font-sans-marketing">Ideal für Tests: 30 Fotos, 7 Tage Galerie, mit Watermark.</p>
|
<p className="text-gray-600 dark:text-gray-300 font-sans-marketing">{t('packages.faq_free_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
<h3 className="text-xl font-semibold mb-2 font-display">Kann ich upgraden?</h3>
|
<h3 className="text-xl font-semibold mb-2 font-display">{t('packages.faq_upgrade')}</h3>
|
||||||
<p className="text-gray-600 font-sans-marketing">Ja, jederzeit im Dashboard – Limits werden sofort erweitert.</p>
|
<p className="text-gray-600 dark:text-gray-300 font-sans-marketing">{t('packages.faq_upgrade_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
<h3 className="text-xl font-semibold mb-2 font-display">Was für Reseller?</h3>
|
<h3 className="text-xl font-semibold mb-2 font-display">{t('packages.faq_reseller')}</h3>
|
||||||
<p className="text-gray-600 font-sans-marketing">Jährliche Subscriptions mit Dashboard, Branding und Support.</p>
|
<p className="text-gray-600 dark:text-gray-300 font-sans-marketing">{t('packages.faq_reseller_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-6 rounded-lg shadow-md">
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||||
<h3 className="text-xl font-semibold mb-2 font-display">Zahlungssicher?</h3>
|
<h3 className="text-xl font-semibold mb-2 font-display">{t('packages.faq_payment')}</h3>
|
||||||
<p className="text-gray-600 font-sans-marketing">Sichere Zahlung via Stripe/PayPal, 14 Tage Rückgaberecht.</p>
|
<p className="text-gray-600 dark:text-gray-300 font-sans-marketing">{t('packages.faq_payment_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,51 +426,51 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-2xl font-display">{selectedPackage.name} - Details</DialogTitle>
|
<DialogTitle className="text-2xl font-display">{selectedPackage.name} - {t('packages.details')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs value={currentStep} onValueChange={setCurrentStep} className="w-full">
|
<Tabs value={currentStep} onValueChange={setCurrentStep} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="step1">Details</TabsTrigger>
|
<TabsTrigger value="step1">{t('packages.details')}</TabsTrigger>
|
||||||
<TabsTrigger value="step2">Kundenmeinungen</TabsTrigger>
|
<TabsTrigger value="step2">{t('packages.customer_opinions')}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="step1" className="mt-4">
|
<TabsContent value="step1" className="mt-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-3xl font-bold font-display">{selectedPackage.name}</h2>
|
<h2 className="text-3xl font-bold font-display">{selectedPackage.name}</h2>
|
||||||
<p className="text-2xl font-bold text-[#FFB6C1] mt-2">
|
<p className="text-2xl font-bold text-[#FFB6C1] mt-2">
|
||||||
{selectedPackage.price === 0 ? 'Kostenlos' : `${selectedPackage.price} €`}
|
{selectedPackage.price === 0 ? t('packages.free') : `${selectedPackage.price} ${t('packages.currency.euro')}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 font-sans-marketing">{selectedPackage.description}</p>
|
<p className="text-gray-600 dark:text-gray-300 font-sans-marketing">{selectedPackage.description}</p>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{selectedPackage.features.map((feature, index) => (
|
{selectedPackage.features.map((feature, index) => (
|
||||||
<Badge key={`feature-${index}`} variant="secondary" className="flex items-center justify-center gap-1">
|
<Badge key={`feature-${index}`} variant="secondary" className="flex items-center justify-center gap-1">
|
||||||
{getFeatureIcon(feature)} {feature}
|
{getFeatureIcon(feature)} {t(`packages.feature_${feature}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{selectedPackage.limits?.max_photos && (
|
{selectedPackage.limits?.max_photos && (
|
||||||
<Badge variant="outline" className="flex items-center justify-center gap-1">
|
<Badge variant="outline" className="flex items-center justify-center gap-1">
|
||||||
<Image className="w-4 h-4" /> Max. {selectedPackage.limits.max_photos} Fotos
|
<Image className="w-4 h-4" /> {t('packages.max_photos')} {selectedPackage.limits.max_photos}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{selectedPackage.limits?.max_guests && (
|
{selectedPackage.limits?.max_guests && (
|
||||||
<Badge variant="outline" className="flex items-center justify-center gap-1">
|
<Badge variant="outline" className="flex items-center justify-center gap-1">
|
||||||
<Users className="w-4 h-4" /> Max. {selectedPackage.limits.max_guests} Gäste
|
<Users className="w-4 h-4" /> {t('packages.max_guests')} {selectedPackage.limits.max_guests}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{selectedPackage.limits?.gallery_days && (
|
{selectedPackage.limits?.gallery_days && (
|
||||||
<Badge variant="outline" className="flex items-center justify-center gap-1">
|
<Badge variant="outline" className="flex items-center justify-center gap-1">
|
||||||
<Calendar className="w-4 h-4" /> {selectedPackage.limits.gallery_days} Tage Galerie
|
<Calendar className="w-4 h-4" /> {t('packages.gallery_days')} {selectedPackage.limits.gallery_days}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{selectedPackage.watermark_allowed === false && (
|
{selectedPackage.watermark_allowed === false && (
|
||||||
<Badge variant="secondary" className="flex items-center justify-center gap-1">
|
<Badge variant="secondary" className="flex items-center justify-center gap-1">
|
||||||
<Shield className="w-4 h-4" /> Kein Watermark
|
<Shield className="w-4 h-4" /> {t('packages.no_watermark')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{selectedPackage.branding_allowed && (
|
{selectedPackage.branding_allowed && (
|
||||||
<Badge variant="secondary" className="flex items-center justify-center gap-1">
|
<Badge variant="secondary" className="flex items-center justify-center gap-1">
|
||||||
<Image className="w-4 h-4" /> Custom Branding
|
<Image className="w-4 h-4" /> {t('packages.custom_branding')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -478,7 +480,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
href={`/buy-packages/${selectedPackage.id}`}
|
href={`/buy-packages/${selectedPackage.id}`}
|
||||||
className="w-full block bg-[#FFB6C1] text-white py-3 rounded-md font-semibold font-sans-marketing hover:bg-[#FF69B4] transition text-center"
|
className="w-full block bg-[#FFB6C1] text-white py-3 rounded-md font-semibold font-sans-marketing hover:bg-[#FF69B4] transition text-center"
|
||||||
>
|
>
|
||||||
Zur Bestellung
|
{t('packages.to_order')}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
@@ -488,7 +490,7 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
|
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Zur Bestellung
|
{t('packages.to_order')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -496,11 +498,11 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="step2" className="mt-4">
|
<TabsContent value="step2" className="mt-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-xl font-semibold mb-4 font-display">Was Kunden sagen</h3>
|
<h3 className="text-xl font-semibold mb-4 font-display">{t('packages.what_customers_say')}</h3>
|
||||||
<div className="grid md:grid-cols-3 gap-4">
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
{testimonials.map((testimonial, index) => (
|
{testimonials.map((testimonial, index) => (
|
||||||
<div key={index} className="bg-white p-4 rounded-lg shadow-md">
|
<div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md">
|
||||||
<p className="text-gray-600 font-sans-marketing mb-2">"{testimonial.text}"</p>
|
<p className="text-gray-600 dark:text-gray-300 font-sans-marketing mb-2">"{testimonial.text}"</p>
|
||||||
<p className="font-semibold font-sans-marketing">{testimonial.name}</p>
|
<p className="font-semibold font-sans-marketing">{testimonial.name}</p>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{[...Array(testimonial.rating)].map((_, i) => <Star key={i} className="w-4 h-4 text-yellow-400 fill-current" />)}
|
{[...Array(testimonial.rating)].map((_, i) => <Star key={i} className="w-4 h-4 text-yellow-400 fill-current" />)}
|
||||||
@@ -508,8 +510,8 @@ const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackag
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setOpen(false)} className="w-full mt-4 text-gray-500 underline">
|
<button onClick={() => setOpen(false)} className="w-full mt-4 text-gray-500 dark:text-gray-400 underline">
|
||||||
Schließen
|
{t('packages.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { usePage, router } from '@inertiajs/react';
|
import { usePage, router } from '@inertiajs/react';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
|
||||||
import { Loader } from 'lucide-react';
|
import { Loader } from 'lucide-react';
|
||||||
|
|
||||||
const Success: React.FC = () => {
|
const Success: React.FC = () => {
|
||||||
const { auth, flash } = usePage().props as any;
|
const { auth, flash } = usePage().props as any;
|
||||||
|
const { t } = useTranslation('success');
|
||||||
|
|
||||||
if (auth.user && auth.user.email_verified_at) {
|
if (auth.user && auth.user.email_verified_at) {
|
||||||
// Redirect to admin
|
// Redirect to admin
|
||||||
@@ -14,7 +16,7 @@ const Success: React.FC = () => {
|
|||||||
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
<div className="flex items-center justify-center min-h-screen bg-gray-50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader className="animate-spin inline-block w-8 h-8 border border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-2" />
|
<Loader className="animate-spin inline-block w-8 h-8 border border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-2" />
|
||||||
<p className="text-gray-600">Wird weitergeleitet...</p>
|
<p className="text-gray-600">{t('redirecting')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -22,26 +24,26 @@ const Success: React.FC = () => {
|
|||||||
|
|
||||||
if (auth.user && !auth.user.email_verified_at) {
|
if (auth.user && !auth.user.email_verified_at) {
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title="E-Mail verifizieren">
|
<MarketingLayout title={t('verify_email')}>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
E-Mail verifizieren
|
{t('verify_email')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Bitte überprüfen Sie Ihre E-Mail und klicken Sie auf den Verifizierungslink.
|
{t('check_email')}
|
||||||
</p>
|
</p>
|
||||||
<form method="POST" action="/email/verification-notification">
|
<form method="POST" action="/email/verification-notification">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300"
|
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300"
|
||||||
>
|
>
|
||||||
Verifizierung erneut senden
|
{t('resend_verification')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p className="mt-4 text-sm text-gray-600">
|
<p className="mt-4 text-sm text-gray-600">
|
||||||
Bereits registriert? <a href="/login" className="text-blue-600 hover:text-blue-500">Anmelden</a>
|
{t('already_registered')} <a href="/login" className="text-blue-600 hover:text-blue-500">{t('login')}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,24 +53,24 @@ const Success: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MarketingLayout title="Kauf abschließen">
|
<MarketingLayout title={t('complete_purchase')}>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
<div className="max-w-md w-full bg-white rounded-lg shadow-md p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
Kauf abschließen
|
{t('complete_purchase')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-6">
|
<p className="text-gray-600 mb-6">
|
||||||
Melden Sie sich an, um fortzufahren.
|
{t('login_to_continue')}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="/login"
|
href="/login"
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300 block mb-2"
|
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300 block mb-2"
|
||||||
>
|
>
|
||||||
Anmelden
|
{t('login')}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Kein Konto? <a href="/register" className="text-blue-600 hover:text-blue-500">Registrieren</a>
|
{t('no_account')} <a href="/register" className="text-blue-600 hover:text-blue-500">{t('register')}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
243
resources/lang/de/marketing.json
Normal file
243
resources/lang/de/marketing.json
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
{
|
||||||
|
"home": {
|
||||||
|
"title": "Startseite - Fotospiel",
|
||||||
|
"hero_title": "Fotospiel",
|
||||||
|
"hero_description": "Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.",
|
||||||
|
"cta_explore": "Pakete entdecken",
|
||||||
|
"hero_image_alt": "Event-Fotos mit QR-Code",
|
||||||
|
"how_title": "So funktioniert es",
|
||||||
|
"step1_title": "Paket wählen",
|
||||||
|
"step1_desc": "Wähle das passende Paket für dein Event.",
|
||||||
|
"step2_title": "QR-Code teilen",
|
||||||
|
"step2_desc": "Teile den QR-Code mit deinen Gästen.",
|
||||||
|
"step3_title": "Fotos sammeln",
|
||||||
|
"step3_desc": "Gäste laden Fotos hoch – sicher und einfach.",
|
||||||
|
"features_title": "Warum Fotospiel?",
|
||||||
|
"feature1_title": "Sicher & Datenschutzkonform",
|
||||||
|
"feature1_desc": "GDPR-konform, keine PII-Speicherung.",
|
||||||
|
"feature2_title": "Mobil & PWA",
|
||||||
|
"feature2_desc": "Funktioniert offline, installierbar wie App.",
|
||||||
|
"feature3_title": "Einfach zu bedienen",
|
||||||
|
"feature3_desc": "Intuitive UI für Gäste und Organisatoren.",
|
||||||
|
"packages_title": "Unsere Pakete",
|
||||||
|
"view_details": "Details ansehen",
|
||||||
|
"all_packages": "Alle Pakete ansehen",
|
||||||
|
"contact_title": "Kontakt",
|
||||||
|
"name_label": "Name",
|
||||||
|
"email_label": "E-Mail",
|
||||||
|
"message_label": "Nachricht",
|
||||||
|
"sending": "Wird gesendet...",
|
||||||
|
"send": "Senden",
|
||||||
|
"testimonials_title": "Was unsere Kunden sagen",
|
||||||
|
"testimonial1": "Toll für Hochzeiten! Einfach und sicher.",
|
||||||
|
"testimonial2": "Beste App für Event-Fotos.",
|
||||||
|
"testimonial3": "Schnell und benutzerfreundlich.",
|
||||||
|
"faq_title": "Häufige Fragen",
|
||||||
|
"faq1_q": "Ist es kostenlos?",
|
||||||
|
"faq1_a": "Ja, es gibt ein kostenloses Paket für kleine Events.",
|
||||||
|
"faq2_q": "Wie funktioniert der QR-Code?",
|
||||||
|
"faq2_a": "Gäste scannen und laden Fotos hoch – einfach!"
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"title": "Unsere Packages",
|
||||||
|
"hero_title": "Entdecken Sie unsere flexiblen Packages",
|
||||||
|
"hero_description": "Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.",
|
||||||
|
"cta_explore": "Pakete entdecken",
|
||||||
|
"tab_endcustomer": "Endkunden",
|
||||||
|
"tab_reseller": "Reseller & Agenturen",
|
||||||
|
"section_endcustomer": "Packages für Endkunden (Einmalkauf pro Event)",
|
||||||
|
"section_reseller": "Packages für Reseller (Jährliches Abo)",
|
||||||
|
"free": "Kostenlos",
|
||||||
|
"one_time": "Einmalkauf",
|
||||||
|
"subscription": "Abo",
|
||||||
|
"year": "Jahr",
|
||||||
|
"max_photos": "Fotos",
|
||||||
|
"max_guests": "Gäste",
|
||||||
|
"gallery_days": "Tage Galerie",
|
||||||
|
"max_events_year": "Events/Jahr",
|
||||||
|
"buy_now": "Jetzt kaufen",
|
||||||
|
"subscribe_now": "Jetzt abonnieren",
|
||||||
|
"register_buy": "Registrieren und kaufen",
|
||||||
|
"register_subscribe": "Registrieren und abonnieren",
|
||||||
|
"faq_title": "Häufige Fragen zu Packages",
|
||||||
|
"faq_q1": "Was ist ein Package?",
|
||||||
|
"faq_a1": "Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.",
|
||||||
|
"faq_q2": "Kann ich upgraden?",
|
||||||
|
"faq_a2": "Ja, wählen Sie bei Event-Erstellung ein höheres Package oder upgraden Sie später.",
|
||||||
|
"faq_q3": "Was passiert bei Ablauf?",
|
||||||
|
"faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.",
|
||||||
|
"faq_q4": "Zahlungssicher?",
|
||||||
|
"faq_a4": "Ja, via Stripe oder PayPal – sicher und GDPR-konform.",
|
||||||
|
"final_cta": "Bereit für Ihr nächstes Event?",
|
||||||
|
"contact_us": "Kontaktieren Sie uns",
|
||||||
|
"feature_live_slideshow": "Live-Slideshow",
|
||||||
|
"feature_analytics": "Analytics",
|
||||||
|
"feature_watermark": "Wasserzeichen",
|
||||||
|
"feature_branding": "Branding",
|
||||||
|
"feature_support": "Support",
|
||||||
|
"feature_basic_uploads": "Basis-Uploads",
|
||||||
|
"feature_unlimited_sharing": "Unbegrenztes Teilen",
|
||||||
|
"feature_no_watermark": "Kein Wasserzeichen",
|
||||||
|
"feature_custom_tasks": "Benutzerdefinierte Tasks",
|
||||||
|
"feature_advanced_analytics": "Erweiterte Analytics",
|
||||||
|
"feature_priority_support": "Priorisierter Support",
|
||||||
|
"feature_limited_sharing": "Begrenztes Teilen",
|
||||||
|
"feature_no_branding": "Kein Branding",
|
||||||
|
"feature_0": "Basis-Feature",
|
||||||
|
"feature_reseller_dashboard": "Reseller-Dashboard",
|
||||||
|
"feature_custom_branding": "Benutzerdefiniertes Branding",
|
||||||
|
"feature_advanced_reporting": "Erweiterte Berichterstattung",
|
||||||
|
"for_endcustomers": "Für Endkunden",
|
||||||
|
"for_resellers": "Für Reseller",
|
||||||
|
"details_show": "Details anzeigen",
|
||||||
|
"comparison_title": "Packages vergleichen",
|
||||||
|
"price": "Preis",
|
||||||
|
"max_photos_label": "Max. Fotos",
|
||||||
|
"max_guests_label": "Max. Gäste",
|
||||||
|
"gallery_days_label": "Galerie-Tage",
|
||||||
|
"watermark_label": "Wasserzeichen",
|
||||||
|
"no_watermark": "Kein Wasserzeichen",
|
||||||
|
"custom_branding": "Benutzerdefiniertes Branding",
|
||||||
|
"max_tenants": "Max. Tenants",
|
||||||
|
"max_events": "Max. Events/Jahr",
|
||||||
|
"faq_free": "Was ist das Free Package?",
|
||||||
|
"faq_upgrade": "Kann ich upgraden?",
|
||||||
|
"faq_reseller": "Was für Reseller?",
|
||||||
|
"faq_payment": "Zahlung sicher?"
|
||||||
|
},
|
||||||
|
"blog": {
|
||||||
|
"title": "Fotospiel - Blog",
|
||||||
|
"hero_title": "Fotospiel Blog",
|
||||||
|
"hero_description": "Tipps, News und Guides für perfekte Event-Fotos mit QR-Codes, PWA und mehr. Bleiben Sie informiert!",
|
||||||
|
"hero_cta": "Mehr über Fotospiel",
|
||||||
|
"posts_title": "Aktuelle Blog-Beiträge",
|
||||||
|
"by": "Von",
|
||||||
|
"team": "Fotospiel Team",
|
||||||
|
"published_at": "Veröffentlicht am",
|
||||||
|
"read_more": "Lesen",
|
||||||
|
"back": "Zurück zum Blog",
|
||||||
|
"empty": "Noch keine Beiträge verfügbar. Bleiben Sie dran!",
|
||||||
|
"our_blog": "Unser Blog",
|
||||||
|
"latest_posts": "Neueste Beiträge",
|
||||||
|
"no_posts": "Keine Beiträge verfügbar.",
|
||||||
|
"read_more_link": "Mehr lesen"
|
||||||
|
},
|
||||||
|
"kontakt": {
|
||||||
|
"title": "Kontakt - Fotospiel",
|
||||||
|
"description": "Haben Sie Fragen? Schreiben Sie uns!",
|
||||||
|
"name": "Name",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"message": "Nachricht",
|
||||||
|
"sending": "Wird gesendet...",
|
||||||
|
"send": "Senden",
|
||||||
|
"back_home": "Zurück zur Startseite"
|
||||||
|
},
|
||||||
|
"occasions": {
|
||||||
|
"title": "Fotospiel für :type",
|
||||||
|
"hero_title": "Fotospiel für :type",
|
||||||
|
"hero_description": "Sammeln Sie unvergessliche Fotos von Ihren Gästen mit QR-Codes. Perfekt für :type – einfach, mobil und datenschutzkonform.",
|
||||||
|
"cta": "Paket wählen",
|
||||||
|
"weddings": {
|
||||||
|
"title": "Hochzeiten mit Fotospiel",
|
||||||
|
"description": "Fangen Sie romantische Momente ein: Gäste teilen Fotos via QR, wählen Emotionen wie 'Romantisch' oder 'Freudig'. Besser als traditionelle Fotoboxen.",
|
||||||
|
"benefits_title": "Vorteile für Hochzeiten",
|
||||||
|
"benefit1": "QR-Code für Gäste: Einfaches Teilen ohne App-Download.",
|
||||||
|
"benefit2": "Emotion-Filter: Kategorisieren von Fotos (z.B. 'Tanz', 'Kuss').",
|
||||||
|
"benefit3": "Private Galerie: Nur genehmigte Fotos sichtbar.",
|
||||||
|
"benefit4": "Download: Hochauflösend für Album.",
|
||||||
|
"image_alt": "Hochzeitsfotos"
|
||||||
|
},
|
||||||
|
"birthdays": {
|
||||||
|
"title": "Geburtstage feiern",
|
||||||
|
"description": "Lassen Sie Freunde und Familie spontane Fotos teilen. QR auf der Torte – Spaß garantiert!",
|
||||||
|
"benefits_title": "Vorteile für Geburtstage",
|
||||||
|
"benefit1": "Schnelle Uploads: Kamera oder Galerie.",
|
||||||
|
"benefit2": "Likes & Shares: Beliebte Momente hervorheben.",
|
||||||
|
"benefit3": "Offline-fähig: PWA funktioniert ohne Internet.",
|
||||||
|
"benefit4": "Anonym: Keine Registrierung erforderlich.",
|
||||||
|
"image_alt": "Geburtstagsfotos"
|
||||||
|
},
|
||||||
|
"corporate": {
|
||||||
|
"title": "Firmenevents professionell",
|
||||||
|
"description": "Networking und Team-Building: Fotos zentral sammeln, Highlights intern teilen.",
|
||||||
|
"benefits_title": "Vorteile für Firmenevents",
|
||||||
|
"benefit1": "QR an Ständen: Gäste fotografieren sich selbst.",
|
||||||
|
"benefit2": "Kategorien: 'Team', 'Netzwerk', 'Präsentation'.",
|
||||||
|
"benefit3": "Export: Für Social Media oder Intranet.",
|
||||||
|
"benefit4": "GDPR-sicher: Keine PII gespeichert.",
|
||||||
|
"image_alt": "Firmenevent-Fotos"
|
||||||
|
},
|
||||||
|
"family": {
|
||||||
|
"title": "Familienfeiern",
|
||||||
|
"description": "Von Taufen bis Jubiläen: Erinnerungen von allen Verwandten sammeln.",
|
||||||
|
"benefits_title": "Vorteile für Familienfeiern",
|
||||||
|
"benefit1": "Einfach für alle Altersgruppen: Große Schrift, touch-freundlich.",
|
||||||
|
"benefit2": "Emotionen: 'Familie', 'Glück', 'Einheit'.",
|
||||||
|
"benefit3": "Teilen: Via Link oder QR für Nachfeier.",
|
||||||
|
"benefit4": "Unbegrenzt: Im Premium-Plan.",
|
||||||
|
"image_alt": "Familienfotos"
|
||||||
|
},
|
||||||
|
"not_found": "Anlass nicht gefunden.",
|
||||||
|
"hochzeit_title": "Hochzeit – Perfekte Gastfotos mit QR",
|
||||||
|
"hochzeit_desc": "Machen Sie Ihre Hochzeit unvergesslich mit Fotospiel. Gäste teilen Fotos einfach via QR-Code – sicher, privat und in Echtzeit. Von Zeremonie bis Party, alle Momente zentral gesammelt.",
|
||||||
|
"hochzeit_feature1": "Live-Slideshow für Gäste",
|
||||||
|
"hochzeit_feature2": "Emotion-basierte Foto-Filter",
|
||||||
|
"hochzeit_feature3": "Unbegrenzte Galerie für 30 Tage",
|
||||||
|
"hochzeit_cta": "Hochzeitspaket wählen",
|
||||||
|
"geburtstag_title": "Geburtstag – Feiern mit geteilten Erinnerungen",
|
||||||
|
"geburtstag_desc": "Feiern Sie Geburtstage mit Fotospiel! QR-Code für Gäste zum Hochladen von Fotos – von Kinder- bis Erwachsenen-Partys. Einfach teilen, liken und downloaden.",
|
||||||
|
"geburtstag_feature1": "Kostenloses Paket für kleine Partys",
|
||||||
|
"geburtstag_feature2": "Schnelle Uploads via PWA",
|
||||||
|
"geburtstag_feature3": "Privat und datenschutzkonform",
|
||||||
|
"geburtstag_cta": "Geburtstagspaket entdecken",
|
||||||
|
"firmenevent_title": "Firmenevent – Team-Events und Konferenzen",
|
||||||
|
"firmenevent_desc": "Für Firmenevents, Teambuildings und Konferenzen: Fotospiel sammelt alle Fotos zentral via QR. Branding, Analytics und sichere Galerie für Ihr Unternehmen.",
|
||||||
|
"firmenevent_feature1": "Benutzerdefiniertes Branding für Firmenlogo",
|
||||||
|
"firmenevent_feature2": "Erweiterte Analytics",
|
||||||
|
"firmenevent_feature3": "Priorisierter Support",
|
||||||
|
"firmenevent_cta": "Firmenpaket anfragen"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Erfolg",
|
||||||
|
"verify_email": "E-Mail verifizieren",
|
||||||
|
"check_email": "Überprüfen Sie Ihre E-Mail auf den Verifizierungslink.",
|
||||||
|
"redirecting": "Weiterleitung zum Admin-Bereich...",
|
||||||
|
"complete_purchase": "Kauf abschließen",
|
||||||
|
"login_to_continue": "Melden Sie sich an, um fortzufahren.",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"email_verify_title": "E-Mail verifizieren",
|
||||||
|
"email_verify_desc": "Bitte überprüfen Sie Ihre E-Mail und klicken Sie auf den Verifizierungslink.",
|
||||||
|
"resend_verification": "Verifizierung erneut senden",
|
||||||
|
"already_registered": "Bereits registriert? Anmelden",
|
||||||
|
"purchase_complete_title": "Kauf abschließen",
|
||||||
|
"purchase_complete_desc": "Melden Sie sich an, um fortzufahren.",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"no_account": "Kein Konto? Registrieren"
|
||||||
|
},
|
||||||
|
"blog_show": {
|
||||||
|
"title_suffix": " - Fotospiel Blog",
|
||||||
|
"by_author": "Von",
|
||||||
|
"published_on": "Veröffentlicht am",
|
||||||
|
"back_to_blog": "Zurück zum Blog"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"how_it_works": "So funktioniert es",
|
||||||
|
"features": "Features",
|
||||||
|
"occasions": "Anlässe",
|
||||||
|
"blog": "Blog",
|
||||||
|
"packages": "Pakete",
|
||||||
|
"contact": "Kontakt",
|
||||||
|
"discover_packages": "Pakete entdecken"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"company": "Fotospiel GmbH",
|
||||||
|
"rights_reserved": "Alle Rechte vorbehalten"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"free": "Kostenlos"
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"euro": "€"
|
||||||
|
}
|
||||||
|
}
|
||||||
243
resources/lang/en/marketing.json
Normal file
243
resources/lang/en/marketing.json
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
{
|
||||||
|
"home": {
|
||||||
|
"title": "Home - Fotospiel",
|
||||||
|
"hero_title": "Fotospiel",
|
||||||
|
"hero_description": "Collect guest photos for events with QR codes. Our secure PWA platform for guests and organizers – simple, mobile and privacy-compliant. Better than competitors, loved by thousands.",
|
||||||
|
"cta_explore": "Discover Packages",
|
||||||
|
"hero_image_alt": "Event Photos with QR Code",
|
||||||
|
"how_title": "How it works",
|
||||||
|
"step1_title": "Choose Package",
|
||||||
|
"step1_desc": "Choose the right package for your event.",
|
||||||
|
"step2_title": "Share QR Code",
|
||||||
|
"step2_desc": "Share the QR code with your guests.",
|
||||||
|
"step3_title": "Collect Photos",
|
||||||
|
"step3_desc": "Guests upload photos – secure and easy.",
|
||||||
|
"features_title": "Why Fotospiel?",
|
||||||
|
"feature1_title": "Secure & Privacy Compliant",
|
||||||
|
"feature1_desc": "GDPR compliant, no PII storage.",
|
||||||
|
"feature2_title": "Mobile & PWA",
|
||||||
|
"feature2_desc": "Works offline, installable like an app.",
|
||||||
|
"feature3_title": "Easy to Use",
|
||||||
|
"feature3_desc": "Intuitive UI for guests and organizers.",
|
||||||
|
"packages_title": "Our Packages",
|
||||||
|
"view_details": "View Details",
|
||||||
|
"all_packages": "View All Packages",
|
||||||
|
"contact_title": "Contact",
|
||||||
|
"name_label": "Name",
|
||||||
|
"email_label": "Email",
|
||||||
|
"message_label": "Message",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"send": "Send",
|
||||||
|
"testimonials_title": "What Our Customers Say",
|
||||||
|
"testimonial1": "Great for weddings! Simple and secure.",
|
||||||
|
"testimonial2": "Best app for event photos.",
|
||||||
|
"testimonial3": "Fast and user-friendly.",
|
||||||
|
"faq_title": "Frequently Asked Questions",
|
||||||
|
"faq1_q": "Is it free?",
|
||||||
|
"faq1_a": "Yes, there's a free package for small events.",
|
||||||
|
"faq2_q": "How does the QR code work?",
|
||||||
|
"faq2_a": "Guests scan and upload photos – easy!"
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"title": "Our Packages",
|
||||||
|
"hero_title": "Discover our flexible Packages",
|
||||||
|
"hero_description": "From free entry to premium features: Tailor your event package to your needs. Simple, secure and scalable.",
|
||||||
|
"cta_explore": "Discover Packages",
|
||||||
|
"tab_endcustomer": "End Customers",
|
||||||
|
"tab_reseller": "Resellers & Agencies",
|
||||||
|
"section_endcustomer": "Packages for End Customers (One-time purchase per Event)",
|
||||||
|
"section_reseller": "Packages for Resellers (Annual Subscription)",
|
||||||
|
"free": "Free",
|
||||||
|
"one_time": "One-time purchase",
|
||||||
|
"subscription": "Subscription",
|
||||||
|
"year": "Year",
|
||||||
|
"max_photos": "Photos",
|
||||||
|
"max_guests": "Guests",
|
||||||
|
"gallery_days": "Gallery Days",
|
||||||
|
"max_events_year": "Events/Year",
|
||||||
|
"buy_now": "Buy Now",
|
||||||
|
"subscribe_now": "Subscribe Now",
|
||||||
|
"register_buy": "Register and Buy",
|
||||||
|
"register_subscribe": "Register and Subscribe",
|
||||||
|
"faq_title": "Frequently Asked Questions about Packages",
|
||||||
|
"faq_q1": "What is a Package?",
|
||||||
|
"faq_a1": "A Package defines limits and features for your event, e.g. number of photos and gallery duration.",
|
||||||
|
"faq_q2": "Can I upgrade?",
|
||||||
|
"faq_a2": "Yes, choose a higher package when creating the event or upgrade later.",
|
||||||
|
"faq_q3": "What happens when it expires?",
|
||||||
|
"faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend it.",
|
||||||
|
"faq_q4": "Payment secure?",
|
||||||
|
"faq_a4": "Yes, via Stripe or PayPal – secure and GDPR-compliant.",
|
||||||
|
"final_cta": "Ready for your next event?",
|
||||||
|
"contact_us": "Contact Us",
|
||||||
|
"feature_live_slideshow": "Live Slideshow",
|
||||||
|
"feature_analytics": "Analytics",
|
||||||
|
"feature_watermark": "Watermark",
|
||||||
|
"feature_branding": "Branding",
|
||||||
|
"feature_support": "Support",
|
||||||
|
"feature_basic_uploads": "Basic Uploads",
|
||||||
|
"feature_unlimited_sharing": "Unlimited Sharing",
|
||||||
|
"feature_no_watermark": "No Watermark",
|
||||||
|
"feature_custom_tasks": "Custom Tasks",
|
||||||
|
"feature_advanced_analytics": "Advanced Analytics",
|
||||||
|
"feature_priority_support": "Priority Support",
|
||||||
|
"feature_limited_sharing": "Limited Sharing",
|
||||||
|
"feature_no_branding": "No Branding",
|
||||||
|
"feature_0": "Basic Feature",
|
||||||
|
"feature_reseller_dashboard": "Reseller Dashboard",
|
||||||
|
"feature_custom_branding": "Custom Branding",
|
||||||
|
"feature_advanced_reporting": "Advanced Reporting",
|
||||||
|
"for_endcustomers": "For End Customers",
|
||||||
|
"for_resellers": "For Resellers",
|
||||||
|
"details_show": "Show Details",
|
||||||
|
"comparison_title": "Compare Packages",
|
||||||
|
"price": "Price",
|
||||||
|
"max_photos_label": "Max. Photos",
|
||||||
|
"max_guests_label": "Max. Guests",
|
||||||
|
"gallery_days_label": "Gallery Days",
|
||||||
|
"watermark_label": "Watermark",
|
||||||
|
"no_watermark": "No Watermark",
|
||||||
|
"custom_branding": "Custom Branding",
|
||||||
|
"max_tenants": "Max. Tenants",
|
||||||
|
"max_events": "Max. Events/Year",
|
||||||
|
"faq_free": "What is the Free Package?",
|
||||||
|
"faq_upgrade": "Can I upgrade?",
|
||||||
|
"faq_reseller": "What for Resellers?",
|
||||||
|
"faq_payment": "Payment secure?"
|
||||||
|
},
|
||||||
|
"blog": {
|
||||||
|
"title": "Fotospiel - Blog",
|
||||||
|
"hero_title": "Fotospiel Blog",
|
||||||
|
"hero_description": "Tips, News and Guides for perfect Event Photos with QR-Codes, PWA and more. Stay informed!",
|
||||||
|
"hero_cta": "More about Fotospiel",
|
||||||
|
"posts_title": "Current Blog Posts",
|
||||||
|
"by": "By",
|
||||||
|
"team": "Fotospiel Team",
|
||||||
|
"published_at": "Published on",
|
||||||
|
"read_more": "Read",
|
||||||
|
"back": "Back to Blog",
|
||||||
|
"empty": "No posts available yet. Stay tuned!",
|
||||||
|
"our_blog": "Our Blog",
|
||||||
|
"latest_posts": "Latest Posts",
|
||||||
|
"no_posts": "No posts available.",
|
||||||
|
"read_more_link": "Read More"
|
||||||
|
},
|
||||||
|
"kontakt": {
|
||||||
|
"title": "Contact - Fotospiel",
|
||||||
|
"description": "Have questions? Write to us!",
|
||||||
|
"name": "Name",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"message": "Message",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"send": "Send",
|
||||||
|
"back_home": "Back to Home"
|
||||||
|
},
|
||||||
|
"occasions": {
|
||||||
|
"title": "Fotospiel for :type",
|
||||||
|
"hero_title": "Fotospiel for :type",
|
||||||
|
"hero_description": "Collect unforgettable photos from your guests with QR-Codes. Perfect for :type – simple, mobile and privacy-compliant.",
|
||||||
|
"cta": "Choose Package",
|
||||||
|
"weddings": {
|
||||||
|
"title": "Weddings with Fotospiel",
|
||||||
|
"description": "Capture romantic moments: Guests share photos via QR, choose emotions like 'Romantic' or 'Joyful'. Better than traditional photo booths.",
|
||||||
|
"benefits_title": "Benefits for Weddings",
|
||||||
|
"benefit1": "QR-Code for Guests: Easy sharing without app download.",
|
||||||
|
"benefit2": "Emotion Filter: Categorize photos (e.g. 'Dance', 'Kiss').",
|
||||||
|
"benefit3": "Private Gallery: Only approved photos visible.",
|
||||||
|
"benefit4": "Download: High-resolution for album.",
|
||||||
|
"image_alt": "Wedding Photos"
|
||||||
|
},
|
||||||
|
"birthdays": {
|
||||||
|
"title": "Celebrate Birthdays",
|
||||||
|
"description": "Let friends and family share spontaneous photos. QR on the cake – fun guaranteed!",
|
||||||
|
"benefits_title": "Benefits for Birthdays",
|
||||||
|
"benefit1": "Quick Uploads: Camera or Gallery.",
|
||||||
|
"benefit2": "Likes & Shares: Highlight popular moments.",
|
||||||
|
"benefit3": "Offline-capable: PWA works without internet.",
|
||||||
|
"benefit4": "Anonymous: No registration required.",
|
||||||
|
"image_alt": "Birthday Photos"
|
||||||
|
},
|
||||||
|
"corporate": {
|
||||||
|
"title": "Corporate Events Professionally",
|
||||||
|
"description": "Networking and Team-Building: Collect photos centrally, share highlights internally.",
|
||||||
|
"benefits_title": "Benefits for Corporate Events",
|
||||||
|
"benefit1": "QR at Booths: Guests photograph themselves.",
|
||||||
|
"benefit2": "Categories: 'Team', 'Network', 'Presentation'.",
|
||||||
|
"benefit3": "Export: For Social Media or Intranet.",
|
||||||
|
"benefit4": "GDPR-secure: No PII stored.",
|
||||||
|
"image_alt": "Corporate Event Photos"
|
||||||
|
},
|
||||||
|
"family": {
|
||||||
|
"title": "Family Celebrations",
|
||||||
|
"description": "From baptisms to anniversaries: Collect memories from all relatives.",
|
||||||
|
"benefits_title": "Benefits for Family Celebrations",
|
||||||
|
"benefit1": "Easy for all ages: Large letters, touch-friendly.",
|
||||||
|
"benefit2": "Emotions: 'Family', 'Happiness', 'Unity'.",
|
||||||
|
"benefit3": "Share: Via link or QR for after-party.",
|
||||||
|
"benefit4": "Unlimited: In premium plan.",
|
||||||
|
"image_alt": "Family Photos"
|
||||||
|
},
|
||||||
|
"not_found": "Occasion not found.",
|
||||||
|
"hochzeit_title": "Wedding – Perfect Guest Photos with QR",
|
||||||
|
"hochzeit_desc": "Make your wedding unforgettable with Fotospiel. Guests share photos easily via QR code – secure, private and in real time. From ceremony to party, all moments collected centrally.",
|
||||||
|
"hochzeit_feature1": "Live Slideshow for Guests",
|
||||||
|
"hochzeit_feature2": "Emotion-based Photo Filters",
|
||||||
|
"hochzeit_feature3": "Unlimited Gallery for 30 Days",
|
||||||
|
"hochzeit_cta": "Choose Wedding Package",
|
||||||
|
"geburtstag_title": "Birthday – Celebrate with Shared Memories",
|
||||||
|
"geburtstag_desc": "Celebrate birthdays with Fotospiel! QR code for guests to upload photos – from kids to adult parties. Easy to share, like and download.",
|
||||||
|
"geburtstag_feature1": "Free Package for Small Parties",
|
||||||
|
"geburtstag_feature2": "Quick Uploads via PWA",
|
||||||
|
"geburtstag_feature3": "Private and Privacy Compliant",
|
||||||
|
"geburtstag_cta": "Discover Birthday Package",
|
||||||
|
"firmenevent_title": "Corporate Event – Team Events and Conferences",
|
||||||
|
"firmenevent_desc": "For corporate events, team buildings and conferences: Fotospiel collects all photos centrally via QR. Branding, analytics and secure gallery for your company.",
|
||||||
|
"firmenevent_feature1": "Custom Branding for Company Logo",
|
||||||
|
"firmenevent_feature2": "Advanced Analytics",
|
||||||
|
"firmenevent_feature3": "Priority Support",
|
||||||
|
"firmenevent_cta": "Request Corporate Package"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"title": "Success",
|
||||||
|
"verify_email": "Verify Email",
|
||||||
|
"check_email": "Check your email for the verification link.",
|
||||||
|
"redirecting": "Redirecting to admin area...",
|
||||||
|
"complete_purchase": "Complete Purchase",
|
||||||
|
"login_to_continue": "Log in to continue.",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"email_verify_title": "Verify Email",
|
||||||
|
"email_verify_desc": "Please check your email and click the verification link.",
|
||||||
|
"resend_verification": "Resend Verification",
|
||||||
|
"already_registered": "Already registered? Login",
|
||||||
|
"purchase_complete_title": "Complete Purchase",
|
||||||
|
"purchase_complete_desc": "Log in to continue.",
|
||||||
|
"login": "Login",
|
||||||
|
"no_account": "No Account? Register"
|
||||||
|
},
|
||||||
|
"blog_show": {
|
||||||
|
"title_suffix": " - Fotospiel Blog",
|
||||||
|
"by_author": "By",
|
||||||
|
"published_on": "Published on",
|
||||||
|
"back_to_blog": "Back to Blog"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"how_it_works": "How it works",
|
||||||
|
"features": "Features",
|
||||||
|
"occasions": "Occasions",
|
||||||
|
"blog": "Blog",
|
||||||
|
"packages": "Packages",
|
||||||
|
"contact": "Contact",
|
||||||
|
"discover_packages": "Discover Packages"
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"company": "Fotospiel GmbH",
|
||||||
|
"rights_reserved": "All rights reserved"
|
||||||
|
},
|
||||||
|
"register": {
|
||||||
|
"free": "Free"
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"euro": "€"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,6 @@
|
|||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 text-gray-900">
|
<body class="bg-gray-50 text-gray-900">
|
||||||
@include('partials.header')
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
@yield('content')
|
@yield('content')
|
||||||
|
|||||||
@@ -11,18 +11,6 @@ use App\Http\Controllers\Auth\VerifyEmailController;
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware('guest')->group(function () {
|
Route::middleware('guest')->group(function () {
|
||||||
Route::get('register', [RegisteredUserController::class, 'create'])
|
|
||||||
->name('register');
|
|
||||||
|
|
||||||
Route::post('register', [RegisteredUserController::class, 'store'])
|
|
||||||
->name('register.store');
|
|
||||||
|
|
||||||
Route::get('login', [AuthenticatedSessionController::class, 'create'])
|
|
||||||
->name('login');
|
|
||||||
|
|
||||||
Route::post('login', [AuthenticatedSessionController::class, 'store'])
|
|
||||||
->name('login.store');
|
|
||||||
|
|
||||||
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
|
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
|
||||||
->name('password.request');
|
->name('password.request');
|
||||||
|
|
||||||
@@ -54,7 +42,4 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store'])
|
Route::post('confirm-password', [ConfirmablePasswordController::class, 'store'])
|
||||||
->middleware('throttle:6,1')
|
->middleware('throttle:6,1')
|
||||||
->name('password.confirm.store');
|
->name('password.confirm.store');
|
||||||
|
|
||||||
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
|
|
||||||
->name('logout');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,23 +4,52 @@ use Illuminate\Support\Facades\Route;
|
|||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
// Marketing-Seite mit Locale-Prefix
|
Route::get('/lang/{locale}/{namespace}', function ($locale, $namespace) {
|
||||||
|
Log::info('Lang route hit', ['locale' => $locale, 'namespace' => $namespace]);
|
||||||
|
$path = public_path("lang/{$locale}/{$namespace}.json");
|
||||||
|
Log::info('Path checked', ['path' => $path, 'exists' => file_exists($path)]);
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
$content = json_decode(file_get_contents($path), true);
|
||||||
|
Log::info('JSON loaded', ['keys' => array_keys($content ?? [])]);
|
||||||
|
return response()->json($content);
|
||||||
|
})->where(['locale' => 'de|en', 'namespace' => 'marketing|auth']);
|
||||||
|
|
||||||
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
|
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
|
||||||
Route::get('/', [\App\Http\Controllers\MarketingController::class, 'index'])->name('marketing');
|
Route::get('/', [\App\Http\Controllers\MarketingController::class, 'index'])->name('marketing');
|
||||||
Route::get('/packages', [\App\Http\Controllers\MarketingController::class, 'packagesIndex'])->name('packages');
|
Route::get('/packages', [\App\Http\Controllers\MarketingController::class, 'packagesIndex'])->name('packages');
|
||||||
Route::get('/register/{package_id?}', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'create'])->name('marketing.register');
|
Route::get('/login', [\App\Http\Controllers\Auth\AuthenticatedSessionController::class, 'create'])->name('login');
|
||||||
Route::post('/register', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'store'])->name('marketing.register.store');
|
Route::post('/login', [\App\Http\Controllers\Auth\AuthenticatedSessionController::class, 'store'])->name('login.store');
|
||||||
|
Route::get('/register/{package_id?}', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'create'])->name('register');
|
||||||
|
Route::post('/register', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'store'])->name('register.store');
|
||||||
|
Route::post('/logout', [\App\Http\Controllers\Auth\AuthenticatedSessionController::class, 'destroy'])->name('logout');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fallback for /login (redirect to default locale)
|
||||||
|
Route::get('/login', function () {
|
||||||
|
return redirect('/de/login');
|
||||||
|
})->name('login.fallback');
|
||||||
|
|
||||||
|
// Fallback for /register (redirect to default locale)
|
||||||
|
Route::get('/register', function () {
|
||||||
|
return redirect('/de/register');
|
||||||
|
})->name('register.fallback');
|
||||||
|
|
||||||
|
// Fallback for /logout (redirect to default locale)
|
||||||
|
Route::post('/logout', function () {
|
||||||
|
return redirect('/de/logout');
|
||||||
|
})->name('logout.fallback');
|
||||||
|
|
||||||
// Fallback for /packages (redirect to default locale)
|
// Fallback for /packages (redirect to default locale)
|
||||||
Route::get('/packages', function () {
|
Route::get('/packages', function () {
|
||||||
return redirect('/de/packages');
|
return redirect('/de/packages');
|
||||||
})->name('packages.fallback');
|
})->name('packages.fallback');
|
||||||
|
|
||||||
// Fallback for /blog (redirect to default locale)
|
// Fallback for /blog (redirect to default locale)
|
||||||
Route::get('/blog', function () {
|
Route::get('/blog', function () {
|
||||||
return redirect('/de/blog');
|
return redirect('/de/blog');
|
||||||
})->name('blog.fallback');
|
})->name('blog.fallback');
|
||||||
|
|
||||||
// Blog Routes (inside locale group for i18n support)
|
// Blog Routes (inside locale group for i18n support)
|
||||||
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
|
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
|
||||||
@@ -58,6 +87,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
// Auth-Routes sind nun in web.php integriert, auth.php nur für andere Auth-Funktionen
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|
||||||
// Guest PWA shell for /event and sub-routes
|
// Guest PWA shell for /event and sub-routes
|
||||||
@@ -76,6 +106,7 @@ Route::prefix('api/v1')->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken
|
|||||||
// PayPal IPN webhook
|
// PayPal IPN webhook
|
||||||
Route::post('/webhooks/paypal', [\App\Http\Controllers\PayPalWebhookController::class, 'handle']);
|
Route::post('/webhooks/paypal', [\App\Http\Controllers\PayPalWebhookController::class, 'handle']);
|
||||||
|
|
||||||
|
|
||||||
// CSV templates for Super Admin imports
|
// CSV templates for Super Admin imports
|
||||||
Route::get('/super-admin/templates/emotions.csv', function () {
|
Route::get('/super-admin/templates/emotions.csv', function () {
|
||||||
$headers = [
|
$headers = [
|
||||||
@@ -110,15 +141,18 @@ Route::get('/super-admin/templates/tasks.csv', function () {
|
|||||||
|
|
||||||
Route::get('/buy-packages/{package_id}', [\App\Http\Controllers\MarketingController::class, 'buyPackages'])->name('buy.packages');
|
Route::get('/buy-packages/{package_id}', [\App\Http\Controllers\MarketingController::class, 'buyPackages'])->name('buy.packages');
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/profile', [\App\Http\Controllers\ProfileController::class, 'edit'])->name('user.profile.edit');
|
Route::get('/profile', [\App\Http\Controllers\ProfileController::class, 'index'])->name('profile');
|
||||||
Route::patch('/profile', [\App\Http\Controllers\ProfileController::class, 'update'])->name('user.profile.update');
|
Route::get('/profile/account', [\App\Http\Controllers\ProfileController::class, 'account'])->name('profile.account');
|
||||||
|
Route::patch('/profile/account', [\App\Http\Controllers\ProfileController::class, 'account'])->name('profile.account.update');
|
||||||
|
Route::get('/profile/orders', [\App\Http\Controllers\ProfileController::class, 'orders'])->name('profile.orders');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/marketing/success/{package_id?}', [\App\Http\Controllers\MarketingController::class, 'success'])->name('marketing.success');
|
Route::get('/marketing/success/{package_id?}', [\App\Http\Controllers\MarketingController::class, 'success'])->name('marketing.success');
|
||||||
|
|
||||||
Route::get('{locale}/occasions/{type}', [\App\Http\Controllers\MarketingController::class, 'occasionsType'])
|
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
|
||||||
|
Route::get('/anlaesse/{type}', [\App\Http\Controllers\MarketingController::class, 'occasionsType'])
|
||||||
->where([
|
->where([
|
||||||
'locale' => 'de|en',
|
'type' => 'hochzeit|geburtstag|firmenevent'
|
||||||
'type' => 'weddings|birthdays|corporate-events|family-celebrations'
|
|
||||||
])
|
])
|
||||||
->name('occasions.type');
|
->name('anlaesse.type');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user