diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php
index 36c300c..e5a369f 100644
--- a/app/Http/Controllers/CheckoutController.php
+++ b/app/Http/Controllers/CheckoutController.php
@@ -1,75 +1,182 @@
- private function transformUser(?User $user): ?array
- {
- if (!$user) {
- return null;
- }
+ $user->id,
- 'email' => $user->email,
- 'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: $user->name,
- 'pending_purchase' => (bool) $user->pending_purchase,
- 'email_verified_at' => $user->email_verified_at,
- ];
+namespace App\Http\Controllers;
+
+use App\Models\Package;
+use App\Models\Tenant;
+use App\Models\User;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rules\Password;
+use Inertia\Inertia;
+use Laravel\Cashier\Cashier;
+use Stripe\PaymentIntent;
+use Stripe\Stripe;
+
+class CheckoutController extends Controller
+{
+ public function show(Package $package)
+ {
+ // Alle verfügbaren Pakete laden
+ $packages = Package::all();
+
+ return Inertia::render('marketing/CheckoutWizardPage', [
+ 'package' => $package,
+ 'packageOptions' => $packages,
+ 'stripePublishableKey' => config('services.stripe.key'),
+ 'privacyHtml' => view('legal.datenschutz-partial')->render(),
+ 'auth' => [
+ 'user' => Auth::user(),
+ ],
+ ]);
}
- /**
- * Track an abandoned checkout for reminder emails
- */
- public function trackAbandonedCheckout(Request $request): JsonResponse
+ public function register(Request $request)
{
- $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',
+ $validator = Validator::make($request->all(), [
+ 'email' => 'required|email|unique:users,email',
+ 'password' => ['required', 'confirmed', Password::defaults()],
+ 'package_id' => 'required|exists:packages,id',
+ 'terms' => 'required|accepted',
]);
+ if ($validator->fails()) {
+ return response()->json([
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ $package = Package::findOrFail($request->package_id);
+
+ DB::transaction(function () use ($request, $package) {
+ // User erstellen
+ $user = User::create([
+ 'email' => $request->email,
+ 'password' => Hash::make($request->password),
+ 'pending_purchase' => true,
+ ]);
+
+ // Tenant erstellen
+ $tenant = Tenant::create([
+ 'name' => 'Neuer Tenant',
+ 'domain' => null,
+ 'database' => null,
+ 'user_id' => $user->id,
+ ]);
+
+ // Package zuweisen
+ $tenant->packages()->attach($package->id, [
+ 'purchased_at' => now(),
+ 'expires_at' => $package->is_free ? null : now()->addYear(),
+ 'is_active' => $package->is_free, // Kostenlose Pakete sofort aktivieren
+ ]);
+
+ // E-Mail-Verifizierung senden
+ $user->sendEmailVerificationNotification();
+
+ // Willkommens-E-Mail senden
+ Mail::to($user->email)->send(new \App\Mail\WelcomeMail($user, $package));
+ });
+
+ return response()->json([
+ 'message' => 'Registrierung erfolgreich. Bitte überprüfen Sie Ihre E-Mail zur Verifizierung.',
+ ]);
+ }
+
+ public function createPaymentIntent(Request $request)
+ {
+ $request->validate([
+ 'package_id' => 'required|exists:packages,id',
+ ]);
+
+ $package = Package::findOrFail($request->package_id);
+
+ \Log::info('Create Payment Intent', [
+ 'package_id' => $package->id,
+ 'package_name' => $package->name,
+ 'price' => $package->price,
+ 'is_free' => $package->is_free,
+ 'user_id' => Auth::id(),
+ ]);
+
+ if ($package->is_free) {
+ \Log::info('Free package detected, returning null client_secret');
+ return response()->json([
+ 'client_secret' => null,
+ 'free_package' => true,
+ ]);
+ }
+
+ // Stripe API Key setzen
+ Stripe::setApiKey(config('services.stripe.secret'));
+
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,
+ $paymentIntent = PaymentIntent::create([
+ 'amount' => $package->price * 100, // Stripe erwartet Cent
+ 'currency' => 'eur',
+ 'metadata' => [
+ 'package_id' => $package->id,
+ 'user_id' => Auth::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]);
+ \Log::info('PaymentIntent created successfully', [
+ 'payment_intent_id' => $paymentIntent->id,
+ 'client_secret' => substr($paymentIntent->client_secret, 0, 50) . '...',
+ ]);
+ return response()->json([
+ 'client_secret' => $paymentIntent->client_secret,
+ ]);
} catch (\Exception $e) {
- Log::error('Failed to track abandoned checkout: ' . $e->getMessage());
- return response()->json(['success' => false, 'message' => 'Failed to track checkout'], 500);
+ \Log::error('Stripe PaymentIntent creation failed', [
+ 'error' => $e->getMessage(),
+ 'package_id' => $package->id,
+ ]);
+
+ return response()->json([
+ 'error' => 'Fehler beim Erstellen der Zahlungsdaten: ' . $e->getMessage(),
+ ], 500);
}
}
+
+ public function confirmPayment(Request $request)
+ {
+ $request->validate([
+ 'payment_intent_id' => 'required|string',
+ 'package_id' => 'required|exists:packages,id',
+ ]);
+
+ // Stripe API Key setzen
+ Stripe::setApiKey(config('services.stripe.secret'));
+
+ $paymentIntent = PaymentIntent::retrieve($request->payment_intent_id);
+
+ if ($paymentIntent->status !== 'succeeded') {
+ return response()->json([
+ 'error' => 'Zahlung nicht erfolgreich.',
+ ], 400);
+ }
+
+ $package = Package::findOrFail($request->package_id);
+ $user = Auth::user();
+
+ // Package dem Tenant zuweisen
+ $user->tenant->packages()->attach($package->id, [
+ 'purchased_at' => now(),
+ 'expires_at' => now()->addYear(),
+ 'is_active' => true,
+ ]);
+
+ // pending_purchase zurücksetzen
+ $user->update(['pending_purchase' => false]);
+
+ return response()->json([
+ 'message' => 'Zahlung erfolgreich bestätigt.',
+ ]);
+ }
}
diff --git a/resources/js/layouts/auth/auth-simple-layout.tsx b/resources/js/layouts/auth/auth-simple-layout.tsx
index 4ecb949..68c0192 100644
--- a/resources/js/layouts/auth/auth-simple-layout.tsx
+++ b/resources/js/layouts/auth/auth-simple-layout.tsx
@@ -1,5 +1,5 @@
import AppLogoIcon from '@/components/app-logo-icon';
-import { marketing } from '@/routes';
+import { home } from '@/routes';
import { Link } from '@inertiajs/react';
import { type PropsWithChildren } from 'react';
@@ -15,7 +15,7 @@ export default function AuthSimpleLayout({ children, title, description }: Props
-
+
diff --git a/resources/js/pages/marketing/CheckoutWizardPage.tsx b/resources/js/pages/marketing/CheckoutWizardPage.tsx
index 28f3871..6f44c46 100644
--- a/resources/js/pages/marketing/CheckoutWizardPage.tsx
+++ b/resources/js/pages/marketing/CheckoutWizardPage.tsx
@@ -19,9 +19,10 @@ export default function CheckoutWizardPage({
stripePublishableKey,
privacyHtml,
}: CheckoutWizardPageProps) {
- const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string } | null } }>();
+ const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null } }>();
const currentUser = page.props.auth?.user ?? null;
+
const dedupedOptions = React.useMemo(() => {
const ids = new Set
();
const list = [initialPackage, ...packageOptions];
@@ -57,7 +58,7 @@ export default function CheckoutWizardPage({
packageOptions={dedupedOptions}
stripePublishableKey={stripePublishableKey}
privacyHtml={privacyHtml}
- initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined } : null}
+ initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null}
/>
diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx
index e0c1f58..559f6c9 100644
--- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx
+++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx
@@ -53,6 +53,7 @@ const stepConfig: { id: CheckoutStepId; title: string; description: string; deta
const WizardBody: React.FC<{ stripePublishableKey: string; privacyHtml: string }> = ({ stripePublishableKey, privacyHtml }) => {
const { currentStep, nextStep, previousStep } = useCheckoutWizard();
+
const currentIndex = useMemo(() => stepConfig.findIndex((step) => step.id === currentStep), [currentStep]);
const progress = useMemo(() => {
if (currentIndex < 0) {
@@ -95,6 +96,7 @@ export const CheckoutWizard: React.FC
= ({
initialAuthUser,
initialStep,
}) => {
+
return (
void;
+ setSelectedPackage: (pkg: CheckoutPackage) => void;
+ setAuthUser: (user: any) => void;
+ nextStep: () => void;
+ prevStep: () => void;
+ previousStep: () => void;
+ goToStep: (step: CheckoutStepId) => void;
+ cancelCheckout: () => void;
+ updatePaymentIntent: (clientSecret: string | null) => void;
+ setLoading: (loading: boolean) => void;
+ setError: (error: string | null) => void;
+ resetPaymentState: () => void;
+}
+
+const CheckoutWizardContext = createContext(null);
+
+const initialState: CheckoutState = {
+ currentStep: 'package',
+ selectedPackage: null,
+ packageOptions: [],
+ authUser: null,
+ isAuthenticated: false,
+ paymentIntent: null,
+ loading: false,
+ error: null,
+};
+
+type CheckoutAction =
+ | { type: 'SELECT_PACKAGE'; payload: CheckoutPackage }
+ | { type: 'SET_AUTH_USER'; payload: any }
+ | { type: 'NEXT_STEP' }
+ | { type: 'PREV_STEP' }
+ | { type: 'GO_TO_STEP'; payload: CheckoutStepId }
+ | { type: 'UPDATE_PAYMENT_INTENT'; payload: string | null }
+ | { type: 'SET_LOADING'; payload: boolean }
+ | { type: 'SET_ERROR'; payload: string | null };
+
+function checkoutReducer(state: CheckoutState, action: CheckoutAction): CheckoutState {
+ switch (action.type) {
+ case 'SELECT_PACKAGE':
+ return { ...state, selectedPackage: action.payload };
+ case 'SET_AUTH_USER':
+ return { ...state, authUser: action.payload };
+ case 'NEXT_STEP':
+ const steps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
+ const currentIndex = steps.indexOf(state.currentStep);
+ if (currentIndex < steps.length - 1) {
+ return { ...state, currentStep: steps[currentIndex + 1] };
+ }
+ return state;
+ case 'PREV_STEP':
+ const prevSteps: CheckoutStepId[] = ['package', 'auth', 'payment', 'confirmation'];
+ const prevIndex = prevSteps.indexOf(state.currentStep);
+ if (prevIndex > 0) {
+ return { ...state, currentStep: prevSteps[prevIndex - 1] };
+ }
+ return state;
+ case 'GO_TO_STEP':
+ return { ...state, currentStep: action.payload };
+ case 'UPDATE_PAYMENT_INTENT':
+ return { ...state, paymentIntent: action.payload };
+ case 'SET_LOADING':
+ return { ...state, loading: action.payload };
+ case 'SET_ERROR':
+ return { ...state, error: action.payload };
+ default:
+ return state;
+ }
+}
+
+interface CheckoutWizardProviderProps {
+ children: React.ReactNode;
+ initialPackage?: CheckoutPackage;
+ packageOptions?: CheckoutPackage[];
+ initialStep?: CheckoutStepId;
+ initialAuthUser?: any;
+ initialIsAuthenticated?: boolean;
+}
+
+export function CheckoutWizardProvider({
+ children,
+ initialPackage,
+ packageOptions,
+ initialStep,
+ initialAuthUser,
+ initialIsAuthenticated
+}: CheckoutWizardProviderProps) {
+ const customInitialState: CheckoutState = {
+ ...initialState,
+ currentStep: initialStep || 'package',
+ selectedPackage: initialPackage || null,
+ packageOptions: packageOptions || [],
+ authUser: initialAuthUser || null,
+ isAuthenticated: initialIsAuthenticated || Boolean(initialAuthUser),
+ };
+
+
+ const [state, dispatch] = useReducer(checkoutReducer, customInitialState);
+
+ // Load state from localStorage on mount
+ useEffect(() => {
+ const savedState = localStorage.getItem('checkout-wizard-state');
+ if (savedState) {
+ try {
+ const parsed = JSON.parse(savedState);
+ // Restore state selectively
+ if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage });
+ if (parsed.currentStep) dispatch({ type: 'GO_TO_STEP', payload: parsed.currentStep });
+ } catch (error) {
+ console.error('Failed to restore checkout state:', error);
+ }
+ }
+ }, []);
+
+ // Save state to localStorage whenever it changes
+ useEffect(() => {
+ localStorage.setItem('checkout-wizard-state', JSON.stringify({
+ selectedPackage: state.selectedPackage,
+ currentStep: state.currentStep,
+ }));
+ }, [state.selectedPackage, state.currentStep]);
+
+ const selectPackage = useCallback((pkg: CheckoutPackage) => {
+ dispatch({ type: 'SELECT_PACKAGE', payload: pkg });
+ }, []);
+
+ const setAuthUser = useCallback((user: any) => {
+ dispatch({ type: 'SET_AUTH_USER', payload: user });
+ }, []);
+
+ const nextStep = useCallback(() => {
+ dispatch({ type: 'NEXT_STEP' });
+ }, []);
+
+ const prevStep = useCallback(() => {
+ dispatch({ type: 'PREV_STEP' });
+ }, []);
+
+ const goToStep = useCallback((step: CheckoutStepId) => {
+ dispatch({ type: 'GO_TO_STEP', payload: step });
+ }, []);
+
+ const updatePaymentIntent = useCallback((clientSecret: string | null) => {
+ dispatch({ type: 'UPDATE_PAYMENT_INTENT', payload: clientSecret });
+ }, []);
+
+ const setLoading = useCallback((loading: boolean) => {
+ dispatch({ type: 'SET_LOADING', payload: loading });
+ }, []);
+
+ const setError = useCallback((error: string | null) => {
+ dispatch({ type: 'SET_ERROR', payload: error });
+ }, []);
+
+ const setSelectedPackage = useCallback((pkg: CheckoutPackage) => {
+ dispatch({ type: 'SELECT_PACKAGE', payload: pkg });
+ }, []);
+
+ const resetPaymentState = useCallback(() => {
+ dispatch({ type: 'UPDATE_PAYMENT_INTENT', payload: null });
+ dispatch({ type: 'SET_LOADING', payload: false });
+ dispatch({ type: 'SET_ERROR', payload: null });
+ }, []);
+
const cancelCheckout = useCallback(() => {
// Track abandoned checkout (fire and forget)
if (state.authUser || state.selectedPackage) {
@@ -8,9 +193,8 @@
'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,
+ package_id: state.selectedPackage?.id,
+ step: state.currentStep,
email: state.authUser?.email,
}),
}).catch(error => {
@@ -23,3 +207,39 @@
// Zur Package-Übersicht zurückleiten
window.location.href = '/packages';
}, [state]);
+
+ const value: CheckoutWizardContextType = {
+ state,
+ selectedPackage: state.selectedPackage,
+ packageOptions: state.packageOptions,
+ currentStep: state.currentStep,
+ isAuthenticated: state.isAuthenticated,
+ authUser: state.authUser,
+ selectPackage,
+ setSelectedPackage,
+ setAuthUser,
+ nextStep,
+ prevStep,
+ previousStep: prevStep,
+ goToStep,
+ cancelCheckout,
+ updatePaymentIntent,
+ setLoading,
+ setError,
+ resetPaymentState,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCheckoutWizard(): CheckoutWizardContextType {
+ const context = useContext(CheckoutWizardContext);
+ if (!context) {
+ throw new Error('useCheckoutWizard must be used within a CheckoutWizardProvider');
+ }
+ return context;
+}
diff --git a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx
index 12c9ec5..35fabf5 100644
--- a/resources/js/pages/marketing/checkout/steps/AuthStep.tsx
+++ b/resources/js/pages/marketing/checkout/steps/AuthStep.tsx
@@ -13,7 +13,7 @@ interface AuthStepProps {
export const AuthStep: React.FC = ({ privacyHtml }) => {
const page = usePage<{ locale?: string }>();
const locale = page.props.locale ?? "de";
- const { isAuthenticated, authUser, markAuthenticated, nextStep, selectedPackage } = useCheckoutWizard();
+ const { isAuthenticated, authUser, setAuthUser, nextStep, selectedPackage } = useCheckoutWizard();
const [mode, setMode] = useState<'login' | 'register'>('register');
const handleLoginSuccess = (payload: AuthUserPayload | null) => {
@@ -21,7 +21,7 @@ export const AuthStep: React.FC = ({ privacyHtml }) => {
return;
}
- markAuthenticated({
+ setAuthUser({
id: payload.id ?? 0,
email: payload.email ?? "",
name: payload.name ?? undefined,
@@ -33,7 +33,7 @@ export const AuthStep: React.FC = ({ privacyHtml }) => {
const handleRegisterSuccess = (result: RegisterSuccessPayload) => {
const nextUser = result?.user ?? null;
if (nextUser) {
- markAuthenticated({
+ setAuthUser({
id: nextUser.id ?? 0,
email: nextUser.email ?? "",
name: nextUser.name ?? undefined,
@@ -84,12 +84,14 @@ export const AuthStep: React.FC = ({ privacyHtml }) => {
{mode === 'register' ? (
-
+ selectedPackage && (
+
+ )
) : (
+
-
-
+
+
{pkg.name}
@@ -26,10 +28,10 @@ function PackageSummary({ pkg }: { pkg: CheckoutPackage }) {
-
+
{pkg.price === 0 ? "Kostenlos" : currencyFormatter.format(pkg.price)}
-
+
{pkg.type === "reseller" ? "Reseller" : "Endkunde"}
@@ -37,7 +39,7 @@ function PackageSummary({ pkg }: { pkg: CheckoutPackage }) {