diff --git a/app/Console/Commands/SendAbandonedCheckoutReminders.php b/app/Console/Commands/SendAbandonedCheckoutReminders.php new file mode 100644 index 0000000..11df770 --- /dev/null +++ b/app/Console/Commands/SendAbandonedCheckoutReminders.php @@ -0,0 +1,150 @@ +option('dry-run'); + + if ($isDryRun) { + $this->info('🔍 DRY RUN MODE - No emails will be sent'); + } + + $this->info('🚀 Starting abandoned checkout reminder process...'); + + // Reminder-Stufen definieren: [Stufe, Stunden, Beschreibung] + $reminderStages = [ + ['1h', 1, '1 hour reminders'], + ['24h', 24, '24 hour reminders'], + ['1w', 168, '1 week reminders'], // 7 * 24 = 168 Stunden + ]; + + $totalProcessed = 0; + $totalSent = 0; + + foreach ($reminderStages as [$stage, $hours, $description]) { + $this->info("📧 Processing {$description}..."); + + $checkouts = AbandonedCheckoutModel::readyForReminder($stage, $hours) + ->with(['user', 'package']) + ->get(); + + $this->info(" Found {$checkouts->count()} checkouts ready for {$stage} reminder"); + + foreach ($checkouts as $checkout) { + try { + if ($this->shouldSendReminder($checkout, $stage)) { + $resumeUrl = $this->generateResumeUrl($checkout); + + if (!$isDryRun) { + Mail::to($checkout->user)->queue( + new AbandonedCheckout( + $checkout->user, + $checkout->package, + $stage, + $resumeUrl + ) + ); + + $checkout->updateReminderStage($stage); + $totalSent++; + } else { + $this->line(" 📧 Would send {$stage} reminder to: {$checkout->email} for package: {$checkout->package->name}"); + } + + $totalProcessed++; + } + } catch (Throwable $e) { + Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: " . $e->getMessage()); + $this->error(" ❌ Failed to process checkout {$checkout->id}: " . $e->getMessage()); + } + } + } + + // Cleanup: Alte Checkouts löschen (älter als 30 Tage) + $oldCheckouts = AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30)) + ->where('converted', false) + ->count(); + + if ($oldCheckouts > 0) { + if (!$isDryRun) { + AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30)) + ->where('converted', false) + ->delete(); + $this->info("🧹 Cleaned up {$oldCheckouts} old abandoned checkouts"); + } else { + $this->info("🧹 Would clean up {$oldCheckouts} old abandoned checkouts"); + } + } + + $this->info("✅ Reminder process completed!"); + $this->info(" Processed: {$totalProcessed} checkouts"); + + if (!$isDryRun) { + $this->info(" Sent: {$totalSent} reminder emails"); + } else { + $this->info(" Would send: {$totalSent} reminder emails"); + } + + return Command::SUCCESS; + } + + /** + * Prüft ob ein Reminder versendet werden sollte + */ + private function shouldSendReminder(AbandonedCheckoutModel $checkout, string $stage): bool + { + // Verfällt der Checkout bald? Dann kein Reminder mehr + if ($checkout->isExpired()) { + return false; + } + + // User existiert noch? + if (!$checkout->user) { + return false; + } + + // Package existiert noch? + if (!$checkout->package) { + return false; + } + + return true; + } + + /** + * Generiert die URL zum Wiederaufnehmen des Checkouts + */ + private function generateResumeUrl(AbandonedCheckoutModel $checkout): string + { + // Für jetzt: Einfache Package-URL + // Später: Persönliche Resume-Token URLs + return route('buy.packages', $checkout->package_id); + } +} diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 457fb4b..36c300c 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -1,198 +1,3 @@ -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) { @@ -207,18 +12,64 @@ class CheckoutController extends Controller 'email_verified_at' => $user->email_verified_at, ]; } + + /** + * Track an abandoned checkout for reminder emails + */ + public function trackAbandonedCheckout(Request $request): JsonResponse + { + $request->validate([ + 'package_id' => 'required|integer|exists:packages,id', + 'last_step' => 'required|integer|min:1|max:4', + 'user_id' => 'nullable|integer|exists:users,id', + 'email' => 'nullable|email', + ]); + + try { + $userId = $request->user_id; + $email = $request->email; + + // Wenn kein user_id aber email, versuche User zu finden + if (!$userId && $email) { + $user = User::where('email', $email)->first(); + $userId = $user?->id; + } + + // Nur tracken wenn wir einen User haben + if (!$userId) { + return response()->json(['success' => false, 'message' => 'No user found to track']); + } + + $user = User::find($userId); + if (!$user) { + return response()->json(['success' => false, 'message' => 'User not found']); + } + + // Erstelle oder update abandoned checkout + AbandonedCheckout::updateOrCreate( + [ + 'user_id' => $userId, + 'package_id' => $request->package_id, + ], + [ + 'email' => $user->email, + 'checkout_state' => null, // Später erweitern + 'last_step' => $request->last_step, + 'abandoned_at' => now(), + 'reminded_at' => null, + 'reminder_stage' => 'none', + 'expires_at' => now()->addDays(7), // 7 Tage gültig + 'converted' => false, + ] + ); + + Log::info("Abandoned checkout tracked for user {$userId}, package {$request->package_id}, step {$request->last_step}"); + + return response()->json(['success' => true]); + + } catch (\Exception $e) { + Log::error('Failed to track abandoned checkout: ' . $e->getMessage()); + return response()->json(['success' => false, 'message' => 'Failed to track checkout'], 500); + } + } } - - - - - - - - - - - - - - diff --git a/app/Http/Controllers/LocaleController.php b/app/Http/Controllers/LocaleController.php index 82ba8d7..503cc61 100644 --- a/app/Http/Controllers/LocaleController.php +++ b/app/Http/Controllers/LocaleController.php @@ -18,11 +18,10 @@ class LocaleController extends Controller Session::put('locale', $locale); } - $previousUrl = $request->header('Referer') ?? '/'; - $currentPath = parse_url($previousUrl, PHP_URL_PATH); - // Remove prefix if present for redirect to prefix-free - $currentPath = preg_replace('/^\/(de|en)/', '', $currentPath); - - return redirect($currentPath); + // Return JSON response for fetch requests + return response()->json([ + 'success' => true, + 'locale' => App::getLocale(), + ]); } } \ No newline at end of file diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 9a331d9..eccdb4c 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -123,32 +123,7 @@ class MarketingController extends Controller return $this->checkout($request, $packageId); } - /** - * Render the purchase wizard. - */ - 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(); - - $csp = "default-src 'self'; script-src 'self' 'unsafe-inline' http://localhost:5173 https://js.stripe.com https://js.stripe.network; style-src 'self' 'unsafe-inline' data: https:; img-src 'self' data: https: blob:; font-src 'self' data: https:; connect-src 'self' http://localhost:5173 ws://localhost:5173 https://api.stripe.com https://api.stripe.network wss://*.stripe.network; media-src data: blob: 'self' https: https://js.stripe.com https://*.stripe.com; frame-src 'self' https://js.stripe.com https://*.stripe.com; object-src 'none'; base-uri 'self'; form-action 'self';"; - - $response = Inertia::render('marketing/PurchaseWizard', [ - 'package' => $package, - 'packageOptions' => $packageOptions, - 'stripePublishableKey' => $stripePublishableKey, - 'privacyHtml' => $privacyHtml, - ])->toResponse($request); - $response->headers->set('Content-Security-Policy', $csp); - return $response; - } + /** * Checkout for Stripe with auth metadata. diff --git a/app/Http/Controllers/StripePaymentController.php b/app/Http/Controllers/StripePaymentController.php new file mode 100644 index 0000000..8a1d511 --- /dev/null +++ b/app/Http/Controllers/StripePaymentController.php @@ -0,0 +1,85 @@ +validate([ + 'package_id' => 'required|integer|exists:packages,id', + ]); + + $user = Auth::user(); + if (!$user) { + return response()->json(['error' => 'Nicht authentifiziert'], 401); + } + + $tenant = $user->tenant; + if (!$tenant) { + return response()->json(['error' => 'Kein Tenant gefunden'], 403); + } + + $package = Package::findOrFail($request->package_id); + + // Kostenlose Pakete brauchen kein Payment Intent + if ($package->price <= 0) { + return response()->json([ + 'type' => 'free', + 'message' => 'Kostenloses Paket - kein Payment Intent nötig' + ]); + } + + try { + $paymentIntent = PaymentIntent::create([ + 'amount' => (int)($package->price * 100), // In Cent + 'currency' => 'eur', + 'metadata' => [ + 'package_id' => $package->id, + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', + ], + 'automatic_payment_methods' => [ + 'enabled' => true, + ], + 'description' => "Paket: {$package->name}", + 'receipt_email' => $user->email, + ]); + + Log::info('Payment Intent erstellt', [ + 'payment_intent_id' => $paymentIntent->id, + 'package_id' => $package->id, + 'tenant_id' => $tenant->id, + 'amount' => $package->price + ]); + + return response()->json([ + 'clientSecret' => $paymentIntent->client_secret, + 'paymentIntentId' => $paymentIntent->id, + ]); + } catch (\Exception $e) { + Log::error('Stripe Payment Intent Fehler', [ + 'error' => $e->getMessage(), + 'package_id' => $request->package_id, + 'user_id' => $user->id + ]); + + return response()->json(['error' => $e->getMessage()], 400); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index 06bc30d..3868943 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -1,105 +1,5 @@ -getContent(); - $sigHeader = $request->header('Stripe-Signature'); - $endpointSecret = config('services.stripe.webhook_secret'); - - try { - $event = Webhook::constructEvent($payload, $sigHeader, $endpointSecret); - } catch (\Exception $e) { - Log::error('Stripe webhook signature verification failed: ' . $e->getMessage()); - return response()->json(['error' => 'Invalid signature'], 400); - } - - switch ($event['type']) { - case 'checkout.session.completed': - $session = $event['data']['object']; - if ($session['mode'] === 'subscription' && isset($session['metadata']['subscription']) && $session['metadata']['subscription'] === 'true') { - $this->handleSubscriptionStarted($session); - } - break; - - case 'payment_intent.succeeded': - $paymentIntent = $event['data']['object']; - $this->handlePaymentIntentSucceeded($paymentIntent); - break; - - case 'invoice.payment_succeeded': - $invoice = $event['data']['object']; - $this->handleInvoicePaymentSucceeded($invoice); - break; - - case 'invoice.payment_failed': - $invoice = $event['data']['object']; - $this->handleInvoicePaymentFailed($invoice); - break; - - default: - Log::info('Unhandled Stripe event type: ' . $event['type']); - } - - return response()->json(['status' => 'success']); - } - - private function handlePaymentIntentSucceeded($paymentIntent) - { - $metadata = $paymentIntent['metadata']; - if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) { - Log::warning('Missing metadata in Stripe payment intent: ' . $paymentIntent['id']); - return; - } - - $userId = $metadata['user_id'] ?? null; - $tenantId = $metadata['tenant_id'] ?? null; - $packageId = $metadata['package_id']; - $type = $metadata['type'] ?? 'endcustomer_event'; - - if ($userId && !$tenantId) { - $tenant = \App\Models\Tenant::where('user_id', $userId)->first(); - if ($tenant) { - $tenantId = $tenant->id; - } else { - Log::error('Tenant not found for user_id: ' . $userId); - return; - } - } - - if (!$tenantId) { - Log::error('No tenant_id found for Stripe payment intent: ' . $paymentIntent['id']); - return; - } - - // Activate user if pending purchase - $user = \App\Models\User::where('id', $tenant->user_id ?? $userId)->first(); - if ($user && $user->pending_purchase) { - $user->update([ - 'email_verified_at' => now(), - 'role' => 'tenant_admin', - 'pending_purchase' => false, - ]); - Log::info('User activated after purchase: ' . $user->id); - } - // Create PackagePurchase for one-off payment - \App\Models\PackagePurchase::create([ + $purchase = \App\Models\PackagePurchase::create([ 'tenant_id' => $tenantId, 'package_id' => $packageId, 'provider_id' => $paymentIntent['id'], @@ -109,162 +9,13 @@ class StripeWebhookController extends Controller 'refunded' => false, ]); - if ($type === 'endcustomer_event') { - // For event packages, assume event_id from metadata or handle separately - // TODO: Link to specific event if provided - } - - Log::info('Package purchase created via Stripe payment intent: ' . $paymentIntent['id'] . ' for tenant ' . $tenantId); - } - - private function handleInvoicePaymentSucceeded($invoice) - { - $subscription = $invoice['subscription']; - $metadata = $invoice['metadata']; - - if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) { - Log::warning('Missing metadata in Stripe invoice: ' . $invoice['id']); - return; - } - - $userId = $metadata['user_id'] ?? null; - $tenantId = $metadata['tenant_id'] ?? null; - $packageId = $metadata['package_id']; - - if ($userId && !$tenantId) { - $tenant = \App\Models\Tenant::where('user_id', $userId)->first(); - if ($tenant) { - $tenantId = $tenant->id; - } else { - Log::error('Tenant not found for user_id: ' . $userId); - return; + // Send purchase confirmation email + try { + $tenant = \App\Models\Tenant::find($tenantId); + if ($tenant && $tenant->user) { + Mail::to($tenant->user)->queue(new PurchaseConfirmation($purchase)); + Log::info('Purchase confirmation email sent for payment intent: ' . $paymentIntent['id']); } - } - - if (!$tenantId) { - Log::error('No tenant_id found for Stripe invoice: ' . $invoice['id']); - return; - } - - // Update or create TenantPackage for subscription - \App\Models\TenantPackage::updateOrCreate( - [ - 'tenant_id' => $tenantId, - 'package_id' => $packageId, - ], - [ - 'purchased_at' => now(), - 'expires_at' => now()->addYear(), // Renew annually - 'active' => true, - ] - ); - - // Create or update PackagePurchase - \App\Models\PackagePurchase::updateOrCreate( - [ - 'tenant_id' => $tenantId, - 'package_id' => $packageId, - 'provider_id' => $subscription, - ], - [ - 'price' => $invoice['amount_paid'] / 100, - 'type' => 'reseller_subscription', - 'purchased_at' => now(), - 'refunded' => false, - ] - ); - - Log::info('Subscription renewed via Stripe invoice: ' . $invoice['id'] . ' for tenant ' . $tenantId); - } - - private function handleInvoicePaymentFailed($invoice) - { - $subscription = $invoice['subscription']; - Log::warning('Stripe invoice payment failed: ' . $invoice['id'] . ' for subscription ' . $subscription); - - // TODO: Deactivate package or notify tenant - // e.g., TenantPackage::where('provider_id', $subscription)->update(['active' => false]); - } - - private function handleSubscriptionStarted($session) - { - $metadata = $session['metadata']; - if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) { - Log::warning('Missing metadata in Stripe checkout session: ' . $session['id']); - return; - } - - $userId = $metadata['user_id'] ?? null; - $tenantId = $metadata['tenant_id'] ?? null; - $packageId = $metadata['package_id']; - $type = $metadata['type'] ?? 'reseller_subscription'; - - if ($userId && !$tenantId) { - $tenant = \App\Models\Tenant::where('user_id', $userId)->first(); - if ($tenant) { - $tenantId = $tenant->id; - } else { - Log::error('Tenant not found for user_id: ' . $userId); - return; - } - } - - if (!$tenantId) { - Log::error('No tenant_id found for Stripe checkout session: ' . $session['id']); - return; - } - - $subscriptionId = $session['subscription']['id'] ?? null; - if (!$subscriptionId) { - Log::error('No subscription ID in checkout session: ' . $session['id']); - return; - } - - // Activate user if pending purchase - $tenant = \App\Models\Tenant::find($tenantId); - $user = $tenant ? $tenant->user : null; - if ($user && $user->pending_purchase) { - $user->update([ - 'email_verified_at' => now(), - 'role' => 'tenant_admin', - 'pending_purchase' => false, - ]); - Log::info('User activated after subscription purchase: ' . $user->id); - } - - // Activate TenantPackage for initial subscription - \App\Models\TenantPackage::updateOrCreate( - [ - 'tenant_id' => $tenantId, - 'package_id' => $packageId, - ], - [ - 'purchased_at' => now(), - 'expires_at' => now()->addYear(), - 'active' => true, - ] - ); - - // Create initial PackagePurchase - \App\Models\PackagePurchase::create([ - 'tenant_id' => $tenantId, - 'package_id' => $packageId, - 'provider_id' => $subscriptionId, - 'price' => $session['amount_total'] / 100, - 'type' => $type, - 'purchased_at' => now(), - 'refunded' => false, - ]); - - // Update tenant subscription fields if needed - $tenant = \App\Models\Tenant::find($tenantId); - if ($tenant) { - $tenant->update([ - 'subscription_id' => $subscriptionId, - 'subscription_status' => 'active', - ]); - } - - Log::info('Initial subscription activated via Stripe checkout session: ' . $session['id'] . ' for tenant ' . $tenantId); - } -} \ No newline at end of file + } catch (\Exception $e) { + Log::error('Failed to send purchase confirmation email: ' . $e->getMessage()); + } \ No newline at end of file diff --git a/app/Mail/AbandonedCheckout.php b/app/Mail/AbandonedCheckout.php new file mode 100644 index 0000000..a75e8a3 --- /dev/null +++ b/app/Mail/AbandonedCheckout.php @@ -0,0 +1,53 @@ +timing; + return new Envelope( + subject: __('emails.abandoned_checkout.subject_' . $this->timing, [ + 'package' => $this->package->name + ]), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.abandoned-checkout', + with: [ + 'user' => $this->user, + 'package' => $this->package, + 'timing' => $this->timing, + 'resumeUrl' => $this->resumeUrl, + ], + ); + } + + public function attachments(): array + { + return []; + } +} \ No newline at end of file diff --git a/app/Mail/PurchaseConfirmation.php b/app/Mail/PurchaseConfirmation.php new file mode 100644 index 0000000..237f20d --- /dev/null +++ b/app/Mail/PurchaseConfirmation.php @@ -0,0 +1,44 @@ + $this->purchase->package->name]), + ); + } + + public function content(): Content + { + return new Content( + view: 'emails.purchase', + with: [ + 'purchase' => $this->purchase, + 'user' => $this->purchase->tenant->user, + 'package' => $this->purchase->package, + ], + ); + } + + public function attachments(): array + { + return []; + } +} \ No newline at end of file diff --git a/app/Mail/Welcome.php b/app/Mail/Welcome.php index 07d6ed0..cba126a 100644 --- a/app/Mail/Welcome.php +++ b/app/Mail/Welcome.php @@ -21,7 +21,7 @@ class Welcome extends Mailable public function envelope(): Envelope { return new Envelope( - subject: 'Welcome to Fotospiel!', + subject: __('emails.welcome.subject', ['name' => $this->user->fullName]), ); } diff --git a/app/Models/AbandonedCheckout.php b/app/Models/AbandonedCheckout.php new file mode 100644 index 0000000..6435a2c --- /dev/null +++ b/app/Models/AbandonedCheckout.php @@ -0,0 +1,85 @@ + 'array', + 'abandoned_at' => 'datetime', + 'reminded_at' => 'datetime', + 'expires_at' => 'datetime', + 'converted' => 'boolean', + 'last_step' => 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function package(): BelongsTo + { + return $this->belongsTo(Package::class); + } + + /** + * Markiert den Checkout als erfolgreich abgeschlossen + */ + public function markAsConverted(): void + { + $this->update([ + 'converted' => true, + 'reminder_stage' => 'converted', + ]); + } + + /** + * Aktualisiert den Reminder-Status + */ + public function updateReminderStage(string $stage): void + { + $this->update([ + 'reminder_stage' => $stage, + 'reminded_at' => now(), + ]); + } + + /** + * Prüft ob der Checkout noch gültig ist + */ + public function isExpired(): bool + { + return $this->expires_at && $this->expires_at->isPast(); + } + + /** + * Scope für Checkouts die erinnert werden sollen + */ + public function scopeReadyForReminder($query, string $stage, int $hours) + { + return $query->where('reminder_stage', '!=', $stage) + ->where('reminder_stage', '!=', 'converted') + ->where('abandoned_at', '<=', now()->subHours($hours)) + ->where(function ($q) { + $q->whereNull('reminded_at') + ->orWhere('reminded_at', '<=', now()->subHours(24)); + }); + } +} diff --git a/database/migrations/2025_10_07_222101_create_abandoned_checkouts_table.php b/database/migrations/2025_10_07_222101_create_abandoned_checkouts_table.php new file mode 100644 index 0000000..5e67543 --- /dev/null +++ b/database/migrations/2025_10_07_222101_create_abandoned_checkouts_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('package_id')->constrained()->onDelete('cascade'); + $table->string('email'); // Denormalisiert für Performance + $table->json('checkout_state')->nullable(); // Gespeicherter Checkout-Zustand + $table->integer('last_step')->default(1); // Letzter erreichter Schritt + $table->timestamp('abandoned_at'); + $table->timestamp('reminded_at')->nullable(); // Wann zuletzt erinnert + $table->string('reminder_stage')->default('none'); // none, 1h, 24h, 1w + $table->timestamp('expires_at')->nullable(); // Wann der Checkout verfällt + $table->boolean('converted')->default(false); // Ob erfolgreich abgeschlossen + $table->timestamps(); + + $table->index(['email', 'reminder_stage']); + $table->index(['abandoned_at']); + $table->index(['reminded_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('abandoned_checkouts'); + } +}; diff --git a/public/lang/de/auth.json b/public/lang/de/auth.json index 2c95255..af6673d 100644 --- a/public/lang/de/auth.json +++ b/public/lang/de/auth.json @@ -29,15 +29,40 @@ "title": "Registrieren", "name": "Vollständiger Name", "username": "Username", + "username_placeholder": "Wählen Sie einen Username", "email": "E-Mail-Adresse", + "email_placeholder": "ihre@email.de", "password": "Passwort", + "password_placeholder": "Mindestens 8 Zeichen", "password_confirmation": "Passwort bestätigen", + "confirm_password": "Passwort bestätigen", + "confirm_password_placeholder": "Passwort wiederholen", "first_name": "Vorname", + "first_name_placeholder": "Max", "last_name": "Nachname", + "last_name_placeholder": "Mustermann", "address": "Adresse", + "address_placeholder": "Straße Hausnummer, PLZ Ort", "phone": "Telefonnummer", + "phone_placeholder": "+49 123 456789", "privacy_consent": "Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.", - "submit": "Registrieren" + "privacy_policy": "Datenschutzerklärung", + "submit": "Registrieren", + "success_toast": "Registrierung erfolgreich", + "validation_failed": "Bitte prüfen Sie Ihre Eingaben.", + "unexpected_error": "Registrierung nicht möglich.", + "errors_title": "Fehler bei der Registrierung", + "errors": { + "username": "Username", + "email": "E-Mail", + "password": "Passwort", + "password_confirmation": "Passwort-Bestätigung", + "first_name": "Vorname", + "last_name": "Nachname", + "address": "Adresse", + "phone": "Telefon", + "privacy_consent": "Datenschutz-Zustimmung" + } }, "verification": { "notice": "Bitte bestätigen Sie Ihre E-Mail-Adresse.", diff --git a/public/lang/en/auth.json b/public/lang/en/auth.json index ea3096a..8a50f57 100644 --- a/public/lang/en/auth.json +++ b/public/lang/en/auth.json @@ -1,10 +1,71 @@ { + "header": { + "home": "Home", + "packages": "Packages", + "blog": "Blog", + "occasions": { + "wedding": "Wedding", + "birthday": "Birthday", + "corporate": "Corporate Event" + }, + "contact": "Contact", + "login": "Login", + "register": "Register" + }, "login_failed": "Invalid email or password.", "login_success": "You are now logged in.", "registration_failed": "Registration failed.", "registration_success": "Registration successful – proceed with purchase.", "already_logged_in": "You are already logged in.", "failed_credentials": "Invalid credentials.", - "header.login": "Login", - "header.register": "Register" + "login": { + "title": "Login", + "username_or_email": "Username or Email", + "password": "Password", + "remember": "Remember me", + "submit": "Login" + }, + "register": { + "title": "Register", + "name": "Full Name", + "username": "Username", + "username_placeholder": "Choose a username", + "email": "Email Address", + "email_placeholder": "your@email.com", + "password": "Password", + "password_placeholder": "At least 8 characters", + "password_confirmation": "Confirm Password", + "confirm_password": "Confirm Password", + "confirm_password_placeholder": "Repeat password", + "first_name": "First Name", + "first_name_placeholder": "John", + "last_name": "Last Name", + "last_name_placeholder": "Doe", + "address": "Address", + "address_placeholder": "Street Number, ZIP City", + "phone": "Phone Number", + "phone_placeholder": "+1 123 456789", + "privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.", + "privacy_policy": "Privacy Policy", + "submit": "Register", + "success_toast": "Registration successful", + "validation_failed": "Please check your input.", + "unexpected_error": "Registration not possible.", + "errors_title": "Registration errors", + "errors": { + "username": "Username", + "email": "Email", + "password": "Password", + "password_confirmation": "Password Confirmation", + "first_name": "First Name", + "last_name": "Last Name", + "address": "Address", + "phone": "Phone", + "privacy_consent": "Privacy Consent" + } + }, + "verification": { + "notice": "Please confirm your email address.", + "resend": "Resend email" + } } \ No newline at end of file diff --git a/resources/js/app.tsx b/resources/js/app.tsx index df0bcd7..63b5116 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -8,14 +8,19 @@ import AppLayout from './Components/Layout/AppLayout'; import { I18nextProvider } from 'react-i18next'; import i18n from './i18n'; import { Toaster } from 'react-hot-toast'; +import { Elements } from '@stripe/react-stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; +// Initialize Stripe +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ''); + createInertiaApp({ title: (title) => title ? `${title} - ${appName}` : appName, resolve: (name) => resolvePageComponent( - `./Pages/${name}.tsx`, - import.meta.glob('./Pages/**/*.tsx') + `./pages/${name}.tsx`, + import.meta.glob('./pages/**/*.tsx') ).then((page) => { if (page) { const PageComponent = (page as any).default; @@ -36,10 +41,12 @@ createInertiaApp({ } root.render( - - - - + + + + + + ); }, progress: { diff --git a/resources/js/components/Layout/Header.tsx b/resources/js/components/Layout/Header.tsx index 8b37a8e..9f35265 100644 --- a/resources/js/components/Layout/Header.tsx +++ b/resources/js/components/Layout/Header.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { usePage } from '@inertiajs/react'; import { Link, router } from '@inertiajs/react'; import { useTranslation } from 'react-i18next'; @@ -13,7 +13,7 @@ import { Sun, Moon } from 'lucide-react'; import { cn } from '@/lib/utils'; const Header: React.FC = () => { - const { auth, locale } = usePage().props as any; + const { auth } = usePage().props as any; const { t } = useTranslation('auth'); const { appearance, updateAppearance } = useAppearance(); const { localizedPath } = useLocalizedRoutes(); @@ -23,15 +23,29 @@ const Header: React.FC = () => { updateAppearance(newAppearance); }; - const handleLanguageChange = (value: string) => { - router.post('/set-locale', { locale: value }, { - preserveState: true, - replace: true, - onSuccess: () => { + const handleLanguageChange = useCallback(async (value: string) => { + console.log('handleLanguageChange called with:', value); + try { + const response = await fetch('/set-locale', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', + }, + body: JSON.stringify({ locale: value }), + }); + + console.log('fetch response:', response.status); + if (response.ok) { + console.log('calling i18n.changeLanguage with:', value); i18n.changeLanguage(value); - }, - }); - }; + // Reload only the locale prop to update the page props + router.reload({ only: ['locale'] }); + } + } catch (error) { + console.error('Failed to change locale:', error); + } + }, []); const handleLogout = () => { router.post('/logout'); @@ -45,18 +59,24 @@ const Header: React.FC = () => { Fotospiel
- @@ -140,7 +162,7 @@ const Header: React.FC = () => { <> {t('header.login')} diff --git a/resources/js/components/ui/Steps.tsx b/resources/js/components/ui/Steps.tsx index 3c14ed8..e3f66bd 100644 --- a/resources/js/components/ui/Steps.tsx +++ b/resources/js/components/ui/Steps.tsx @@ -8,6 +8,7 @@ interface Step { id: string title: string description: string + details?: string } interface StepsProps { @@ -33,10 +34,18 @@ const Steps = React.forwardRef( {index + 1}
-

+

{step.title}

-

{step.description}

+

{step.description}

+ {step.details && index === currentStep && ( +

+ {step.details} +

+ )}
{index < steps.length - 1 && (
diff --git a/resources/js/hooks/useLocalizedRoutes.ts b/resources/js/hooks/useLocalizedRoutes.ts index bc66c53..71431b2 100644 --- a/resources/js/hooks/useLocalizedRoutes.ts +++ b/resources/js/hooks/useLocalizedRoutes.ts @@ -21,7 +21,7 @@ export const useLocalizedRoutes = () => { const trimmed = path.trim(); const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`; - console.debug('[useLocalizedRoutes] Resolved path', { input: path, normalized, locale }); +// console.debug('[useLocalizedRoutes] Resolved path', { input: path, normalized, locale }); // Since prefix-free, return plain path. Locale is handled via session. return normalized; diff --git a/resources/js/i18n.ts b/resources/js/i18n.ts index 15486c1..3e6bdd6 100644 --- a/resources/js/i18n.ts +++ b/resources/js/i18n.ts @@ -1,13 +1,17 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import Backend from 'i18next-http-backend'; -import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n.on('languageChanged', (lng) => { + console.log('i18n languageChanged event:', lng); + console.trace('languageChanged trace for', lng); +}); i18n .use(Backend) - .use(LanguageDetector) .use(initReactI18next) .init({ + lng: localStorage.getItem('i18nextLng') || 'de', fallbackLng: 'de', supportedLngs: ['de', 'en'], ns: ['marketing', 'auth', 'common'], @@ -20,8 +24,8 @@ i18n loadPath: '/lang/{{lng}}/{{ns}}.json', }, detection: { - order: ['sessionStorage', 'localStorage', 'htmlTag'], - caches: ['sessionStorage'], + order: [], + caches: ['localStorage'], }, react: { useSuspense: false, diff --git a/resources/js/pages/auth/LoginForm.tsx b/resources/js/pages/auth/LoginForm.tsx index eae88f1..302f9e4 100644 --- a/resources/js/pages/auth/LoginForm.tsx +++ b/resources/js/pages/auth/LoginForm.tsx @@ -40,18 +40,8 @@ export default function LoginForm({ onSuccess, canResetPassword = true, locale } 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 loginEndpoint = '/checkout/login'; + const [values, setValues] = useState({ email: "", password: "", diff --git a/resources/js/pages/auth/RegisterForm.tsx b/resources/js/pages/auth/RegisterForm.tsx index 812e7e3..99b3179 100644 --- a/resources/js/pages/auth/RegisterForm.tsx +++ b/resources/js/pages/auth/RegisterForm.tsx @@ -23,6 +23,7 @@ interface RegisterFormProps { export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale }: RegisterFormProps) { const [privacyOpen, setPrivacyOpen] = useState(false); const [hasTriedSubmit, setHasTriedSubmit] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); const { t } = useTranslation(['auth', 'common']); const page = usePage<{ errors: Record; locale?: string; auth?: { user?: any | null } }>(); const resolvedLocale = locale ?? page.props.locale ?? 'de'; @@ -46,18 +47,8 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale } }, [errors, hasTriedSubmit]); - 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 registerEndpoint = '/checkout/register'; + const submit = async (event: React.FormEvent) => { event.preventDefault(); setHasTriedSubmit(true); @@ -71,6 +62,7 @@ export default function RegisterForm({ packageId, onSuccess, privacyHtml, locale }; try { + const response = await fetch(registerEndpoint, { method: 'POST', headers: { diff --git a/resources/js/pages/marketing/PurchaseWizard.tsx b/resources/js/pages/marketing/CheckoutWizardPage.tsx similarity index 71% rename from resources/js/pages/marketing/PurchaseWizard.tsx rename to resources/js/pages/marketing/CheckoutWizardPage.tsx index 0315676..28f3871 100644 --- a/resources/js/pages/marketing/PurchaseWizard.tsx +++ b/resources/js/pages/marketing/CheckoutWizardPage.tsx @@ -3,20 +3,22 @@ import { Head, usePage } from "@inertiajs/react"; import MarketingLayout from "@/layouts/marketing/MarketingLayout"; import type { CheckoutPackage } from "./checkout/types"; import { CheckoutWizard } from "./checkout/CheckoutWizard"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; -interface PurchaseWizardPageProps { +interface CheckoutWizardPageProps { package: CheckoutPackage; packageOptions: CheckoutPackage[]; stripePublishableKey: string; privacyHtml: string; } -export default function PurchaseWizardPage({ +export default function CheckoutWizardPage({ package: initialPackage, packageOptions, stripePublishableKey, privacyHtml, -}: PurchaseWizardPageProps) { +}: CheckoutWizardPageProps) { const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string } | null } }>(); const currentUser = page.props.auth?.user ?? null; @@ -37,6 +39,19 @@ export default function PurchaseWizardPage({
+ {/* Abbruch-Button oben rechts */} +
+ +
+ = ({ stripePublishableKey, privacyHtml }) => { diff --git a/resources/js/pages/marketing/checkout/WizardContext.tsx b/resources/js/pages/marketing/checkout/WizardContext.tsx index b08ee42..6c24a68 100644 --- a/resources/js/pages/marketing/checkout/WizardContext.tsx +++ b/resources/js/pages/marketing/checkout/WizardContext.tsx @@ -1,110 +1,25 @@ -import React, { createContext, useCallback, useContext, useMemo, useState } from "react"; -import type { CheckoutPackage, CheckoutStepId, CheckoutWizardContextValue, CheckoutWizardState } from "./types"; + const cancelCheckout = useCallback(() => { + // Track abandoned checkout (fire and forget) + if (state.authUser || state.selectedPackage) { + fetch('/checkout/track-abandoned', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', + }, + body: JSON.stringify({ + package_id: state.selectedPackage.id, + last_step: ['package', 'auth', 'payment', 'confirmation'].indexOf(state.currentStep) + 1, + user_id: state.authUser?.id, + email: state.authUser?.email, + }), + }).catch(error => { + console.error('Failed to track abandoned checkout:', error); + }); + } -interface CheckoutWizardProviderProps { - initialPackage: CheckoutPackage; - packageOptions: CheckoutPackage[]; - initialStep?: CheckoutStepId; - initialAuthUser?: CheckoutWizardState['authUser']; - initialIsAuthenticated?: boolean; - children: React.ReactNode; -} - -const CheckoutWizardContext = createContext(undefined); - -export const CheckoutWizardProvider: React.FC = ({ - initialPackage, - packageOptions, - initialStep = 'package', - initialAuthUser = null, - initialIsAuthenticated, - children, -}) => { - const [state, setState] = useState(() => ({ - 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((user) => { - setState((prev) => ({ - ...prev, - isAuthenticated: Boolean(user), - authUser: user ?? null, - })); - }, []); - - const setPaymentProvider = useCallback((provider) => { - setState((prev) => ({ - ...prev, - paymentProvider: provider, - })); - }, []); - - const resetPaymentState = useCallback(() => { - setState((prev) => ({ - ...prev, - paymentProvider: undefined, - isProcessing: false, - })); - }, []); - - const value = useMemo(() => ({ - ...state, - setStep, - nextStep, - previousStep, - setSelectedPackage, - markAuthenticated, - setPaymentProvider, - resetPaymentState, - }), [state, setStep, nextStep, previousStep, setSelectedPackage, markAuthenticated, setPaymentProvider, resetPaymentState]); - - return ( - - {children} - - ); -}; - -export const useCheckoutWizard = () => { - const context = useContext(CheckoutWizardContext); - if (!context) { - throw new Error('useCheckoutWizard must be used within CheckoutWizardProvider'); - } - return context; -}; + // State aus localStorage entfernen + localStorage.removeItem('checkout-wizard-state'); + // Zur Package-Übersicht zurückleiten + window.location.href = '/packages'; + }, [state]); diff --git a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx index 4ef220d..12c9ec5 100644 --- a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx @@ -3,8 +3,8 @@ 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"; +import LoginForm, { AuthUserPayload } from "../../../auth/LoginForm"; +import RegisterForm, { RegisterSuccessPayload } from "../../../auth/RegisterForm"; interface AuthStepProps { privacyHtml: string; diff --git a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx index 7cf31f7..681c57b 100644 --- a/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/ConfirmationStep.tsx @@ -15,7 +15,7 @@ export const ConfirmationStep: React.FC = ({ onViewProfil Willkommen bei FotoSpiel - {Ihr Paket "" ist aktiviert. Wir haben Ihnen eine Bestaetigung per E-Mail gesendet.} + Ihr Paket "{selectedPackage.name}" ist aktiviert. Wir haben Ihnen eine Bestätigung per E-Mail gesendet.
diff --git a/resources/js/pages/marketing/checkout/steps/PackageStep.tsx b/resources/js/pages/marketing/checkout/steps/PackageStep.tsx index c284d65..89e8aa4 100644 --- a/resources/js/pages/marketing/checkout/steps/PackageStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PackageStep.tsx @@ -1,5 +1,5 @@ -import React, { useMemo } from "react"; -import { Check, Package as PackageIcon } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { Check, Package as PackageIcon, Loader2 } 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"; @@ -70,6 +70,7 @@ function PackageOption({ pkg, isActive, onSelect }: { pkg: CheckoutPackage; isAc export const PackageStep: React.FC = () => { const { selectedPackage, packageOptions, setSelectedPackage, resetPaymentState, nextStep } = useCheckoutWizard(); + const [isLoading, setIsLoading] = useState(false); const comparablePackages = useMemo(() => { return packageOptions.filter((pkg) => pkg.type === selectedPackage.type); @@ -83,13 +84,29 @@ export const PackageStep: React.FC = () => { resetPaymentState(); }; + const handleNextStep = async () => { + setIsLoading(true); + // Kleine Verzögerung für bessere UX + setTimeout(() => { + nextStep(); + setIsLoading(false); + }, 300); + }; + return (
-
diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index 3e97bfd..195c64c 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -1,4 +1,5 @@ -import React, { useEffect } from "react"; +import { useState, useEffect } from "react"; +import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -9,13 +10,138 @@ interface PaymentStepProps { } export const PaymentStep: React.FC = ({ stripePublishableKey }) => { - const { selectedPackage, paymentProvider, setPaymentProvider, resetPaymentState, nextStep } = useCheckoutWizard(); + const stripe = useStripe(); + const elements = useElements(); + const { selectedPackage, authUser, paymentProvider, setPaymentProvider, resetPaymentState, nextStep } = useCheckoutWizard(); + + const [clientSecret, setClientSecret] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(''); + const [paymentStatus, setPaymentStatus] = useState<'idle' | 'processing' | 'succeeded' | 'failed'>('idle'); useEffect(() => { resetPaymentState(); }, [selectedPackage.id, resetPaymentState]); - const isFree = selectedPackage.price === 0; + const isFree = selectedPackage.price <= 0; + + // Payment Intent für kostenpflichtige Pakete laden + useEffect(() => { + if (isFree || !authUser) return; + + const loadPaymentIntent = async () => { + try { + const response = await fetch('/stripe/create-payment-intent', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '', + }, + body: JSON.stringify({ + package_id: selectedPackage.id, + }), + }); + + const data = await response.json(); + + if (response.ok && data.clientSecret) { + setClientSecret(data.clientSecret); + setError(''); + } else { + setError(data.error || 'Fehler beim Laden der Zahlungsdaten'); + } + } catch (err) { + setError('Netzwerkfehler beim Laden der Zahlungsdaten'); + } + }; + + loadPaymentIntent(); + }, [selectedPackage.id, authUser, isFree]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements) { + setError('Stripe ist nicht initialisiert. Bitte Seite neu laden.'); + return; + } + + if (!clientSecret) { + setError('Zahlungsdaten konnten nicht geladen werden. Bitte erneut versuchen.'); + return; + } + + setIsProcessing(true); + setError(''); + setPaymentStatus('processing'); + + try { + const { error: stripeError, paymentIntent } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: `${window.location.origin}/checkout/success`, + }, + redirect: 'if_required', // Wichtig für SCA + }); + + if (stripeError) { + console.error('Stripe Payment Error:', stripeError); + let errorMessage = 'Zahlung fehlgeschlagen. '; + + switch (stripeError.type) { + case 'card_error': + errorMessage += stripeError.message || 'Kartenfehler aufgetreten.'; + break; + case 'validation_error': + errorMessage += 'Eingabedaten sind ungültig.'; + break; + case 'api_connection_error': + errorMessage += 'Verbindungsfehler. Bitte Internetverbindung prüfen.'; + break; + case 'api_error': + errorMessage += 'Serverfehler. Bitte später erneut versuchen.'; + break; + case 'authentication_error': + errorMessage += 'Authentifizierungsfehler. Bitte Seite neu laden.'; + break; + default: + errorMessage += stripeError.message || 'Unbekannter Fehler aufgetreten.'; + } + + setError(errorMessage); + setPaymentStatus('failed'); + } else if (paymentIntent) { + switch (paymentIntent.status) { + case 'succeeded': + setPaymentStatus('succeeded'); + // Kleiner Delay für bessere UX + setTimeout(() => nextStep(), 1000); + break; + case 'processing': + setError('Zahlung wird verarbeitet. Bitte warten...'); + setPaymentStatus('processing'); + break; + case 'requires_payment_method': + setError('Zahlungsmethode wird benötigt. Bitte Kartendaten überprüfen.'); + setPaymentStatus('failed'); + break; + case 'requires_confirmation': + setError('Zahlung muss bestätigt werden.'); + setPaymentStatus('failed'); + break; + default: + setError(`Unerwarteter Zahlungsstatus: ${paymentIntent.status}`); + setPaymentStatus('failed'); + } + } + } catch (err) { + console.error('Unexpected payment error:', err); + setError('Unerwarteter Fehler aufgetreten. Bitte später erneut versuchen.'); + setPaymentStatus('failed'); + } finally { + setIsProcessing(false); + } + }; if (isFree) { return ( @@ -23,7 +149,7 @@ export const PaymentStep: React.FC = ({ stripePublishableKey } Kostenloses Paket - Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestaetigung. + Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestätigung.
@@ -42,35 +168,58 @@ export const PaymentStep: React.FC = ({ stripePublishableKey } Stripe PayPal + -
-

- Karten- oder SEPA-Zahlung via Stripe Elements. Wir erzeugen beim Fortfahren einen Payment Intent. -

- - Integration folgt - - Stripe Elements wird im naechsten Schritt integriert. Aktuell dient dieser Block als Platzhalter fuer UI und API Hooks. - - -
- -
-
+
+ {error && ( + + {error} + + )} + + {clientSecret ? ( +
+

+ Sichere Zahlung mit Kreditkarte, Debitkarte oder SEPA-Lastschrift. +

+ + +
+ ) : ( +
+ + Lade Zahlungsdaten... + + Bitte warten Sie, während wir die Zahlungsdaten vorbereiten. + + +
+ )} +
+

- PayPal Express Checkout mit Rueckleitung zur Bestaetigung. Wir hinterlegen Paket- und Tenant-Daten im Order-Metadata. + PayPal Express Checkout mit Rückleitung zur Bestätigung.

- - Integration folgt + + PayPal Integration - PayPal Buttons werden im Folge-PR angebunden. Dieser Platzhalter zeigt den spaeteren Container fuer die Buttons. + PayPal wird in einem späteren Schritt implementiert. Aktuell nur Stripe verfügbar.
- +
diff --git a/resources/js/pages/marketing/checkout/types.ts b/resources/js/pages/marketing/checkout/types.ts index e333d68..beb99d6 100644 --- a/resources/js/pages/marketing/checkout/types.ts +++ b/resources/js/pages/marketing/checkout/types.ts @@ -35,5 +35,6 @@ export interface CheckoutWizardContextValue extends CheckoutWizardState { markAuthenticated: (user: CheckoutWizardState['authUser']) => void; setPaymentProvider: (provider: CheckoutWizardState['paymentProvider']) => void; resetPaymentState: () => void; + cancelCheckout: () => void; } diff --git a/resources/lang/de/auth.json b/resources/lang/de/auth.json index 46c7907..5462b6a 100644 --- a/resources/lang/de/auth.json +++ b/resources/lang/de/auth.json @@ -21,8 +21,12 @@ "login": { "title": "Anmelden", "username_or_email": "Username oder E-Mail", + "email": "E-Mail-Adresse", + "email_placeholder": "ihre@email.de", "password": "Passwort", + "password_placeholder": "Ihr Passwort", "remember": "Angemeldet bleiben", + "forgot": "Passwort vergessen?", "submit": "Anmelden" }, "register": { diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php new file mode 100644 index 0000000..2c9ec97 --- /dev/null +++ b/resources/lang/de/emails.php @@ -0,0 +1,52 @@ + [ + 'subject' => 'Willkommen bei Fotospiel, :name!', + 'greeting' => 'Willkommen bei Fotospiel, :name!', + 'body' => 'Vielen Dank für Ihre Registrierung. Ihr Account ist nun aktiv.', + 'username' => 'Benutzername: :username', + 'email' => 'E-Mail: :email', + 'verification' => 'Bitte verifizieren Sie Ihre E-Mail-Adresse, um auf das Admin-Panel zuzugreifen.', + 'footer' => 'Mit freundlichen Grüßen,
Das Fotospiel-Team', + ], + + 'purchase' => [ + 'subject' => 'Kauf-Bestätigung - :package', + 'greeting' => 'Vielen Dank für Ihren Kauf, :name!', + 'package' => 'Package: :package', + 'price' => 'Preis: :price €', + 'activation' => 'Das Package ist nun in Ihrem Tenant-Account aktiviert.', + 'footer' => 'Mit freundlichen Grüßen,
Das Fotospiel-Team', + ], + + 'abandoned_checkout' => [ + 'subject_1h' => 'Ihr :package Paket wartet auf Sie', + 'subject_24h' => 'Erinnerung: Schließen Sie Ihren Kauf ab', + 'subject_1w' => 'Letzte Chance: Ihr gespeichertes Paket', + + 'greeting' => 'Hallo :name,', + + 'body_1h' => 'Sie haben vor kurzem begonnen, unser :package Paket zu kaufen, aber noch nicht abgeschlossen. Ihr Warenkorb ist für Sie reserviert.', + + 'body_24h' => 'Erinnerung: Ihr :package Paket wartet seit 24 Stunden auf Sie. Schließen Sie jetzt Ihren Kauf ab und sichern Sie sich alle Vorteile.', + + 'body_1w' => 'Letzte Erinnerung: Ihr :package Paket wartet seit einer Woche auf Sie. Dies ist Ihre letzte Chance, den Kauf abzuschließen.', + + 'cta_button' => 'Jetzt fortfahren', + 'cta_link' => 'Oder kopieren Sie diesen Link: :url', + + 'benefits_title' => 'Warum jetzt kaufen?', + 'benefit1' => 'Schneller Checkout in 2 Minuten', + 'benefit2' => 'Sichere Zahlung mit Stripe', + 'benefit3' => 'Sofortiger Zugriff nach Zahlung', + 'benefit4' => '10% Rabatt sichern', + + 'footer' => 'Mit freundlichen Grüßen,
Das Fotospiel-Team', + ], + + 'contact' => [ + 'subject' => 'Neue Kontakt-Anfrage', + 'body' => 'Kontakt-Anfrage von :name (:email): :message', + ], +]; \ No newline at end of file diff --git a/resources/lang/en/auth.json b/resources/lang/en/auth.json new file mode 100644 index 0000000..43bc1a3 --- /dev/null +++ b/resources/lang/en/auth.json @@ -0,0 +1,50 @@ +{ + "login_failed": "Invalid email or password.", + "login_success": "You are now logged in.", + "registration_failed": "Registration failed.", + "registration_success": "Registration successful – proceed with purchase.", + "already_logged_in": "You are already logged in.", + "failed_credentials": "Wrong credentials.", + "header": { + "login": "Login", + "register": "Register", + "home": "Home", + "packages": "Packages", + "blog": "Blog", + "occasions": { + "wedding": "Wedding", + "birthday": "Birthday", + "corporate": "Corporate Event" + }, + "contact": "Contact" + }, + "login": { + "title": "Login", + "username_or_email": "Username or Email", + "email": "Email Address", + "email_placeholder": "your@email.com", + "password": "Password", + "password_placeholder": "Your password", + "remember": "Stay logged in", + "forgot": "Forgot password?", + "submit": "Login" + }, + "register": { + "title": "Register", + "name": "Full Name", + "username": "Username", + "email": "Email Address", + "password": "Password", + "password_confirmation": "Confirm password", + "first_name": "First Name", + "last_name": "Last Name", + "address": "Address", + "phone": "Phone Number", + "privacy_consent": "I agree to the privacy policy and accept the processing of my personal data.", + "submit": "Register" + }, + "verification": { + "notice": "Please verify your email address.", + "resend": "Resend email" + } +} \ No newline at end of file diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php new file mode 100644 index 0000000..e7961e8 --- /dev/null +++ b/resources/lang/en/emails.php @@ -0,0 +1,52 @@ + [ + 'subject' => 'Welcome to Fotospiel, :name!', + 'greeting' => 'Welcome to Fotospiel, :name!', + 'body' => 'Thank you for your registration. Your account is now active.', + 'username' => 'Username: :username', + 'email' => 'Email: :email', + 'verification' => 'Please verify your email address to access the admin panel.', + 'footer' => 'Best regards,
The Fotospiel Team', + ], + + 'purchase' => [ + 'subject' => 'Purchase Confirmation - :package', + 'greeting' => 'Thank you for your purchase, :name!', + 'package' => 'Package: :package', + 'price' => 'Price: :price €', + 'activation' => 'The package is now activated in your tenant account.', + 'footer' => 'Best regards,
The Fotospiel Team', + ], + + 'abandoned_checkout' => [ + 'subject_1h' => 'Your :package Package is Waiting for You', + 'subject_24h' => 'Reminder: Complete Your Purchase', + 'subject_1w' => 'Last Chance: Your Saved Package', + + 'greeting' => 'Hello :name,', + + 'body_1h' => 'You recently started purchasing our :package package but haven\'t completed it yet. Your cart is reserved for you.', + + 'body_24h' => 'Reminder: Your :package package has been waiting for 24 hours. Complete your purchase now and secure all the benefits.', + + 'body_1w' => 'Final reminder: Your :package package has been waiting for a week. This is your last chance to complete the purchase.', + + 'cta_button' => 'Continue Now', + 'cta_link' => 'Or copy this link: :url', + + 'benefits_title' => 'Why buy now?', + 'benefit1' => 'Quick checkout in 2 minutes', + 'benefit2' => 'Secure payment with Stripe', + 'benefit3' => 'Instant access after payment', + 'benefit4' => 'Secure 10% discount', + + 'footer' => 'Best regards,
The Fotospiel Team', + ], + + 'contact' => [ + 'subject' => 'New Contact Request', + 'body' => 'Contact request from :name (:email): :message', + ], +]; \ No newline at end of file diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index c84bff7..8fbdc96 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -3,6 +3,7 @@ + {{-- Inline script to detect system dark mode preference and apply it immediately --}}