diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php index 701b48b..a601eed 100644 --- a/app/Http/Controllers/Api/PackageController.php +++ b/app/Http/Controllers/Api/PackageController.php @@ -56,6 +56,117 @@ class PackageController extends Controller return $this->handlePaidPurchase($request, $package, $tenant); } + public function createPaymentIntent(Request $request): JsonResponse + { + $request->validate([ + 'package_id' => 'required|exists:packages,id', + ]); + + $package = Package::findOrFail($request->package_id); + $tenant = $request->attributes->get('tenant'); + + if (!$tenant) { + throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); + } + + \Stripe\Stripe::setApiKey(config('services.stripe.secret')); + + $paymentIntent = \Stripe\PaymentIntent::create([ + 'amount' => $package->price * 100, + 'currency' => 'eur', + 'metadata' => [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'type' => 'endcustomer_event', + ], + ]); + + return response()->json([ + 'client_secret' => $paymentIntent->client_secret, + ]); + } + + public function completePurchase(Request $request): JsonResponse + { + $request->validate([ + 'package_id' => 'required|exists:packages,id', + 'payment_method_id' => 'required|string', + ]); + + $package = Package::findOrFail($request->package_id); + $tenant = $request->attributes->get('tenant'); + + if (!$tenant) { + throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); + } + + DB::transaction(function () use ($request, $package, $tenant) { + PackagePurchase::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => $request->payment_method_id, + 'price' => $package->price, + 'type' => 'endcustomer_event', + 'purchased_at' => now(), + 'metadata' => json_encode(['note' => 'Wizard purchase']), + ]); + + TenantPackage::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'price' => $package->price, + 'purchased_at' => now(), + 'active' => true, + ]); + }); + + return response()->json([ + 'message' => 'Purchase completed successfully.', + ], 201); + } + + public function assignFree(Request $request): JsonResponse + { + $request->validate([ + 'package_id' => 'required|exists:packages,id', + ]); + + $package = Package::findOrFail($request->package_id); + $tenant = $request->attributes->get('tenant'); + + if (!$tenant) { + throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); + } + + if ($package->price != 0) { + throw ValidationException::withMessages(['package' => 'Not a free package.']); + } + + DB::transaction(function () use ($request, $package, $tenant) { + PackagePurchase::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => 'free_wizard', + 'price' => $package->price, + 'type' => 'endcustomer_event', + 'purchased_at' => now(), + 'metadata' => json_encode(['note' => 'Free via wizard']), + ]); + + TenantPackage::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'price' => $package->price, + 'purchased_at' => now(), + 'active' => true, + ]); + }); + + return response()->json([ + 'message' => 'Free package assigned successfully.', + ], 201); + } + private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse { DB::transaction(function () use ($request, $package, $tenant) { diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 825fc0c..b471f67 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -123,6 +123,22 @@ 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']); + $stripePublishableKey = config('services.stripe.key'); + $privacyHtml = view('legal.datenschutz-partial', ['locale' => app()->getLocale()])->render(); + + return Inertia::render('marketing/PurchaseWizard', [ + 'package' => $package, + 'stripePublishableKey' => $stripePublishableKey, + 'paypalClientId' => config('services.paypal.client_id'), + 'privacyHtml' => $privacyHtml, + ]); + } /** * Checkout for Stripe with auth metadata. */ diff --git a/app/Http/Controllers/PurchaseWizardController.php b/app/Http/Controllers/PurchaseWizardController.php new file mode 100644 index 0000000..a5e0e38 --- /dev/null +++ b/app/Http/Controllers/PurchaseWizardController.php @@ -0,0 +1,465 @@ +validate([ + 'login' => ['required', 'string'], + 'password' => ['required', 'string'], + 'remember' => ['nullable', 'boolean'], + ]); + + $credentials = ['password' => $data['password']]; + + if (filter_var($data['login'], FILTER_VALIDATE_EMAIL)) { + $credentials['email'] = $data['login']; + } else { + $credentials['username'] = $data['login']; + } + + if (! Auth::attempt($credentials, (bool) ($data['remember'] ?? false))) { + throw ValidationException::withMessages([ + 'login' => __('auth.failed'), + ]); + } + + $request->session()->regenerate(); + + $user = $request->user(); + + return response()->json([ + 'status' => 'authenticated', + 'user' => $this->transformUser($user), + 'next_step' => 'payment', + 'needs_verification' => $user?->email_verified_at === null, + ]); +} + +public function register(Request $request): JsonResponse +{ + $data = $request->validate([ + 'username' => ['required', 'string', 'max:255', 'unique:users,username'], + 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'confirmed', \Illuminate\Validation\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', 'exists:packages,id'], + ]); + + $shouldAutoVerify = app()->environment(['local', 'testing']); + $package = $data['package_id'] ? Package::find($data['package_id']) : null; + + DB::beginTransaction(); + + try { + $user = User::create([ + 'username' => $data['username'], + 'email' => $data['email'], + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'address' => $data['address'], + 'phone' => $data['phone'], + 'password' => Hash::make($data['password']), + 'role' => 'user', + 'pending_purchase' => $package && (($package->price ?? 0) > 0), + ]); + + $tenant = Tenant::create([ + 'user_id' => $user->id, + 'name' => trim($data['first_name'].' '.$data['last_name']), + 'slug' => Str::slug($data['first_name'].' '.$data['last_name'].'-'.now()->timestamp), + 'email' => $data['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' => $data['email'], + 'event_default_type' => 'general', + ]), + ]); + + if ($shouldAutoVerify) { + $user->forceFill(['email_verified_at' => now()])->save(); + } + + $assignedPackage = null; + + if ($package && (float) $package->price <= 0.0) { + $assignedPackage = $package; + + TenantPackage::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + ], + [ + 'price' => 0, + 'active' => true, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + ] + ); + + PackagePurchase::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => 'free', + 'price' => 0, + 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', + 'purchased_at' => now(), + 'refunded' => false, + ]); + + $tenant->update(['subscription_status' => 'active']); + $user->forceFill(['pending_purchase' => false, 'role' => 'tenant_admin'])->save(); + } + + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + event(new Registered($user)); + + Auth::login($user); + $request->session()->regenerate(); + + Mail::to($user)->queue(new \App\Mail\Welcome($user)); + + $nextStep = 'payment'; + + if ($assignedPackage) { + $nextStep = 'success'; + } + + return response()->json([ + 'status' => 'registered', + 'user' => $this->transformUser($user), + 'next_step' => $nextStep, + 'needs_verification' => $user->email_verified_at === null, + 'package' => $package ? [ + 'id' => $package->id, + 'name' => $package->name, + 'price' => $package->price, + 'type' => $package->type, + ] : null, + ]); +} + + +public function createStripeIntent(Request $request): JsonResponse +{ + $data = $request->validate([ + 'package_id' => ['required', 'exists:packages,id'], + ]); + + $user = $request->user(); + if (! $user) { + throw ValidationException::withMessages(['auth' => __('auth.login')]); + } + + $tenant = $user->tenant; + if (! $tenant) { + throw ValidationException::withMessages(['tenant' => 'Tenant not found']); + } + + $package = Package::findOrFail($data['package_id']); + if ($package->price <= 0) { + throw ValidationException::withMessages(['package_id' => 'Stripe payment is not required for this package.']); + } + + Stripe::setApiKey(config('services.stripe.secret')); + + $intent = PaymentIntent::create([ + 'amount' => (int) round($package->price * 100), + 'currency' => 'eur', + 'metadata' => [ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'package_type' => $package->type, + ], + 'automatic_payment_methods' => ['enabled' => true], + ]); + + return response()->json([ + 'client_secret' => $intent->client_secret, + 'payment_intent_id' => $intent->id, + ]); +} + +public function completeStripe(Request $request): JsonResponse +{ + $data = $request->validate([ + 'package_id' => ['required', 'exists:packages,id'], + 'payment_intent_id' => ['required', 'string'], + ]); + + $user = $request->user(); + if (! $user) { + throw ValidationException::withMessages(['auth' => __('auth.login')]); + } + + $package = Package::findOrFail($data['package_id']); + $tenant = $this->resolveTenant($user->id); + + Stripe::setApiKey(config('services.stripe.secret')); + $intent = PaymentIntent::retrieve($data['payment_intent_id']); + + if ($intent->status !== 'succeeded') { + throw ValidationException::withMessages(['payment' => 'The payment is not completed.']); + } + + $this->finalizePurchase($tenant, $package, 'stripe', [ + 'payment_intent' => $intent->id, + ]); + + return response()->json(['status' => 'completed']); +} + +public function createPaypalOrder(Request $request): JsonResponse +{ + $data = $request->validate([ + 'package_id' => ['required', 'exists:packages,id'], + ]); + + $user = $request->user(); + if (! $user) { + throw ValidationException::withMessages(['auth' => __('auth.login')]); + } + + $tenant = $this->resolveTenant($user->id); + $package = Package::findOrFail($data['package_id']); + if ($package->price <= 0) { + throw ValidationException::withMessages(['package_id' => 'PayPal payment is not required for this package.']); + } + + $client = $this->makePaypalClient(); + $orders = $client->orders(); + + $createRequest = new OrdersCreateRequest(); + $createRequest->prefer('return=representation'); + $createRequest->body = [ + 'intent' => 'CAPTURE', + 'purchase_units' => [[ + 'amount' => [ + 'currency_code' => 'EUR', + 'value' => number_format($package->price, 2, '.', ''), + ], + 'description' => 'Package: '.$package->name, + 'custom_id' => json_encode([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'package_type' => $package->type, + ]), + ]], + ]; + + try { + $response = $orders->createOrder($createRequest); + $order = $response->result; + + return response()->json([ + 'order_id' => $order->id, + 'status' => $order->status ?? 'CREATED', + ]); + } catch (HttpException $exception) { + Log::error('PayPal order creation failed', [ + 'message' => $exception->getMessage(), + 'status_code' => $exception->statusCode ?? null, + ]); + + return response()->json(['error' => 'Unable to create PayPal order.'], 422); + } +} + +public function capturePaypalOrder(Request $request): JsonResponse +{ + $data = $request->validate([ + 'order_id' => ['required', 'string'], + 'package_id' => ['required', 'exists:packages,id'], + ]); + + $user = $request->user(); + if (! $user) { + throw ValidationException::withMessages(['auth' => __('auth.login')]); + } + + $package = Package::findOrFail($data['package_id']); + $tenant = $this->resolveTenant($user->id); + + $client = $this->makePaypalClient(); + $orders = $client->orders(); + + $captureRequest = new OrdersCaptureRequest($data['order_id']); + $captureRequest->prefer('return=representation'); + + try { + $response = $orders->captureOrder($captureRequest); + $capture = $response->result; + + if (($capture->status ?? null) !== 'COMPLETED') { + return response()->json(['error' => 'Capture incomplete.'], 422); + } + + $customId = $capture->purchaseUnits[0]->customId ?? null; + if ($customId) { + $metadata = json_decode($customId, true); + + if (($metadata['package_id'] ?? null) !== $package->id || ($metadata['tenant_id'] ?? null) !== $tenant->id) { + return response()->json(['error' => 'Order metadata mismatch.'], 422); + } + } + + $this->finalizePurchase($tenant, $package, 'paypal', [ + 'order_id' => $data['order_id'], + 'capture_status' => $capture->status ?? null, + ]); + + return response()->json([ + 'status' => 'captured', + ]); + } catch (HttpException $exception) { + Log::error('PayPal capture failed', [ + 'message' => $exception->getMessage(), + 'status_code' => $exception->statusCode ?? null, + ]); + + return response()->json(['error' => 'Unable to capture PayPal order.'], 422); + } +} + +public function assignFreePackage(Request $request): JsonResponse +{ + $data = $request->validate([ + 'package_id' => ['required', 'exists:packages,id'], + ]); + + $user = $request->user(); + if (! $user) { + throw ValidationException::withMessages(['auth' => __('auth.login')]); + } + + $package = Package::findOrFail($data['package_id']); + if ($package->price > 0) { + throw ValidationException::withMessages(['package_id' => 'Package is not free.']); + } + + $tenant = $this->resolveTenant($user->id); + $this->finalizePurchase($tenant, $package, 'free_wizard'); + + return response()->json(['status' => 'assigned']); +} + +private function resolveTenant(int $userId): Tenant +{ + $tenant = Tenant::where('user_id', $userId)->first(); + + if (! $tenant) { + throw ValidationException::withMessages(['tenant' => 'Tenant not found']); + } + + return $tenant; +} + +private function finalizePurchase(Tenant $tenant, Package $package, string $providerId, array $metadata = []): void +{ + TenantPackage::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + ], + [ + 'price' => $package->price, + 'active' => true, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + ] + ); + + PackagePurchase::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => $providerId, + 'price' => $package->price, + 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', + 'purchased_at' => now(), + 'metadata' => $metadata ? json_encode($metadata) : null, + 'refunded' => false, + ]); +} + +private function makePaypalClient(): Client +{ + return Client::create([ + 'clientId' => config('services.paypal.client_id'), + 'clientSecret' => config('services.paypal.secret'), + 'environment' => config('services.paypal.sandbox', true) ? 'sandbox' : 'live', + ]); +} + +private function transformUser(?User $user): array +{ + if (! $user) { + return []; + } + + return [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: $user->username, + 'pending_purchase' => (bool) $user->pending_purchase, + 'email_verified' => (bool) $user->email_verified_at, + ]; +} +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index afee9b9..ae11b25 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -66,5 +66,6 @@ class Kernel extends HttpKernel 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 'locale' => \App\Http\Middleware\SetLocale::class, + 'stripe.csp' => \App\Http\Middleware\StripeCSP::class, ]; -} \ No newline at end of file +} diff --git a/app/Http/Middleware/StripeCSP.php b/app/Http/Middleware/StripeCSP.php new file mode 100644 index 0000000..adb6130 --- /dev/null +++ b/app/Http/Middleware/StripeCSP.php @@ -0,0 +1,160 @@ +environment('local'); + + $scriptSrc = [ + "'self'", + "'unsafe-inline'", + 'https://js.stripe.com', + 'https://js.stripe.network', + 'https://m.stripe.network', + 'https://*.stripe.com', + 'https://*.stripe.network', + 'https://www.paypal.com', + 'https://*.paypal.com', + 'https://www.paypalobjects.com', + 'https://*.paypalobjects.com', + ]; + + $styleSrc = [ + "'self'", + "'unsafe-inline'", + 'data:', + 'https:', + 'https://*.stripe.com', + 'https://*.stripe.network', + 'https://www.paypal.com', + 'https://*.paypal.com', + 'https://www.paypalobjects.com', + 'https://*.paypalobjects.com', + ]; + + $imgSrc = [ + "'self'", + 'data:', + 'https:', + 'blob:', + 'https://*.stripe.com', + 'https://*.stripe.network', + 'https://q.stripe.com', + 'https://r.stripe.com', + 'https://www.paypal.com', + 'https://*.paypal.com', + 'https://www.paypalobjects.com', + 'https://*.paypalobjects.com', + ]; + + $fontSrc = [ + "'self'", + 'data:', + 'https:', + 'https://*.stripe.com', + 'https://*.stripe.network', + 'https://www.paypalobjects.com', + 'https://*.paypalobjects.com', + ]; + + $connectSrc = [ + "'self'", + 'https://api.stripe.com', + 'https://api.stripe.network', + 'https://js.stripe.com', + 'https://m.stripe.com', + 'https://m.stripe.network', + 'https://connect.stripe.com', + 'https://*.stripe.com', + 'https://*.stripe.network', + 'https://r.stripe.com', + 'https://q.stripe.com', + 'https://www.paypal.com', + 'https://*.paypal.com', + 'https://www.paypalobjects.com', + 'https://*.paypalobjects.com', + 'wss://*.stripe.network', + ]; + + $mediaSrc = [ + "'self'", + 'data:', + 'blob:', + 'https:', + 'https://js.stripe.com', + 'https://*.stripe.com', + 'https://*.stripe.network', + 'https://m.stripe.network', + 'https://www.paypal.com', + 'https://*.paypal.com', + 'https://www.paypalobjects.com', + 'https://*.paypalobjects.com', + ]; + + $frameSrc = [ + "'self'", + 'https://js.stripe.com', + 'https://*.stripe.com', + 'https://hooks.stripe.com', + 'https://www.paypal.com', + 'https://*.paypal.com', + ]; + + $workerSrc = [ + "'self'", + 'blob:', + 'https://js.stripe.com', + 'https://*.stripe.com', + 'https://*.stripe.network', + 'https://m.stripe.network', + 'https://www.paypal.com', + 'https://*.paypal.com', + ]; + + if ($isLocal) { + $devHost = 'http://localhost:5173'; + + $scriptSrc[] = $devHost; + $styleSrc[] = $devHost; + $imgSrc[] = $devHost; + $fontSrc[] = $devHost; + $connectSrc[] = $devHost; + $connectSrc[] = 'ws://localhost:5173'; + $mediaSrc[] = $devHost; + $frameSrc[] = $devHost; + $workerSrc[] = $devHost; + } + + $directives = [ + "default-src 'self'", + 'script-src ' . implode(' ', $scriptSrc), + 'style-src ' . implode(' ', $styleSrc), + 'img-src ' . implode(' ', $imgSrc), + 'font-src ' . implode(' ', $fontSrc), + 'connect-src ' . implode(' ', $connectSrc), + 'media-src ' . implode(' ', $mediaSrc), + 'frame-src ' . implode(' ', $frameSrc), + 'worker-src ' . implode(' ', $workerSrc), + 'child-src ' . implode(' ', $frameSrc), + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ]; + + $response->headers->set('Content-Security-Policy', implode('; ', $directives) . ';'); + + return $response; + } +} diff --git a/docs/prp/03-api.md b/docs/prp/03-api.md index cb7bd63..a6c83a3 100644 --- a/docs/prp/03-api.md +++ b/docs/prp/03-api.md @@ -28,3 +28,58 @@ Guest Polling (no WebSockets in v1) Webhooks - Payment provider events, media pipeline status, and deletion callbacks. All signed with shared secret per provider. + +## Purchase Wizard Endpoints (Marketing Flow) + +These endpoints support the frontend purchase wizard for package selection, authentication, and payment. They are web routes under `/purchase/` (not `/api/v1`), designed for Inertia.js integration with JSON responses for AJAX/fetch calls. No tenant middleware for auth steps (pre-tenant creation); auth required for payment. + +### Flow Overview +1. **Package Selection**: User selects package via marketing page; redirects to wizard with package ID. +2. **Auth (Login/Register)**: Handle user creation/login; creates tenant if registering. Returns user data and next_step ('payment' or 'success' for free packages). +3. **Payment**: Create intent/order, complete via provider callback, finalize purchase (assign package, update tenant). +4. **Success**: Redirect to success page; email welcome if new user. + +Error Handling: +- 422 Validation: `{ errors: { field: ['message'] }, message: 'Summary' }` – display in forms without reload. +- 401/403: `{ error: 'Auth required' }` – show login prompt. +- 500/Other: `{ error: 'Server error' }` – generic alert, log trace_id. +- Non-JSON (e.g., 404): Frontend catches "unexpected end of data" and shows "Endpoint not found" or retry. + +All responses: JSON only for AJAX; CSRF-protected. + +### Endpoints + +- **POST /purchase/auth/login** + - Body: `{ login: string (email/username), password: string, remember?: boolean }` + - Response (200): `{ status: 'authenticated', user: { id, email, name, pending_purchase, email_verified }, next_step: 'payment', needs_verification: boolean }` + - Errors: 422 `{ errors: { login: ['Invalid credentials'] } }` + +- **POST /purchase/auth/register** + - Body: `{ username, email, password, password_confirmation, first_name, last_name, address, phone, privacy_consent: boolean, package_id?: number }` + - Response (200): `{ status: 'registered', user: { ... }, next_step: 'payment'|'success', needs_verification: boolean, package?: { id, name, price, type } }` + - Errors: 422 `{ errors: { email: ['Taken'], password: ['Too weak'] } }`; creates tenant/user on success. + +- **POST /purchase/stripe/intent** (auth required) + - Body: `{ package_id: number }` + - Response (200): `{ client_secret: string, payment_intent_id: string }` + - Errors: 422 `{ errors: { package_id: ['Invalid'] } }` + +- **POST /purchase/stripe/complete** (auth required) + - Body: `{ package_id: number, payment_intent_id: string }` + - Response (200): `{ status: 'completed' }` + - Errors: 422 `{ errors: { payment: ['Not succeeded'] } }` – finalizes purchase. + +- **POST /purchase/paypal/order** (auth required) + - Body: `{ package_id: number }` + - Response (200): `{ order_id: string, status: 'CREATED' }` + - Errors: 422 `{ error: 'Order creation failed' }` + +- **POST /purchase/paypal/capture** (auth required) + - Body: `{ order_id: string, package_id: number }` + - Response (200): `{ status: 'captured' }` + - Errors: 422 `{ error: 'Capture incomplete' }` – finalizes purchase. + +- **POST /purchase/free** (auth required) + - Body: `{ package_id: number }` + - Response (200): `{ status: 'assigned' }` + - Errors: 422 `{ errors: { package_id: ['Not free'] } }` – assigns for zero-price packages. diff --git a/package-lock.json b/package-lock.json index adc63a7..85eb9c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,8 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", + "@stripe/react-stripe-js": "^5.0.0", + "@stripe/stripe-js": "^8.0.0", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.90.2", "@types/react": "^19.0.3", @@ -3297,6 +3299,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@stripe/react-stripe-js": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.0.0.tgz", + "integrity": "sha512-SUv97BPNxV4VxTRj+QbkHsZMGVMREBTuO38wuSIPCXyKRSsy/IzzqKEkxRUympLD9TXRHIJwZNhCzhOdx0mVTw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=8.0.0 <9.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.0.0.tgz", + "integrity": "sha512-dLvD55KT1cBmrqzgYRgY42qNcw6zW4HS5oRZs0xRvHw9gBWig5yDnWNop/E+/t2JK+OZO30zsnupVBN2MqW2mg==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/helpers": { "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", @@ -8000,7 +8025,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -8387,7 +8411,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -9086,7 +9109,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -9246,8 +9268,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-refresh": { "version": "0.17.0", diff --git a/package.json b/package.json index c32f3a6..ae67f02 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", + "@stripe/react-stripe-js": "^5.0.0", + "@stripe/stripe-js": "^8.0.0", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.90.2", "@types/react": "^19.0.3", diff --git a/resources/js/components/ui/Steps.tsx b/resources/js/components/ui/Steps.tsx new file mode 100644 index 0000000..3c14ed8 --- /dev/null +++ b/resources/js/components/ui/Steps.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +interface Step { + id: string + title: string + description: string +} + +interface StepsProps { + steps: Step[] + currentStep: number + className?: string +} + +const Steps = React.forwardRef( + ({ steps, currentStep, className }, ref) => { + return ( +
+ {steps.map((step, index) => ( +
+
+ {index + 1} +
+
+

+ {step.title} +

+

{step.description}

+
+ {index < steps.length - 1 && ( +
+
+
+ )} +
+ ))} +
+ ) + } +) +Steps.displayName = "Steps" + +export { Steps } \ No newline at end of file diff --git a/resources/js/pages/auth/LoginForm.tsx b/resources/js/pages/auth/LoginForm.tsx new file mode 100644 index 0000000..10b3e32 --- /dev/null +++ b/resources/js/pages/auth/LoginForm.tsx @@ -0,0 +1,203 @@ + +import React, { useEffect, useMemo, useState } from 'react'; +import { useForm } from '@inertiajs/react'; +import { useTranslation } from 'react-i18next'; +import { LoaderCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import InputError from '@/components/input-error'; +import TextLink from '@/components/text-link'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +interface LoginFormProps { + onSuccess?: (payload: any) => void; + canResetPassword?: boolean; +} + +const getCsrfToken = () => + (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? ''; + +const parseJson = async (response: Response) => { + if (response.headers.get('Content-Type')?.includes('application/json')) { + const json = await response.json().catch(() => null); + if (json) return json; + } + + const text = await response.text(); + throw new Error(text || 'Invalid server response (unexpected end of data or non-JSON).'); +}; + +export default function LoginForm({ onSuccess, canResetPassword = true }: LoginFormProps) { + const { t } = useTranslation('auth'); + const csrfToken = useMemo(getCsrfToken, []); + + const { data, setData, errors, setError, clearErrors, reset } = useForm({ + login: '', + password: '', + remember: false, + }); + + const [hasTriedSubmit, setHasTriedSubmit] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setHasTriedSubmit(true); + setSubmitting(true); + setFormError(null); + clearErrors(); + + try { + const response = await fetch('/purchase/auth/login', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + login: data.login, + password: data.password, + remember: data.remember, + }), + }); + + if (response.ok) { + const payload = await parseJson(response); + reset({ login: payload?.user?.email ?? data.login, password: '', remember: false }); + setHasTriedSubmit(false); + if (onSuccess) { + onSuccess(payload); + } + return; + } + + if (response.status === 422) { + const body = await parseJson(response); + const validationErrors = body.errors ?? {}; + let fallbackMessage: string | null = body.message ?? null; + + Object.entries(validationErrors as Record).forEach(([key, value]) => { + const message = Array.isArray(value) ? value[0] : value; + if (typeof message === 'string') { + setError(key as keyof typeof data, message); + if (!fallbackMessage) { + fallbackMessage = message; + } + } + }); + + if (fallbackMessage) { + setFormError(fallbackMessage); + } + return; + } + + setFormError(t('login.generic_error', { defaultValue: 'Login failed. Please try again.' })); + } catch (error) { + setFormError(t('login.generic_error', { defaultValue: 'Login failed. Please try again.' })); + } finally { + setSubmitting(false); + } + }; + + useEffect(() => { + if (!hasTriedSubmit) { + return; + } + + const errorKeys = Object.keys(errors); + if (errorKeys.length === 0) { + return; + } + + const field = document.querySelector(`[name="${errorKeys[0]}"]`); + + if (field) { + field.scrollIntoView({ behavior: 'smooth', block: 'center' }); + field.focus(); + } + }, [errors, hasTriedSubmit]); + + return ( +
+
+
+ + { + setData('login', event.target.value); + if (errors.login) { + clearErrors('login'); + } + }} + /> + +
+ +
+
+ + {canResetPassword && ( + + {t('login.forgot')} + + )} +
+ { + setData('password', event.target.value); + if (errors.password) { + clearErrors('password'); + } + }} + /> + +
+ +
+ setData('remember', Boolean(checked))} + /> + +
+ + +
+ + {(formError || Object.keys(errors).length > 0) && ( + + + {formError || Object.values(errors).join(' ')} + + + )} +
+ ); +} diff --git a/resources/js/pages/auth/RegisterForm.tsx b/resources/js/pages/auth/RegisterForm.tsx new file mode 100644 index 0000000..be72fac --- /dev/null +++ b/resources/js/pages/auth/RegisterForm.tsx @@ -0,0 +1,412 @@ + +import React, { useEffect, useMemo, useState } from 'react'; +import { useForm } from '@inertiajs/react'; +import { useTranslation } from 'react-i18next'; +import { LoaderCircle, User, Mail, Phone, Lock, MapPin } from 'lucide-react'; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'; +import { Alert, AlertDescription } from '@/components/ui/alert'; + +interface RegisterFormProps { + packageId?: number; + onSuccess?: (payload: any) => void; + privacyHtml: string; +} + +const getCsrfToken = () => + (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? ''; + +export default function RegisterForm({ packageId, onSuccess, privacyHtml }: RegisterFormProps) { + const { t } = useTranslation(['auth', 'common']); + const csrfToken = useMemo(getCsrfToken, []); + + const { data, setData, errors, setError, clearErrors, reset } = useForm({ + username: '', + email: '', + password: '', + password_confirmation: '', + first_name: '', + last_name: '', + address: '', + phone: '', + privacy_consent: false, + package_id: packageId ?? null, + }); + + const [privacyOpen, setPrivacyOpen] = useState(false); + const [hasTriedSubmit, setHasTriedSubmit] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [formError, setFormError] = useState(null); + + useEffect(() => { + setData('package_id', packageId ?? null); + }, [packageId]); + + useEffect(() => { + if (!hasTriedSubmit) { + return; + } + + const errorKeys = Object.keys(errors); + if (errorKeys.length === 0) { + return; + } + + const firstError = errorKeys[0]; + const field = document.querySelector(`[name="${firstError}"]`); + + if (field) { + field.scrollIntoView({ behavior: 'smooth', block: 'center' }); + field.focus(); + } + }, [errors, hasTriedSubmit]); + + const parseJson = async (response: Response) => { + if (response.headers.get('Content-Type')?.includes('application/json')) { + const json = await response.json().catch(() => null); + if (json) return json; + } + + const text = await response.text(); + throw new Error(text || 'Invalid server response (unexpected end of data or non-JSON).'); + }; + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setHasTriedSubmit(true); + setSubmitting(true); + setFormError(null); + clearErrors(); + + try { + const response = await fetch('/purchase/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify({ + ...data, + privacy_consent: Boolean(data.privacy_consent), + }), + }); + + if (response.ok) { + const payload = await parseJson(response); + reset({ + username: '', + email: '', + password: '', + password_confirmation: '', + first_name: '', + last_name: '', + address: '', + phone: '', + privacy_consent: false, + package_id: packageId ?? null, + }); + setHasTriedSubmit(false); + if (onSuccess) { + onSuccess(payload); + } + return; + } + + if (response.status === 422) { + const body = await parseJson(response); + const validationErrors = body.errors ?? {}; + let fallbackMessage: string | null = body.message ?? null; + + Object.entries(validationErrors).forEach(([key, value]) => { + const message = Array.isArray(value) ? value[0] : value; + if (typeof message === 'string') { + setError(key, message); + if (!fallbackMessage) { + fallbackMessage = message; + } + } + }); + + if (fallbackMessage) { + setFormError(fallbackMessage); + } + return; + } + + setFormError(t('register.generic_error', { defaultValue: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' })); + } catch (error) { + const message = (error as Error).message || t('register.generic_error', { defaultValue: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' }); + setFormError(message); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+ +
+ + { + setData('first_name', event.target.value); + if (errors.first_name) { + clearErrors('first_name'); + } + }} + className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.first_name ? 'border-red-500' : 'border-gray-300'}`} + placeholder={t('register.first_name_placeholder')} + /> +
+ {errors.first_name &&

{errors.first_name}

} +
+ +
+ +
+ + { + setData('last_name', event.target.value); + if (errors.last_name) { + clearErrors('last_name'); + } + }} + className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.last_name ? 'border-red-500' : 'border-gray-300'}`} + placeholder={t('register.last_name_placeholder')} + /> +
+ {errors.last_name &&

{errors.last_name}

} +
+ +
+ +
+ + { + setData('email', event.target.value); + if (errors.email) { + clearErrors('email'); + } + }} + className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.email ? 'border-red-500' : 'border-gray-300'}`} + placeholder={t('register.email_placeholder')} + /> +
+ {errors.email &&

{errors.email}

} +
+ +
+ +
+ + { + setData('username', event.target.value); + if (errors.username) { + clearErrors('username'); + } + }} + className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.username ? 'border-red-500' : 'border-gray-300'}`} + placeholder={t('register.username_placeholder')} + /> +
+ {errors.username &&

{errors.username}

} +
+ +
+ +
+ + { + setData('phone', event.target.value); + if (errors.phone) { + clearErrors('phone'); + } + }} + className={`block w-full pl-10 pr-3 py-3 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[#FFB6C1] focus:border-[#FFB6C1] sm:text-sm ${errors.phone ? 'border-red-500' : 'border-gray-300'}`} + placeholder={t('register.phone_placeholder')} + /> +
+ {errors.phone &&

{errors.phone}

} +
+ +
+ +
+ +