Fix auth form errors and redirects: Add React keys/useEffects for error rendering and scroll, Inertia::location in controllers for SPA navigation, extend RegistrationTest and add E2E. Update docs (changes/2025-10-02-registration-fixes.md, prp/13-backend-authentication.md). Add new UI components (accordion, carousel, progress, table, tabs), marketing/legal pages (Blog, Kontakt, Datenschutz, etc.), fonts, user migration (remove_name), views/css/package updates, seeders/factories.

This commit is contained in:
Codex Agent
2025-10-02 11:40:48 +02:00
parent 1945f664c6
commit 7475210893
101 changed files with 3406 additions and 376 deletions

View File

@@ -19,6 +19,10 @@ class AddDummyTenantUser extends Command
{--password=secret123!}
{--tenant="Demo Tenant"}
{--name="Demo Admin"}
{--first_name="Demo"}
{--last_name="Admin"}
{--address="Demo Str. 1, 12345 Demo City"}
{--phone="+49 123 4567890"}
{--update-password : Overwrite password if user already exists}
';
protected $description = 'Create a demo tenant and a tenant user with given credentials.';
@@ -29,6 +33,12 @@ class AddDummyTenantUser extends Command
$password = (string) $this->option('password');
$tenantName = (string) $this->option('tenant');
$userName = (string) $this->option('name');
$firstName = (string) $this->option('first_name');
$lastName = (string) $this->option('last_name');
$address = (string) $this->option('address');
$phone = (string) $this->option('phone');
$this->info('Starting dummy tenant creation with email: ' . $email);
// Pre-flight checks for common failures
if (! Schema::hasTable('users')) {
@@ -53,12 +63,17 @@ class AddDummyTenantUser extends Command
$tenant->domain = null;
$tenant->contact_name = $userName;
$tenant->contact_email = $email;
$tenant->contact_phone = null;
$tenant->contact_phone = $phone ?: null;
$tenant->event_credits_balance = 1;
$tenant->max_photos_per_event = 500;
$tenant->max_storage_mb = 1024;
$tenant->features = ['custom_branding' => false];
$tenant->is_active = true;
$tenant->is_suspended = false;
$tenant->save();
$this->info('Created new tenant: ' . $tenant->name);
} else {
$this->info('Using existing tenant: ' . $tenant->name);
}
// Create or fetch user
@@ -70,9 +85,15 @@ class AddDummyTenantUser extends Command
if (Schema::hasColumn($user->getTable(), 'name')) $user->name = $userName;
$user->email = $email;
$user->password = Hash::make($password);
$this->info('Creating new user: ' . $email);
} else if ($updatePassword) {
$user->password = Hash::make($password);
$this->info('Updating password for existing user: ' . $email);
}
if (Schema::hasColumn($user->getTable(), 'first_name')) $user->first_name = $firstName;
if (Schema::hasColumn($user->getTable(), 'last_name')) $user->last_name = $lastName;
if (Schema::hasColumn($user->getTable(), 'address')) $user->address = $address;
if (Schema::hasColumn($user->getTable(), 'phone')) $user->phone = $phone;
if (Schema::hasColumn($user->getTable(), 'tenant_id')) {
$user->tenant_id = $tenant->id;
}
@@ -80,11 +101,13 @@ class AddDummyTenantUser extends Command
$user->role = 'tenant_admin';
}
$user->save();
$this->info('User saved successfully.');
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
$this->error('Failed: '.$e->getMessage());
$this->error('Stack trace: ' . $e->getTraceAsString());
return self::FAILURE;
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
class Handler extends ExceptionHandler
{
/**
* The list of the inputs that are never flashed to the session on validation exceptions.
*
* @var array<int, string>
*/
protected $dontFlash = [
'current_password',
'password',
'password_confirmation',
];
/**
* Register the exception handling callbacks for the application.
*/
public function register(): void
{
$this->reportable(function (Throwable $e) {
//
});
}
public function render($request, Throwable $e)
{
\Illuminate\Support\Facades\Log::info('Handler render called', ['inertia' => $request->inertia(), 'exception' => get_class($e)]);
if ($request->inertia()) {
if ($e instanceof ValidationException) {
\Illuminate\Support\Facades\Log::info('ValidationException in Inertia', ['errors' => $e->errors()]);
return response()->json([
'message' => 'The given data was invalid.',
'errors' => $e->errors(),
], 422)->header('X-Inertia-Error', 'true');
}
if ($e instanceof \Exception) {
\Illuminate\Support\Facades\Log::info('Exception in Inertia', ['message' => $e->getMessage()]);
return response()->json([
'message' => 'Registrierung fehlgeschlagen.',
'errors' => ['general' => $e->getMessage()],
], 500)->header('X-Inertia-Error', 'true');
}
}
return parent::render($request, $e);
}
}

View File

@@ -40,10 +40,12 @@ class TenantResource extends Resource
{
return $form->schema([
TextInput::make('name')
TextInput::make('user.full_name')
->label(__('admin.tenants.fields.name'))
->required()
->maxLength(255),
->readOnly()
->dehydrated(false)
->getStateUsing(fn (Tenant $record) => $record->user->full_name),
TextInput::make('slug')
->label(__('admin.tenants.fields.slug'))
->required()
@@ -90,7 +92,11 @@ class TenantResource extends Resource
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('user.full_name')
->label(__('admin.tenants.fields.name'))
->searchable()
->sortable()
->getStateUsing(fn (Tenant $record) => $record->user->full_name),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('active_reseller_package_id')

View File

@@ -41,11 +41,11 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
$user = Auth::user();
if ($user && !$user->hasVerifiedEmail()) {
return redirect()->route('verification.notice');
if ($user && $user->email_verified_at === null) {
return Inertia::location(route('verification.notice'));
}
return redirect()->intended(route('dashboard', absolute: false));
return Inertia::location(route('dashboard', absolute: false));
}
/**

View File

@@ -15,18 +15,24 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\App;
use Inertia\Inertia;
use Inertia\Response;
class MarketingRegisterController extends Controller
{
/**
* Show the registration page.
*/
public function create(Request $request, $package_id = null): \Illuminate\View\View
public function create(Request $request, $package_id = null): Response
{
$package = $package_id ? Package::find($package_id) : null;
return view('marketing.register', [
App::setLocale('de');
return Inertia::render('Auth/Register', [
'package' => $package,
'privacyHtml' => view('legal.datenschutz')->render(),
]);
}
@@ -38,7 +44,6 @@ class MarketingRegisterController extends Controller
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
@@ -51,7 +56,6 @@ class MarketingRegisterController extends Controller
]);
$user = User::create([
'name' => $request->name,
'username' => $request->username,
'email' => $request->email,
'first_name' => $request->first_name,
@@ -122,12 +126,12 @@ class MarketingRegisterController extends Controller
$tenant->update(['subscription_status' => 'active']);
} else {
// Redirect to buy for paid package
return redirect()->route('buy.packages', $package->id);
return Inertia::location(route('buy.packages', $package->id));
}
}
return $user->hasVerifiedEmail()
? redirect()->intended(route('dashboard'))
: redirect()->route('verification.notice');
? Inertia::location(route('dashboard'))
: Inertia::location(route('verification.notice'));
}
}

View File

@@ -14,6 +14,7 @@ use Inertia\Inertia;
use Inertia\Response;
use App\Models\Tenant;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;
class RegisteredUserController extends Controller
{
@@ -36,8 +37,9 @@ class RegisteredUserController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
$fullName = trim($request->first_name . ' ' . $request->last_name);
$validated = $request->validate([
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
@@ -50,23 +52,20 @@ class RegisteredUserController extends Controller
]);
$user = User::create([
'name' => $request->name,
'username' => $request->username,
'email' => $request->email,
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'address' => $request->address,
'phone' => $request->phone,
'password' => Hash::make($request->password),
'username' => $validated['username'],
'email' => $validated['email'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'address' => $validated['address'],
'phone' => $validated['phone'],
'password' => Hash::make($validated['password']),
'privacy_consent_at' => now(), // Neues Feld für Consent (füge Migration hinzu, falls nötig)
]);
\Illuminate\Support\Facades\Log::info('Creating tenant for user ID: ' . $user->id);
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $request->name,
'slug' => Str::slug($request->name . '-' . now()->timestamp),
'name' => $fullName,
'slug' => Str::slug($fullName . '-' . now()->timestamp),
'email' => $request->email,
'is_active' => true,
'is_suspended' => false,
@@ -92,8 +91,6 @@ class RegisteredUserController extends Controller
]),
]);
\Illuminate\Support\Facades\Log::info('Tenant created with ID: ' . $tenant->id);
event(new Registered($user));
// Send Welcome Email
@@ -126,9 +123,7 @@ class RegisteredUserController extends Controller
}
}
\Illuminate\Support\Facades\Log::info('Logging in user ID: ' . $user->id);
Auth::login($user);
\Illuminate\Support\Facades\Log::info('User logged in: ' . (Auth::check() ? 'Yes' : 'No'));
return $user->hasVerifiedEmail()
? redirect()->intended(route('dashboard'))

View File

@@ -22,6 +22,7 @@ use App\Models\Package;
use App\Models\TenantPackage;
use App\Models\PackagePurchase;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class MarketingController extends Controller
{
@@ -38,7 +39,7 @@ class MarketingController extends Controller
['id' => 'premium', 'name' => 'Premium', 'events' => 50, 'price' => 199, 'description' => '50 Events, Support & Custom, Alle Features'],
];
return view('marketing', compact('packages'));
return Inertia::render('marketing/Home', compact('packages'));
}
public function contact(Request $request)
@@ -57,6 +58,11 @@ class MarketingController extends Controller
return redirect()->back()->with('success', 'Nachricht gesendet!');
}
public function contactView()
{
return Inertia::render('marketing/Kontakt');
}
/**
* Handle package purchase flow.
*/
@@ -341,7 +347,7 @@ class MarketingController extends Controller
return redirect('/admin')->with('success', __('marketing.success.welcome'));
}
return view('marketing.success', compact('packageId'));
return Inertia::render('marketing/Success', compact('packageId'));
}
public function blogIndex(Request $request)
@@ -377,7 +383,7 @@ class MarketingController extends Controller
Log::info('Blog Index Debug - Final Posts', ['count' => $posts->count(), 'total' => $posts->total()]);
return view('marketing.blog', compact('posts'));
return Inertia::render('marketing/Blog', compact('posts'));
}
public function blogShow($slug)
@@ -394,26 +400,28 @@ class MarketingController extends Controller
->whereJsonContains("translations->locale->title->{$locale}", true)
->firstOrFail();
return view('marketing.blog-show', compact('post'));
return Inertia::render('marketing/BlogShow', compact('post'));
}
public function packagesIndex()
{
$endcustomerPackages = Package::where('type', 'endcustomer')->orderBy('price')->get()->map(function ($p) {
return $p->append(['features', 'limits']);
});
$resellerPackages = Package::where('type', 'reseller')->orderBy('price')->get()->map(function ($p) {
return $p->append(['features', 'limits']);
});
$endcustomerPackages = Package::where('type', 'endcustomer')->orderBy('price')->get();
$resellerPackages = Package::where('type', 'reseller')->orderBy('price')->get();
return view('marketing.packages', compact('endcustomerPackages', 'resellerPackages'));
return Inertia::render('marketing/Packages', compact('endcustomerPackages', 'resellerPackages'));
}
public function occasionsType($locale, $type)
{
$validTypes = ['weddings', 'birthdays', 'corporate-events', 'family-celebrations'];
if (!in_array($type, $validTypes)) {
abort(404, 'Invalid occasion type');
}
return view('marketing.occasions', ['type' => $type]);
return Inertia::render('marketing/Occasions', ['type' => $type]);
}
}

View File

@@ -30,7 +30,6 @@ class ProfileController extends Controller
// Authorized via auth middleware
$request->validate([
'name' => 'required|string|max:255',
'username' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:users,username,' . $user->id],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,' . $user->id],
'first_name' => ['required', 'string', 'max:255'],
@@ -40,7 +39,7 @@ class ProfileController extends Controller
]);
$user->update($request->only([
'name', 'username', 'email', 'first_name', 'last_name', 'address', 'phone'
'username', 'email', 'first_name', 'last_name', 'address', 'phone'
]));
return back()->with('status', 'profile-updated');

View File

@@ -22,7 +22,6 @@ class User extends Authenticatable implements MustVerifyEmail
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
'username',

View File

@@ -24,7 +24,8 @@ class UserFactory extends Factory
public function definition(): array
{
return [
'name' => fake()->name(),
'first_name' => fake()->firstName(),
'last_name' => fake()->lastName(),
'username' => fake()->unique()->userName(),
'email' => fake()->unique()->safeEmail(),
'first_name' => fake()->firstName(),

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('name')->after('id');
});
}
};

View File

