codex has reworked checkout, but frontend doesnt work

This commit is contained in:
Codex Agent
2025-10-05 20:39:30 +02:00
parent fdaa2bec62
commit d70faf7a9d
35 changed files with 2105 additions and 430 deletions

View File

@@ -0,0 +1,222 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\Auth\LoginRequest;
use App\Mail\Welcome;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\\Support\\Facades\\DB;\r\nuse Illuminate\\Support\\Facades\\Hash;\r\nuse Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Inertia\Response;
use Throwable;
class CheckoutController extends Controller
{
/**
* Render the checkout wizard using the legacy marketing controller for now.
*/
public function show(Request $request, Package $package): Response
{
$marketingController = app(MarketingController::class);
return $marketingController->purchaseWizard($request, $package->getKey());
}
public function login(LoginRequest $request): JsonResponse
{
app()->setLocale($request->input('locale', app()->getLocale()));
$request->authenticate();
$request->session()->regenerate();
$user = $request->user()?->fresh();
return response()->json([
'success' => true,
'user' => $this->transformUser($user),
]);
}
public function register(Request $request): JsonResponse
{
app()->setLocale($request->input('locale', app()->getLocale()));
try {
$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()],
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'address' => ['required', 'string', 'max:500'],
'phone' => ['required', 'string', 'max:20'],
'privacy_consent' => ['accepted'],
'package_id' => ['nullable', 'integer'],
]);
} catch (ValidationException $exception) {
throw $exception;
}
$shouldAutoVerify = app()->environment(['local', 'testing']);
DB::beginTransaction();
try {
$user = User::create([
'username' => $validated['username'],
'email' => $validated['email'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'address' => $validated['address'],
'phone' => $validated['phone'],
'password' => bcrypt($validated['password']),
'role' => 'user',
'pending_purchase' => !empty($validated['package_id']),
]);
if ($user->pending_purchase) {
$request->session()->put('pending_user_id', $user->id);
}
if ($shouldAutoVerify) {
$user->forceFill(['email_verified_at' => now()])->save();
}
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $validated['first_name'].' '.$validated['last_name'],
'slug' => Str::slug($validated['first_name'].' '.$validated['last_name'].'-'.now()->timestamp),
'email' => $validated['email'],
'is_active' => true,
'is_suspended' => false,
'event_credits_balance' => 0,
'subscription_tier' => 'free',
'subscription_expires_at' => null,
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'font_family' => 'Inter, sans-serif',
],
'features' => [
'photo_likes_enabled' => false,
'event_checklist' => false,
'custom_domain' => false,
'advanced_analytics' => false,
],
'custom_domain' => null,
'contact_email' => $validated['email'],
'event_default_type' => 'general',
]),
]);
event(new Registered($user));
Auth::login($user);
$request->session()->regenerate();
DB::commit();
Mail::to($user)->queue(new Welcome($user));
$redirect = $shouldAutoVerify ? route('dashboard') : route('verification.notice');
$pendingPurchase = $user->pending_purchase;
if (!empty($validated['package_id'])) {
$package = Package::find($validated['package_id']);
if (!$package) {
throw ValidationException::withMessages([
'package_id' => __('validation.exists', ['attribute' => 'package'])
]);
}
if ((float) $package->price <= 0.0) {
TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'price' => 0,
'active' => true,
]);
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'price' => 0,
'purchased_at' => now(),
'provider_id' => 'free',
]);
$tenant->update(['subscription_status' => 'active']);
$user->update(['role' => 'tenant_admin', 'pending_purchase' => false]);
$pendingPurchase = false;
$redirect = $shouldAutoVerify ? route('dashboard') : route('verification.notice');
} else {
$pendingPurchase = true;
$redirect = route('buy.packages', $package->id);
}
}
$freshUser = $user->fresh();
return response()->json([
'success' => true,
'user' => $this->transformUser($freshUser),
'pending_purchase' => $pendingPurchase,
'redirect' => $redirect,
]);
} catch (ValidationException $validationException) {
DB::rollBack();
throw $validationException;
} catch (Throwable $throwable) {
DB::rollBack();
report($throwable);
return response()->json([
'success' => false,
'message' => __('auth.registration_failed'),
], 500);
}
}
private function transformUser(?User $user): ?array
{
if (!$user) {
return null;
}
return [
'id' => $user->id,
'email' => $user->email,
'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: $user->name,
'pending_purchase' => (bool) $user->pending_purchase,
'email_verified_at' => $user->email_verified_at,
];
}
}

View File

@@ -129,6 +129,12 @@ class MarketingController extends Controller
public function purchaseWizard(Request $request, $packageId)
{
$package = Package::findOrFail($packageId)->append(['features', 'limits']);
$packageOptions = Package::where('type', $package->type)
->orderBy('price')
->get()
->map(function ($candidate) {
return $candidate->append(['features', 'limits']);
});
$stripePublishableKey = config('services.stripe.key');
$privacyHtml = view('legal.datenschutz-partial', ['locale' => app()->getLocale()])->render();
@@ -136,6 +142,7 @@ class MarketingController extends Controller
$response = Inertia::render('marketing/PurchaseWizard', [
'package' => $package,
'packageOptions' => $packageOptions,
'stripePublishableKey' => $stripePublishableKey,
'privacyHtml' => $privacyHtml,
])->toResponse($request);

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class CheckoutSession extends Model
{
use HasFactory;
use HasUuids;
use SoftDeletes;
public const STATUS_DRAFT = 'draft';
public const STATUS_AWAITING_METHOD = 'awaiting_payment_method';
public const STATUS_REQUIRES_CUSTOMER_ACTION = 'requires_customer_action';
public const STATUS_PROCESSING = 'processing';
public const STATUS_COMPLETED = 'completed';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELLED = 'cancelled';
public const PROVIDER_NONE = 'none';
public const PROVIDER_STRIPE = 'stripe';
public const PROVIDER_PAYPAL = 'paypal';
public const PROVIDER_FREE = 'free';
/**
* The attributes that aren't mass assignable.
*/
protected $guarded = [];
/**
* Indicates if the IDs are auto-incrementing.
*/
public $incrementing = false;
/**
* The data type of the primary key.
*/
protected $keyType = 'string';
/**
* The attributes that should be cast.
*/
protected $casts = [
'package_snapshot' => 'array',
'status_history' => 'array',
'provider_metadata' => 'array',
'expires_at' => 'datetime',
'completed_at' => 'datetime',
'amount_subtotal' => 'decimal:2',
'amount_total' => 'decimal:2',
];
/**
* Default attributes.
*/
protected $attributes = [
'status_history' => '[]',
'package_snapshot' => '[]',
'provider_metadata' => '[]',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function package(): BelongsTo
{
return $this->belongsTo(Package::class);
}
public function scopeActive($query)
{
return $query->whereNotIn('status', [
self::STATUS_COMPLETED,
self::STATUS_CANCELLED,
])->where(function ($inner) {
$inner->whereNull('expires_at')->orWhere('expires_at', '>', now());
});
}
public function scopeByProviderId($query, string $providerField, string $value)
{
return $query->where($providerField, $value);
}
public function isExpired(): bool
{
return (bool) $this->expires_at && $this->expires_at->isPast();
}
public function requiresCustomerAction(): bool
{
return $this->status === self::STATUS_REQUIRES_CUSTOMER_ACTION;
}
}

View File

@@ -2,6 +2,9 @@
namespace App\Providers;
use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutPaymentService;
use App\Services\Checkout\CheckoutSessionService;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
@@ -14,7 +17,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->singleton(CheckoutSessionService::class);
$this->app->singleton(CheckoutAssignmentService::class);
$this->app->singleton(CheckoutPaymentService::class);
}
/**

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Services\Checkout;
use App\Mail\Welcome;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
class CheckoutAssignmentService
{
/**
* Persist the purchase artefacts for a completed checkout session.
*
* @param array{provider_reference?: string, payload?: array} $options
*/
public function finalise(CheckoutSession $session, array $options = []): void
{
DB::transaction(function () use ($session, $options) {
$tenant = $session->tenant;
$user = $session->user;
if (! $tenant && $user) {
$tenant = $this->ensureTenant($user, $session);
}
if (! $tenant) {
Log::warning('Checkout assignment skipped: missing tenant', ['session' => $session->id]);
return;
}
$package = $session->package;
if (! $package) {
Log::warning('Checkout assignment skipped: missing package', ['session' => $session->id]);
return;
}
$providerReference = $options['provider_reference']
?? $session->stripe_payment_intent_id
?? $session->paypal_order_id
?? 'free';
$purchase = PackagePurchase::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => $providerReference,
],
[
'price' => $session->amount_total,
'type' => $package->type === 'reseller' ? 'reseller_subscription' : 'endcustomer_event',
'purchased_at' => now(),
'metadata' => $options['payload'] ?? null,
]
);
TenantPackage::updateOrCreate(
[
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'price' => $session->amount_total,
'active' => true,
'purchased_at' => now(),
'expires_at' => $this->resolveExpiry($package, $tenant),
]
);
if ($user && $user->pending_purchase) {
$this->activateUser($user);
}
if ($user) {
Mail::to($user)->queue(new Welcome($user));
}
Log::info('Checkout session assigned', [
'session' => $session->id,
'tenant' => $tenant->id,
'package' => $package->id,
'purchase' => $purchase->id,
]);
});
}
protected function ensureTenant(User $user, CheckoutSession $session): ?Tenant
{
if ($user->tenant) {
return $user->tenant;
}
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $session->package_snapshot['name'] ?? $user->name,
'slug' => Str::slug(($user->name ?: $user->email).' '.now()->timestamp),
'email' => $user->email,
'is_active' => true,
'is_suspended' => false,
'event_credits_balance' => 0,
'subscription_tier' => 'free',
'subscription_status' => 'active',
'settings' => [
'contact_email' => $user->email,
],
]);
event(new Registered($user));
return $tenant;
}
protected function resolveExpiry(Package $package, Tenant $tenant)
{
if ($package->type === 'reseller') {
$hasActive = TenantPackage::where('tenant_id', $tenant->id)
->where('active', true)
->exists();
return $hasActive ? now()->addYear() : now()->addDays(14);
}
return now()->addYear();
}
protected function activateUser(User $user): void
{
$user->forceFill([
'email_verified_at' => $user->email_verified_at ?? now(),
'role' => $user->role === 'user' ? 'tenant_admin' : $user->role,
'pending_purchase' => false,
])->save();
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Services\Checkout;
use App\Models\CheckoutSession;
use App\Models\Tenant;
use LogicException;
class CheckoutPaymentService
{
public function __construct(
private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment,
) {
}
public function initialiseStripe(CheckoutSession $session, array $payload = []): array
{
if ($session->provider !== CheckoutSession::PROVIDER_STRIPE) {
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_STRIPE);
}
// TODO: integrate Stripe PaymentIntent creation and return client_secret + publishable key
return [
'session_id' => $session->id,
'status' => $session->status,
'message' => 'Stripe integration pending implementation.',
];
}
public function confirmStripe(CheckoutSession $session, array $payload = []): CheckoutSession
{
if ($session->provider !== CheckoutSession::PROVIDER_STRIPE) {
throw new LogicException('Cannot confirm Stripe payment on a non-Stripe session.');
}
// TODO: verify PaymentIntent status with Stripe SDK and update session metadata
$this->sessions->markProcessing($session);
return $session;
}
public function initialisePayPal(CheckoutSession $session, array $payload = []): array
{
if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) {
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
}
// TODO: integrate PayPal Orders API and return order id + approval link
return [
'session_id' => $session->id,
'status' => $session->status,
'message' => 'PayPal integration pending implementation.',
];
}
public function capturePayPal(CheckoutSession $session, array $payload = []): CheckoutSession
{
if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) {
throw new LogicException('Cannot capture PayPal payment on a non-PayPal session.');
}
// TODO: call PayPal capture endpoint and persist order/subscription identifiers
$this->sessions->markProcessing($session);
return $session;
}
public function finaliseFree(CheckoutSession $session): CheckoutSession
{
if ($session->provider !== CheckoutSession::PROVIDER_FREE) {
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);
}
$this->sessions->markProcessing($session);
$this->assignment->finalise($session, ['source' => 'free']);
return $this->sessions->markCompleted($session);
}
public function attachTenantAndResume(CheckoutSession $session, Tenant $tenant): CheckoutSession
{
$this->sessions->attachTenant($session, $tenant);
$this->sessions->refreshExpiration($session);
return $session;
}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace App\Services\Checkout;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use RuntimeException;
class CheckoutSessionService
{
private int $sessionTtlMinutes;
private int $historyRetention;
public function __construct(?int $sessionTtlMinutes = null, ?int $historyRetention = null)
{
$this->sessionTtlMinutes = $sessionTtlMinutes ?? (int) config('checkout.session_ttl_minutes', 30);
$this->historyRetention = $historyRetention ?? (int) config('checkout.status_history_max', 25);
}
public function createOrResume(?User $user, Package $package, array $context = []): CheckoutSession
{
return DB::transaction(function () use ($user, $package, $context) {
$existing = $this->findActiveSession($user, $package);
if ($existing) {
$this->refreshExpiration($existing);
return $existing;
}
$session = new CheckoutSession();
$session->id = (string) Str::uuid();
$session->status = CheckoutSession::STATUS_DRAFT;
$session->provider = CheckoutSession::PROVIDER_NONE;
$session->user()->associate($user);
$session->tenant()->associate($context['tenant'] ?? null);
$session->package()->associate($package);
$session->package_snapshot = $this->packageSnapshot($package);
$session->currency = Arr::get($session->package_snapshot, 'currency', 'EUR');
$session->amount_subtotal = Arr::get($session->package_snapshot, 'price', 0);
$session->amount_total = Arr::get($session->package_snapshot, 'price', 0);
$session->locale = $context['locale'] ?? app()->getLocale();
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
$session->status_history = [];
$this->appendStatus($session, CheckoutSession::STATUS_DRAFT, 'session_created');
$session->save();
return $session;
});
}
public function updatePackage(CheckoutSession $session, Package $package): CheckoutSession
{
return DB::transaction(function () use ($session, $package) {
$session->package()->associate($package);
$session->package_snapshot = $this->packageSnapshot($package);
$session->amount_subtotal = Arr::get($session->package_snapshot, 'price', 0);
$session->amount_total = Arr::get($session->package_snapshot, 'price', 0);
$session->provider = CheckoutSession::PROVIDER_NONE;
$session->status = CheckoutSession::STATUS_DRAFT;
$session->stripe_payment_intent_id = null;
$session->stripe_customer_id = null;
$session->stripe_subscription_id = null;
$session->paypal_order_id = null;
$session->paypal_subscription_id = null;
$session->provider_metadata = [];
$session->failure_reason = null;
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
$this->appendStatus($session, CheckoutSession::STATUS_DRAFT, 'package_switched');
$session->save();
return $session;
});
}
public function selectProvider(CheckoutSession $session, string $provider): CheckoutSession
{
$provider = strtolower($provider);
if (! in_array($provider, [CheckoutSession::PROVIDER_STRIPE, CheckoutSession::PROVIDER_PAYPAL, CheckoutSession::PROVIDER_FREE], true)) {
throw new RuntimeException("Unsupported checkout provider [{$provider}]");
}
$session->provider = $provider;
$session->status = $provider === CheckoutSession::PROVIDER_FREE
? CheckoutSession::STATUS_PROCESSING
: CheckoutSession::STATUS_AWAITING_METHOD;
$session->failure_reason = null;
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
$this->appendStatus($session, $session->status, 'provider_selected');
$session->save();
return $session;
}
public function markRequiresCustomerAction(CheckoutSession $session, string $reason = null): CheckoutSession
{
$session->status = CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION;
$session->failure_reason = $reason;
$this->appendStatus($session, CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION, $reason ?? 'requires_action');
$session->save();
return $session;
}
public function markProcessing(CheckoutSession $session, array $metadata = []): CheckoutSession
{
$session->status = CheckoutSession::STATUS_PROCESSING;
if (! empty($metadata)) {
$session->provider_metadata = array_merge($session->provider_metadata ?? [], $metadata);
}
$session->failure_reason = null;
$this->appendStatus($session, CheckoutSession::STATUS_PROCESSING, 'processing');
$session->save();
return $session;
}
public function markCompleted(CheckoutSession $session, ?CarbonInterface $completedAt = null): CheckoutSession
{
$session->status = CheckoutSession::STATUS_COMPLETED;
$session->completed_at = $completedAt ?? now();
$session->failure_reason = null;
$this->appendStatus($session, CheckoutSession::STATUS_COMPLETED, 'completed');
$session->save();
return $session;
}
public function markFailed(CheckoutSession $session, string $reason): CheckoutSession
{
$session->status = CheckoutSession::STATUS_FAILED;
$session->failure_reason = $reason;
$this->appendStatus($session, CheckoutSession::STATUS_FAILED, $reason);
$session->save();
return $session;
}
public function cancel(CheckoutSession $session, string $reason = 'cancelled'): CheckoutSession
{
$session->status = CheckoutSession::STATUS_CANCELLED;
$session->failure_reason = $reason;
$this->appendStatus($session, CheckoutSession::STATUS_CANCELLED, $reason);
$session->save();
return $session;
}
public function refreshExpiration(CheckoutSession $session): CheckoutSession
{
$session->expires_at = now()->addMinutes($this->sessionTtlMinutes);
$session->save();
return $session;
}
public function attachTenant(CheckoutSession $session, Tenant $tenant): CheckoutSession
{
$session->tenant()->associate($tenant);
$session->save();
return $session;
}
protected function appendStatus(CheckoutSession $session, string $status, ?string $reason = null): void
{
$history = $session->status_history ?? [];
$history[] = [
'status' => $status,
'reason' => $reason,
'at' => now()->toIso8601String(),
];
if (count($history) > $this->historyRetention) {
$history = array_slice($history, -1 * $this->historyRetention);
}
$session->status_history = $history;
}
protected function packageSnapshot(Package $package): array
{
return [
'id' => $package->getKey(),
'name' => $package->name,
'type' => $package->type,
'price' => (float) $package->price,
'currency' => $package->currency ?? 'EUR',
'features' => $package->features,
'limits' => $package->limits,
];
}
protected function findActiveSession(?User $user, Package $package): ?CheckoutSession
{
if (! $user) {
return null;
}
return CheckoutSession::query()
->where('user_id', $user->getKey())
->where('package_id', $package->getKey())
->active()
->orderByDesc('created_at')
->first();
}
}

