diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php index fa4f465..b9d9c35 100644 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ b/app/Http/Controllers/Checkout/CheckoutController.php @@ -4,18 +4,26 @@ use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; use Illuminate\View\View; +use Lunar\Exceptions\Carts\CartException; +use Lunar\Exceptions\FingerprintMismatchException; +use Lunar\Facades\CartSession; use Lunar\Facades\ShippingManifest; use Lunar\Models\Cart; use Lunar\Models\Country; +use Lunar\Models\Order; use Lunar\Models\State; use Modules\Core\Cart\Services\CartService; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; +use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException; +use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; use Modules\Core\Checkout\Services\CheckoutService; +use Modules\Core\Payment\Enums\PaymentResultStatus; /** * The checkout page — one page, sections (contact / billing / shipping / @@ -65,6 +73,7 @@ public function show(string $locale): View 'billingAddress' => $cart?->billingAddress, 'shippingAddress' => $cart?->shippingAddress, 'shippingOptions' => $shippingOptions, + 'paymentMethods' => $this->checkout->getPaymentMethods(), 'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true), 'storeCountry' => $storeCountry, 'countries' => $storeCountry @@ -213,6 +222,142 @@ public function selectShippingOption(string $locale, Request $request): JsonResp return $this->fragments($cart, $options); } + /** + * Autosave-select a payment method (radio change). Persists it via + * CheckoutService (which also records it on Cart::meta and re-snapshots + * the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary. + */ + public function selectPaymentMethod(string $locale, Request $request): JsonResponse + { + $type = (string) $request->input('payment_type'); + + try { + $this->checkout->selectPaymentMethod($type); + } catch (UnknownPaymentTypeException) { + // Radio value out of sync with what's offered — ignore, the summary + // just won't reflect a method fee. place-order re-checks properly. + } + + return response()->json([ + 'summaryHtml' => view('checkout::partials.cart-body')->render(), + ]); + } + + /** + * The real submit — the hard gate. Re-selects the payment method (fresh + * fingerprint), then hands off to CheckoutService::initiatePayment(), which + * creates the draft order, records terms acceptance, and charges the driver. + * Returns JSON the bbk-payment controller routes on: + * { redirect } — placed, go to confirmation + * { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls + * { status: 'failed', message } — declined + * { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection + */ + public function placeOrder(string $locale, Request $request): JsonResponse + { + if (! $request->boolean('terms_accepted')) { + return response()->json(['error' => __('checkout.page.terms_required')], 422); + } + + try { + $this->checkout->selectPaymentMethod((string) $request->input('payment_type')); + } catch (UnknownPaymentTypeException) { + return response()->json(['error' => __('checkout.page.choose_payment_method')], 422); + } + + $fingerprint = (string) ($this->cart->current()?->meta['checkout_fingerprint'] ?? ''); + + $data = $request->filled('payment_method') + ? ['payment_method' => (string) $request->input('payment_method')] + : []; + + try { + $result = $this->checkout->initiatePayment( + $fingerprint, + termsAccepted: true, + policyVersion: (string) config('legal.terms_version'), + data: $data, + ); + } catch (FingerprintMismatchException) { + return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409); + } catch (CartException $e) { + return response()->json([ + 'status' => 'invalid', + 'message' => __('checkout.page.payment_incomplete_details'), + 'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(), + ], 422); + } catch (TermsNotAcceptedException) { + return response()->json(['error' => __('checkout.page.terms_required')], 422); + } + + return match ($result->status) { + PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale), + PaymentResultStatus::Pending => response()->json([ + 'status' => 'pending', + 'clientSecret' => $result->continuation?->value, + ]), + PaymentResultStatus::Failed => response()->json([ + 'status' => 'failed', + 'message' => $result->failureReason ?: __('checkout.page.payment_failed'), + 'retriable' => $result->retriable, + ], 422), + }; + } + + /** + * Poll target for the 3-D Secure path: has the webhook placed the order yet? + * boboko-core's StripeWebhookController -> handleCallback -> PaymentCaptured + * -> ApplyResolvedPaymentStatus sets placed_at. + */ + public function orderStatus(string $locale): JsonResponse + { + $order = $this->placedOrder(); + + if (! $order) { + return response()->json(['placed' => false]); + } + + session(['checkout.order_id' => $order->id]); + CartSession::forget(); + + return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]); + } + + public function confirmation(string $locale): View|RedirectResponse + { + $orderId = session('checkout.order_id'); + + $order = $orderId + ? Order::with(['lines', 'shippingAddress', 'billingAddress'])->find($orderId) + : null; + + if (! $order) { + return redirect()->route('products', $locale); + } + + return view('checkout::confirmation', ['order' => $order]); + } + + private function orderPlacedResponse(string $locale): JsonResponse + { + if ($order = $this->placedOrder()) { + session(['checkout.order_id' => $order->id]); + } + + CartSession::forget(); + + return response()->json(['redirect' => route('checkout.confirmation', $locale)]); + } + + private function placedOrder(): ?Order + { + return $this->cart->current() + ?->orders() + ->whereNotNull('placed_at') + ->latest('placed_at') + ->first(); + } + /** * Re-resolve shipping options for the cart's current address and keep the * selection sane: auto-select when exactly one resolves, and drop a diff --git a/config/services.php b/config/services.php index 5307b21..7c13ec1 100644 --- a/config/services.php +++ b/config/services.php @@ -40,4 +40,17 @@ 'host' => env('STOIC_HOST'), ], + // Keys read by lunarphp/stripe + Modules\Core\Payment\Drivers\StripePaymentDriver. + // `key` is the SECRET key (this ecosystem's convention — StripeManager calls + // Stripe::setApiKey(config('services.stripe.key'))); `public_key` is the + // publishable key for Stripe.js; `webhooks.lunar` is the signing secret the + // webhook route verifies against. + 'stripe' => [ + 'key' => env('STRIPE_SECRET'), + 'public_key' => env('STRIPE_PUBLIC_KEY'), + 'webhooks' => [ + 'lunar' => env('STRIPE_WEBHOOK_SECRET'), + ], + ], + ]; diff --git a/database/seeders/CheckoutTranslationsSeeder.php b/database/seeders/CheckoutTranslationsSeeder.php index 817337d..f650db6 100644 --- a/database/seeders/CheckoutTranslationsSeeder.php +++ b/database/seeders/CheckoutTranslationsSeeder.php @@ -124,6 +124,56 @@ private function lines(): array ], 'page.continue_to_payment' => ['Continue to payment', 'Συνέχεια στην πληρωμή'], 'page.order_summary_heading' => ['Order summary', 'Σύνοψη παραγγελίας'], + + // ── Payment step ──────────────────────────────────────────── + 'page.payment_heading' => ['Payment', 'Πληρωμή'], + 'page.payment_method_none' => [ + 'No payment methods are available right now.', + 'Δεν υπάρχουν διαθέσιμοι τρόποι πληρωμής αυτή τη στιγμή.', + ], + 'page.terms_accept' => [ + "I accept the Terms of Sale and the Privacy Policy", + "Αποδέχομαι τους Όρους Πώλησης και την Πολιτική Απορρήτου", + ], + 'page.terms_required' => [ + 'You must accept the terms to place your order.', + 'Πρέπει να αποδεχτείς τους όρους για να ολοκληρώσεις την παραγγελία.', + ], + 'page.withdrawal_notice' => [ + "You have a 14-day right of withdrawal. See details.", + "Έχεις δικαίωμα υπαναχώρησης εντός 14 ημερών. Δες λεπτομέρειες.", + ], + 'page.place_order' => ['Place order — payment obligation', 'Παραγγελία με υποχρέωση πληρωμής'], + 'page.choose_payment_method' => ['Choose a payment method.', 'Επίλεξε τρόπο πληρωμής.'], + 'page.payment_failed' => ['Payment failed. Please try again.', 'Η πληρωμή απέτυχε. Δοκίμασε ξανά.'], + 'page.payment_incomplete_details' => [ + 'Complete your billing and shipping details above.', + 'Συμπλήρωσε τα στοιχεία χρέωσης και αποστολής παραπάνω.', + ], + 'page.payment_cart_changed' => [ + 'Your cart changed. Refresh the page and place your order again.', + 'Το καλάθι σου άλλαξε. Ανανέωσε τη σελίδα και ολοκλήρωσε ξανά.', + ], + 'page.payment_processing' => ['Confirming your payment…', 'Επιβεβαίωση πληρωμής…'], + 'page.payment_processing_slow' => [ + "Your payment is still processing. You'll get an email once it's confirmed.", + 'Η πληρωμή σου επεξεργάζεται ακόμη. Θα λάβεις email μόλις επιβεβαιωθεί.', + ], + + // ── Confirmation page ────────────────────────────────────── + 'page.confirmation_title' => ['Your order', 'Η παραγγελία σου'], + 'page.confirmation_heading' => [ + 'Thank you! Your order is confirmed.', + 'Ευχαριστούμε! Η παραγγελία σου καταχωρήθηκε.', + ], + 'page.confirmation_order_number' => ['Order number', 'Αριθμός παραγγελίας'], + 'page.confirmation_email_note' => [ + 'A confirmation email will follow shortly.', + 'Θα λάβεις email επιβεβαίωσης σύντομα.', + ], + 'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'], + 'page.confirmation_billing' => ['Billing', 'Χρέωση'], + 'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'], ]; } } diff --git a/resources/css/checkout.css b/resources/css/checkout.css index ad52daa..56b9437 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -661,3 +661,140 @@ .bbk-checkout-summary-heading { /* Already on the checkout page — the drawer's own "go to checkout" CTA has nowhere further to send you from here. */ .bbk-checkout-summary .bbk-cart-checkout { display: none; } + +/* ── Payment ───────────────────────────────────────────────────────── */ + +.bbk-checkout-payment-options { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.bbk-checkout-payment-option { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + border: 1px solid var(--bbk-color-border); + border-radius: var(--bbk-radius-sm); + cursor: pointer; + transition: border-color 0.15s ease; +} + +.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); } + +.bbk-checkout-payment-option-name { font-weight: 600; } + +.bbk-payment-element { margin: 0.25rem 0; } + +.bbk-checkout-withdrawal { + margin: 0; + font-size: 0.8125rem; + color: var(--bbk-color-muted); +} + +.bbk-checkout-withdrawal a { color: inherit; } + +.bbk-checkout-error { + margin: 0; + font-size: 0.875rem; + color: var(--bbk-color-danger); +} + +/* Processing overlay — fixed, covers the page while a payment confirms. */ +.bbk-checkout-processing { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent); + text-align: center; + padding: 1.5rem; +} + +.bbk-spinner { + width: 2rem; + height: 2rem; + border: 3px solid var(--bbk-color-border); + border-top-color: var(--bbk-color-accent); + border-radius: 50%; + animation: bbk-spin 0.8s linear infinite; +} + +@keyframes bbk-spin { + to { transform: rotate(360deg); } +} + +/* ── Confirmation page ─────────────────────────────────────────────── */ + +.bbk-confirmation { + max-width: 720px; + margin: 0 auto; + padding: 3rem 1.5rem 5rem; + font-family: var(--bbk-font); + color: var(--bbk-color-text); +} + +.bbk-confirmation-heading { + margin: 0 0 1rem; + font-size: 1.75rem; + font-weight: 700; +} + +.bbk-confirmation-ref { margin: 0 0 0.25rem; } + +.bbk-confirmation-body { + margin: 2rem 0; + display: grid; + gap: 2.5rem; +} + +@media (min-width: 640px) { + .bbk-confirmation-body { grid-template-columns: 1fr 1fr; } +} + +.bbk-confirmation-lines { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.bbk-confirmation-line { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.bbk-confirmation-line-qty { color: var(--bbk-color-muted); } + +.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; } + +.bbk-confirmation-addresses { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.bbk-confirmation-address-heading { + margin: 0 0 0.5rem; + font-size: 0.9375rem; + font-weight: 700; +} + +.bbk-address-lines { + font-style: normal; + display: flex; + flex-direction: column; + gap: 0.125rem; + font-size: 0.875rem; + color: var(--bbk-color-muted); +} + +.bbk-confirmation-continue { + max-width: 280px; + text-decoration: none; +} diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js index 5ec63e7..83648d4 100644 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ b/resources/js/checkout/bbk-checkout-form-controller.js @@ -86,11 +86,14 @@ export default class extends Controller { // ── Autosave ─────────────────────────────────────────────────────── scheduleSave(event) { - // The shipping-method radios live inside this controller's element too, - // but they have their own handler — don't also autosave the address for them. - if (this.hasShippingOptionsTarget && this.shippingOptionsTarget.contains(event.target)) { - return - } + // The shipping-method and payment radios live inside this controller's + // element too, and this action is bound on .bbk-checkout-main to also + // catch the contact email/consent that sit outside the