@@ -17,7 +17,7 @@ class PackageSeeder extends Seeder
// Endcustomer Packages
Package::create([
'name' => 'Free / Test',
'slug' => Str::slug('Free / Test'),
'slug' => 'free-package',
'type' => PackageType::ENDCUSTOMER,
'price' => 0.00,
'max_photos' => 30,

View File

@@ -13,7 +13,8 @@ class SuperAdminSeeder extends Seeder
$email = env('ADMIN_EMAIL', 'admin@example.com');
$password = env('ADMIN_PASSWORD', 'ChangeMe123!');
User::updateOrCreate(['email'=>$email], [
'name' => 'Super Admin',
'first_name' => 'Super',
'last_name' => 'Admin',
'password' => Hash::make($password),
'role' => 'super_admin',
]);

View File

@@ -0,0 +1,69 @@
# Vereinheitlichung des Marketing-Frontends: Von Hybrid (Blade + React) zu konsistentem Inertia/React-Layout
## Problemstellung
Das aktuelle Marketing-Frontend kombiniert Blade-Templates (z.B. für statische Seiten wie Blog, Legal) mit Vite/React-Komponenten (z.B. Packages, Register). Bei rein React-gerenderten Seiten fehlt das Layout (Header, Footer) und das Styling (z.B. Aurora-Gradient, Fonts), was zu inkonsistentem UX führt:
- Blade-Seiten: Vollständiges Layout via @extends('layouts.marketing').
- React-Seiten: Nur Komponente, kein Wrapper → Kein Header/Footer, anderes Styling.
Ziel: Vollständige Migration zu Inertia.js für SPA-ähnliche Konsistenz, mit einem zentralen React-Layout für alle Marketing-Seiten. Vorteile: Einheitliches Design, bessere Navigation, einfachere Wartung.
## Architektur-Vorschlag
### 1. Kernkomponenten
- **MarketingLayout.tsx** (resources/js/layouts/MarketingLayout.tsx): Wrapper für alle Marketing-Seiten.
- Header: Logo, Navigation (Home, Packages, Blog, Occasions, Register/Login).
- Hauptinhalt: `{children}` (die spezifische Page-Komponente).
- Footer: Impressum, Datenschutz, Social-Links, Copyright.
- Styling: Tailwind-Klassen für Aurora-Gradient (bg-aurora-enhanced), Fonts (Playfair Display für Überschriften, Montserrat für Text).
- **Globale Styles** (resources/css/app.css):
- @font-face für Montserrat und Playfair Display (via Google Fonts oder lokal).
- .bg-aurora-enhanced: radial-gradient(circle at 20% 80%, #a8edea 0%, #fed6e3 50%, #d299c2 100%) + linear-gradient + animation (shadcn-Style).
- Theme: Primärfarbe #FFB6C1, responsive (mobile-first).
### 2. Routing & Controller
- **web.php** (routes/web.php): Alle /marketing/*-Routes zu Inertia::render umstellen.
- Beispiel: Route::inertia('/marketing/packages', 'Marketing/Packages');
- **MarketingController.php** (app/Http/Controllers/MarketingController.php):
- Methoden (z.B. packages(), blog(), register()) liefern Props (z.B. packages: Package::all()->map(fn($p) => ['id' => $p->id, 'features' => $p->features, 'limits' => $p->limits])).
- Für dynamische Inhalte: DB-Queries (z.B. BlogPosts für /blog).
### 3. Page-Komponenten
- Alle Marketing-Seiten als React/TSX (resources/js/pages/marketing/*.tsx):
- z.B. Packages.tsx: Rendert Paket-Karten in Grid/Carousel (shadcn), mit Modal für Details/Upsell.
- Wrapper: In App.tsx oder router.tsx: if (route.startsWith('/marketing')) return <MarketingLayout><Page /></MarketingLayout>;
- Migration-Reihenfolge:
1. Statische Seiten (Home, Blog-Index): Von Blade zu Inertia.
2. Dynamische (Packages, Register): Props integrieren.
3. Legal-Seiten: Als einfache Inertia-Pages (statischer Text).
### 4. Technische Umsetzung
- **Inertia-Setup**: Stelle sicher, config/inertia.php hat middleware für SSR (optional) und shared props (z.B. auth, flash).
- **Auth-Integration**: usePage().props.auth für CTAs (z.B. Login-Button im Header).
- **Responsive Design**: Tailwind: block md:hidden für Mobile-Carousel in Packages.
- **Fonts & Assets**: Vite-Plugin für Fonts/SVGs; preload in Layout.
- **Tests**: E2E mit Playwright (z.B. navigate to /packages, check header/footer presence).
### 5. Diagramm: Layout-Struktur
```
MarketingLayout.tsx
├── Header (Navigation, Logo)
├── {children} (z.B. Packages.tsx)
│ ├── Hero (Aurora-Gradient)
│ ├── Content (Grid/Carousel)
│ └── CTA-Section
└── Footer (Legal-Links)
```
### 6. Migrations-Schritte (für Code-Modus)
1. Erstelle MarketingLayout.tsx und integriere in Router.
2. Migriere eine Test-Seite (z.B. /packages): Controller + Page-Komponente.
3. Passe app.css an (Fonts, Gradients).
4. Test: npm run dev, Browser-Check auf Layout-Konsistenz.
5. Vollständige Migration: Alle Blade-Seiten umstellen.
6. Edge-Cases: SEO (Inertia Head), Performance (Lazy-Loading).
### 7. Risiken & Mitigation
- Layout-Brüche während Migration: Fallback zu Blade via Feature-Flag.
- Styling-Konflikte: CSS-Isolation mit Tailwind-Prefix.
- Performance: Code-Splitting für große Pages.
Dieser Plan basiert auf bestehender Struktur (docs/prp/ als Referenz). Nach Umsetzung: Update PRP (docs/prp/01-architecture.md).

View File

@@ -0,0 +1,41 @@
# Packages-Seite UI-Verbesserungen: Analyse und Plan
## Recherche-Zusammenfassung
### Features und Abgrenzungen (aus docs/prp/15-packages-design.md, Model/Seeder)
- **Endkunden-Pakete (Einmalkauf pro Event)**:
- Free/Test (0€): max_photos=30, max_guests=50, gallery_days=7, max_tasks=5, watermark_allowed=true, features=['basic_uploads', 'limited_sharing'].
- Starter (29€): max_photos=200, max_guests=100, gallery_days=30, max_tasks=10, watermark_allowed=true, features=['basic_uploads', 'unlimited_sharing', 'no_watermark', 'custom_tasks'].
- Pro (79€): max_photos=1000, max_guests=500, gallery_days=90, max_tasks=20, watermark_allowed=false, features=['basic_uploads', 'unlimited_sharing', 'no_watermark', 'custom_tasks', 'advanced_analytics', 'priority_support', 'live_slideshow'].
- **Reseller-Pakete (Subscription jährlich)**:
- S (199€): max_events_per_year=5, max_photos=500 (per Event), features=['reseller_dashboard', 'custom_branding', 'priority_support'].
- M (399€): max_events_per_year=15, max_photos=1000, features=['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'].
- Enterprise (999€+): Unlimited, White-Label, Custom Domain.
- **Limits**: max_photos, max_guests, gallery_days, max_tasks, max_events_per_year (vererbt an Events).
- **Abgrenzungen**: Watermark/Branding (bool), Support (priority), Analytics (advanced), Galerie-Dauer, Task-Anzahl. Filament: TenantResource trackt active_package, remaining_events; RelationManagers für Purchases/Packages (manual add).
### Carousel-Umsetzung
- Ja, vollständig: shadcn Carousel für mobile (block md:hidden, Swipe mit Previous/Next Buttons, basis-full Items); Desktop: hidden md:block Grid (md:grid-cols-3/2). Dynamisch aus Props (endcustomerPackages, resellerPackages).
### Umgesetzte UI-Verbesserungen
- Responsive Design: Mobile Carousel vs Desktop Grid.
- Dynamische Darstellung: Features map() aus JSON, partial Limits (max_photos, max_tenants).
- Hero-Section: Aurora-Gradient (bg-aurora-enhanced), Playfair Display Überschrift, Montserrat Text.
- Konsistentes Layout: MarketingLayout (Header mit Nav, Footer mit Legal-Links).
- CTA: Links zu /buy-packages/{id}, Hover-Transitions.
- Fonts: font-display (Playfair), font-sans-marketing (Montserrat).
### Offene/Mögliche Verbesserungen
1. **Multi-Step-Modal auf Card-Click**: Dialog (shadcn) mit Tabs (Step 1: Details + Social Proof/Testimonials (3 Cards mit Stars); Step 2: Upsell-Tabelle (shadcn Table, Spalten: Features/Limits, Zeilen: alle Packages, Highlight selected mit bg-[#FFB6C1]); Step 3: CTA (usePage().props.auth ? Link /buy-packages : /register?package_id, localStorage pre-fill für Name/Email)).
2. **Erweiterte Limits-Darstellung**: Vollständig in Cards (gallery_days, max_guests, max_tasks als <li>, watermark/branding als Badge/Check/X-Icons).
3. **UI-Enhancements**: Progress Bar (33/66/100% für Steps), Micro-Interactions (Card-Hover: scale-105/shadow-lg), FAQ-Section (Accordion mit 4 Fragen: Free-Paket, Upgrade, Reseller, Zahlung), Testimonials-Section (3 Cards mit Quotes/Ratings).
4. **Desktop Pricing Table**: Toggle-Button neben Grid (View: Table-Modus, Vergleichs-View mit Checkmarks für Features).
5. **Weitere**: A/B-Testing (CTAs), Accessibility (ARIA-Labels für Carousel/Modal, Keyboard-Nav), SEO (Head meta description pro Package), Performance (Lazy Testimonials), Integration (Track Clicks mit Analytics).
## Implementierungs-Plan (Code-Modus)
1. **Modal hinzufügen**: useState für open/selected/step; Dialog mit Tabs; Step 1: Details + Testimonials; Step 2: Table (alle Packages); Step 3: CTA (auth-check, pre-fill).
2. **Limits erweitern**: In Cards <li> für gallery_days/max_guests/max_tasks; Badges für watermark/branding.
3. **UI-Verbesserungen**: Progress in Modal, Hover auf Cards, FAQ-Accordion, Testimonials-Section.
4. **Pricing Table**: useState für viewMode (Grid/Table); Table mit Check/X für Features.
5. **Test**: npm run build/dev; Browser: Card-Click → Modal-Steps, Tabelle-Vergleich, CTA-Redirect, Responsiveness.
Nach Umsetzung: Update PRP (docs/prp/15-packages-design.md mit UI-Details).

BIN
free-step1-home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

BIN
free-step1-packages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

BIN
free-step2-packages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

135
package-lock.json generated
View File

@@ -8,16 +8,19 @@
"@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0",
"@playwright/mcp": "^0.0.37",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
@@ -29,6 +32,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"globals": "^15.14.0",
"html5-qrcode": "^2.3.8",
"laravel-vite-plugin": "^2.0",
@@ -1860,6 +1866,37 @@
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
@@ -2023,6 +2060,7 @@
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
@@ -2372,6 +2410,30 @@
}
}
},
"node_modules/@radix-ui/react-progress": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
@@ -2470,6 +2532,7 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
@@ -2483,6 +2546,36 @@
}
}
},
"node_modules/@radix-ui/react-tabs": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
@@ -4821,6 +4914,43 @@
"integrity": "sha512-/1hFJ39wkW01ogqSyYoA4goOXOtMRy6B+yvA1u42nnsEGtHzIzmk93aPISumVQeblj47JUHLC9coCjUxb1EvtQ==",
"license": "ISC"
},
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
},
"node_modules/embla-carousel-autoplay": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.6.0.tgz",
"integrity": "sha512-OBu5G3nwaSXkZCo1A6LTaFMZ8EpkYbwIaH+bPqdBnDGQ2fh4+NbzjXjs2SktoPNKCtflfVMc75njaDHOYXcrsA==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -7198,6 +7328,7 @@
"version": "0.475.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz",
"integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}

View File

@@ -29,16 +29,19 @@
"@headlessui/react": "^2.2.0",
"@inertiajs/react": "^2.1.0",
"@playwright/mcp": "^0.0.37",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
@@ -50,6 +53,9 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.0.1",
"embla-carousel": "^8.6.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0",
"globals": "^15.14.0",
"html5-qrcode": "^2.3.8",
"laravel-vite-plugin": "^2.0",

BIN
paid-end-step1-packages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

BIN
paid-res-step1-packages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -1,36 +0,0 @@
# Page snapshot
```yaml
- generic [ref=e4]:
- generic [ref=e5]:
- link [ref=e6] [cursor=pointer]:
- /url: https://laravel.com
- img [ref=e7] [cursor=pointer]
- img [ref=e9]
- link [ref=e11] [cursor=pointer]:
- /url: https://vitejs.dev
- img [ref=e12] [cursor=pointer]
- generic [ref=e15]:
- generic [ref=e16]:
- paragraph [ref=e17]: This is the Vite development server that provides Hot Module Replacement for your Laravel application.
- paragraph [ref=e18]: To access your Laravel application, you will need to run a local development server.
- heading "Artisan Serve" [level=2] [ref=e19]:
- link "Artisan Serve" [ref=e20] [cursor=pointer]:
- /url: https://laravel.com/docs/installation#your-first-laravel-project
- paragraph [ref=e21]: Laravel's local development server powered by PHP's built-in web server.
- heading "Laravel Sail" [level=2] [ref=e22]:
- link "Laravel Sail" [ref=e23] [cursor=pointer]:
- /url: https://laravel.com/docs/sail
- paragraph [ref=e24]: A light-weight command-line interface for interacting with Laravel's default Docker development environment.
- generic [ref=e25]:
- paragraph [ref=e26]:
- text: Your Laravel application's configured
- code [ref=e27]: APP_URL
- text: "is:"
- link "http://localhost:8000" [ref=e28] [cursor=pointer]:
- /url: http://localhost:8000
- paragraph [ref=e29]: Want more information on Laravel's Vite integration?
- paragraph [ref=e30]:
- link "Read the docs →" [ref=e31] [cursor=pointer]:
- /url: https://laravel.com/docs/vite
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -0,0 +1,274 @@
# Page snapshot
```yaml
- generic:
- generic:
- generic:
- banner:
- generic:
- generic:
- link:
- /url: /
- text: Die Fotospiel.App
- img
- navigation:
- link:
- /url: /#how-it-works
- text: So funktioniert es
- link:
- /url: /#features
- text: Features
- generic:
- button: Anlässe
- link:
- /url: /blog
- text: Blog
- link:
- /url: /packages
- text: Packages
- link:
- /url: /kontakt
- text: Kontakt
- link:
- /url: /packages
- text: Packages entdecken
- main:
- generic:
- generic:
- heading [level=1]: Unsere Packages
- paragraph: Wählen Sie das passende Paket für Ihr Event von kostenlos bis premium.
- link:
- /url: "#endcustomer"
- text: Jetzt entdecken
- generic:
- generic:
- heading [level=2]: Für Endkunden
- generic:
- generic:
- generic:
- generic:
- img
- heading [level=3]: Free / Test
- paragraph: 0.00 €
- list:
- listitem: • Events
- listitem: • Max. 30 Fotos
- listitem: • Galerie 7 Tage
- listitem: • Max. 50 Gäste
- button: Details anzeigen
- generic:
- generic:
- img
- heading [level=3]: Starter
- paragraph: 29.00 €
- list:
- listitem: • Events
- listitem: • Max. 200 Fotos
- listitem: • Galerie 30 Tage
- listitem: • Max. 100 Gäste
- button: Details anzeigen
- generic:
- generic:
- img
- heading [level=3]: Pro
- paragraph: 79.00 €
- list:
- listitem: • Events
- listitem: • Max. 1000 Fotos
- listitem: • Galerie 90 Tage
- listitem: • Max. 500 Gäste
- listitem:
- generic: Kein Watermark
- button: Details anzeigen
- generic:
- heading [level=3]: Endkunden-Pakete vergleichen
- generic:
- generic:
- table:
- rowgroup:
- row:
- cell: Feature
- cell: Free / Test
- cell: Starter
- cell: Pro
- rowgroup:
- row:
- cell: Preis
- cell: 0.00 €
- cell: 29.00 €
- cell: 79.00 €
- row:
- cell:
- text: Max. Fotos
- img
- cell: "30"
- cell: "200"
- cell: "1000"
- row:
- cell:
- text: Max. Gäste
- img
- cell: "50"
- cell: "100"
- cell: "500"
- row:
- cell:
- text: Galerie Tage
- img
- cell: "7"
- cell: "30"
- cell: "90"
- row:
- cell:
- text: Watermark
- img
- cell:
- img
- cell:
- img
- cell:
- img
- generic:
- generic:
- heading [level=2]: Für Reseller
- generic:
- generic:
- generic:
- generic:
- img
- heading [level=3]: S (Small Reseller)
- paragraph: 199.00 € / Jahr
- list:
- listitem:
- generic: Custom Branding
- button: Details anzeigen
- generic:
- generic:
- img
- heading [level=3]: M (Medium Reseller)
- paragraph: 399.00 € / Jahr
- list:
- listitem:
- generic: Custom Branding
- button: Details anzeigen
- generic:
- generic:
- heading [level=2]: Häufige Fragen
- generic:
- generic:
- heading [level=3]: Was ist das Free-Paket?
- paragraph: "Ideal für Tests: 30 Fotos, 7 Tage Galerie, mit Watermark."
- generic:
- heading [level=3]: Kann ich upgraden?
- paragraph: Ja, jederzeit im Dashboard Limits werden sofort erweitert.
- generic:
- heading [level=3]: Was für Reseller?
- paragraph: Jährliche Subscriptions mit Dashboard, Branding und Support.
- generic:
- heading [level=3]: Zahlungssicher?
- paragraph: Sichere Zahlung via Stripe/PayPal, 14 Tage Rückgaberecht.
- generic:
- generic:
- heading [level=2]: Was unsere Kunden sagen
- generic:
- generic:
- paragraph: "\"Das Starter-Paket war perfekt für unsere Hochzeit einfach und günstig!\""
- generic:
- generic:
- img
- img
- img
- img
- img
- paragraph: Anna M.
- generic:
- paragraph: "\"Pro-Paket mit Analytics hat uns geholfen, die besten Momente zu finden.\""
- generic:
- generic:
- img
- img
- img
- img
- img
- paragraph: Max B.
- generic:
- paragraph: "\"Als Reseller spare ich Zeit mit dem M-Paket super Support!\""
- generic:
- generic:
- img
- img
- img
- img
- img
- paragraph: Lisa K.
- contentinfo:
- generic:
- paragraph: © 2025 Fotospiel GmbH. Alle Rechte vorbehalten.
- generic:
- link:
- /url: /impressum
- text: Impressum
- link:
- /url: /datenschutz
- text: Datenschutz
- link:
- /url: /kontakt
- text: Kontakt
- dialog "Starter - Details" [ref=e2]:
- heading "Starter - Details" [level=2] [ref=e4]
- generic [ref=e5]:
- tablist [ref=e6]:
- tab "Details" [active] [selected] [ref=e7]
- tab "Kaufen" [ref=e8]
- progressbar [ref=e9]
- tabpanel "Details" [ref=e11]:
- generic [ref=e12]:
- generic [ref=e13]:
- heading "Starter" [level=2] [ref=e14]
- paragraph [ref=e15]: 29.00 €
- paragraph
- generic [ref=e16]:
- generic [ref=e17]:
- img
- text: Max. 200 Fotos
- generic [ref=e18]:
- img
- text: Max. 100 Gäste
- generic [ref=e19]:
- img
- text: 30 Tage Galerie
- generic [ref=e20]:
- heading "Was Kunden sagen" [level=3] [ref=e21]
- generic [ref=e22]:
- generic [ref=e23]:
- paragraph [ref=e24]: "\"Das Starter-Paket war perfekt für unsere Hochzeit einfach und günstig!\""
- paragraph [ref=e25]: Anna M.
- generic [ref=e26]:
- img [ref=e27]
- img [ref=e29]
- img [ref=e31]
- img [ref=e33]
- img [ref=e35]
- generic [ref=e37]:
- paragraph [ref=e38]: "\"Pro-Paket mit Analytics hat uns geholfen, die besten Momente zu finden.\""
- paragraph [ref=e39]: Max B.
- generic [ref=e40]:
- img [ref=e41]
- img [ref=e43]
- img [ref=e45]
- img [ref=e47]
- img [ref=e49]
- generic [ref=e51]:
- paragraph [ref=e52]: "\"Als Reseller spare ich Zeit mit dem M-Paket super Support!\""
- paragraph [ref=e53]: Lisa K.
- generic [ref=e54]:
- img [ref=e55]
- img [ref=e57]
- img [ref=e59]
- img [ref=e61]
- img [ref=e63]
- button "Zum Kauf" [ref=e65]
- button "Close" [ref=e66]:
- img
- generic [ref=e67]: Close
```

View File

@@ -0,0 +1,274 @@
# Page snapshot
```yaml
- generic:
- generic:
- generic:
- banner:
- generic:
- generic:
- link:
- /url: /
- text: Die Fotospiel.App
- img
- navigation:
- link:
- /url: /#how-it-works
- text: So funktioniert es
- link:
- /url: /#features
- text: Features
- generic:
- button: Anlässe
- link:
- /url: /blog
- text: Blog
- link:
- /url: /packages
- text: Packages
- link:
- /url: /kontakt
- text: Kontakt
- link:
- /url: /packages
- text: Packages entdecken
- main:
- generic:
- generic:
- heading [level=1]: Unsere Packages
- paragraph: Wählen Sie das passende Paket für Ihr Event von kostenlos bis premium.
- link:
- /url: "#endcustomer"
- text: Jetzt entdecken
- generic:
- generic:
- heading [level=2]: Für Endkunden
- generic:
- generic:
- generic:
- generic:
- img
- heading [level=3]: Free / Test
- paragraph: 0.00 €
- list:
- listitem: • Events
- listitem: • Max. 30 Fotos
- listitem: • Galerie 7 Tage
- listitem: • Max. 50 Gäste
- button: Details anzeigen
- generic:
- generic:
- img
- heading [level=3]: Starter
- paragraph: 29.00 €
- list:
- listitem: • Events
- listitem: • Max. 200 Fotos
- listitem: • Galerie 30 Tage
- listitem: • Max. 100 Gäste
- button: Details anzeigen
- generic:
- generic:
- img
- heading [level=3]: Pro
- paragraph: 79.00 €
- list:
- listitem: • Events
- listitem: • Max. 1000 Fotos
- listitem: • Galerie 90 Tage
- listitem: • Max. 500 Gäste
- listitem:
- generic: Kein Watermark
- button: Details anzeigen
- generic:
- heading [level=3]: Endkunden-Pakete vergleichen
- generic:
- generic:
- table:
- rowgroup:
- row:
- cell: Feature
- cell: Free / Test
- cell: Starter
- cell: Pro
- rowgroup:
- row:
- cell: Preis
- cell: 0.00 €
- cell: 29.00 €
- cell: 79.00 €
- row:
- cell:
- text: Max. Fotos
- img
- cell: "30"
- cell: "200"
- cell: "1000"
- row:
- cell:
- text: Max. Gäste
- img
- cell: "50"
- cell: "100"
- cell: "500"
- row:
- cell:
- text: Galerie Tage
- img
- cell: "7"
- cell: "30"
- cell: "90"
- row:
- cell:
- text: Watermark
- img
- cell:
- img
- cell:
- img
- cell:
- img
- generic:
- generic:
- heading [level=2]: Für Reseller
- generic:
- generic:
- generic:
- generic:
- img
- heading [level=3]: S (Small Reseller)
- paragraph: 199.00 € / Jahr
- list:
- listitem:
- generic: Custom Branding
- button: Details anzeigen
- generic:
- generic:
- img
- heading [level=3]: M (Medium Reseller)
- paragraph: 399.00 € / Jahr
- list:
- listitem:
- generic: Custom Branding
- button: Details anzeigen
- generic:
- generic:
- heading [level=2]: Häufige Fragen
- generic:
- generic:
- heading [level=3]: Was ist das Free-Paket?
- paragraph: "Ideal für Tests: 30 Fotos, 7 Tage Galerie, mit Watermark."
- generic:
- heading [level=3]: Kann ich upgraden?
- paragraph: Ja, jederzeit im Dashboard Limits werden sofort erweitert.
- generic:
- heading [level=3]: Was für Reseller?
- paragraph: Jährliche Subscriptions mit Dashboard, Branding und Support.
- generic:
- heading [level=3]: Zahlungssicher?
- paragraph: Sichere Zahlung via Stripe/PayPal, 14 Tage Rückgaberecht.
- generic:
- generic:
- heading [level=2]: Was unsere Kunden sagen
- generic:
- generic:
- paragraph: "\"Das Starter-Paket war perfekt für unsere Hochzeit einfach und günstig!\""
- generic:
- generic:
- img
- img
- img
- img
- img
- paragraph: Anna M.
- generic:
- paragraph: "\"Pro-Paket mit Analytics hat uns geholfen, die besten Momente zu finden.\""
- generic:
- generic:
- img
- img
- img
- img
- img
- paragraph: Max B.
- generic:
- paragraph: "\"Als Reseller spare ich Zeit mit dem M-Paket super Support!\""
- generic:
- generic:
- img
- img
- img
- img
- img
- paragraph: Lisa K.
- contentinfo:
- generic:
- paragraph: © 2025 Fotospiel GmbH. Alle Rechte vorbehalten.
- generic:
- link:
- /url: /impressum
- text: Impressum
- link:
- /url: /datenschutz
- text: Datenschutz
- link:
- /url: /kontakt
- text: Kontakt
- dialog "Free / Test - Details" [ref=e2]:
- heading "Free / Test - Details" [level=2] [ref=e4]
- generic [ref=e5]:
- tablist [ref=e6]:
- tab "Details" [active] [selected] [ref=e7]
- tab "Kaufen" [ref=e8]
- progressbar [ref=e9]
- tabpanel "Details" [ref=e11]:
- generic [ref=e12]:
- generic [ref=e13]:
- heading "Free / Test" [level=2] [ref=e14]
- paragraph [ref=e15]: 0.00 €
- paragraph
- generic [ref=e16]:
- generic [ref=e17]:
- img
- text: Max. 30 Fotos
- generic [ref=e18]:
- img
- text: Max. 50 Gäste
- generic [ref=e19]:
- img
- text: 7 Tage Galerie
- generic [ref=e20]:
- heading "Was Kunden sagen" [level=3] [ref=e21]
- generic [ref=e22]:
- generic [ref=e23]:
- paragraph [ref=e24]: "\"Das Starter-Paket war perfekt für unsere Hochzeit einfach und günstig!\""
- paragraph [ref=e25]: Anna M.
- generic [ref=e26]:
- img [ref=e27]
- img [ref=e29]
- img [ref=e31]
- img [ref=e33]
- img [ref=e35]
- generic [ref=e37]:
- paragraph [ref=e38]: "\"Pro-Paket mit Analytics hat uns geholfen, die besten Momente zu finden.\""
- paragraph [ref=e39]: Max B.
- generic [ref=e40]:
- img [ref=e41]
- img [ref=e43]
- img [ref=e45]
- img [ref=e47]
- img [ref=e49]
- generic [ref=e51]:
- paragraph [ref=e52]: "\"Als Reseller spare ich Zeit mit dem M-Paket super Support!\""
- paragraph [ref=e53]: Lisa K.
- generic [ref=e54]:
- img [ref=e55]
- img [ref=e57]
- img [ref=e59]
- img [ref=e61]
- img [ref=e63]
- button "Zum Kauf" [ref=e65]
- button "Close" [ref=e66]:
- img
- generic [ref=e67]: Close
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

BIN
public/fonts/Lora-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -10,6 +10,10 @@
@theme {
--font-sans:
'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-display: 'Playfair Display', serif;
--font-serif: 'Lora', serif;
--font-sans-marketing: 'Montserrat', sans-serif;
--font-script: 'Great Vibes', cursive;
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
@@ -59,6 +63,77 @@
--color-sidebar-ring: var(--sidebar-ring);
}
@font-face {
font-family: 'Montserrat';
src: url('/fonts/Montserrat-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Montserrat';
src: url('/fonts/Montserrat-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Lora';
src: url('/fonts/Lora-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Lora';
src: url('/fonts/Lora-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Playfair Display';
src: url('/fonts/PlayfairDisplay-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Playfair Display';
src: url('/fonts/PlayfairDisplay-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Great Vibes';
src: url('/fonts/GreatVibes-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@layer utilities {
.font-display {
font-family: var(--font-display);
}
.font-serif-custom {
font-family: var(--font-serif);
}
.font-sans-marketing {
font-family: var(--font-sans-marketing);
}
.font-script {
font-family: var(--font-script);
}
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
@@ -174,3 +249,14 @@
@apply bg-background text-foreground;
}
}
@keyframes aurora {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.bg-aurora-enhanced {
background: radial-gradient(circle at 20% 80%, #a8edea 0%, #fed6e3 50%, #d299c2 100%), linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%, 400% 400%;
animation: aurora 20s ease infinite;
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Link } from '@inertiajs/react';
const MarketingFooter: React.FC = () => {
return (
<footer className="bg-gray-800 text-white py-8 px-4">
<div className="container mx-auto text-center">
<p>&copy; 2025 Fotospiel GmbH. Alle Rechte vorbehalten.</p>
<div className="mt-4 space-x-4">
<Link href="/impressum" className="hover:text-[#FFB6C1]">
Impressum
</Link>
<Link href="/datenschutz" className="hover:text-[#FFB6C1]">
Datenschutz
</Link>
<Link href="/kontakt" className="hover:text-[#FFB6C1]">
Kontakt
</Link>
</div>
</div>
</footer>
);
};
export default MarketingFooter;

View File

@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import { Link } from '@inertiajs/react';
import { usePage } from '@inertiajs/react';
const MarketingHeader: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const { url } = usePage();
const occasions = [
{ href: '/de/occasions/weddings', label: 'Hochzeiten' },
{ href: '/de/occasions/birthdays', label: 'Geburtstage' },
{ href: '/de/occasions/corporate-events', label: 'Firmenevents' },
{ href: '/de/occasions/family-celebrations', label: 'Familienfeiern' },
];
return (
<header className="bg-white shadow-md sticky top-0 z-50">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Link href="/" className="text-2xl font-bold text-gray-900">
Die Fotospiel.App
</Link>
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<nav className="hidden md:flex space-x-6 items-center">
<Link href="/#how-it-works" className="text-gray-600 hover:text-gray-900">
So funktioniert es
</Link>
<Link href="/#features" className="text-gray-600 hover:text-gray-900">
Features
</Link>
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="text-gray-600 hover:text-gray-900"
>
Anlässe
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-2 bg-white border rounded shadow-lg z-10">
{occasions.map((occasion) => (
<Link
key={occasion.href}
href={occasion.href}
className="block px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-50 transition"
onClick={() => setIsOpen(false)}
>
{occasion.label}
</Link>
))}
</div>
)}
</div>
<Link href="/blog" className="text-gray-600 hover:text-gray-900">
Blog
</Link>
<Link href="/packages" className="text-gray-600 hover:text-gray-900">
Packages
</Link>
<Link href="/kontakt" className="text-gray-600 hover:text-gray-900">
Kontakt
</Link>
<Link
href="/packages"
className="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition"
>
Packages entdecken
</Link>
</nav>
<button className="md:hidden text-gray-600"></button>
</div>
</header>
);
};
export default MarketingHeader;

View File

@@ -0,0 +1,55 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,144 @@
"use client"
import * as React from "react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import Autoplay from "embla-carousel-autoplay"
import useEmblaCarousel from "embla-carousel-react"
interface CarouselApi {
slideNodes(): HTMLElement[]
on(event: string, listener: (...args: any[]) => void): void
scrollPrev(): void
scrollNext(): void
reInit(): void
}
const CarouselContext = React.createContext<CarouselApi | null>(null)
interface CarouselProps {
opts?: {
align?: "start" | "center" | "end"
loop?: boolean
}
plugins?: any[]
setApi?: (api: CarouselApi) => void
[key: string]: any
}
const Carousel = React.forwardRef<HTMLDivElement, CarouselProps>(
({ opts, plugins = [Autoplay()], setApi, className, children, ...props }, ref) => {
const [api, setApiInternal] = React.useState<CarouselApi | null>(null)
const [current, setCurrent] = React.useState(0)
const [count, setCount] = React.useState(0)
const [emblaRef] = useEmblaCarousel(opts, plugins)
React.useEffect(() => {
if (!api) {
return
}
setCount(api.slideNodes().length)
api.on("reInit", setCount)
api.on("slideChanged", ({ slide }: { slide: number }) => setCurrent(slide))
setApi?.(api)
}, [api, setApi])
return (
<CarouselContext.Provider value={api}>
<div
ref={ref}
className={cn(
"relative w-full",
className
)}
{...props}
>
<div
className="overflow-hidden"
ref={emblaRef}
>
<div className="flex">{children}</div>
</div>
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
interface CarouselContentProps {
children: React.ReactNode
className?: string
}
const CarouselContent = React.forwardRef<HTMLDivElement, CarouselContentProps>(
({ children, className }, ref) => (
<div ref={ref} className={cn("flex", className)}>
{children}
</div>
)
)
CarouselContent.displayName = "CarouselContent"
interface CarouselItemProps {
children: React.ReactNode
className?: string
}
const CarouselItem = React.forwardRef<HTMLDivElement, CarouselItemProps>(
({ children, className }, ref) => (
<div
ref={ref}
className={cn("min-w-0 shrink-0 grow-0 basis-full pl-4 md:pl-6", className)}
>
{children}
</div>
)
)
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => (
<Button
ref={ref}
variant="outline"
size="icon"
className={cn(
"absolute -left-1 top-1/2 -translate-y-1/2 rounded-full h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background/90 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
))
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, ...props }, ref) => (
<Button
ref={ref}
variant="outline"
size="icon"
className={cn(
"absolute -right-1 top-1/2 -translate-y-1/2 rounded-full h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background/90 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
))
CarouselNext.displayName = "CarouselNext"
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,108 @@
import React from 'react';
import { Link } from '@inertiajs/react';
import { usePage } from '@inertiajs/react';
interface MarketingLayoutProps {
children: React.ReactNode;
title?: string;
}
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) => {
const { props } = usePage();
const { auth } = props as any;
return (
<div className="min-h-screen flex flex-col bg-gray-50">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
{/* Logo */}
<Link href="/marketing" className="text-2xl font-bold font-display text-pink-500">
FotoSpiel
</Link>
{/* Navigation */}
<nav className="hidden md:flex space-x-8">
<Link href="/marketing" className="text-gray-700 hover:text-pink-500 font-sans-marketing transition-colors">
Home
</Link>
<Link href="/marketing/packages" className="text-gray-700 hover:text-pink-500 font-sans-marketing transition-colors">
Pakete
</Link>
<Link href="/marketing/blog" className="text-gray-700 hover:text-pink-500 font-sans-marketing transition-colors">
Blog
</Link>
<Link href="/marketing/occasions" className="text-gray-700 hover:text-pink-500 font-sans-marketing transition-colors">
Anlässe
</Link>
{auth.user ? (
<Link href="/dashboard" className="text-gray-700 hover:text-pink-500 font-sans-marketing transition-colors">
Dashboard
</Link>
) : (
<Link href="/marketing/register" className="text-gray-700 hover:text-pink-500 font-sans-marketing transition-colors">
Registrieren
</Link>
)}
</nav>
{/* Mobile Menu Button TODO: Implementiere Dropdown */}
<div className="md:hidden">
<button className="text-gray-700 hover:text-pink-500"></button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-grow">
{title && (
<title>{title}</title>
)}
{children}
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="col-span-1 md:col-span-2">
<Link href="/marketing" className="text-2xl font-bold font-display text-pink-500">
FotoSpiel
</Link>
<p className="text-gray-600 font-sans-marketing mt-2">
Deine Plattform für Event-Fotos.
</p>
</div>
<div>
<h3 className="font-semibold font-display text-gray-900 mb-4">Rechtliches</h3>
<ul className="space-y-2 text-sm text-gray-600 font-sans-marketing">
<li><Link href="/legal/impressum" className="hover:text-pink-500">Impressum</Link></li>
<li><Link href="/legal/datenschutz" className="hover:text-pink-500">Datenschutz</Link></li>
<li><Link href="/legal/agb" className="hover:text-pink-500">AGB</Link></li>
<li><Link href="/legal/kontakt" className="hover:text-pink-500">Kontakt</Link></li>
</ul>
</div>
<div>
<h3 className="font-semibold font-display text-gray-900 mb-4">Social</h3>
<ul className="space-y-2 text-sm text-gray-600 font-sans-marketing">
<li><a href="#" className="hover:text-pink-500">Instagram</a></li>
<li><a href="#" className="hover:text-pink-500">Facebook</a></li>
<li><a href="#" className="hover:text-pink-500">YouTube</a></li>
</ul>
</div>
</div>
<div className="border-t border-gray-200 mt-8 pt-8 text-center text-sm text-gray-500 font-sans-marketing">
&copy; 2025 FotoSpiel. Alle Rechte vorbehalten.
</div>
</div>
</footer>
</div>
);
};
export default MarketingLayout;