6
config/checkout.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
return [
'session_ttl_minutes' => env('CHECKOUT_SESSION_TTL', 30),
'status_history_max' => env('CHECKOUT_STATUS_HISTORY_MAX', 25),
];

View File

@@ -0,0 +1,63 @@
<?php
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
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::create('checkout_sessions', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete();
$table->foreignIdFor(Tenant::class)->nullable()->constrained()->nullOnDelete();
$table->foreignIdFor(Package::class)->constrained()->cascadeOnDelete();
$table->json('package_snapshot');
$table->string('status', 40)->default('draft');
$table->string('provider', 30)->default('none');
$table->json('status_history')->nullable();
$table->char('currency', 3)->default('EUR');
$table->decimal('amount_subtotal', 10, 2)->default(0);
$table->decimal('amount_total', 10, 2)->default(0);
$table->string('stripe_payment_intent_id')->nullable();
$table->string('stripe_customer_id')->nullable();
$table->string('stripe_subscription_id')->nullable();
$table->string('paypal_order_id')->nullable();
$table->string('paypal_subscription_id')->nullable();
$table->json('provider_metadata')->nullable();
$table->string('locale', 5)->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->text('failure_reason')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique('stripe_payment_intent_id');
$table->unique('paypal_order_id');
$table->index(['provider', 'status']);
$table->index('expires_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('checkout_sessions');
}
};

View File

