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(
-
+
{step.title}
-{step.description}
+{step.description}
+ {step.details && index === currentStep && ( ++ {step.details} +
+ )}- Karten- oder SEPA-Zahlung via Stripe Elements. Wir erzeugen beim Fortfahren einen Payment Intent. -
-- 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.
-