View File

@@ -0,0 +1,45 @@
import React, { ReactNode } from 'react';
import { Head } from '@inertiajs/react';
import MarketingHeader from '@/components/marketing/MarketingHeader';
import MarketingFooter from '@/components/marketing/MarketingFooter';
interface MarketingLayoutProps {
children: ReactNode;
title?: string;
description?: string;
}
const MarketingLayout: React.FC<MarketingLayoutProps> = ({
children,
title = 'Fotospiel - Event-Fotos einfach und sicher mit QR-Codes',
description = 'Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.'
}) => {
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content={description} />
<style>{`
@keyframes aurora {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.bg-aurora {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab);
background-size: 400% 400%;
animation: aurora 15s ease infinite;
}
`}</style>
</Head>
<div className="bg-gray-50 text-gray-900 min-h-screen flex flex-col font-sans antialiased">
<MarketingHeader />
<main className="flex-grow">
{children}
</main>
<MarketingFooter />
</div>
</>
);
};
export default MarketingLayout;

View File

@@ -25,14 +25,28 @@ export default function Login({ status, canResetPassword }: LoginProps) {
const submit = (e: React.FormEvent) => {
e.preventDefault();
post('/login');
post('/login', {
preserveState: true,
onSuccess: () => {
console.log('Login successful');
},
onError: (errors: Record<string, string>) => {
console.log('Login errors:', errors);
},
});
};
React.useEffect(() => {
if (Object.keys(errors).length > 0) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [errors]);
return (
<AuthLayout title="Log in to your account" description="Enter your email and password below to log in">
<Head title="Log in" />
<form onSubmit={submit} className="flex flex-col gap-6">
<form key={`login-form-${Object.keys(errors).length}`} onSubmit={submit} className="flex flex-col gap-6">
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
@@ -48,7 +62,7 @@ export default function Login({ status, canResetPassword }: LoginProps) {
value={data.email}
onChange={(e) => setData('email', e.target.value)}
/>
<InputError message={errors.email} />
<InputError key={`error-email`} message={errors.email} />
</div>
<div className="grid gap-2">
@@ -71,7 +85,7 @@ export default function Login({ status, canResetPassword }: LoginProps) {
value={data.password}
onChange={(e) => setData('password', e.target.value)}
/>
<InputError message={errors.password} />
<InputError key={`error-password`} message={errors.password} />
</div>
<div className="flex items-center space-x-3">
@@ -100,6 +114,14 @@ export default function Login({ status, canResetPassword }: LoginProps) {
</form>
{status && <div className="mb-4 text-center text-sm font-medium text-green-600">{status}</div>}
{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-4">
<p className="text-sm text-red-800">
{Object.values(errors).join(' ')}
</p>
</div>
)}
</AuthLayout>
);
}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import React, { useState } from 'react';
import { useForm, router } from '@inertiajs/react';
import { Head } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { LoaderCircle, User, Mail, Phone, Lock, Home, MapPin } from 'lucide-react';
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
interface RegisterProps {
package?: {
@@ -10,11 +11,15 @@ interface RegisterProps {
description: string;
price: number;
} | null;
privacyHtml: string;
}
export default function Register({ package: initialPackage }: RegisterProps) {
const { data, setData, post, processing, errors } = useForm({
name: '',
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
export default function Register({ package: initialPackage, privacyHtml }: RegisterProps) {
const [privacyOpen, setPrivacyOpen] = useState(false);
const { data, setData, post, processing, errors, setError } = useForm({
username: '',
email: '',
password: '',
@@ -27,225 +32,295 @@ export default function Register({ package: initialPackage }: RegisterProps) {
package_id: initialPackage?.id || null,
});
React.useEffect(() => {
if (Object.keys(errors).length > 0) {
console.log('Validation errors received:', errors);
}
if (!processing) {
console.log('Registration processing completed');
}
}, [errors, processing, data]);
React.useEffect(() => {
if (Object.keys(errors).length > 0) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [errors]);
React.useEffect(() => {
if (Object.keys(errors).length > 0) {
// Force re-render or scroll to errors
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [errors]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
router.post('/register');
console.log('Submitting registration form with data:', data);
router.post('/register', data, {
preserveState: true,
forceFormData: true,
onSuccess: () => {
console.log('Registration successful');
},
onError: (errors) => {
console.log('Registration errors:', errors);
setError(errors);
},
});
console.log('POST to /register initiated');
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<Head title="Registrieren" />
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Registrieren
</h2>
{initialPackage && (
<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>
<p className="text-blue-800 mb-2">{initialPackage.description}</p>
<p className="text-sm text-blue-700">
{initialPackage.price === 0 ? 'Kostenlos' : `${initialPackage.price}`}
<MarketingLayout title="Registrieren">
<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="bg-white rounded-lg shadow-md p-8">
<div>
<h2 className="mt-6 text-center text-3xl font-bold text-gray-900 font-display">
Willkommen bei Fotospiel Erstellen Sie Ihren Account
</h2>
<p className="mt-4 text-center text-gray-600 font-sans-marketing">
Registrierung ermöglicht Zugriff auf Events, Galerien und personalisierte Features.
</p>
{initialPackage && (
<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>
<p className="text-blue-800 mb-2">{initialPackage.description}</p>
<p className="text-sm text-blue-700">
{initialPackage.price === 0 ? 'Kostenlos' : `${initialPackage.price}`}
</p>
</div>
)}
</div>
<form key={`form-${processing ? 'submitting' : 'idle'}-${Object.keys(errors).length}`} onSubmit={submit} className="mt-8 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-1">
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700 mb-1">
Vorname *
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
id="first_name"
name="first_name"
type="text"
required
value={data.first_name}
onChange={(e) => setData('first_name', e.target.value)}
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"
/>
</div>
{errors.first_name && <p key={`error-first_name`} className="text-sm text-red-600 mt-1">{errors.first_name}</p>}
</div>
<div className="md:col-span-1">
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700 mb-1">
Nachname *
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
id="last_name"
name="last_name"
type="text"
required
value={data.last_name}
onChange={(e) => setData('last_name', e.target.value)}
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"
/>
</div>
{errors.last_name && <p key={`error-last_name`} className="text-sm text-red-600 mt-1">{errors.last_name}</p>}
</div>
<div className="md:col-span-2">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
E-Mail-Adresse *
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
id="email"
name="email"
type="email"
required
value={data.email}
onChange={(e) => setData('email', e.target.value)}
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"
/>
</div>
{errors.email && <p key={`error-email`} className="text-sm text-red-600 mt-1">{errors.email}</p>}
</div>
<div className="md:col-span-2">
<label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-1">
Adresse *
</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
id="address"
name="address"
type="text"
required
value={data.address}
onChange={(e) => setData('address', e.target.value)}
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"
/>
</div>
{errors.address && <p key={`error-address`} className="text-sm text-red-600 mt-1">{errors.address}</p>}
</div>
<div className="md:col-span-1">
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
Telefon *
</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
id="phone"
name="phone"
type="tel"
required
value={data.phone}
onChange={(e) => setData('phone', e.target.value)}
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"
/>
</div>
{errors.phone && <p key={`error-phone`} className="text-sm text-red-600 mt-1">{errors.phone}</p>}
</div>
<div className="md:col-span-1">
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
Benutzername *
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
id="username"
name="username"
type="text"
required
value={data.username}
onChange={(e) => setData('username', e.target.value)}
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"
/>
</div>
{errors.username && <p key={`error-username`} className="text-sm text-red-600 mt-1">{errors.username}</p>}
</div>
<div className="md:col-span-1">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Passwort *
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
id="password"
name="password"
type="password"
required
value={data.password}
onChange={(e) => setData('password', e.target.value)}
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"
/>
</div>
{errors.password && <p key={`error-password`} className="text-sm text-red-600 mt-1">{errors.password}</p>}
</div>
<div className="md:col-span-1">
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700 mb-1">
Passwort bestätigen *
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
id="password_confirmation"
name="password_confirmation"
type="password"
required
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
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"
/>
</div>
{errors.password_confirmation && <p className="text-sm text-red-600 mt-1">{errors.password_confirmation}</p>}
</div>
<div className="md:col-span-2 flex items-start">
<input
id="privacy_consent"
name="privacy_consent"
type="checkbox"
required
checked={data.privacy_consent}
onChange={(e) => setData('privacy_consent', e.target.checked)}
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">
Ich stimme der{' '}
<button
type="button"
onClick={() => setPrivacyOpen(true)}
className="text-[#FFB6C1] hover:underline inline bg-transparent border-none cursor-pointer p-0 font-medium"
>
Datenschutzerklärung
</button>{' '}
zu.
</label>
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
</div>
</div>
{Object.keys(errors).length > 0 && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-800">
{Object.entries(errors).map(([key, value]) => (
<span key={key}>
{value}
</span>
))}
</p>
</div>
)}
<button
type="submit"
disabled={processing}
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" />}
Account erstellen
</button>
<div className="text-center">
<p className="text-sm text-gray-600">
Bereits registriert?{' '}
<a href="/login" className="font-medium text-[#FFB6C1] hover:text-[#FF69B4]">
Anmelden
</a>
</p>
</div>
)}
</form>
</div>
<form onSubmit={submit} className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name
</label>
<input
id="name"
name="name"
type="text"
required
autoFocus
value={data.name}
onChange={(e) => setData('name', e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Vollständiger Name"
/>
{errors.name && <p className="mt-2 text-sm text-red-600">{errors.name}</p>}
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
Benutzername
</label>
<input
id="username"
name="username"
type="text"
required
value={data.username}
onChange={(e) => setData('username', e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Benutzername"
/>
{errors.username && <p className="text-sm text-red-600">{errors.username}</p>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
E-Mail-Adresse
</label>
<input
id="email"
name="email"
type="email"
required
value={data.email}
onChange={(e) => setData('email', e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="email@example.com"
/>
{errors.email && <p className="text-sm text-red-600">{errors.email}</p>}
</div>
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
Vorname
</label>
<input
id="first_name"
name="first_name"
type="text"
required
value={data.first_name}
onChange={(e) => setData('first_name', e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Vorname"
/>
{errors.first_name && <p className="text-sm text-red-600">{errors.first_name}</p>}
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700">
Nachname
</label>
<input
id="last_name"
name="last_name"
type="text"
required
value={data.last_name}
onChange={(e) => setData('last_name', e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Nachname"
/>
{errors.last_name && <p className="text-sm text-red-600">{errors.last_name}</p>}
</div>
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
Adresse
</label>
<textarea
id="address"
name="address"
required
value={data.address}
onChange={(e) => setData('address', e.target.value)}
rows={3}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Adresse"
/>
{errors.address && <p className="text-sm text-red-600">{errors.address}</p>}
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
Telefon
</label>
<input
id="phone"
name="phone"
type="tel"
required
value={data.phone}
onChange={(e) => setData('phone', e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Telefonnummer"
/>
{errors.phone && <p className="text-sm text-red-600">{errors.phone}</p>}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
value={data.password}
onChange={(e) => setData('password', e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Passwort"
/>
{errors.password && <p className="text-sm text-red-600">{errors.password}</p>}
</div>
<div>
<label htmlFor="password_confirmation" className="block text-sm font-medium text-gray-700">
Passwort bestätigen
</label>
<input
id="password_confirmation"
name="password_confirmation"
type="password"
required
value={data.password_confirmation}
onChange={(e) => setData('password_confirmation', e.target.value)}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Passwort bestätigen"
/>
{errors.password_confirmation && <p className="text-sm text-red-600">{errors.password_confirmation}</p>}
</div>
<div className="flex items-start">
<input
id="privacy_consent"
name="privacy_consent"
type="checkbox"
required
checked={data.privacy_consent}
onChange={(e) => setData('privacy_consent', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="privacy_consent" className="ml-2 block text-sm text-gray-900">
Ich stimme der{' '}
<a href="/de/datenschutz" className="text-blue-600 hover:underline">
Datenschutzerklärung
</a>{' '}
zu.
</label>
{errors.privacy_consent && <p className="mt-2 text-sm text-red-600">{errors.privacy_consent}</p>}
</div>
</div>
<button
type="submit"
disabled={processing}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-300 disabled:opacity-50"
>
{processing && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
Account erstellen
</button>
<div className="text-center">
<p className="text-sm text-gray-600">
Bereits registriert?{' '}
<a href="/login" className="font-medium text-blue-600 hover:text-blue-500">
Anmelden
</a>
</p>
</div>
</form>
</div>
<Dialog open={privacyOpen} onOpenChange={setPrivacyOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto p-0">
<div className="p-6">
<div dangerouslySetInnerHTML={{ __html: privacyHtml }} />
</div>
</DialogContent>
</Dialog>
</div>
</MarketingLayout>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import MarketingLayout from '@/layouts/MarketingLayout';
import { usePage } from '@inertiajs/react';
const Datenschutz: React.FC = () => {
const { props } = usePage();
const { __ } = props as any; // Für i18n
return (
<MarketingLayout title={__('legal.datenschutz_title')}>
<Head title={__('legal.datenschutz_title')} />
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-4 font-display">{__('legal.datenschutz')}</h1>
<p className="mb-4 font-sans-marketing">{__('legal.datenschutz_intro')}</p>
<p className="mb-4 font-sans-marketing">{__('legal.responsible')}</p>
<p className="mb-4 font-sans-marketing">{__('legal.data_collection')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{__('legal.payments')}</h2>
<p className="mb-4 font-sans-marketing">
{__('legal.payments_desc')} <a href="https://stripe.com/de/privacy" target="_blank" rel="noopener noreferrer">{__('legal.stripe_privacy')}</a> {__('legal.and')} <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank" rel="noopener noreferrer">{__('legal.paypal_privacy')}</a>.
</p>
<p className="mb-4 font-sans-marketing">{__('legal.data_retention')}</p>
<p className="mb-4 font-sans-marketing">
{__('legal.rights')} <Link href="/de/kontakt">{__('legal.contact')}</Link>.
</p>
<p className="mb-4 font-sans-marketing">{__('legal.cookies')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{__('legal.personal_data')}</h2>
<p className="mb-4 font-sans-marketing">{__('legal.personal_data_desc')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{__('legal.account_deletion')}</h2>
<p className="mb-4 font-sans-marketing">{__('legal.account_deletion_desc')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{__('legal.data_security')}</h2>
<p className="mb-4 font-sans-marketing">{__('legal.data_security_desc')}</p>
</div>
</MarketingLayout>
);
};
export default Datenschutz;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import MarketingLayout from '@/layouts/MarketingLayout';
import { usePage } from '@inertiajs/react';
const Impressum: React.FC = () => {
const { props } = usePage();
const { __ } = props as any; // Für i18n
return (
<MarketingLayout title={__('legal.impressum_title')}>
<Head title={__('legal.impressum_title')} />
<div className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-4 font-display">{__('legal.impressum')}</h1>
<p className="mb-4 font-sans-marketing">{__('legal.impressum_section')}</p>
<p className="mb-4 font-sans-marketing">
{__('legal.company')}<br />
{__('legal.address')}<br />
{__('legal.representative')}<br />
{__('legal.contact')}: <Link href={route('kontakt')}>{__('legal.contact')}</Link>
</p>
<p className="mb-4 font-sans-marketing">{__('legal.vat_id')}</p>
<h2 className="text-xl font-semibold mb-2 font-display">{__('legal.monetization')}</h2>
<p className="mb-4 font-sans-marketing">{__('legal.monetization_desc')}</p>
<p className="mb-4 font-sans-marketing">{__('legal.register_court')}</p>
<p className="mb-4 font-sans-marketing">{__('legal.commercial_register')}</p>
</div>
</MarketingLayout>
);
};
export default Impressum;

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { Head, Link, usePage } from '@inertiajs/react';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
interface Props {
posts: {
data: any[];
links: any[];
current_page: number;
last_page: number;
};
}
const Blog: React.FC<Props> = ({ posts }) => {
const { url } = usePage();
const renderPagination = () => {
if (!posts.links || posts.links.length <= 3) return null;
return (
<div className="mt-12 text-center">
<div className="flex justify-center space-x-2">
{posts.links.map((link, index) => (
<Link
key={index}
href={link.url || '#'}
className={`px-3 py-2 rounded ${
link.active
? 'bg-[#FFB6C1] text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
))}
</div>
</div>
);
};
return (
<MarketingLayout title="Blog - Fotospiel">
<Head title="Blog - Fotospiel" />
{/* Hero Section */}
<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">Unser Blog</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>
<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">
Zum Blog
</Link>
</div>
</section>
{/* Posts Section */}
<section id="posts" className="py-20 px-4 bg-white">
<div className="container mx-auto max-w-4xl">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Neueste Beiträge</h2>
{posts.data.length > 0 ? (
<>
<div className="grid md:grid-cols-2 gap-8">
{posts.data.map((post) => (
<div key={post.id} className="bg-gray-50 p-6 rounded-lg">
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
className="w-full h-48 object-cover rounded mb-4"
/>
)}
<h3 className="text-xl font-semibold mb-2 font-sans-marketing">
<Link href={`/blog/${post.slug}`} className="hover:text-[#FFB6C1]">
{post.title}
</Link>
</h3>
<p className="mb-4 text-gray-700 font-serif-custom">{post.excerpt}</p>
<p className="text-sm text-gray-500 mb-4 font-sans-marketing">
Veröffentlicht am {post.published_at}
</p>
<Link
href={`/blog/${post.slug}`}
className="text-[#FFB6C1] font-semibold font-sans-marketing hover:underline"
>
Weiterlesen
</Link>
</div>
))}
</div>
{renderPagination()}
</>
) : (
<p className="text-center text-gray-600 font-serif-custom">Keine Beiträge verfügbar.</p>
)}
</div>
</section>
</MarketingLayout>
);
};
export default Blog;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Head, Link, usePage } from '@inertiajs/react';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
interface Props {
post: {
id: number;
title: string;
excerpt?: string;
content: string;
featured_image?: string;
published_at: string;
author?: { name: string };
slug: string;
};
}
const BlogShow: React.FC<Props> = ({ post }) => {
return (
<MarketingLayout title={`${post.title} - Fotospiel Blog`}>
<Head title={`${post.title} - Fotospiel Blog`} />
{/* Hero Section */}
<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">
<h1 className="text-4xl md:text-5xl font-bold mb-4">{post.title}</h1>
<p className="text-lg mb-8">
Von {post.author?.name || 'Dem Fotospiel Team'} | {new Date(post.published_at).toLocaleDateString('de-DE')}
</p>
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
className="mx-auto rounded-lg shadow-lg max-w-2xl"
/>
)}
</div>
</section>
{/* Post Content */}
<section className="py-20 px-4 bg-white">
<div className="container mx-auto max-w-4xl prose prose-lg max-w-none">
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
</section>
{/* Back to Blog */}
<section className="py-10 px-4 bg-gray-50">
<div className="container mx-auto text-center">
<Link
href="/blog"
className="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#FF69B4] transition"
>
Zurück zum Blog
</Link>
</div>
</section>
</MarketingLayout>
);
};
export default BlogShow;

View File

@@ -0,0 +1,262 @@
import React from 'react';
import { Head, Link, useForm } from '@inertiajs/react';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
import { Package } from '@/types'; // Annahme: Typ für Package
interface Props {
packages: Package[];
}
const Home: React.FC<Props> = ({ packages }) => {
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
email: '',
message: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post('/kontakt', {
onSuccess: () => reset(),
});
};
return (
<MarketingLayout title="Home - Fotospiel">
<Head title="Fotospiel - Event-Fotos einfach und sicher mit QR-Codes" />
{/* Hero Section */}
<section id="hero" className="bg-aurora-enhanced text-white py-20 px-4">
<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">
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">Fotospiel</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>
<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">
Jetzt starten Kostenlos
</Link>
</div>
<div className="md:w-1/2">
<img
src="https://images.unsplash.com/photo-1511285560929-80b456fea0bc?w=600&h=400&fit=crop"
alt="Event-Fotos mit QR"
className="rounded-lg shadow-lg w-full"
style={{ filter: 'drop-shadow(0 10px 8px rgba(0,0,0,0.1))' }}
/>
</div>
</div>
</section>
{/* How it works Section */}
<section id="how-it-works" className="py-20 px-4 bg-gray-50">
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">So funktioniert es in 4 einfachen Schritten mit QR-Codes</h2>
<div className="grid md:grid-cols-4 gap-8">
<div className="text-center">
<img
src="https://images.unsplash.com/photo-1558618047-3c8d6b4d3b0a?w=300&h=200&fit=crop"
alt="QR-Code generieren"
className="w-12 h-12 mx-auto mb-4 rounded-full"
/>
<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 className="text-center">
<img
src="https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=300&h=200&fit=crop"
alt="Fotos hochladen"
className="w-12 h-12 mx-auto mb-4 rounded-full"
/>
<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 className="text-center">
<img
src="https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=300&h=200&fit=crop"
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 className="text-center">
<img
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>
</section>
{/* Features Section */}
<section id="features" className="py-20 px-4 bg-white">
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Warum Fotospiel mit QR?</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center p-6">
<img
src="https://images.unsplash.com/photo-1560472354-b33ff0c44a43?w=400&h=250&fit=crop"
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 className="text-center p-6">
<img
src="https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=400&h=250&fit=crop"
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 className="text-center p-6">
<img
src="https://images.unsplash.com/photo-1554224155-6726b3ff858f?w=400&h=250&fit=crop"
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>
</section>
{/* Packages Teaser Section */}
<section id="pricing" className="py-20 px-4 bg-gray-50">
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Unsere Packages</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="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">
Alle Packages ansehen
</Link>
</div>
</div>
</section>
{/* Contact Section */}
<section id="contact" className="py-20 px-4 bg-white">
<div className="container mx-auto max-w-2xl">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Kontakt</h2>
<form key={`home-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2 font-sans-marketing">Name</label>
<input
type="text"
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
required
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>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2 font-sans-marketing">E-Mail</label>
<input
type="email"
id="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
required
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>}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-2 font-sans-marketing">Nachricht</label>
<textarea
id="message"
value={data.message}
onChange={(e) => setData('message', e.target.value)}
rows={4}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]"
></textarea>
{errors.message && <p key={`error-message`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.message}</p>}
</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">
{processing ? 'Sendet...' : 'Senden'}
</button>
</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>
</section>
{/* Testimonials Section */}
<section className="py-20 px-4 bg-gray-50">
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Was unsere Kunden sagen</h2>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<div className="bg-white p-6 rounded-lg">
<p className="mb-4 font-serif-custom">"Perfekt für unsere Hochzeit! QR-Sharing war super einfach."</p>
<p className="font-semibold font-sans-marketing">- Anna & Max</p>
</div>
<div className="bg-white p-6 rounded-lg">
<p className="mb-4 font-serif-custom">"Großes Firmenevent alle Fotos zentral via QR."</p>
<p className="font-semibold font-sans-marketing">- Team XYZ GmbH</p>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section className="py-20 px-4 bg-white">
<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">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Unsere Pakete</h2>
<div className="grid md:grid-cols-3 gap-8">
{packages.map((pkg) => (
<div key={pkg.id} className="bg-gray-50 p-6 rounded-lg text-center">
<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-2xl font-bold text-[#FFB6C1] mb-4 font-display">{pkg.price}</p>
<Link
href={`/marketing/buy/${pkg.id}`}
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>
</section>
</MarketingLayout>
);
};
export default Home;

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
const Kontakt: React.FC = () => {
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
email: '',
message: '',
});
const { flash } = usePage().props as any;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post('/kontakt', {
onSuccess: () => reset(),
});
};
return (
<MarketingLayout title="Kontakt - Fotospiel">
<Head title="Kontakt - Fotospiel" />
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold text-center mb-8 font-display">Kontakt</h1>
<p className="text-center text-gray-600 mb-8 font-sans-marketing">Haben Sie Fragen? Schreiben Sie uns!</p>
<form key={`kontakt-form-${Object.keys(errors).length}`} onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2 font-sans-marketing">Name</label>
<input
type="text"
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
required
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>}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2 font-sans-marketing">E-Mail</label>
<input
type="email"
id="email"
value={data.email}
onChange={(e) => setData('email', e.target.value)}
required
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>}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2 font-sans-marketing">Nachricht</label>
<textarea
id="message"
value={data.message}
onChange={(e) => setData('message', e.target.value)}
rows={4}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-[#FFB6C1]"
></textarea>
{errors.message && <p key={`error-message`} className="text-red-500 text-sm mt-1 font-serif-custom">{errors.message}</p>}
</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">
{processing ? 'Sendet...' : 'Senden'}
</button>
</form>
{flash?.success && <p className="mt-4 text-green-600 text-center font-serif-custom">{flash.success}</p>}
{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">
<ul className="list-disc list-inside">
{Object.values(errors).map((error, index) => (
<li key={`error-${index}`} className="font-serif-custom">{error}</li>
))}
</ul>
</div>
)}
React.useEffect(() => {
if (Object.keys(errors).length > 0) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [errors]);
<div className="mt-8 text-center">
<Link href="/" className="text-[#FFB6C1] hover:underline font-sans-marketing">Zurück zur Startseite</Link>
</div>
</div>
</div>
</MarketingLayout>
);
};
export default Kontakt;

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
interface Props {
type: string;
}
const Occasions: React.FC<Props> = ({ type }) => {
const occasions = {
weddings: {
title: 'Hochzeiten',
description: 'Erfangen Sie die magischen Momente Ihrer Hochzeit mit professionellen Fotos.',
features: ['Unbegrenzte Fotos', 'Sofort-Download', 'Privat-Event-Code', 'Emotionen tracken'],
image: '/images/wedding-lights-background.svg' // Platzhalter
},
birthdays: {
title: 'Geburtstage',
description: 'Feiern Sie Geburtstage unvergesslich mit unseren Event-Foto-Lösungen.',
features: ['Schnelle Einrichtung', 'Gäste teilen Fotos', 'Themen-Filter', 'Druck-Optionen'],
image: '/images/birthday-placeholder.jpg'
},
'corporate-events': {
title: 'Firmenevents',
description: 'Professionelle Fotos für Teamevents, Konferenzen und Unternehmensfeiern.',
features: ['Branding-Integration', 'Sichere Cloud-Speicher', 'Analytics & Reports', 'Schnelle Bearbeitung'],
image: '/images/corporate-placeholder.jpg'
},
'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;
return (
<MarketingLayout title={`${occasion.title} - Fotospiel`}>
<Head title={`${occasion.title} - Fotospiel`} />
{/* Hero Section */}
<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">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Warum Fotospiel für {occasion.title}?</h2>
<div className="grid md:grid-cols-2 gap-8">
{occasion.features.map((feature, index) => (
<div key={index} className="bg-gray-50 p-6 rounded-lg flex items-center">
<div className="w-8 h-8 bg-[#FFB6C1] rounded-full flex items-center justify-center mr-4">
<span className="text-white text-sm font-bold font-sans-marketing"></span>
</div>
<p className="text-gray-700 font-serif-custom">{feature}</p>
</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>
</section>
</MarketingLayout>
);
};
export default Occasions;

View File

@@ -0,0 +1,553 @@
import React, { useState } from 'react';
import { Head, Link, usePage } from '@inertiajs/react';
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Button } from "@/components/ui/button"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
import { ArrowRight, ShoppingCart, Check, X, Users, Image, Calendar, Shield, Star } from 'lucide-react';
interface Package {
id: number;
name: string;
description: string;
price: number;
events: number;
features: string[];
limits?: {
max_photos?: number;
max_guests?: number;
max_tenants?: number;
max_events?: number;
gallery_days?: number;
};
watermark_allowed?: boolean;
branding_allowed?: boolean;
}
interface PackagesProps {
endcustomerPackages: Package[];
resellerPackages: Package[];
}
const Packages: React.FC<PackagesProps> = ({ endcustomerPackages, resellerPackages }) => {
const [open, setOpen] = useState(false);
const [selectedPackage, setSelectedPackage] = useState<Package | null>(null);
const [currentStep, setCurrentStep] = useState('step1');
const { props } = usePage();
const { auth } = props as any;
const testimonials = [
{ name: 'Anna M.', text: 'Das Starter-Paket war perfekt für unsere Hochzeit einfach und günstig!', rating: 5 },
{ name: 'Max B.', text: 'Pro-Paket mit Analytics hat uns geholfen, die besten Momente zu finden.', rating: 5 },
{ name: 'Lisa K.', text: 'Als Reseller spare ich Zeit mit dem M-Paket super Support!', rating: 5 },
];
const allPackages = [...endcustomerPackages, ...resellerPackages];
const handleCardClick = (pkg: Package) => {
setSelectedPackage(pkg);
setCurrentStep('step1');
setOpen(true);
};
const nextStep = () => {
if (currentStep === 'step1') setCurrentStep('step3');
};
const getFeatureIcon = (feature: string) => {
switch (feature) {
case 'basic_uploads': return <Image className="w-4 h-4" />;
case 'unlimited_sharing': return <ArrowRight className="w-4 h-4" />;
case 'no_watermark': return <Shield className="w-4 h-4" />;
case 'custom_tasks': return <Check className="w-4 h-4" />;
case 'advanced_analytics': return <Star className="w-4 h-4" />;
case 'priority_support': return <Users className="w-4 h-4" />;
case 'reseller_dashboard': return <ShoppingCart className="w-4 h-4" />;
case 'custom_branding': return <Image className="w-4 h-4" />;
default: return <Check className="w-4 h-4" />;
}
};
return (
<MarketingLayout title="Packages">
{/* Hero Section */}
<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">Unsere Packages</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>
<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">
Jetzt entdecken
</Link>
</div>
</section>
<section id="endcustomer" className="py-20 px-4">
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Für Endkunden</h2>
{/* Mobile Carousel for Endcustomer Packages */}
<div className="block md:hidden">
<Carousel className="w-full max-w-md mx-auto">
<CarouselContent className="-ml-1">
{endcustomerPackages.map((pkg) => (
<CarouselItem key={pkg.id} className="pl-1 basis-full">
<div
className="bg-white p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
</div>
<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-2xl font-bold text-[#FFB6C1] mb-4 font-sans-marketing">
{pkg.price === 0 ? 'Kostenlos' : `${pkg.price}`}
</p>
<ul className="text-sm text-gray-600 mb-6 space-y-1 font-sans-marketing">
<li> {pkg.events} Events</li>
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-center">
{getFeatureIcon(feature)} {feature}
</li>
))}
{pkg.limits?.max_photos && <li> Max. {pkg.limits.max_photos} Fotos</li>}
{pkg.limits?.gallery_days && <li> Galerie {pkg.limits.gallery_days} Tage</li>}
{pkg.limits?.max_guests && <li> Max. {pkg.limits.max_guests} Gäste</li>}
{pkg.watermark_allowed === false && <li><Badge variant="secondary">Kein Watermark</Badge></li>}
{pkg.branding_allowed && <li><Badge variant="secondary">Custom Branding</Badge></li>}
</ul>
<Button
variant="outline"
onClick={() => handleCardClick(pkg)}
className="w-full mt-4 font-sans-marketing"
>
Details anzeigen
</Button>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
{/* Desktop Grid for Endcustomer Packages */}
<div className="hidden md:block">
<div className="grid md:grid-cols-3 gap-8">
{endcustomerPackages.map((pkg) => (
<div
key={pkg.id}
className="bg-white p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFB6C1] mx-auto" />
</div>
<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-2xl font-bold text-[#FFB6C1] mb-4 font-sans-marketing">
{pkg.price === 0 ? 'Kostenlos' : `${pkg.price}`}
</p>
<ul className="text-sm text-gray-600 mb-6 space-y-1 font-sans-marketing">
<li> {pkg.events} Events</li>
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-center">
{getFeatureIcon(feature)} {feature}
</li>
))}
{pkg.limits?.max_photos && <li> Max. {pkg.limits.max_photos} Fotos</li>}
{pkg.limits?.gallery_days && <li> Galerie {pkg.limits.gallery_days} Tage</li>}
{pkg.limits?.max_guests && <li> Max. {pkg.limits.max_guests} Gäste</li>}
{pkg.watermark_allowed === false && <li><Badge variant="secondary">Kein Watermark</Badge></li>}
{pkg.branding_allowed && <li><Badge variant="secondary">Custom Branding</Badge></li>}
</ul>
<Button
variant="outline"
onClick={() => handleCardClick(pkg)}
className="w-full mt-4 font-sans-marketing"
>
Details anzeigen
</Button>
</div>
))}
</div>
</div>
</div>
{/* Comparison Section for Endcustomer */}
<div className="mt-12">
<h3 className="text-2xl font-bold text-center mb-6 font-display">Endkunden-Pakete vergleichen</h3>
<div className="block md:hidden">
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="price">
<AccordionTrigger className="font-sans-marketing">Preis</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-3 gap-4 p-4">
{endcustomerPackages.map((pkg) => (
<div key={pkg.id} className="text-center">
<p className="font-bold">{pkg.name}</p>
<p>{pkg.price === 0 ? 'Kostenlos' : `${pkg.price}`}</p>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="max-photos">
<AccordionTrigger className="font-sans-marketing">Max. Fotos {getFeatureIcon('max_photos')}</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-3 gap-4 p-4">
{endcustomerPackages.map((pkg) => (
<div key={pkg.id} className="text-center">
<p className="font-bold">{pkg.name}</p>
<p>{pkg.limits?.max_photos || 'Unbegrenzt'}</p>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="max-guests">
<AccordionTrigger className="font-sans-marketing">Max. Gäste {getFeatureIcon('max_guests')}</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-3 gap-4 p-4">
{endcustomerPackages.map((pkg) => (
<div key={pkg.id} className="text-center">
<p className="font-bold">{pkg.name}</p>
<p>{pkg.limits?.max_guests || 'Unbegrenzt'}</p>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="gallery-days">
<AccordionTrigger className="font-sans-marketing">Galerie Tage {getFeatureIcon('gallery_days')}</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-3 gap-4 p-4">
{endcustomerPackages.map((pkg) => (
<div key={pkg.id} className="text-center">
<p className="font-bold">{pkg.name}</p>
<p>{pkg.limits?.gallery_days || 'Unbegrenzt'}</p>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="watermark">
<AccordionTrigger className="font-sans-marketing">Watermark {getFeatureIcon('no_watermark')}</AccordionTrigger>
<AccordionContent>
<div className="grid grid-cols-3 gap-4 p-4">
{endcustomerPackages.map((pkg) => (
<div key={pkg.id} className="text-center">
<p className="font-bold">{pkg.name}</p>
{pkg.watermark_allowed === false ? <Check className="w-4 h-4 text-green-500 mx-auto" /> : <X className="w-4 h-4 text-red-500 mx-auto" />}
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
<div className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Feature</TableHead>
{endcustomerPackages.map((pkg) => (
<TableHead key={pkg.id} className="text-center">
{pkg.name}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-semibold">Preis</TableCell>
{endcustomerPackages.map((pkg) => (
<TableCell key={pkg.id} className="text-center">
{pkg.price === 0 ? 'Kostenlos' : `${pkg.price}`}
</TableCell>
))}
</TableRow>
<TableRow>
<TableCell className="font-semibold">Max. Fotos {getFeatureIcon('max_photos')}</TableCell>
{endcustomerPackages.map((pkg) => (
<TableCell key={pkg.id} className="text-center">
{pkg.limits?.max_photos || 'Unbegrenzt'}
</TableCell>
))}
</TableRow>
<TableRow>
<TableCell className="font-semibold">Max. Gäste {getFeatureIcon('max_guests')}</TableCell>
{endcustomerPackages.map((pkg) => (
<TableCell key={pkg.id} className="text-center">
{pkg.limits?.max_guests || 'Unbegrenzt'}
</TableCell>
))}
</TableRow>
<TableRow>
<TableCell className="font-semibold">Galerie Tage {getFeatureIcon('gallery_days')}</TableCell>
{endcustomerPackages.map((pkg) => (
<TableCell key={pkg.id} className="text-center">
{pkg.limits?.gallery_days || 'Unbegrenzt'}
</TableCell>
))}
</TableRow>
<TableRow>
<TableCell className="font-semibold">Watermark {getFeatureIcon('no_watermark')}</TableCell>
{endcustomerPackages.map((pkg) => (
<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" />}
</TableCell>
))}
</TableRow>
</TableBody>
</Table>
</div>
</div>
</section>
<section className="py-20 px-4 bg-gray-50">
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Für Reseller</h2>
{/* Mobile Carousel for Reseller Packages */}
<div className="block md:hidden">
<Carousel className="w-full max-w-md mx-auto">
<CarouselContent className="-ml-1">
{resellerPackages.map((pkg) => (
<CarouselItem key={pkg.id} className="pl-1 basis-full">
<div
className="bg-white p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />
</div>
<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-2xl font-bold text-[#FFD700] mb-4 font-sans-marketing">
{pkg.price} / Jahr
</p>
<ul className="text-sm text-gray-600 mb-6 space-y-1 font-sans-marketing">
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-center">
{getFeatureIcon(feature)} {feature}
</li>
))}
{pkg.limits?.max_tenants && <li> Max. {pkg.limits.max_tenants} Tenants</li>}
{pkg.limits?.max_events && <li> Max. {pkg.limits.max_events} Events/Jahr</li>}
{pkg.branding_allowed && <li><Badge variant="secondary">Custom Branding</Badge></li>}
</ul>
<Button
variant="outline"
onClick={() => handleCardClick(pkg)}
className="w-full mt-4 font-sans-marketing"
>
Details anzeigen
</Button>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
{/* Desktop Grid for Reseller Packages */}
<div className="hidden md:block">
<div className="grid md:grid-cols-2 gap-8">
{resellerPackages.map((pkg) => (
<div
key={pkg.id}
className="bg-white p-6 rounded-lg shadow-md border hover:shadow-lg transition-all duration-300"
>
<div className="text-center mb-4">
<ShoppingCart className="w-12 h-12 text-[#FFD700] mx-auto" />
</div>
<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-2xl font-bold text-[#FFD700] mb-4 font-sans-marketing">
{pkg.price} / Jahr
</p>
<ul className="text-sm text-gray-600 mb-6 space-y-1 font-sans-marketing">
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-center">
{getFeatureIcon(feature)} {feature}
</li>
))}
{pkg.limits?.max_tenants && <li> Max. {pkg.limits.max_tenants} Tenants</li>}
{pkg.limits?.max_events && <li> Max. {pkg.limits.max_events} Events/Jahr</li>}
{pkg.branding_allowed && <li><Badge variant="secondary">Custom Branding</Badge></li>}
</ul>
<Button
variant="outline"
onClick={() => handleCardClick(pkg)}
className="w-full mt-4 font-sans-marketing"
>
Details anzeigen
</Button>
</div>
))}
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section className="py-20 px-4">
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Häufige Fragen</h2>
<div className="grid md:grid-cols-2 gap-8">
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-2 font-display">Was ist das Free-Paket?</h3>
<p className="text-gray-600 font-sans-marketing">Ideal für Tests: 30 Fotos, 7 Tage Galerie, mit Watermark.</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-2 font-display">Kann ich upgraden?</h3>
<p className="text-gray-600 font-sans-marketing">Ja, jederzeit im Dashboard Limits werden sofort erweitert.</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-2 font-display">Was für Reseller?</h3>
<p className="text-gray-600 font-sans-marketing">Jährliche Subscriptions mit Dashboard, Branding und Support.</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-md">
<h3 className="text-xl font-semibold mb-2 font-display">Zahlungssicher?</h3>
<p className="text-gray-600 font-sans-marketing">Sichere Zahlung via Stripe/PayPal, 14 Tage Rückgaberecht.</p>
</div>
</div>
</div>
</section>
{/* Modal */}
{selectedPackage && (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-display">{selectedPackage.name} - Details</DialogTitle>
</DialogHeader>
<Tabs value={currentStep} onValueChange={setCurrentStep} className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="step1">Details</TabsTrigger>
<TabsTrigger value="step3">Kaufen</TabsTrigger>
</TabsList>
<Progress value={(currentStep === 'step1' ? 50 : 100)} className="w-full mt-4" />
<TabsContent value="step1" className="mt-4">
<div className="space-y-4">
<div className="text-center">
<h2 className="text-3xl font-bold font-display">{selectedPackage.name}</h2>
<p className="text-2xl font-bold text-[#FFB6C1] mt-2">
{selectedPackage.price === 0 ? 'Kostenlos' : `${selectedPackage.price}`}
</p>
</div>
<p className="text-gray-600 font-sans-marketing">{selectedPackage.description}</p>
<div className="grid grid-cols-2 gap-4">
{selectedPackage.features.map((feature, index) => (
<Badge key={`feature-${index}`} variant="secondary" className="flex items-center justify-center gap-1">
{getFeatureIcon(feature)} {feature}
</Badge>
))}
{selectedPackage.limits?.max_photos && (
<Badge variant="outline" className="flex items-center justify-center gap-1">
<Image className="w-4 h-4" /> Max. {selectedPackage.limits.max_photos} Fotos
</Badge>
)}
{selectedPackage.limits?.max_guests && (
<Badge variant="outline" className="flex items-center justify-center gap-1">
<Users className="w-4 h-4" /> Max. {selectedPackage.limits.max_guests} Gäste
</Badge>
)}
{selectedPackage.limits?.gallery_days && (
<Badge variant="outline" className="flex items-center justify-center gap-1">
<Calendar className="w-4 h-4" /> {selectedPackage.limits.gallery_days} Tage Galerie
</Badge>
)}
{selectedPackage.watermark_allowed === false && (
<Badge variant="secondary" className="flex items-center justify-center gap-1">
<Shield className="w-4 h-4" /> Kein Watermark
</Badge>
)}
{selectedPackage.branding_allowed && (
<Badge variant="secondary" className="flex items-center justify-center gap-1">
<Image className="w-4 h-4" /> Custom Branding
</Badge>
)}
</div>
{/* Social Proof - unten verschoben */}
<div className="mt-8">
<h3 className="text-xl font-semibold mb-4 font-display">Was Kunden sagen</h3>
<div className="grid md:grid-cols-3 gap-4">
{testimonials.map((testimonial, index) => (
<div key={index} className="bg-white p-4 rounded-lg shadow-md">
<p className="text-gray-600 font-sans-marketing mb-2">"{testimonial.text}"</p>
<p className="font-semibold font-sans-marketing">{testimonial.name}</p>
<div className="flex">
{[...Array(testimonial.rating)].map((_, i) => <Star key={i} className="w-4 h-4 text-yellow-400 fill-current" />)}
</div>
</div>
))}
</div>
</div>
<button onClick={nextStep} className="w-full bg-[#FFB6C1] text-white py-3 rounded-md font-semibold font-sans-marketing hover:bg-[#FF69B4] transition">
Zum Kauf
</button>
</div>
</TabsContent>
<TabsContent value="step3" className="mt-4">
<h3 className="text-xl font-semibold mb-4 font-display">Bereit zum Kaufen?</h3>
<div className="text-center">
<p className="text-gray-600 font-sans-marketing mb-4">Sie haben {selectedPackage.name} ausgewählt.</p>
{auth.user ? (
<Link
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"
>
Jetzt kaufen
</Link>
) : (
<Link
href={`/register?package_id=${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"
onClick={() => {
localStorage.setItem('preferred_package', JSON.stringify(selectedPackage));
}}
>
Registrieren & Kaufen
</Link>
)}
</div>
<button onClick={() => setOpen(false)} className="w-full mt-4 text-gray-500 underline">
Schließen
</button>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)}
{/* Testimonials Section */}
<section className="py-20 px-4 bg-gray-50">
<div className="container mx-auto">
<h2 className="text-3xl font-bold text-center mb-12 font-display">Was unsere Kunden sagen</h2>
<div className="grid md:grid-cols-3 gap-8">
{testimonials.map((testimonial, index) => (
<div key={index} className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600 font-sans-marketing mb-4">"{testimonial.text}"</p>
<div className="flex items-center">
<div className="flex">
{[...Array(testimonial.rating)].map((_, i) => <Star key={i} className="w-5 h-5 text-yellow-400 fill-current" />)}
</div>
<p className="ml-2 font-semibold font-sans-marketing">{testimonial.name}</p>
</div>
</div>
))}
</div>
</div>
</section>
</MarketingLayout>
);
};
export default Packages;

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { usePage, router } from '@inertiajs/react';
import { Head } from '@inertiajs/react';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
import { Loader } from 'lucide-react';
const Success: React.FC = () => {
const { auth, flash } = usePage().props as any;
if (auth.user && auth.user.email_verified_at) {
// Redirect to admin
router.visit('/admin', { preserveState: false });
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50">
<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" />
<p className="text-gray-600">Wird weitergeleitet...</p>
</div>
</div>
);
}
if (auth.user && !auth.user.email_verified_at) {
return (
<MarketingLayout title="E-Mail verifizieren">
<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="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
E-Mail verifizieren
</h2>
<p className="text-gray-600 mb-6">
Bitte überprüfen Sie Ihre E-Mail und klicken Sie auf den Verifizierungslink.
</p>
<form method="POST" action="/email/verification-notification">
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition duration-300"
>
Verifizierung erneut senden
</button>
</form>
<p className="mt-4 text-sm text-gray-600">
Bereits registriert? <a href="/login" className="text-blue-600 hover:text-blue-500">Anmelden</a>
</p>
</div>
</div>
</div>
</MarketingLayout>
);
}
return (
<MarketingLayout title="Kauf abschließen">
<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="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Kauf abschließen
</h2>
<p className="text-gray-600 mb-6">
Melden Sie sich an, um fortzufahren.
</p>
<a
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"
>
Anmelden
</a>
<p className="text-sm text-gray-600">
Kein Konto? <a href="/register" className="text-blue-600 hover:text-blue-500">Registrieren</a>
</p>
</div>
</div>
</div>
</MarketingLayout>
);
};
export default Success;

View File

@@ -41,22 +41,6 @@ export default function Profile({ mustVerifyEmail, status }: { mustVerifyEmail:
>
{({ processing, recentlySuccessful, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="mt-1 block w-full"
defaultValue={auth.user.name}
name="name"
required
autoComplete="name"
placeholder="Full name"
/>
<InputError className="mt-2" message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>

View File

@@ -6,6 +6,7 @@
<title>@yield('title', 'Fotospiel - Event-Fotos einfach und sicher mit QR-Codes')</title>
<meta name="description" content="Sammle Gastfotos für Events mit QR-Codes und unserer PWA-Plattform. Für Hochzeiten, Firmenevents und mehr. Kostenloser Einstieg.">
<link rel="icon" href="{{ asset('logo.svg') }}" type="image/svg+xml">
<meta name="csrf-token" content="{{ csrf_token() }}">
@vite(['resources/css/app.css', 'resources/js/app.tsx'])
<style>
@keyframes aurora {

View File

@@ -25,20 +25,6 @@
<input type="hidden" name="package_id" value="{{ $package->id }}">
@endif
<!-- Name Field -->
<div>
<label for="name" class="block text-sm font-medium text-gray-700">
{{ __('auth.name') }}
</label>
<input id="name" name="name" type="text" required
value="{{ old('name') }}"
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm @error('name') border-red-500 @enderror"
placeholder="{{ __('auth.name_placeholder') }}">
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<!-- Username Field -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700">

View File

@@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Log;
// Marketing-Seite mit Locale-Prefix
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
Route::view('/', 'marketing')->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('/register/{package_id?}', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'create'])->name('marketing.register');
Route::post('/register', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'store'])->name('marketing.register.store');
@@ -47,9 +47,7 @@ Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->
Route::get('/datenschutz', function () {
return view('legal.datenschutz');
})->name('datenschutz');
Route::get('/kontakt', function () {
return view('legal.kontakt');
})->name('kontakt');
Route::get('/kontakt', [\App\Http\Controllers\MarketingController::class, 'contactView'])->name('kontakt');
Route::post('/kontakt', [\App\Http\Controllers\MarketingController::class, 'contact'])->name('kontakt.submit');
});

View File

@@ -28,7 +28,6 @@ class FullUserFlowTest extends TestCase
$freePackage = Package::factory()->endcustomer()->create(['price' => 0]);
$registrationData = [
'name' => 'Flow User',
'username' => 'flowuser',
'email' => 'flow@example.com',
'password' => 'Password123!',

View File

@@ -22,7 +22,6 @@ class RegistrationTest extends TestCase
$freePackage = Package::factory()->create(['price' => 0]);
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'username' => 'testuser',
'email' => 'test@example.com',
'password' => 'password',
@@ -62,7 +61,6 @@ class RegistrationTest extends TestCase
public function test_registration_without_package()
{
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'username' => 'testuser2',
'email' => 'test2@example.com',
'password' => 'password',
@@ -86,7 +84,6 @@ class RegistrationTest extends TestCase
public function test_registration_validation_fails()
{
$response = $this->post(route('register.store'), [
'name' => '',
'username' => '',
'email' => 'invalid',
'password' => 'short',
@@ -99,7 +96,7 @@ class RegistrationTest extends TestCase
]);
$response->assertSessionHasErrors([
'name', 'username', 'email', 'password', 'first_name', 'last_name', 'address', 'phone', 'privacy_consent',
'username', 'email', 'password', 'first_name', 'last_name', 'address', 'phone', 'privacy_consent',
]);
}
@@ -110,7 +107,6 @@ class RegistrationTest extends TestCase
$freePackage = Package::factory()->create(['price' => 0]);
$response = $this->post(route('register.store'), [
'name' => 'Test User',
'username' => 'testuser3',
'email' => 'test3@example.com',
'password' => 'password',

View File

@@ -0,0 +1,69 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process'; // Für artisan seed
test.describe('Marketing Package Flow: Auswahl → Registrierung → Kauf (Free & Paid)', () => {
test.beforeAll(async () => {
// Seed Test-Tenant (einmalig)
execSync('php artisan tenant:add-dummy --email=test@example.com --password=password123 --first_name=Test --last_name=User --address="Teststr. 1" --phone="+49123"');
// Mock Verifizierung: Update DB (in Test-Env)
execSync('php artisan tinker --execute="App\\Models\\User::where(\'email\', \'test@example.com\')->update([\'email_verified_at\' => now()]);"');
});
test('Free-Paket-Flow (ID=1, Starter)', async ({ page }) => {
await page.goto('http://localhost:8000/de'); // Lokaler Server (vite dev)
await expect(page).toHaveTitle(/Fotospiel/);
await page.screenshot({ path: 'free-step1-home.png', fullPage: true });
// Paketauswahl
await page.getByRole('link', { name: 'Alle Packages ansehen' }).click();
await expect(page).toHaveURL(/\/de\/packages/);
await page.screenshot({ path: 'free-step2-packages.png', fullPage: true });
await page.getByRole('button', { name: 'Details anzeigen' }).first().click(); // Erstes Paket (Free)
await expect(page.locator('dialog')).toBeVisible();
await page.screenshot({ path: 'free-step3-modal.png', fullPage: true });
await page.getByRole('tab', { name: 'Kaufen' }).click();
await page.getByRole('link', { name: 'Registrieren & Kaufen' }).click();
await expect(page).toHaveURL(/\/de\/register\?package_id=1/);
await page.screenshot({ path: 'free-step4-register.png', fullPage: true });
// Registrierung (Test-Daten, aber seedet vorab hier Login simulieren falls nötig)
// Da seeded: Verwende Login statt neuer Registrierung für Test
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.getByRole('button', { name: 'Anmelden' }).click(); // Falls Login-Form nach Redirect
await expect(page).toHaveURL(/\/buy-packages\/1/);
await page.screenshot({ path: 'free-step5-buy.png', fullPage: true });
// Kauf (Free: Direkte Success)
await expect(page.locator('text=Free package assigned')).toContainText('success'); // API-Response oder Page-Text
await page.goto('/marketing/success');
await expect(page).toHaveURL(/\/marketing\/success/);
await page.screenshot({ path: 'free-step6-success.png', fullPage: true });
await expect(page).toHaveURL(/\/admin/); // Redirect
await page.screenshot({ path: 'free-step7-admin.png', fullPage: true });
await expect(page.locator('text=Remaining Photos')).toContainText('300'); // Limits aus package-flow.test.ts integriert
});
test('Paid-Paket-Flow (ID=2, Pro mit Stripe-Test)', async ({ page }) => {
// Ähnlich wie Free, aber package_id=2
await page.goto('http://localhost:8000/de/packages');
await page.getByRole('button', { name: 'Details anzeigen' }).nth(1).click(); // Zweites Paket (Paid)
// ... (Modal, Register/Login wie oben)
await expect(page).toHaveURL(/\/buy-packages\/2/);
// Mock Stripe
await page.route('https://checkout.stripe.com/**', async route => {
await route.fulfill({ status: 200, body: '<html>Mock Stripe Success</html>' });
});
// Simuliere Checkout: Fill Test-Karte
await page.fill('[name="cardNumber"]', '4242424242424242');
await page.fill('[name="cardExpiry"]', '12/25');
await page.fill('[name="cardCvc"]', '123');
await page.click('[name="submit"]');
await page.waitForURL(/\/marketing\/success/); // Nach Webhook
await page.screenshot({ path: 'paid-step6-success.png', fullPage: true });
// Integration: Limits-Check wie in package-flow.test.ts
await expect(page.locator('text=Remaining Photos')).toContainText('Unbegrenzt'); // Pro-Limit
});
});

Some files were not shown because too many files have changed in this diff Show More