@@ -0,0 +1,39 @@
# Checkout Refactor TODOs (2025-10-05)
## Scope
- Marketing checkout only; guest and admin PWAs stay untouched.
- Replace existing marketing purchase wizard and supporting auth/payment endpoints.
## Action Items
### Wizard Foundations
- [ ] Rebuild the package step with a side panel for comparable packages and reset payment state when the selected package changes.
- [ ] Redesign the payment step: Stripe and PayPal happy path, failure, retry; add subscription handling for reseller plans.
- [ ] Update the confirmation step and surface the admin link inside `resources/js/pages/Profile/Index.tsx`.
### Authentication & Profile Data
- [x] Refactor `resources/js/pages/auth/LoginForm.tsx` and `RegisterForm.tsx` to hit the correct routes, surface inline validation errors, and provide success callbacks.
- [ ] Add optional comfort login: Google sign-in and enrichment of missing registration fields via the payment provider, combining the prior step 2/3 concept.
### Backend Alignment
- [ ] Implement a dedicated `CheckoutController` plus marketing API routes, migrating any remaining checkout logic out of the marketing controller.
- [ ] Audit existing marketing payment flows (`resources/js/pages/marketing/PurchaseWizard.tsx`, `PaymentForm.tsx`) and plan removals or migration.
### Quality & Rollout
- [ ] Expand automated coverage: Playwright end-to-end scenarios for auth, payment success/failure, Google login; PHPUnit and webhook tests for new checkout endpoints.
- [ ] Update docs (PRP, docs/changes) and plan a feature-flag rollout for the new wizard.
## Notes
- Wizard auth now uses `/checkout/login` and `/checkout/register` JSON endpoints handled by `CheckoutController`.
- Ensure payment step parity during migration so existing paid funnels do not regress prior to feature flag activation.
- 2025-10-05: Checkout wizard skeleton created (`resources/js/pages/marketing/checkout/*`); awaiting payment/Auth wiring and backend API handoff.
- 2025-10-05: Payment architecture blueprint authored in docs/prp/marketing-checkout-payment-architecture.md.
- 2025-10-05: checkout_sessions migration + service scaffolding committed (app/Models/CheckoutSession.php, app/Services/Checkout/*, config/checkout.php).
### Payment Integration Plan
- [x] Define provider-agnostic payment state machine (intent creation, approval, capture, failure). See docs/prp/marketing-checkout-payment-architecture.md.
- [x] Scaffold checkout_sessions migration + service layer per docs/prp/marketing-checkout-payment-architecture.md.
- [ ] Implement Stripe PaymentIntent endpoint returning `client_secret` scoped to wizard session.
- [ ] Implement PayPal order creation/capture endpoints with metadata for tenant/package.
- [ ] Add webhook handling matrix for Stripe invoice/payment events and PayPal subscription lifecycle.
- [ ] Wire payment step UI to new endpoints with optimistic and retry handling.

View File

@@ -0,0 +1,134 @@
# Marketing Checkout Payment Architecture (2025 Refactor)
## Goals
- Replace the legacy marketing checkout flow with a single `CheckoutController` that owns auth, payment, and confirmation steps.
- Support Stripe card payments and PayPal orders with a consistent state machine that can be extended to other providers.
- Keep package activation logic idempotent and traceable while respecting GDPR (no new PII logging, no leaked tokens).
- Prepare the frontend wizard to drive the flow as an SPA without relying on server-side redirects.
## Core Building Blocks
- **CheckoutSession model/table** keeps one purchase attempt per user + package. It stores provider choice, status, pricing snapshot, and external ids (Stripe intent, PayPal order, etc.).
- **CheckoutPaymentService** orchestrates provider-specific actions (create intent/order, capture, sync metadata) and normalises responses for the wizard.
- **CheckoutAssignmentService** performs the idempotent write workflow (create/update TenantPackage, PackagePurchase, tenant subscription fields, welcome mail) once payment succeeds.
- **Wizard API surface** (JSON routes under `/checkout/*`) is session-authenticated, CSRF-protected, and returns structured payloads consumed by the PWA.
- **Webhooks** (Stripe, PayPal) map incoming provider events back to `CheckoutSession` rows to guarantee reconciliation and support 3DS / async capture paths.
## Payment State Machine
State constants live on `CheckoutSession` (`status` column, enum):
| State | When it is used | Transitions |
| --- | --- | --- |
| `draft` | Session created, package locked in, no provider chosen. | `awaiting_payment_method`, `completed` (free), `cancelled` |
| `awaiting_payment_method` | Paid package; provider picked, waiting for client to initialise SDK. | `requires_customer_action`, `processing`, `cancelled` |
| `requires_customer_action` | Stripe 3DS, PayPal approval window open, or additional customer steps needed. | `processing`, `failed`, `cancelled` |
| `processing` | Provider reported success, backend validating / capturing / assigning. | `completed`, `failed` |
| `completed` | Checkout finished, package assigned, confirmation step unblocked. | none |
| `failed` | Provider declined or capture check failed; retain reason. | `awaiting_payment_method` (retry), `cancelled` |
| `cancelled` | User backed out or session expired (TTL 30 minutes). | none |
Each transition is recorded with a `status_history` JSON column (array of `{status, reason, at}`) for debugging.
## Backend Architecture
### Data Model
Add `checkout_sessions` table + model:
- `id` UUID primary key.
- `user_id` (FK users) and optional `tenant_id` (set after registration creates tenant).
- `package_id` (FK packages) and `package_snapshot` JSON (price, currency, package name, type, feature hash).
- `status` enum (states above), `provider` enum (`stripe`, `paypal`, `free`, `none`).
- `currency`, `amount_subtotal`, `amount_total` (DECIMAL(10,2)).
- Provider fields: `stripe_payment_intent_id`, `stripe_customer_id`, `paypal_order_id`, `paypal_subscription_id`, `provider_metadata` JSON.
- `locale`, `expires_at` (default now()+30 minutes), `completed_at`.
- Timestamps + soft deletes (retain audit trail).
Create Eloquent model `App\Models\CheckoutSession` with casts for JSON columns and helper scopes (`active`, `byProviderId`).
### Routes & Controller Methods
Group under `web.php` with `middleware(['auth', 'verified', 'locale', 'throttle:checkout'])`:
- `POST /checkout/session` (`CheckoutController@storeSession`): create or resume active session for selected package, return `{id, status, amount, package_snapshot}`.
- `PATCH /checkout/session/{session}/package` (`updatePackage`): allow switching package before payment; resets provider-specific fields and status to `draft`.
- `POST /checkout/session/{session}/provider` (`selectProvider`): set provider (`stripe` or `paypal`), transitions to `awaiting_payment_method` and returns provider configuration (publishable key, PayPal client id, feature flags).
- `POST /checkout/session/{session}/stripe-intent` (`createStripeIntent`): idempotently create/update PaymentIntent with metadata (user, tenant, package, session id) and deliver `{client_secret, intent_id}`.
- `POST /checkout/session/{session}/stripe/confirm` (`confirmStripeIntent`): server-side verify PaymentIntent status (retrieve from Stripe) and transition to `processing` when `succeeded` or `requires_action`.
- `POST /checkout/session/{session}/paypal/order` (`createPayPalOrder`): create order with `custom_id` payload (session, tenant, package) and return `{order_id, approve_url}`.
- `POST /checkout/session/{session}/paypal/capture` (`capturePayPalOrder`): capture order server-side, transition to `processing` if status `COMPLETED`.
- `POST /checkout/session/{session}/free` (`activateFreePackage`): bypass providers, run assignment service, mark `completed`.
- `POST /checkout/session/{session}/complete` (`finalise`): provider-agnostic finishing hook used after `processing` to run `CheckoutAssignmentService`, persist `PackagePurchase`, queue mails, and respond with summary.
- `GET /checkout/session/{session}` (`show`): used by wizard polling to keep state in sync (status, provider display data, failure reasons).
- `DELETE /checkout/session/{session}` (`cancel`): expire session, clean provider artefacts (cancel intent/order if applicable).
Stripe/PayPal routes remain under `routes/web.php` but call into new service classes; legacy marketing payment methods are removed once parity is verified.
### Services & Jobs
- `CheckoutSessionService`: create/resume session, guard transitions, enforce TTL, and wrap DB transactions.
- `CheckoutPaymentService`: entry point with methods `initialiseStripe`, `confirmStripe`, `initialisePayPal`, `capturePayPal`, `finaliseFree`. Delegates to provider-specific helpers (Stripe SDK, PayPal SDK) and persists external ids.
- `CheckoutAssignmentService`: generates or reuses tenant, writes `TenantPackage`, `PackagePurchase`, updates user role/status, dispatches `Welcome` + purchase receipts, and emits domain events (`CheckoutCompleted`).
- `SyncCheckoutFromWebhook` job: invoked by webhook controllers with provider payload, looks up `CheckoutSession` via provider id, runs assignment if needed, records failure states.
### Webhook Alignment
- Update `StripeWebhookController` to resolve `CheckoutSession::where('stripe_payment_intent_id', intentId)`; when event indicates success, transition to `processing` (if not already), enqueue `SyncCheckoutFromWebhook` to finish assignment, and mark `completed` once done.
- Update `PayPalWebhookController` similarly using `paypal_order_id` or `paypal_subscription_id`.
- Webhooks become source-of-truth for delayed confirmations; wizard polls `GET /checkout/session/{id}` until `completed`.
### Validation & Security
- All mutating routes use CSRF tokens and `auth` guard (session-based). Add `EnsureCheckoutSessionOwner` middleware enforcing that the session belongs to `request->user()`.
- Input validation via dedicated Form Request classes (e.g., `StoreCheckoutSessionRequest`, `StripeIntentRequest`).
- Provider responses are never logged raw; only store ids + safe metadata.
- Abandon expired sessions via scheduler (`checkout:expire-sessions` artisan command). Command cancels open PaymentIntents and PayPal orders.
## Frontend Touchpoints
### Wizard Context Enhancements
- Extend `CheckoutWizardState` to include `checkoutSessionId`, `paymentStatus`, `paymentError`, `isProcessing`, `provider`.
- Add actions `initialiseSession(packageId)`, `selectProvider(provider)`, `updatePaymentStatus(status, payload)`.
- Persist `checkoutSessionId` + status in `sessionStorage` for reload resilience (respect TTL).
### PaymentStep Implementation Plan
- On mount (and whenever package changes), call `/checkout/session` to create/resume session. Reset state if API reports new id.
- Provider tabs call `selectProvider`. Stripe tab loads Stripe.js dynamically (import from `@stripe/stripe-js`) and mounts Elements once `client_secret` arrives.
- Stripe flow: submit button triggers `stripe.confirmCardPayment(clientSecret)`, handle `requires_action`, then POST `/checkout/session/{id}/stripe/confirm`. On success, call `/checkout/session/{id}/complete` and advance to confirmation step.
- PayPal flow: render PayPal Buttons with `createOrder` -> call `/checkout/session/{id}/paypal/order`; `onApprove` -> POST `/checkout/session/{id}/paypal/capture`, then `/checkout/session/{id}/complete`.
- Free packages skip provider selection; call `/checkout/session/{id}/free` and immediately advance.
- Display status toasts based on `paymentStatus`; show inline error block when `failed` with `failure_reason` from API.
### Confirmation Step & Profile Link
- Confirmation fetches `/checkout/session/{id}` summary (package, next steps, admin URL) and surfaces the dashboard link. Update `resources/js/pages/Profile/Index.tsx` to show "Checkout history" link pointing to marketing success page (from TODO item 4/5).
## Provider Flows
### Stripe (one-off and subscription)
1. Session created (`draft`).
2. Provider selected `stripe` -> `awaiting_payment_method`.
3. `createStripeIntent` builds PaymentIntent with amount from package snapshot, metadata: session_id, package_id, user_id, tenant_id (if known), package_type.
4. Frontend confirms card payment. If Stripe returns `requires_action`, wizard stores `requires_customer_action` and surfaces modal.
5. Once Stripe marks intent `succeeded`, backend transitions to `processing`, calls `CheckoutAssignmentService`, and marks `completed`.
6. For reseller packages, Stripe subscription is created after assignment using configured price ids; resulting subscription id stored on session + tenant record.
### PayPal (one-off and subscription)
1. Session `draft` -> provider `paypal`.
2. `createPayPalOrder` returns order id + approval link. Metadata includes session, tenant, package, package_type.
3. After approval, `capturePayPalOrder` verifies capture status; on `COMPLETED`, transitions to `processing`.
4. Assignment service runs, storing order id as `provider_id`. For subscriptions, capture handler stores subscription id and updates tenant subscription status.
5. Webhooks handle late captures or cancellations (updates session -> `failed` or `cancelled`).
### Free Packages
- `activateFreePackage` bypasses payment providers, writes TenantPackage + PackagePurchase with `provider_id = 'free'`, marks `completed`, and pushes the user to confirmation immediately.
## Migration Strategy
1. **Phase 1** (current): land schema, services, new API routes; keep legacy MarketingController flow for fallback.
2. **Phase 2**: wire the new wizard PaymentStep behind feature flag `checkout_v2` (in `.env` / config). Run internal QA with Stripe/PayPal sandbox.
3. **Phase 3**: enable feature flag for production tenants, monitor Stripe/PayPal events, then delete legacy marketing payment paths and routes.
4. **Phase 4**: tighten webhook logic and remove `MarketingController::checkout`, `::paypalCheckout`, `::stripeSubscription` once new flow is stable.
## Testing & QA
- **Feature tests**: JSON endpoints for session lifecycle (create, provider select, intent creation, capture success/failure, free activation). Include multi-locale assertions.
- **Payment integration tests**: use Stripe + PayPal SDK test doubles to simulate success, requires_action, cancellation, and ensure state machine behaves.
- **Playwright**: wizard flow covering Stripe happy path, Stripe 3DS stub, PayPal approval, failure retry, free package shortcut, session resume after refresh.
- **Webhooks**: unit tests for mapping provider ids to sessions, plus job tests for idempotent assignment.
- **Scheduler**: test `checkout:expire-sessions` to confirm PaymentIntents are cancelled and sessions flagged `cancelled`.
## Open Questions / Follow-Ups
- Map package records to Stripe price ids and PayPal plan ids (store on `packages` table or config?).
- Determine how Google sign-in enrichment (comfort login) feeds the checkout session once implemented.
- Confirm legal copy updates for new checkout experience before GA.
- Align email templates (welcome, receipt) with new assignment service outputs.

31
neu 6.txt Normal file
View File

@@ -0,0 +1,31 @@
die webseite benötigt einen modernen checkout, der die seite nicht neu lädt, sondern immer nur den jeweiligen schritt anzeigt. Er soll wir ein Multi-Step Wizard aufgebaut sein. Er startet von der Paket-Auswahlseite kommend mit der bestätigung des Pakets anhand der package_id.
Schritt 1: gewähltes Paket
Anzeige des ausgewählten pakets mit allen details. möglichkeit, ein anderes paket (aber nur gleichartig, also wenn das paket vom typ "endcustomer" ist, nur solche andere pakete anzeigen, wenn das Paket vom Typ "reseller" ist, nur solche pakete anzeigen).
schritt 2: "login" und "registrierung"
nahtlose integration beider formular mit umschalter. Loginformular: fehleingaben sollen direkt am formular und per toast sichtbar sein. Der erfolgreiche Login sollte das sichtbare formular ersetzen mit "Sie sind nun eingeloggt" => weiter zum nächsten schritt. toast-benachrichtigung. Fehler bei der registrierung sollen im formular widergespiegelt werden. ist der nutzer angemeldet, soll beides nicht mehr sichtbar sein, sondern "Sie sind bereits eingeloggt". Eine erfolgreiche Registrierung loggt den nutzer mit status "purchase_pending" ein und leitet zu schritt 3.
Frage klären: kann man den login oder die registrierung ersetzen durch daten von paypal/strip registrierung (callback)?
schritt 3: Zahlung
pakettyp "endcustomer":
auswahl paypal / Stripe. Buttons für "Mit PayPal bezahlen" und "Mit Stripe bezahlen" anzeigen. Der Benutzer klickt einen aus.
Zahlungsinitierung:
PayPal: Umleitung zu PayPal's Express Checkout (via API-Call in Laravel-Controller, z. B. create_order). Der Benutzer loggt sich bei PayPal ein, bestätigt den Einmalkauf (keine Subscription-Option). Rückleitung mit Token zur Bestätigung (Webhook oder Redirect-Handler).
Stripe: Client-seitige Integration mit Stripe Elements (React-Komponente in Ihrer PWA). Der Benutzer gibt Kartendaten ein (ohne Umleitung), oder nutzt Stripe Checkout (hosted Page). Backend-Call zu Stripe API für PaymentIntent erstellen und bestätigen.
Bestätigung: Nach Zahlung (z. B. 29,99 €) wird der Kauf im Backend gespeichert (z. B. TenantPackage::createPurchase()), Zugang freigeschaltet (z. B. Event-Zugriff via EventController), und der Benutzer sieht eine Erfolgsseite.
Fehlerbehandlung: Abbruch → Zurück zur Bestellübersicht mit Fehlermeldung (z. B. "Zahlung fehlgeschlagen").
pakettyp "reseller":
PayPal:
Nutzung von PayPal Subscriptions API (in Laravel via SDK). Erstellen eines Subscription-Plans (z. B. create_subscription), Umleitung zu PayPal für Autorisierung. Der Benutzer stimmt wiederkehrenden Abbuchungen zu. Rückleitung mit Subscription-ID, die im Backend (z. B. PackagePurchases) gespeichert wird. Webhooks für Updates (z. B. Kündigung).
Stripe:
Erstellen eines Subscription-Plans via Stripe Dashboard/API (z. B. stripe.subscriptions.create()). Client-seitig: Stripe Elements für SetupIntent (Kartenspeicherung), dann Subscription aktivieren. Keine Umleitung nötig, wenn Sie benutzerdefinierte UI bauen. Backend-Handling für Billing-Cycles, Invoices und Webhooks (z. B. für invoice.paid).
Bestätigung: Erste Zahlung erfolgt sofort, Subscription startet. Backend-Update: Reseller-Status aktivieren (z. B. in Tenants-Tabelle), Willkommens-E-Mail.
Für Kündigungen: Link im profil und in der Admin-PWA (z. B. via Filament).
Fehlerbehandlung: Bei Kartenablehnung → Retry-Option oder Wechsel zu anderem Anbieter. Proration bei Paket-Wechsel (z. B. via Stripe's Proration-Feature).
Schritt 4: Abschluss und bestätigen der bestellung, falls noch erforderlich.
Bestätigungsseite mit link zum tenant-admin bereich. Ergänzen des profilmenüs um den link zum tenant-admin.
bei allem die lokalisierung beachten

View File

@@ -4,5 +4,7 @@
"registration_failed": "Registrierung fehlgeschlagen.",
"registration_success": "Registrierung erfolgreich fortfahren mit Kauf.",
"already_logged_in": "Sie sind bereits eingeloggt.",
"failed_credentials": "Falsche Anmeldedaten."
"failed_credentials": "Falsche Anmeldedaten.",
"header.login": "Anmelden",
"header.register": "Registrieren"
}

View File

@@ -1,4 +1,8 @@
{
"header": {
"login:" : "Anmelden",
"register": "Registrieren"
},
"home": {
"title": "Startseite - Fotospiel",
"hero_title": "Fotospiel",
@@ -102,13 +106,11 @@
"for_resellers": "Für Reseller",
"details_show": "Details anzeigen",
"comparison_title": "Packages vergleichen",
"price": "Preis",
"max_photos_label": "Max. Fotos",
"max_guests_label": "Max. Gäste",
"gallery_days_label": "Galerie-Tage",
"watermark_label": "Wasserzeichen",
"no_watermark": "Kein Wasserzeichen",
"custom_branding": "Benutzerdefiniertes Branding",
"max_tenants": "Max. Tenants",
"max_events": "Max. Events/Jahr",
"faq_free": "Was ist das Free Package?",

View File

@@ -4,5 +4,7 @@
"registration_failed": "Registration failed.",
"registration_success": "Registration successful proceed with purchase.",
"already_logged_in": "You are already logged in.",
"failed_credentials": "Invalid credentials."
"failed_credentials": "Invalid credentials.",
"header.login": "Login",
"header.register": "Register"
}

View File

@@ -40,7 +40,6 @@
},
"packages": {
"title": "Our Packages",
"price": "Price",
"features": "Features",
"subscription_annual": "Annual Subscription",
"auto_renew": "auto-renew",
@@ -48,7 +47,6 @@
"trial_start": "Free Trial for :days days",
"reseller_benefits": "Benefits for Resellers",
"unlimited_events": "Unlimited Events",
"custom_branding": "Custom Branding",
"priority_support": "Priority Support",
"cancel_link": "Cancel Subscription: :link",
"hero_title": "Discover our flexible Packages",

View File

@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import { usePage } from '@inertiajs/react';
declare const route: ((name: string, params?: Record<string, unknown>, absolute?: boolean) => string) | undefined;
type PageProps = {
locale?: string;
url?: string;
};
export const useLocalizedRoutes = () => {
const page = usePage<PageProps>();
const currentUrl = page.url ?? (typeof window !== 'undefined' ? window.location.pathname : '/') ?? '/';
return useMemo(() => {
let locale = page.props.locale;
if (!locale) {
if (currentUrl.startsWith('/en')) {
locale = 'en';
} else if (currentUrl.startsWith('/de')) {
locale = 'de';
}
}
if (!locale) {
locale = 'de';
}
const localePrefix = locale ? `/${locale}` : '';
const localizedPath = (path = '/') => {
if (!path || path === '/') {
return localePrefix || '/';
}
const normalized = path.startsWith('/') ? path : `/${path}`;
const result = `${localePrefix}${normalized}`;
return result.replace(/\/+$/, '').replace(/\/+/g, '/');
};
const localizedRoute = (name: string, params: Record<string, unknown> = {}, absolute = false) => {
if (typeof route === 'function') {
const payload = locale ? { locale, ...params } : params;
try {
return route(name, payload, absolute);
} catch (error) {
console.warn('Failed to resolve route', name, error);
}
}
return localizedPath(name.startsWith('/') ? name : `/${name}`);
};
return {
locale,
localePrefix,
localizedPath,
localizedRoute,
};
}, [page.props.locale, currentUrl]);
};

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import React, { useEffect } from 'react';
import { Head, Link, usePage, router } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
interface MarketingLayoutProps {
@@ -8,38 +9,51 @@ interface MarketingLayoutProps {
}
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) => {
const page = usePage<{ translations?: Record<string, Record<string, string>> }>();
const { url } = page;
const { t } = useTranslation('marketing');
const { url } = usePage();
const i18n = useTranslation();
const { locale, localizedPath } = useLocalizedRoutes();
useEffect(() => {
const locale = url.startsWith('/en/') ? 'en' : 'de';
if (i18n.i18n.language !== locale) {
i18n.i18n.changeLanguage(locale);
}
}, [url, i18n]);
const localeCandidate = locale || (url.startsWith('/en/') ? 'en' : 'de');
const { translations } = usePage().props as any;
const marketing = translations?.marketing || {};
if (localeCandidate && i18n.i18n.language !== localeCandidate) {
i18n.i18n.changeLanguage(localeCandidate);
}
}, [url, i18n, locale]);
const marketing = page.props.translations?.marketing ?? {};
const getString = (key: string, fallback: string) => {
const value = marketing[key];
return typeof value === 'string' ? value : fallback;
};
const currentLocale = url.startsWith('/en/') ? 'en' : 'de';
const alternateLocale = currentLocale === 'de' ? 'en' : 'de';
const path = url.replace(/^\/(de|en)?/, '');
const canonicalUrl = `https://fotospiel.app/${currentLocale}${path}`;
const alternateUrl = `https://fotospiel.app/${alternateLocale}${path}`;
const activeLocale = locale || (url.startsWith('/en/') ? 'en' : 'de');
const alternateLocale = activeLocale === 'de' ? 'en' : 'de';
const path = url.replace(/^\/(de|en)/, '');
const canonicalUrl = `https://fotospiel.app${localizedPath(path || '/')}`;
const handleLocaleChange = (nextLocale: string) => {
const normalizedPath = url.replace(/^\/(de|en)/, '') || '/';
const destination = normalizedPath === '/' ? `/${nextLocale}` : `/${nextLocale}${normalizedPath}`;
router.visit(destination);
};
return (
<>
<Head>
<title>{title || t('meta.title', getString('title', 'Fotospiel'))}</title>
<meta name="description" content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))} />
<meta
name="description"
content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))}
/>
<meta property="og:title" content={title || t('meta.title', getString('title', 'Fotospiel'))} />
<meta property="og:description" content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))} />
<meta
property="og:description"
content={t('meta.description', getString('description', 'Sammle Gastfotos für Events mit QR-Codes'))}
/>
<meta property="og:url" content={canonicalUrl} />
<link rel="canonical" href={canonicalUrl} />
<link rel="alternate" hrefLang="de" href={`https://fotospiel.app/de${path}`} />
@@ -50,31 +64,27 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
<header className="bg-white shadow-sm">
<div className="container mx-auto px-4 py-4">
<nav className="flex justify-between items-center">
<Link href="/" className="text-xl font-bold text-gray-900">
<Link href={localizedPath('/')} className="text-xl font-bold text-gray-900">
Fotospiel
</Link>
<div className="hidden md:flex space-x-8">
<Link href="/" className="text-gray-700 hover:text-gray-900">
<Link href={localizedPath('/')} className="text-gray-700 hover:text-gray-900">
{t('nav.home')}
</Link>
<Link href="/packages" className="text-gray-700 hover:text-gray-900">
<Link href={localizedPath('/packages')} className="text-gray-700 hover:text-gray-900">
{t('nav.packages')}
</Link>
<Link href="/blog" className="text-gray-700 hover:text-gray-900">
<Link href={localizedPath('/blog')} className="text-gray-700 hover:text-gray-900">
{t('nav.blog')}
</Link>
<Link href="/kontakt" className="text-gray-700 hover:text-gray-900">
<Link href={localizedPath('/kontakt')} className="text-gray-700 hover:text-gray-900">
{t('nav.contact')}
</Link>
</div>
<div className="flex items-center space-x-4">
<select
value={currentLocale}
onChange={(e) => {
const newLocale = e.target.value;
const newPath = url.replace(/^\/(de|en)?/, `/${newLocale}`);
router.visit(newPath);
}}
value={activeLocale}
onChange={(event) => handleLocaleChange(event.target.value)}
className="border border-gray-300 rounded-md px-2 py-1"
>
<option value="de">DE</option>
@@ -91,13 +101,13 @@ const MarketingLayout: React.FC<MarketingLayoutProps> = ({ children, title }) =>
<div className="container mx-auto px-4 text-center">
<p>&copy; 2025 Fotospiel. Alle Rechte vorbehalten.</p>
<div className="mt-4 space-x-4">
<Link href="/datenschutz" className="hover:underline">
<Link href={localizedPath('/datenschutz')} className="hover:underline">
{t('nav.privacy')}
</Link>
<Link href="/impressum" className="hover:underline">
<Link href={localizedPath('/impressum')} className="hover:underline">
{t('nav.impressum')}
</Link>
<Link href="/kontakt" className="hover:underline">
<Link href={localizedPath('/kontakt')} className="hover:underline">
{t('nav.contact')}
</Link>
</div>

View File

@@ -1,98 +1,181 @@
import React, { useEffect, useState } from 'react';
import { useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { LoaderCircle, Mail, Lock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import InputError from '@/components/input-error';
import TextLink from '@/components/text-link';
import React, { useEffect, useMemo, useState } from "react";
import { usePage } from "@inertiajs/react";
import { useTranslation } from "react-i18next";
import toast from "react-hot-toast";
import { LoaderCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import InputError from "@/components/input-error";
import TextLink from "@/components/text-link";
interface LoginFormProps {
onSuccess?: (userData: any) => void;
canResetPassword?: boolean;
declare const route: (name: string, params?: Record<string, unknown>) => string;
export interface AuthUserPayload {
id?: number;
email?: string;
name?: string | null;
pending_purchase?: boolean;
}
export default function LoginForm({ onSuccess, canResetPassword = true }: LoginFormProps) {
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const { t } = useTranslation('auth');
const { props } = usePage<{ errors: Record<string, string> }>();
interface LoginFormProps {
onSuccess?: (userData: AuthUserPayload | null) => void;
canResetPassword?: boolean;
locale?: string;
}
const { data, setData, post, processing, errors, clearErrors, reset } = useForm({
email: '',
password: '',
type SharedPageProps = {
locale?: string;
};
type FieldErrors = Record<string, string>;
const fallbackRoute = (locale: string) => `/${locale}/login`;
const csrfToken = () => (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? "";
export default function LoginForm({ onSuccess, canResetPassword = true, locale }: LoginFormProps) {
const page = usePage<SharedPageProps>();
const { t } = useTranslation("auth");
const resolvedLocale = locale ?? page.props.locale ?? "de";
const loginEndpoint = useMemo(() => {
if (typeof route === "function") {
try {
return route("checkout.login");
} catch (error) {
// Ziggy might not be booted yet; fall back to locale-aware path.
}
}
return fallbackRoute(resolvedLocale);
}, [resolvedLocale]);
const [values, setValues] = useState({
email: "",
password: "",
remember: false,
});
useEffect(() => {
if (hasTriedSubmit && Object.keys(errors).length > 0) {
toast.error(Object.values(errors).join(' '));
}
}, [errors, hasTriedSubmit]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
setHasTriedSubmit(true);
post('/login', {
preserveScroll: true,
onSuccess: () => {
if (onSuccess) {
onSuccess({ user: { email: data.email } }); // Pass basic user info; full user from props in parent
}
reset();
},
});
};
const [errors, setErrors] = useState<FieldErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
useEffect(() => {
if (!hasTriedSubmit) {
return;
}
const errorKeys = Object.keys(errors);
if (errorKeys.length === 0) {
return;
}
const field = document.querySelector<HTMLInputElement>(`[name="${errorKeys[0]}"]`);
if (field) {
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
field.focus();
const collected = Object.values(errors).filter(Boolean);
if (collected.length > 0) {
toast.error(collected.join(" \u2022 "));
}
}, [errors, hasTriedSubmit]);
useEffect(() => {
if (!hasTriedSubmit) {
return;
}
const firstKey = Object.keys(errors).find((key) => Boolean(errors[key]));
if (!firstKey) {
return;
}
const field = document.querySelector<HTMLInputElement>(`[name="${firstKey}"]`);
field?.scrollIntoView({ behavior: "smooth", block: "center" });
field?.focus();
}, [errors, hasTriedSubmit]);
const updateValue = (key: keyof typeof values, value: string | boolean) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => ({ ...current, [key as string]: "" }));
};
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setHasTriedSubmit(true);
setErrors({});
setIsSubmitting(true);
try {
const response = await fetch(loginEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"X-CSRF-TOKEN": csrfToken(),
},
credentials: "same-origin",
body: JSON.stringify({
email: values.email,
password: values.password,
remember: values.remember,
locale: resolvedLocale,
}),
});
if (response.ok) {
const payload = await response.json();
const nextUser: AuthUserPayload | null = payload?.user ?? payload?.data ?? null;
toast.success(t("login.success_toast", "Login erfolgreich"));
onSuccess?.(nextUser);
setValues((current) => ({ ...current, password: "" }));
setHasTriedSubmit(false);
return;
}
if (response.status === 422) {
const payload = await response.json();
const fieldErrors: FieldErrors = {};
const source = payload?.errors ?? {};
Object.entries(source).forEach(([key, message]) => {
if (Array.isArray(message) && message.length > 0) {
fieldErrors[key] = String(message[0]);
} else if (typeof message === "string") {
fieldErrors[key] = message;
}
});
setErrors(fieldErrors);
toast.error(t("login.failed_generic", "Ungueltige Anmeldedaten"));
return;
}
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
} catch (error) {
console.error("Login request failed", error);
toast.error(t("login.unexpected_error", "Beim Login ist ein Fehler aufgetreten."));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col gap-6">
<form onSubmit={submit} className="flex flex-col gap-6" noValidate>
<div className="grid gap-6">
<div className="grid gap-2">
<Label htmlFor="email">{t('login.email')}</Label>
<Label htmlFor="email">{t("login.email")}</Label>
<Input
id="email"
type="email"
name="email"
required
autoFocus
placeholder={t('login.email_placeholder')}
value={data.email}
onChange={(e) => {
setData('email', e.target.value);
if (errors.email) {
clearErrors('email');
}
}}
placeholder={t("login.email_placeholder")}
value={values.email}
onChange={(event) => updateValue("email", event.target.value)}
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">{t('login.password')}</Label>
<Label htmlFor="password">{t("login.password")}</Label>
{canResetPassword && (
<TextLink href="/forgot-password" className="ml-auto text-sm">
{t('login.forgot')}
<TextLink href={typeof route === "function" ? route("password.request") : "/forgot-password"} className="ml-auto text-sm">
{t("login.forgot")}
</TextLink>
)}
</div>
@@ -101,14 +184,9 @@ export default function LoginForm({ onSuccess, canResetPassword = true }: LoginF
type="password"
name="password"
required
placeholder={t('login.password_placeholder')}
value={data.password}
onChange={(e) => {
setData('password', e.target.value);
if (errors.password) {
clearErrors('password');
}
}}
placeholder={t("login.password_placeholder")}
value={values.password}
onChange={(event) => updateValue("password", event.target.value)}
/>
<InputError message={errors.password} />
</div>
@@ -117,25 +195,24 @@ export default function LoginForm({ onSuccess, canResetPassword = true }: LoginF
<Checkbox
id="remember"
name="remember"
checked={data.remember}
onCheckedChange={(checked) => setData('remember', Boolean(checked))}
checked={values.remember}
onCheckedChange={(checked) => updateValue("remember", Boolean(checked))}
/>
<Label htmlFor="remember">{t('login.remember')}</Label>
<Label htmlFor="remember">{t("login.remember")}</Label>
</div>
<Button type="button" onClick={submit} className="w-full" disabled={processing}>
{processing && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
{t('login.submit')}
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
{t("login.submit")}
</Button>
</div>
{Object.keys(errors).length > 0 && (
{Object.values(errors).some(Boolean) && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-800">
{Object.values(errors).join(' ')}
</p>
<p className="text-sm text-red-800">{Object.values(errors).filter(Boolean).join(" \u2022 ")}</p>
</div>
)}
</div>
</form>
);
}

View File

@@ -1,23 +1,33 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
interface RegisterFormProps {
packageId?: number;
onSuccess?: (userData: any) => void;
privacyHtml: string;
declare const route: (name: string, params?: Record<string, unknown>) => string;
export interface RegisterSuccessPayload {
user: any | null;
redirect?: string | null;
pending_purchase?: boolean;
}
export default function RegisterForm({ packageId, onSuccess, privacyHtml }: RegisterFormProps) {
interface RegisterFormProps {
packageId?: number;
onSuccess?: (payload: RegisterSuccessPayload) => void;
privacyHtml: string;
locale?: string;
}
export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale }: RegisterFormProps) {
const [privacyOpen, setPrivacyOpen] = useState(false);
const [hasTriedSubmit, setHasTriedSubmit] = useState(false);
const { t } = useTranslation(['auth', 'common']);
const { props } = usePage<{ errors: Record<string, string> }>();
const page = usePage<{ errors: Record<string, string>; locale?: string; auth?: { user?: any | null } }>();
const resolvedLocale = locale ?? page.props.locale ?? 'de';
const { data, setData, post, processing, errors, clearErrors, reset } = useForm({
const { data, setData, errors, clearErrors, reset, setError } = useForm({
username: '',
email: '',
password: '',
@@ -36,18 +46,76 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml }: Regi
}
}, [errors, hasTriedSubmit]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
const registerEndpoint = useMemo(() => {
if (typeof route === 'function') {
try {
return route('checkout.register');
} catch (error) {
// ignore ziggy errors and fall back
}
}
return `/${resolvedLocale}/register`;
}, [resolvedLocale]);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setHasTriedSubmit(true);
post('/register', {
preserveScroll: true,
onSuccess: (page) => {
if (onSuccess) {
onSuccess((page as any).props.auth.user);
}
setIsSubmitting(true);
clearErrors();
const body = {
...data,
locale: resolvedLocale,
package_id: data.package_id ?? packageId ?? null,
};
try {
const response = await fetch(registerEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '',
},
credentials: 'same-origin',
body: JSON.stringify(body),
});
if (response.ok) {
const json = await response.json();
toast.success(t('register.success_toast', 'Registrierung erfolgreich'));
onSuccess?.({
user: json?.user ?? null,
redirect: json?.redirect ?? null,
pending_purchase: json?.pending_purchase ?? json?.user?.pending_purchase ?? false,
});
reset();
},
});
setHasTriedSubmit(false);
return;
}
if (response.status === 422) {
const json = await response.json();
const fieldErrors = json?.errors ?? {};
Object.entries(fieldErrors).forEach(([key, message]) => {
if (Array.isArray(message) && message.length > 0) {
setError(key, message[0] as string);
} else if (typeof message === 'string') {
setError(key, message);
}
});
toast.error(t('register.validation_failed', 'Bitte pruefen Sie Ihre Eingaben.'));
return;
}
toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.'));
} catch (error) {
console.error('Register request failed', error);
toast.error(t('register.unexpected_error', 'Registrierung nicht moeglich.'));
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
@@ -323,10 +391,10 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml }: Regi
<button
type="button"
onClick={submit}
disabled={processing}
disabled={isSubmitting}
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" />}
{isSubmitting && <LoaderCircle className="h-4 w-4 animate-spin mr-2" />}
{t('register.submit')}
</button>
@@ -342,3 +410,11 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml }: Regi
</div>
);
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Head, Link, usePage } from '@inertiajs/react';
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
@@ -13,7 +14,8 @@ interface Props {
}
const Blog: React.FC<Props> = ({ posts }) => {
const { url } = usePage();
const { localizedPath } = useLocalizedRoutes();
const { t } = useTranslation('marketing');
const renderPagination = () => {
@@ -69,7 +71,7 @@ const Blog: React.FC<Props> = ({ posts }) => {
/>
)}
<h3 className="text-xl font-semibold mb-2 font-sans-marketing text-gray-900 dark:text-gray-100">
<Link href={`/blog/${post.slug}`} className="hover:text-[#FFB6C1]">
<Link href={`${localizedPath(`/blog/${post.slug}`)}`} className="hover:text-[#FFB6C1]">
{post.title}
</Link>
</h3>
@@ -78,7 +80,7 @@ const Blog: React.FC<Props> = ({ posts }) => {
{t('blog.by')} {post.author?.name || t('blog.team')} | {t('blog.published_at')} {post.published_at}
</p>
<Link
href={`/blog/${post.slug}`}
href={`${localizedPath(`/blog/${post.slug}`)}`}
className="text-[#FFB6C1] font-semibold font-sans-marketing hover:underline"
>
{t('blog.read_more')}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Head, Link, usePage } from '@inertiajs/react';
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
@@ -17,6 +18,7 @@ interface Props {
}
const BlogShow: React.FC<Props> = ({ post }) => {
const { localizedPath } = useLocalizedRoutes();
const { t } = useTranslation('blog_show');
return (
@@ -50,7 +52,7 @@ const BlogShow: React.FC<Props> = ({ post }) => {
<section className="py-10 px-4 bg-gray-50">
<div className="container mx-auto text-center">
<Link
href="/blog"
href={localizedPath('/blog')}
className="bg-[#FFB6C1] text-white px-8 py-3 rounded-full font-semibold hover:bg-[#FF69B4] transition"
>
{t('back_to_blog')}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import React from 'react';
import { Head, Link, useForm } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
interface Package {
@@ -16,6 +17,7 @@ interface Props {
const Home: React.FC<Props> = ({ packages }) => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const { data, setData, post, processing, errors, reset } = useForm({
name: '',
email: '',
@@ -24,7 +26,7 @@ const Home: React.FC<Props> = ({ packages }) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post('/kontakt', {
post(localizedPath('/kontakt'), {
onSuccess: () => reset(),
});
};
@@ -46,7 +48,7 @@ const Home: React.FC<Props> = ({ packages }) => {
<h1 className="text-4xl md:text-6xl font-bold mb-4 font-display">{t('home.hero_title')}</h1>
<p className="text-xl md:text-2xl mb-8 font-sans-marketing">{t('home.hero_description')}</p>
<Link
href="/packages"
href={localizedPath('/packages')}
className="bg-white dark:bg-gray-800 text-[#FFB6C1] px-8 py-4 rounded-full font-bold hover:bg-gray-100 dark:hover:bg-gray-700 transition duration-300 inline-block"
>
{t('home.cta_explore')}
@@ -123,14 +125,14 @@ const Home: React.FC<Props> = ({ packages }) => {
<h3 className="text-2xl font-bold mb-2">{pkg.name}</h3>
<p className="text-gray-600 dark:text-gray-300 mb-4">{pkg.description}</p>
<p className="text-3xl font-bold text-[#FFB6C1]">{pkg.price} {t('common.currency.euro')}</p>
<Link href={`/register?package_id=${pkg.id}`} className="mt-4 inline-block bg-[#FFB6C1] text-white px-6 py-2 rounded-full hover:bg-pink-600">
<Link href={`${localizedPath('/register')}?package_id=${pkg.id}`} className="mt-4 inline-block bg-[#FFB6C1] text-white px-6 py-2 rounded-full hover:bg-pink-600">
{t('home.view_details')}
</Link>
</div>
))}
</div>
<div className="text-center">
<Link href="/packages" className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
<Link href={localizedPath('/packages')} className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
{t('home.all_packages')}
</Link>
</div>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React from 'react';
import { Head, Link, useForm, usePage } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
@@ -15,7 +15,7 @@ const Kontakt: React.FC = () => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post('/kontakt', {
post(localizedPath('/kontakt'), {
onSuccess: () => reset(),
});
};
@@ -85,7 +85,7 @@ const Kontakt: React.FC = () => {
</div>
)}
<div className="mt-8 text-center">
<Link href="/" className="text-[#FFB6C1] hover:underline font-sans-marketing">{t('kontakt.back_home')}</Link>
<Link href={localizedPath('/')} className="text-[#FFB6C1] hover:underline font-sans-marketing">{t('kontakt.back_home')}</Link>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { Head, usePage } from '@inertiajs/react';
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
@@ -9,6 +10,7 @@ interface OccasionsProps {
const Occasions: React.FC<OccasionsProps> = ({ type }) => {
const { t } = useTranslation('marketing');
const { localizedPath } = useLocalizedRoutes();
const occasionsContent = {
hochzeit: {
@@ -56,9 +58,9 @@ const Occasions: React.FC<OccasionsProps> = ({ type }) => {
<div className="text-center mb-12">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-gray-100 mb-6 font-display">{content.title}</h1>
<p className="text-xl text-gray-600 dark:text-gray-300 mb-8 font-sans-marketing">{content.description}</p>
<a href="/packages" className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
<Link href={localizedPath('/packages')} className="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-bold hover:bg-pink-600 transition">
{content.cta}
</a>
</Link>
</div>
<div className="grid md:grid-cols-3 gap-8">
{content.features.map((feature, index) => (

View File

@@ -1,257 +1,49 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Head, useForm, usePage, router } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { Steps } from '@/components/ui/Steps'; // Correct casing for Shadcn Steps
import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Loader2 } from 'lucide-react';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
import RegisterForm from '../auth/RegisterForm'; // Extract Register form to separate component
import LoginForm from '../auth/LoginForm'; // Extract Login form
import PaymentForm from './PaymentForm'; // New component for Stripe payment
import SuccessStep from './SuccessStep'; // New component for success
import React from "react";
import { Head, usePage } from "@inertiajs/react";
import MarketingLayout from "@/layouts/marketing/MarketingLayout";
import type { CheckoutPackage } from "./checkout/types";
import { CheckoutWizard } from "./checkout/CheckoutWizard";
interface Package {
id: number;
name: string;
description: string;
price: number;
features: string[];
type?: 'endcustomer' | 'reseller';
trial_days?: number;
// Add other fields as needed
}
interface UserData {
id: number;
email: string;
pending_purchase?: boolean;
}
interface PurchaseWizardProps {
package: Package;
interface PurchaseWizardPageProps {
package: CheckoutPackage;
packageOptions: CheckoutPackage[];
stripePublishableKey: string;
privacyHtml: string;
}
const steps = [
{ id: 'package', title: 'Paket auswählen', description: 'Bestätigen Sie Ihr gewähltes Paket' },
{ id: 'auth', title: 'Anmelden oder Registrieren', description: 'Erstellen oder melden Sie sich an' },
{ id: 'payment', title: 'Zahlung', description: 'Sichern Sie Ihr Paket ab' },
{ id: 'success', title: 'Erfolg', description: 'Willkommen!' },
];
export default function PurchaseWizardPage({
package: initialPackage,
packageOptions,
stripePublishableKey,
privacyHtml,
}: PurchaseWizardPageProps) {
const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string } | null } }>();
const currentUser = page.props.auth?.user ?? null;
export default function PurchaseWizard({ package: initialPackage, stripePublishableKey, privacyHtml }: PurchaseWizardProps) {
const STORAGE_KEY = 'fotospiel_wizard_state';
const STORAGE_TTL = 30 * 60 * 1000; // 30 minutes in ms
const [currentStep, setCurrentStep] = useState(0);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authCompleted, setAuthCompleted] = useState(false);
const [authType, setAuthType] = useState<'register' | 'login'>('register'); // Toggle for auth step
const [wizardData, setWizardData] = useState<{ package: Package; user: UserData | null }>({ package: initialPackage, user: null });
const { t } = useTranslation(['marketing', 'auth']);
const { props } = usePage();
const { auth } = props as any;
// Load state from sessionStorage on mount
useEffect(() => {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
const { currentStep: savedCurrentStep, wizardData: savedWizardData, isAuthenticated: savedIsAuthenticated, authType: savedAuthType, timestamp } = parsed;
if (Date.now() - timestamp < STORAGE_TTL && savedWizardData?.package?.id === initialPackage.id) {
setCurrentStep(savedCurrentStep || 0);
setWizardData(savedWizardData || { package: initialPackage, user: null });
setIsAuthenticated(savedIsAuthenticated || false);
setAuthType(savedAuthType || 'register');
// If in payment step and pending purchase, reload client_secret if needed
if ((savedCurrentStep || 0) === 2 && savedWizardData?.user?.pending_purchase) {
// Optional: router.reload() or API call to refresh payment intent
}
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
} catch (e) {
console.error('Failed to load wizard state:', e);
sessionStorage.removeItem(STORAGE_KEY);
const dedupedOptions = React.useMemo(() => {
const ids = new Set<number>();
const list = [initialPackage, ...packageOptions];
return list.filter((pkg) => {
if (ids.has(pkg.id)) {
return false;
}
}
}, [initialPackage.id]);
// Save state to sessionStorage on changes
const saveState = useCallback(() => {
const state = {
currentStep,
wizardData: {
package: wizardData.package,
user: wizardData.user ? { id: wizardData.user.id, email: wizardData.user.email, pending_purchase: wizardData.user.pending_purchase } : null,
},
isAuthenticated,
authType,
timestamp: Date.now(),
};
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [currentStep, wizardData, isAuthenticated, authType]);
useEffect(() => {
saveState();
}, [saveState]);
// Cleanup on unmount or success
useEffect(() => {
return () => {
if (currentStep === 3) {
sessionStorage.removeItem(STORAGE_KEY);
}
};
}, [currentStep]);
useEffect(() => {
if (auth.user) {
setIsAuthenticated(true);
// Do not skip step, handle in render
// Update wizardData with auth.user if pending_purchase
if (auth.user.pending_purchase) {
setWizardData(prev => ({ ...prev, user: auth.user }));
}
}
}, [auth]);
const stripePromise = loadStripe(stripePublishableKey);
const nextStep = () => {
if (currentStep < steps.length - 1) {
setCurrentStep((prev) => prev + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1);
}
};
const handleAuthSuccess = (userData: any) => {
setWizardData((prev) => ({ ...prev, user: userData }));
setIsAuthenticated(true);
setAuthCompleted(true);
// Show success message briefly then proceed
setTimeout(() => {
nextStep();
setAuthCompleted(false);
}, 2000);
};
const handlePaymentSuccess = () => {
// Call API to assign package
router.post('/api/purchase/complete', { package_id: initialPackage.id }, {
onSuccess: () => nextStep(),
ids.add(pkg.id);
return true;
});
};
const renderStepContent = () => {
switch (steps[currentStep].id) {
case 'package':
return (
<Card>
<CardHeader>
<CardTitle>{initialPackage.name}</CardTitle>
<CardDescription>{initialPackage.description}</CardDescription>
</CardHeader>
<CardContent>
<p>Preis: {initialPackage.price === 0 ? 'Kostenlos' : `${initialPackage.price}`}</p>
<ul>
{initialPackage.features.map((feature, index) => (
<li key={index}>{feature}</li>
))}
</ul>
<Button onClick={nextStep} className="w-full mt-4">Weiter</Button>
</CardContent>
</Card>
);
case 'auth':
if (isAuthenticated) {
if (authCompleted) {
return (
<div className="text-center py-8">
<h2 className="text-2xl font-bold mb-4">{t('auth.login_success')}</h2>
<p className="text-gray-600 mb-6">Sie sind nun eingeloggt und werden weitergeleitet...</p>
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
</div>
);
} else {
return (
<div className="text-center py-8">
<h2 className="text-2xl font-bold mb-4">{t('auth.already_logged_in')}</h2>
<Button onClick={nextStep} className="w-full">
Weiter zum Zahlungsschritt
</Button>
</div>
);
}
}
return (
<div>
<div className="flex justify-center mb-4">
<Button
variant={authType === 'register' ? 'default' : 'outline'}
onClick={() => setAuthType('register')}
>
Registrieren
</Button>
<Button
variant={authType === 'login' ? 'default' : 'outline'}
onClick={() => setAuthType('login')}
className="ml-2"
>
Anmelden
</Button>
</div>
{authType === 'register' ? (
<RegisterForm onSuccess={handleAuthSuccess} packageId={initialPackage.id} privacyHtml={privacyHtml} />
) : (
<LoginForm onSuccess={handleAuthSuccess} />
)}
</div>
);
case 'payment':
if (initialPackage.price === 0) {
// Skip for free, assign directly
router.post('/api/purchase/free', { package_id: initialPackage.id });
return <div>Free package assigned! Redirecting...</div>;
}
return (
<Elements stripe={stripePromise}>
<PaymentForm packageId={initialPackage.id} packagePrice={initialPackage.price} onSuccess={handlePaymentSuccess} />
</Elements>
);
case 'success':
return <SuccessStep package={initialPackage} />;
default:
return null;
}
};
}, [initialPackage, packageOptions]);
return (
<MarketingLayout title="Kauf-Wizard">
<Head title="Kauf-Wizard" />
<div className="min-h-screen bg-gray-50 py-12">
<div className="max-w-2xl mx-auto px-4">
<Progress value={(currentStep / (steps.length - 1)) * 100} className="mb-6" />
<Steps steps={steps} currentStep={currentStep} />
{renderStepContent()}
{currentStep > 0 && currentStep < 3 && (
<div className="flex justify-between mt-6">
<Button variant="outline" onClick={prevStep}>Zurück</Button>
{currentStep < 3 && <Button onClick={nextStep}>Weiter</Button>}
</div>
)}
<MarketingLayout title="Checkout Wizard">
<Head title="Checkout Wizard" />
<div className="min-h-screen bg-muted/20 py-12">
<div className="mx-auto w-full max-w-4xl px-4">
<CheckoutWizard
initialPackage={initialPackage}
packageOptions={dedupedOptions}
stripePublishableKey={stripePublishableKey}
privacyHtml={privacyHtml}
initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined } : null}
/>
</div>
</div>
</MarketingLayout>

View File

@@ -1,17 +1,19 @@
import React from 'react';
import React from 'react';
import { usePage, router } from '@inertiajs/react';
import { Head } from '@inertiajs/react';
import { useTranslation } from 'react-i18next';
import MarketingLayout from '@/layouts/marketing/MarketingLayout';
import { Loader } from 'lucide-react';
import { useLocalizedRoutes } from '@/hooks/useLocalizedRoutes';
const Success: React.FC = () => {
const { auth, flash } = usePage().props as any;
const { auth } = usePage().props as any;
const { t } = useTranslation('success');
const { localizedPath } = useLocalizedRoutes();
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">
@@ -28,12 +30,8 @@ const Success: React.FC = () => {
<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">
{t('verify_email')}
</h2>
<p className="text-gray-600 mb-6">
{t('check_email')}
</p>
<h2 className="text-2xl font-bold text-gray-900 mb-4">{t('verify_email')}</h2>
<p className="text-gray-600 mb-6">{t('check_email')}</p>
<form method="POST" action="/email/verification-notification">
<button
type="submit"
@@ -43,7 +41,10 @@ const Success: React.FC = () => {
</button>
</form>
<p className="mt-4 text-sm text-gray-600">
{t('already_registered')} <a href="/login" className="text-blue-600 hover:text-blue-500">{t('login')}</a>
{t('already_registered')}{' '}
<a href={localizedPath('/login')} className="text-blue-600 hover:text-blue-500">
{t('login')}
</a>
</p>
</div>
</div>
@@ -57,20 +58,19 @@ const Success: React.FC = () => {
<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">
{t('complete_purchase')}
</h2>
<p className="text-gray-600 mb-6">
{t('login_to_continue')}
</p>
<h2 className="text-2xl font-bold text-gray-900 mb-4">{t('complete_purchase')}</h2>
<p className="text-gray-600 mb-6">{t('login_to_continue')}</p>
<a
href="/login"
href={localizedPath('/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"
>
{t('login')}
</a>
<p className="text-sm text-gray-600">
{t('no_account')} <a href="/register" className="text-blue-600 hover:text-blue-500">{t('register')}</a>
{t('no_account')}{' '}
<a href={localizedPath('/register')} className="text-blue-600 hover:text-blue-500">
{t('register')}
</a>
</p>
</div>
</div>

View File

@@ -0,0 +1,89 @@
import React, { useMemo } from "react";
import { Steps } from "@/components/ui/Steps";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { CheckoutWizardProvider, useCheckoutWizard } from "./WizardContext";
import type { CheckoutPackage, CheckoutStepId } from "./types";
import { PackageStep } from "./steps/PackageStep";
import { AuthStep } from "./steps/AuthStep";
import { PaymentStep } from "./steps/PaymentStep";
import { ConfirmationStep } from "./steps/ConfirmationStep";
interface CheckoutWizardProps {
initialPackage: CheckoutPackage;
packageOptions: CheckoutPackage[];
stripePublishableKey: string;
privacyHtml: string;
initialAuthUser?: {
id: number;
email: string;
name?: string;
pending_purchase?: boolean;
} | null;
initialStep?: CheckoutStepId;
}
const stepConfig: { id: CheckoutStepId; title: string; description: string }[] = [
{ id: "package", title: "Paket", description: "Auswahl und Vergleich" },
{ id: "auth", title: "Konto", description: "Login oder Registrierung" },
{ id: "payment", title: "Zahlung", description: "Stripe oder PayPal" },
{ id: "confirmation", title: "Fertig", description: "Zugang aktiv" },
];
const WizardBody: React.FC<{ stripePublishableKey: string; privacyHtml: string }> = ({ stripePublishableKey, privacyHtml }) => {
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
const currentIndex = useMemo(() => stepConfig.findIndex((step) => step.id === currentStep), [currentStep]);
const progress = useMemo(() => {
if (currentIndex < 0) {
return 0;
}
return (currentIndex / (stepConfig.length - 1)) * 100;
}, [currentIndex]);
return (
<div className="space-y-8">
<div className="space-y-4">
<Progress value={progress} />
<Steps steps={stepConfig} currentStep={currentIndex >= 0 ? currentIndex : 0} />
</div>
<div className="space-y-6">
{currentStep === "package" && <PackageStep />}
{currentStep === "auth" && <AuthStep privacyHtml={privacyHtml} />}
{currentStep === "payment" && <PaymentStep stripePublishableKey={stripePublishableKey} />}
{currentStep === "confirmation" && <ConfirmationStep />}
</div>
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={previousStep} disabled={currentIndex <= 0}>
Zurueck
</Button>
<Button onClick={nextStep} disabled={currentIndex >= stepConfig.length - 1}>
Weiter
</Button>
</div>
</div>
);
};
export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
initialPackage,
packageOptions,
stripePublishableKey,
privacyHtml,
initialAuthUser,
initialStep,
}) => {
return (
<CheckoutWizardProvider
initialPackage={initialPackage}
packageOptions={packageOptions}
initialStep={initialStep}
initialAuthUser={initialAuthUser ?? undefined}
initialIsAuthenticated={Boolean(initialAuthUser)}
>
<WizardBody stripePublishableKey={stripePublishableKey} privacyHtml={privacyHtml} />
</CheckoutWizardProvider>
);
};

View File

@@ -0,0 +1,110 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from "react";
import type { CheckoutPackage, CheckoutStepId, CheckoutWizardContextValue, CheckoutWizardState } from "./types";
interface CheckoutWizardProviderProps {
initialPackage: CheckoutPackage;
packageOptions: CheckoutPackage[];
initialStep?: CheckoutStepId;
initialAuthUser?: CheckoutWizardState['authUser'];
initialIsAuthenticated?: boolean;
children: React.ReactNode;
}
const CheckoutWizardContext = createContext<CheckoutWizardContextValue | undefined>(undefined);
export const CheckoutWizardProvider: React.FC<CheckoutWizardProviderProps> = ({
initialPackage,
packageOptions,
initialStep = 'package',
initialAuthUser = null,
initialIsAuthenticated,
children,
}) => {
const [state, setState] = useState<CheckoutWizardState>(() => ({
currentStep: initialStep,
selectedPackage: initialPackage,
packageOptions,
isAuthenticated: Boolean(initialIsAuthenticated || initialAuthUser),
authUser: initialAuthUser ?? null,
paymentProvider: undefined,
isProcessing: false,
}));
const setStep = useCallback((step: CheckoutStepId) => {
setState((prev) => ({ ...prev, currentStep: step }));
}, []);
const nextStep = useCallback(() => {
setState((prev) => {
const order: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
const currentIndex = order.indexOf(prev.currentStep);
const nextIndex = currentIndex === -1 ? 0 : Math.min(order.length - 1, currentIndex + 1);
return { ...prev, currentStep: order[nextIndex] };
});
}, []);
const previousStep = useCallback(() => {
setState((prev) => {
const order: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
const currentIndex = order.indexOf(prev.currentStep);
const nextIndex = currentIndex <= 0 ? 0 : currentIndex - 1;
return { ...prev, currentStep: order[nextIndex] };
});
}, []);
const setSelectedPackage = useCallback((pkg: CheckoutPackage) => {
setState((prev) => ({
...prev,
selectedPackage: pkg,
paymentProvider: undefined,
}));
}, []);
const markAuthenticated = useCallback<CheckoutWizardContextValue['markAuthenticated']>((user) => {
setState((prev) => ({
...prev,
isAuthenticated: Boolean(user),
authUser: user ?? null,
}));
}, []);
const setPaymentProvider = useCallback<CheckoutWizardContextValue['setPaymentProvider']>((provider) => {
setState((prev) => ({
...prev,
paymentProvider: provider,
}));
}, []);
const resetPaymentState = useCallback(() => {
setState((prev) => ({
...prev,
paymentProvider: undefined,
isProcessing: false,
}));
}, []);
const value = useMemo<CheckoutWizardContextValue>(() => ({
...state,
setStep,
nextStep,
previousStep,
setSelectedPackage,
markAuthenticated,
setPaymentProvider,
resetPaymentState,
}), [state, setStep, nextStep, previousStep, setSelectedPackage, markAuthenticated, setPaymentProvider, resetPaymentState]);
return (
<CheckoutWizardContext.Provider value={value}>
{children}
</CheckoutWizardContext.Provider>
);
};
export const useCheckoutWizard = () => {
const context = useContext(CheckoutWizardContext);
if (!context) {
throw new Error('useCheckoutWizard must be used within CheckoutWizardProvider');
}
return context;
};

View File

@@ -0,0 +1,102 @@
import React, { useState } from "react";
import { usePage } from "@inertiajs/react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext";
import LoginForm, { AuthUserPayload } from "../../auth/LoginForm";
import RegisterForm, { RegisterSuccessPayload } from "../../auth/RegisterForm";
interface AuthStepProps {
privacyHtml: string;
}
export const AuthStep: React.FC<AuthStepProps> = ({ privacyHtml }) => {
const page = usePage<{ locale?: string }>();
const locale = page.props.locale ?? "de";
const { isAuthenticated, authUser, markAuthenticated, nextStep, selectedPackage } = useCheckoutWizard();
const [mode, setMode] = useState<'login' | 'register'>('register');
const handleLoginSuccess = (payload: AuthUserPayload | null) => {
if (!payload) {
return;
}
markAuthenticated({
id: payload.id ?? 0,
email: payload.email ?? "",
name: payload.name ?? undefined,
pending_purchase: Boolean(payload.pending_purchase),
});
nextStep();
};
const handleRegisterSuccess = (result: RegisterSuccessPayload) => {
const nextUser = result?.user ?? null;
if (nextUser) {
markAuthenticated({
id: nextUser.id ?? 0,
email: nextUser.email ?? "",
name: nextUser.name ?? undefined,
pending_purchase: Boolean(result?.pending_purchase ?? nextUser.pending_purchase),
});
}
nextStep();
};
if (isAuthenticated && authUser) {
return (
<div className="space-y-6">
<Alert>
<AlertTitle>Bereits eingeloggt</AlertTitle>
<AlertDescription>
{authUser.email ? `Sie sind als ${authUser.email} angemeldet.` : "Sie sind bereits angemeldet."}
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button size="lg" onClick={nextStep}>
Weiter zur Zahlung
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2">
<Button
variant={mode === 'register' ? 'default' : 'outline'}
onClick={() => setMode('register')}
>
Registrieren
</Button>
<Button
variant={mode === 'login' ? 'default' : 'outline'}
onClick={() => setMode('login')}
>
Anmelden
</Button>
<span className="text-xs text-muted-foreground">
Google Login folgt im Komfort-Delta.
</span>
</div>
<div className="rounded-lg border bg-card p-6 shadow-sm">
{mode === 'register' ? (
<RegisterForm
packageId={selectedPackage.id}
privacyHtml={privacyHtml}
locale={locale}
onSuccess={handleRegisterSuccess}
/>
) : (
<LoginForm
locale={locale}
onSuccess={handleLoginSuccess}
/>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,29 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useCheckoutWizard } from "../WizardContext";
interface ConfirmationStepProps {
onViewProfile?: () => void;
}
export const ConfirmationStep: React.FC<ConfirmationStepProps> = ({ onViewProfile }) => {
const { selectedPackage } = useCheckoutWizard();
return (
<div className="space-y-6">
<Alert>
<AlertTitle>Willkommen bei FotoSpiel</AlertTitle>
<AlertDescription>
{Ihr Paket "" ist aktiviert. Wir haben Ihnen eine Bestaetigung per E-Mail gesendet.}
</AlertDescription>
</Alert>
<div className="flex flex-wrap gap-3 justify-end">
<Button variant="outline" onClick={onViewProfile}>
Profil oeffnen
</Button>
<Button>Zum Admin-Bereich</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,118 @@
import React, { useMemo } from "react";
import { Check, Package as PackageIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useCheckoutWizard } from "../WizardContext";
import type { CheckoutPackage } from "../types";
const currencyFormatter = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
});
function PackageSummary({ pkg }: { pkg: CheckoutPackage }) {
return (
<Card className="shadow-sm">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-3 text-2xl">
<PackageIcon className="h-6 w-6 text-primary" />
{pkg.name}
</CardTitle>
<CardDescription className="text-base text-muted-foreground">
{pkg.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{pkg.price === 0 ? "Kostenlos" : currencyFormatter.format(pkg.price)}
</span>
<Badge variant="secondary" className="uppercase tracking-wider text-xs">
{pkg.type === "reseller" ? "Reseller" : "Endkunde"}
</Badge>
</div>
{Array.isArray(pkg.features) && pkg.features.length > 0 && (
<ul className="space-y-3">
{pkg.features.map((feature, index) => (
<li key={index} className="flex items-start gap-3 text-sm text-muted-foreground">
<Check className="mt-0.5 h-4 w-4 text-primary" />
<span>{feature}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
function PackageOption({ pkg, isActive, onSelect }: { pkg: CheckoutPackage; isActive: boolean; onSelect: () => void }) {
return (
<button
type="button"
onClick={onSelect}
className={`w-full rounded-md border bg-background p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 ${
isActive ? "border-primary shadow-sm" : "border-border hover:border-primary/40"
}`}
>
<div className="flex items-center justify-between text-sm font-medium">
<span>{pkg.name}</span>
<span className="text-muted-foreground">
{pkg.price === 0 ? "Kostenlos" : currencyFormatter.format(pkg.price)}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{pkg.description}</p>
</button>
);
}
export const PackageStep: React.FC = () => {
const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState, nextStep } = useCheckoutWizard();
const comparablePackages = useMemo(() => {
return packageOptions.filter((pkg) => pkg.type === selectedPackage.type);
}, [packageOptions, selectedPackage.type]);
const handlePackageChange = (pkg: CheckoutPackage) => {
if (pkg.id === selectedPackage.id) {
return;
}
setSelectedPackage(pkg);
resetPaymentState();
};
return (
<div className="grid gap-8 lg:grid-cols-[2fr_1fr]">
<div className="space-y-6">
<PackageSummary pkg={selectedPackage} />
<div className="flex justify-end">
<Button size="lg" onClick={nextStep}>
Weiter zum Konto
</Button>
</div>
</div>
<aside className="space-y-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Alternative Pakete
</h3>
<div className="space-y-3">
{comparablePackages.map((pkg) => (
<PackageOption
key={pkg.id}
pkg={pkg}
isActive={pkg.id === selectedPackage.id}
onSelect={() => handlePackageChange(pkg)}
/>
))}
{comparablePackages.length === 0 && (
<p className="text-xs text-muted-foreground">
Keine weiteren Pakete in dieser Kategorie verfuegbar.
</p>
)}
</div>
</aside>
</div>
);
};

View File

@@ -0,0 +1,80 @@
import React, { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCheckoutWizard } from "../WizardContext";
interface PaymentStepProps {
stripePublishableKey: string;
}
export const PaymentStep: React.FC<PaymentStepProps> = ({ stripePublishableKey }) => {
const { selectedPackage, paymentProvider, setPaymentProvider, resetPaymentState, nextStep } = useCheckoutWizard();
useEffect(() => {
resetPaymentState();
}, [selectedPackage.id, resetPaymentState]);
const isFree = selectedPackage.price === 0;
if (isFree) {
return (
<div className="space-y-6">
<Alert>
<AlertTitle>Kostenloses Paket</AlertTitle>
<AlertDescription>
Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestaetigung.
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button size="lg" onClick={nextStep}>
Paket aktivieren
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<Tabs value={paymentProvider ?? 'stripe'} onValueChange={(value) => setPaymentProvider(value as 'stripe' | 'paypal')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="stripe">Stripe</TabsTrigger>
<TabsTrigger value="paypal">PayPal</TabsTrigger>
</TabsList>
<TabsContent value="stripe" className="mt-4">
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
<p className="text-sm text-muted-foreground">
Karten- oder SEPA-Zahlung via Stripe Elements. Wir erzeugen beim Fortfahren einen Payment Intent.
</p>
<Alert variant="secondary">
<AlertTitle>Integration folgt</AlertTitle>
<AlertDescription>
Stripe Elements wird im naechsten Schritt integriert. Aktuell dient dieser Block als Platzhalter fuer UI und API Hooks.
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button disabled>Stripe Zahlung starten</Button>
</div>
</div>
</TabsContent>
<TabsContent value="paypal" className="mt-4">
<div className="rounded-lg border bg-card p-6 shadow-sm space-y-4">
<p className="text-sm text-muted-foreground">
PayPal Express Checkout mit Rueckleitung zur Bestaetigung. Wir hinterlegen Paket- und Tenant-Daten im Order-Metadata.
</p>
<Alert variant="secondary">
<AlertTitle>Integration folgt</AlertTitle>
<AlertDescription>
PayPal Buttons werden im Folge-PR angebunden. Dieser Platzhalter zeigt den spaeteren Container fuer die Buttons.
</AlertDescription>
</Alert>
<div className="flex justify-end">
<Button disabled>PayPal Bestellung anlegen</Button>
</div>
</div>
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -0,0 +1,39 @@
export type CheckoutStepId = 'package' | 'auth' | 'payment' | 'confirmation';
export interface CheckoutPackage {
id: number;
name: string;
description: string;
price: number;
currency?: string;
type: 'endcustomer' | 'reseller';
features: string[];
limits?: Record<string, unknown>;
[key: string]: unknown;
}
export interface CheckoutWizardState {
currentStep: CheckoutStepId;
selectedPackage: CheckoutPackage;
packageOptions: CheckoutPackage[];
isAuthenticated: boolean;
authUser?: {
id: number;
email: string;
name?: string;
pending_purchase?: boolean;
} | null;
paymentProvider?: 'stripe' | 'paypal';
isProcessing?: boolean;
}
export interface CheckoutWizardContextValue extends CheckoutWizardState {
setStep: (step: CheckoutStepId) => void;
nextStep: () => void;
previousStep: () => void;
setSelectedPackage: (pkg: CheckoutPackage) => void;
markAuthenticated: (user: CheckoutWizardState['authUser']) => void;
setPaymentProvider: (provider: CheckoutWizardState['paymentProvider']) => void;
resetPaymentState: () => void;
}

View File

@@ -4,6 +4,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use Illuminate\Support\Facades\Log;
use App\Http\Controllers\CheckoutController;
Route::get('/lang/{locale}/{namespace}', function ($locale, $namespace) {
Log::info('Lang route hit', ['locale' => $locale, 'namespace' => $namespace]);
@@ -41,7 +42,7 @@ Route::post('/login', function (Request $request) {
})->name('login.fallback.post');
// Fallback for /register (redirect to default locale)
Route::get('/register', function (Request $request) {
Route::post('/register', function (Request $request) {
return redirect('/de/register' . $request->getQueryString());
})->name('register.fallback');
@@ -148,7 +149,10 @@ Route::get('/super-admin/templates/tasks.csv', function () {
return response()->stream($callback, 200, $headers);
});
Route::get('/purchase-wizard/{package_id}', [\App\Http\Controllers\MarketingController::class, 'purchaseWizard'])->name('purchase.wizard');
Route::get('/purchase-wizard/{package}', [CheckoutController::class, 'show'])->name('purchase.wizard');
Route::get('/checkout/{package}', [CheckoutController::class, 'show'])->name('checkout.show');
Route::post('/checkout/login', [CheckoutController::class, 'login'])->name('checkout.login');
Route::post('/checkout/register', [CheckoutController::class, 'register'])->name('checkout.register');
Route::get('/buy-packages/{package_id}', [\App\Http\Controllers\MarketingController::class, 'buyPackages'])->name('buy.packages');
Route::middleware('auth')->group(function () {
Route::get('/profile', [\App\Http\Controllers\ProfileController::class, 'index'])->name('profile');
@@ -166,3 +170,15 @@ Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->
])
->name('anlaesse.type');
});