diff --git a/app/Http/Controllers/Checkout/CartController.php b/app/Http/Controllers/Checkout/CartController.php deleted file mode 100644 index cda24f4..0000000 --- a/app/Http/Controllers/Checkout/CartController.php +++ /dev/null @@ -1,253 +0,0 @@ -validate([ - 'purchasable_id' => ['required', 'integer'], - 'quantity' => ['nullable', 'integer', 'min:1'], - 'custom_fields' => ['nullable', 'array'], - ]); - - $variant = ProductVariant::findOrFail($data['purchasable_id']); - - try { - $meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []); - } catch (ValidationException $e) { - return response()->json(['error' => collect($e->errors())->flatten()->first()], 422); - } - - try { - $this->cart->addLine($variant, $data['quantity'] ?? 1, $meta); - } catch (CartException) { - return $this->stockError($variant); - } - - return view('checkout::partials.cart-body'); - } - - /** - * The shopper's answers to the product's custom fields (boboko-core's - * Product::$custom_fields — {key, type: text|textarea|file, label, - * required}), as cart line meta. Lunar copies CartLine.meta onto the - * OrderLine at order creation, so this is also what the order keeps. - * - * Only keys the product actually defines are kept, nested under - * `custom_fields` — line meta also carries behavior flags (core's - * `saved_for_later` zeroes the line's price), so shopper input must never - * be merged into it directly. Label and type are snapshotted alongside - * each value so the cart/order still reads correctly if the product's - * fields are edited later. - * - * A `file` answer is the id of a File row the host's own upload endpoint - * (CustomFieldUploadController) already created via boboko-core's - * FileService — never the file's bytes, disk, or path, all of which - * FileService alone is the source of truth for. A shopper can't point - * this at someone else's file: the id must resolve to a File that is - * BOTH unowned (isFileAnswerValid()) and tagged with - * CustomFieldUploadController::PURPOSE, so an id belonging to an - * already-ordered file (owned, and/or a different purpose entirely) is - * rejected. Attaching the File to the real CartLine it belongs to - * happens afterward, in boboko-core's own Modules\Core\File\Listeners\ - * AttachCustomFieldFileToCartLine (listening for Cart\Events\ - * CartLineAdded) — not here, since this method only builds the meta - * $this->cart->addLine() is about to receive, before any CartLine - * actually exists to own anything. - * - * Two adds with identical answers merge into one line (Lunar matches - * existing lines on meta); different answers stay separate lines. - */ - private function customFieldsMeta(ProductVariant $variant, array $input): array - { - $fields = collect($variant->product?->custom_fields ?? []) - ->keyBy('key') - ->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]); - - if ($fields->isEmpty()) { - return []; - } - - $validated = Validator::make( - $input, - $fields->map(fn (array $field) => [ - ($field['required'] ?? false) ? 'required' : 'nullable', - ...match ($field['type']) { - 'textarea' => ['string', 'max:2000'], - 'file' => [function (string $attribute, mixed $value, Closure $fail) { - if (! $this->isFileAnswerValid($value)) { - $fail('validation.uploaded')->translate(); - } - }], - default => ['string', 'max:255'], - }, - ])->all(), - [], - $fields->map(fn (array $field) => $field['label'])->all(), - )->validate(); - - $answers = $fields - ->filter(fn (array $field) => filled($validated[$field['key']] ?? null)) - ->map(fn (array $field) => [ - 'key' => $field['key'], - 'label' => $field['label'], - 'type' => $field['type'], - ...($field['type'] === 'file' - ? ['file_id' => (int) $validated[$field['key']]] - : ['value' => $validated[$field['key']]]), - ]) - ->values() - ->all(); - - return $answers === [] ? [] : ['custom_fields' => $answers]; - } - - /** - * Product::$custom_fields stores `label` as {locale: string} (see - * boboko-core's Catalog\Filament\Pages\ManageProductCustomFields) — this - * resolves it to the single current-locale string cart/order line meta - * actually needs, the same filled()-over-?? fallback boboko-core's - * ProductDocumentLocalizer uses for every other translated field (an - * empty string for the current locale still falls through to the - * store's default language, rather than showing blank). A product - * saved before labels became translatable still has a plain string - * here, returned as-is. - */ - private function resolveLabel(mixed $label): string - { - if (! is_array($label)) { - return (string) $label; - } - - $locale = App::getLocale(); - $fallbackLocale = app(LanguageCache::class)->defaultLocale(); - - return filled($label[$locale] ?? null) - ? $label[$locale] - : ($label[$fallbackLocale] ?? ''); - } - - private function isFileAnswerValid(mixed $fileId): bool - { - $file = File::find($fileId); - - return $file !== null - && $file->purpose === CustomFieldUploadController::PURPOSE - && $file->owner_id === null - && app(FileService::class)->exists($file); - } - - public function updateLine(string $locale, Request $request, int $line): View|JsonResponse - { - $quantity = (int) $request->validate([ - 'quantity' => ['required', 'integer', 'min:0'], - ])['quantity']; - - try { - $quantity === 0 - ? $this->cart->removeLine($line) - : $this->cart->updateLine($line, $quantity); - } catch (CartException) { - $variant = CartLine::find($line)?->purchasable; - - return $this->stockError($variant instanceof ProductVariant ? $variant : null); - } - - return view('checkout::partials.cart-body'); - } - - /** - * getTotalInventory() is the same number canBeFulfilledAtQuantity() - * checked against (stock, for a tracked in_stock variant) — telling the - * shopper how many are actually left beats a generic "not enough stock" - * they'd otherwise have to guess around by trial and error. - */ - private function stockError(?ProductVariant $variant): JsonResponse - { - $available = $variant?->getTotalInventory() ?? 0; - - return response()->json([ - 'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]), - ], 422); - } - - public function remove(string $locale, int $line): View - { - $this->cart->removeLine($line); - - return view('checkout::partials.cart-body'); - } - - /** - * A bad code is a normal, expected outcome here (typo, expired code), not - * an error state for the request — it re-renders the same cart-body - * partial with $couponError set, rather than a 4xx/redirect, so the fetch - * + swap in bbk-cart-controller stays the one code path for every cart - * mutation. - */ - public function applyCoupon(string $locale, Request $request): View - { - $code = $request->validate([ - 'code' => ['required', 'string'], - ])['code']; - - $couponError = false; - - try { - $this->cart->applyCoupon($code); - } catch (InvalidCouponException) { - $couponError = true; - } - - return view('checkout::partials.cart-body', ['couponError' => $couponError]); - } - - public function removeCoupon(string $locale): View - { - $this->cart->removeCoupon(); - - return view('checkout::partials.cart-body'); - } -} diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php deleted file mode 100644 index cd3e84c..0000000 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ /dev/null @@ -1,676 +0,0 @@ -cart->current(); - $lines = $cart ? $this->cart->activeLines($cart) : collect(); - $storeCountry = $this->storeCountry(); - - $shippingOptions = collect(); - - // Captured before prefillFromAccount(), which may recreate the address - // row (dropping its shipping_option) — same reason as in saveAddress(). - $previousOption = $cart?->shippingAddress?->shipping_option; - - if ($cart && Auth::check()) { - $cart = $this->prefillFromAccount($cart); - - // Nothing chosen on this cart yet: carry over the account's standing - // opt-in (an explicit earlier choice, recorded with its own - // timestamp/policy version). Never opts anyone in by default. - if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? []) - && data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) { - $cart = $this->checkout->setRecoveryConsent(true); - } - } - - if ($cart?->shippingAddress) { - $shippingOptions = $this->syncShipping($cart, $previousOption); - - // Cart's CachesProperties::refresh() explicitly nulls total/ - // subTotal/shippingTotal/etc. back to their defaults — every - // Lunar call site pairs it with recalculate() for exactly that - // reason. Bare refresh() here was leaving $cart->total null on - // reload, which fed a 0 amount straight into the Stripe Element. - $cart->refresh()->recalculate(); - } - - $paymentMethods = $this->checkout->getPaymentMethods(); - - // Nothing checked yet (fresh cart), or the shopper's earlier pick is - // no longer offered (method disabled/removed since) — auto-select - // the first one, same as a manual click would, so the payment - // section (and the Stripe Element mounting under it) isn't sitting - // inert behind an unchecked radio. A still-valid previous choice is - // left alone. - $firstMethod = $paymentMethods->first(); - - if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) { - $cart = $this->checkout->selectPaymentMethod($firstMethod->type); - } - - return view('checkout::page', [ - 'cart' => $cart, - 'lines' => $lines, - 'billingAddress' => $cart?->billingAddress, - 'shippingAddress' => $cart?->shippingAddress, - 'shippingOptions' => $shippingOptions, - 'paymentMethods' => $paymentMethods, - 'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true), - 'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false), - 'storeCountry' => $storeCountry, - 'countries' => $storeCountry - ? collect() - : Country::orderBy('name')->get(['id', 'name']), - 'regions' => $storeCountry - ? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name']) - : collect(), - ]); - } - - public function saveAddress(string $locale, Request $request): JsonResponse - { - $storeCountry = $this->storeCountry(); - $sameAsBilling = $request->boolean('same_as_billing'); - - // Only the fields shipping rates resolve against — if none of these - // changed (shopper edited their name, phone, email, …) there's no point - // re-quoting shipping or re-rendering the summary. - $addressBefore = $this->cart->current()?->shippingAddress; - $rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']); - - // setShippingAddress() below always deletes + recreates this row (see - // syncShipping()'s docblock) — capture what was selected NOW, before - // it's gone, so it can be carried forward onto the fresh row. - $previousOption = $addressBefore?->shipping_option; - - $stateRule = $storeCountry - ? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)] - : ['nullable', 'string', 'max:255']; - $countryRule = $storeCountry - ? ['nullable'] - : ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id']; - - // Lenient — only format checks. Anything that fails is simply left out - // of what gets persisted, and reported back for inline display. - $validator = Validator::make($request->all(), [ - 'contact_email' => ['nullable', 'email'], - - 'billing_first_name' => ['nullable', 'string', 'max:255'], - 'billing_last_name' => ['nullable', 'string', 'max:255'], - 'billing_company_name' => ['nullable', 'string', 'max:255'], - 'billing_tax_identifier' => ['nullable', 'string', 'max:255'], - 'billing_line_one' => ['nullable', 'string', 'max:255'], - 'billing_city' => ['nullable', 'string', 'max:255'], - 'billing_state' => $stateRule, - 'billing_postcode' => ['nullable', 'string', 'max:20'], - 'billing_country_id' => $countryRule, - 'billing_contact_phone' => ['nullable', 'string', 'max:50'], - - 'shipping_first_name' => ['nullable', 'string', 'max:255'], - 'shipping_last_name' => ['nullable', 'string', 'max:255'], - 'shipping_line_one' => ['nullable', 'string', 'max:255'], - 'shipping_city' => ['nullable', 'string', 'max:255'], - 'shipping_state' => $stateRule, - 'shipping_postcode' => ['nullable', 'string', 'max:20'], - 'shipping_country_id' => $countryRule, - 'shipping_contact_phone' => ['nullable', 'string', 'max:50'], - 'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'], - ]); - - $errors = $validator->errors()->toArray(); - $data = $validator->valid(); - - // Logged in: the order email is always the account's. It isn't a field - // on the page then, and a submitted value isn't trusted. - if ($user = Auth::user()) { - $data['contact_email'] = $user->email; - } - - $billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null); - $shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId); - - // Company/ΑΦΜ only count when "I want an invoice" is ticked; the fields - // stay in the DOM (just hidden) when it isn't, so ignore what they send. - $wantsInvoice = $request->boolean('wants_invoice'); - - $billing = [ - 'first_name' => $data['billing_first_name'] ?? null, - 'last_name' => $data['billing_last_name'] ?? null, - 'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null, - 'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null, - 'line_one' => $data['billing_line_one'] ?? null, - 'city' => $data['billing_city'] ?? null, - 'state' => $data['billing_state'] ?? null, - 'postcode' => $data['billing_postcode'] ?? null, - 'country_id' => $billingCountryId, - 'contact_email' => $data['contact_email'] ?? null, - 'contact_phone' => $data['billing_contact_phone'] ?? null, - ]; - - $shipping = $sameAsBilling - ? [ - ...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]), - 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null, - ] - : [ - 'first_name' => $data['shipping_first_name'] ?? null, - 'last_name' => $data['shipping_last_name'] ?? null, - 'line_one' => $data['shipping_line_one'] ?? null, - 'city' => $data['shipping_city'] ?? null, - 'state' => $data['shipping_state'] ?? null, - 'postcode' => $data['shipping_postcode'] ?? null, - 'country_id' => $shippingCountryId, - 'contact_email' => $data['contact_email'] ?? null, - 'contact_phone' => $data['shipping_contact_phone'] ?? null, - 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null, - ]; - - $this->checkout->setBillingAddress($billing); - $cart = $this->checkout->setShippingAddress($shipping); - - $cart->meta = [ - ...($cart->meta?->toArray() ?? []), - 'ship_to_billing' => $sameAsBilling, - 'wants_invoice' => $wantsInvoice, - ]; - $cart->save(); - - // Abandoned-cart-recovery opt-in — boboko-core owns the record (bool + - // timestamp + policy version on Cart::meta, RecoveryConsentSet event). - // Deliberately its own scope, not merged with any future newsletter opt-in. - $this->checkout->setRecoveryConsent($request->boolean('recovery_consent')); - - if (Auth::check()) { - $this->account->setRecoveryConsent(Auth::user(), $request->boolean('recovery_consent')); - } - - $rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']); - $rateChanged = $rateKeyAfter != $rateKeyBefore; - - // setShippingAddress() above always deletes and recreates the - // CartAddress row (Lunar's AddAddress action), which drops whatever - // shipping_option was previously selected — regardless of whether the - // rate-determining fields actually changed. So this always has to run - // to restore/re-validate it, even on a save that only touched e.g. the - // phone number. Only the fragment RE-RENDER is skippable when nothing - // rate-relevant moved — the re-select itself is not optional. - $options = $this->syncShipping($cart, $previousOption); - - if (! $rateChanged) { - return $this->fragments($cart, null, $errors); - } - - return $this->fragments($cart, $options, $errors); - } - - public function selectShippingOption(string $locale, Request $request): JsonResponse - { - $identifier = (string) $request->input('shipping_option'); - - try { - $this->checkout->selectShippingOption($identifier); - } catch (InvalidShippingOptionException) { - // Re-render with whatever is currently valid; no hard error surfaced. - } - - $cart = $this->cart->current(); - $options = $cart?->shippingAddress - ? $this->checkout->getShippingOptions() - : collect(); - - 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); - } - - $cart = $this->cart->current(); - - // Captured now, before initiatePayment() can place the order — Lunar's - // CartSessionManager::fetchOrCreate() silently swaps the session onto a - // BRAND NEW empty cart the moment the current one hasCompletedOrders() - // (i.e. has an order with placed_at set), which happens synchronously - // for an immediately-captured payment. Any later $this->cart->current() - // call in this same flow (here, or in a subsequent orderStatus() poll - // once the 3-D Secure webhook sets placed_at) would then resolve to - // that fresh, order-less cart instead of the one that was just placed. - // Storing the real cart id ourselves, under our own session key, - // sidesteps CartSession entirely for the rest of the placement flow. - session(['checkout.cart_id' => $cart?->id]); - - // Lunar's own ValidateCartForOrderCreation (order_create validator) - // never checks for this — an empty cart with a valid billing address - // sails straight through it and would place a real, zero-line order. - // The disabled "place order" button is only the client-side half of - // this fix; this is the half that actually matters. - if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) { - return response()->json([ - 'status' => 'invalid', - 'message' => __('checkout.page.cart_empty'), - ], 422); - } - - // Lenient autosave never requires these; this is the gate. - if (data_get($cart, 'meta.wants_invoice') - && (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) { - return response()->json([ - 'status' => 'invalid', - 'message' => __('checkout.page.invoice_required'), - ], 422); - } - - // Same check Lunar's own ValidateCartForOrderCreation runs inside - // initiatePayment() (a product unpublished/deleted after it was - // added to the cart) — checked here first so the shopper is told - // which product is the problem, rather than falling into the - // catch-all "complete your billing/shipping details" message below, - // which is what actually happened and is generic to every - // CartException reason, misleading when the real cause is a line, - // not an address. - $unavailableLines = $this->cart->activeLines($cart)->filter( - fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(), - ); - - if ($unavailableLines->isNotEmpty()) { - $names = $unavailableLines - ->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier()) - ->filter() - ->implode(', '); - - return response()->json([ - 'status' => 'invalid', - 'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]), - ], 422); - } - - // The one incomplete-cart case worth a specific message + pointing the - // shopper at the right section: a region resolving 2+ methods needs an - // explicit pick (no auto-select), easy to miss since nothing else on - // the page demands it. Everything else CartException catches below. - if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) { - return response()->json([ - 'status' => 'invalid', - 'message' => __('checkout.page.shipping_method_required'), - 'field' => 'shipping_option', - ], 422); - } - - $fingerprint = (string) ($cart?->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); - } - - // Pending with no continuation (cash-on-delivery, or any other - // deferred/offline method) means CheckoutService::initiatePayment() - // already created the placed order — money just hasn't changed - // hands yet. Only a Pending WITH a continuation (Stripe's client - // secret) means the shopper still has something to do before the - // order exists as far as the storefront is concerned. - return match (true) { - $result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale), - $result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale), - $result->status === PaymentResultStatus::Pending => response()->json([ - 'status' => 'pending', - 'clientSecret' => $result->continuation?->value, - ]), - default => 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.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId) - : null; - - if (! $order) { - return redirect()->route('products', $locale); - } - - // Looked up by type rather than a stored relation — the method may since - // have been disabled/deleted, but the order still needs to show what was - // actually used at the time. - $paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null) - ->first() - ?->translate('name'); - - return view('checkout::confirmation', [ - 'order' => $order, - 'paymentMethodName' => $paymentMethodName, - ]); - } - - 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 - { - $cartId = session('checkout.cart_id'); - - if ($cartId === null) { - return null; - } - - return Order::where('cart_id', $cartId) - ->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, or carry a - * previous pick forward when it's still among the resolved options. - * - * $previousOption must be captured by the CALLER before setShippingAddress() - * runs — Lunar's AddAddress action always deletes and recreates the - * CartAddress row on every save (see saveAddress()), so by the time this - * runs, $address->shipping_option is unconditionally null regardless of - * what was selected a moment ago. There is nothing meaningful left to read - * off $address itself; $previousOption is the only source of truth for - * "what was chosen before this save wiped the row." show() passes the - * address's own (not-just-wiped) current value, since nothing recreated - * anything in that path. - * - * Always (re-)applies the resolved target via selectShippingOption() rather - * than comparing against the (always-blank, post-recreation) current value - * — the fresh row needs the write regardless of whether the decision - * "which option" actually changed. - * - * @return Collection - */ - private function syncShipping(Cart $cart, ?string $previousOption): Collection - { - if (! $cart->shippingAddress) { - return collect(); - } - - $options = $this->checkout->getShippingOptions(); - - $target = match (true) { - $options->count() === 1 => $options->first()->identifier, - $previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption, - default => null, - }; - - if ($target !== null) { - try { - $this->checkout->selectShippingOption($target); - } catch (InvalidShippingOptionException) { - // $target came from $options itself — shouldn't happen, stay defensive - } - } - - return $options; - } - - /** - * $options === null means "nothing money-relevant changed" — acknowledge the - * save (and any field errors) without re-rendering the shipping options or - * the order summary, so a plain name/phone edit is a cheap round-trip. - */ - private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse - { - return response()->json([ - 'errors' => collect($errors) - ->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages) - ->all(), - 'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [ - 'shippingAddress' => $cart?->shippingAddress, - 'shippingOptions' => $options, - ])->render(), - // Composer (CheckoutModuleServiceProvider) fills $cart / $lines. - 'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(), - ]); - } - - /** - * Logged-in shopper: fills any BLANK cart address field from the account - * (name, saved default address, phone, email), on every checkout load, so - * an account filled in after checkout started still shows up. Never - * overwrites anything already in the cart. - * - * Company/ΑΦΜ (and ticking "I want an invoice") only on the first pass - * (meta.account_prefilled): someone who then clears them or unticks the - * box for this order shouldn't get them back on the next reload. - * - * Writes only when something actually changes, so a normal reload costs - * nothing extra. - */ - private function prefillFromAccount(Cart $cart): Cart - { - $user = Auth::user(); - $customer = $this->account->customer($user); - $addresses = collect($this->account->addresses($user)); - $saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first(); - $firstPass = ! data_get($cart, 'meta.account_prefilled'); - - $fromAccount = array_filter([ - 'first_name' => $customer?->first_name ?: $saved?->first_name, - 'last_name' => $customer?->last_name ?: $saved?->last_name, - 'line_one' => $saved?->line_one, - 'city' => $saved?->city, - 'state' => $saved?->state, - 'postcode' => $saved?->postcode, - 'country_id' => $this->storeCountry()?->id ?? $saved?->country_id, - 'contact_email' => $user->email, - 'contact_phone' => $saved?->contact_phone, - ], 'filled'); - - $invoice = $firstPass - ? array_filter([ - 'company_name' => $customer?->company_name, - 'tax_identifier' => $customer?->tax_identifier, - ], 'filled') - : []; - - $fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city', - 'state', 'postcode', 'country_id', 'contact_email', 'contact_phone']; - - $fillBlanks = function (?array $current, array $values) { - $current ??= []; - - foreach ($values as $key => $value) { - if (blank($current[$key] ?? null)) { - $current[$key] = $value; - } - } - - return $current; - }; - - $billingBefore = $cart->billingAddress?->only($fields); - $billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]); - - // Shipping has no company/ΑΦΜ (same shape saveAddress() writes). - $shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true); - $shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions']; - - $shippingBefore = $cart->shippingAddress?->only($shippingFields); - $shipping = $shipToBilling - ? [ - ...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]), - 'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null, - ] - : $fillBlanks($shippingBefore, $fromAccount); - - if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) { - $this->checkout->setBillingAddress($billing); - $cart = $this->checkout->setShippingAddress($shipping); - } - - if ($firstPass) { - $cart->meta = [ - ...($cart->meta?->toArray() ?? []), - 'account_prefilled' => true, - 'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [], - ]; - $cart->save(); - } - - return $cart; - } - - private function storeCountry(): ?Country - { - if (self::STORE_COUNTRY_ISO3 === null) { - return null; - } - - return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first(); - } -} diff --git a/app/Providers/CheckoutModuleServiceProvider.php b/app/Providers/CheckoutModuleServiceProvider.php deleted file mode 100644 index 0080fd7..0000000 --- a/app/Providers/CheckoutModuleServiceProvider.php +++ /dev/null @@ -1,53 +0,0 @@ -loadViewsFrom(resource_path('views/checkout'), 'checkout'); - Blade::anonymousComponentNamespace('checkout::components', 'checkout'); - - Route::middleware('web')->group(base_path('routes/checkout.php')); - - // The drawer is rendered on every page (from the layout) and its body - // partial is re-rendered on every cart mutation — both need the current - // cart without a controller in the loop. - View::composer( - ['checkout::drawer', 'checkout::partials.cart-body'], - function (ViewInstance $view) { - $service = app(CartService::class); - $cart = $service->current(); - - $view->with('cart', $cart); - $view->with('lines', $cart ? $service->activeLines($cart) : collect()); - }, - ); - } -} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index ff2f1e3..7efcc45 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,11 +1,9 @@ [ + SameCategoryRule::class, + RandomRule::class, + ], +]; diff --git a/config/checkout.php b/config/checkout.php index 9758154..54589b3 100644 --- a/config/checkout.php +++ b/config/checkout.php @@ -2,17 +2,43 @@ /* * Per-site settings for the cart + checkout module (see - * App\Providers\CheckoutModuleServiceProvider). Becomes the package's - * publishable config when the module moves to boboko-core. + * Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable — + * artisan vendor:publish --tag=core-config. */ return [ /* * Name of the storefront's login route. The checkout's login tab and the * confirmation page link to it with `?redirect=`, so the - * login page must send the shopper back there afterwards (3dealer's - * Auth\LoginController does). null: no login offered in checkout at all. + * login page must send the shopper back there afterwards. null: no login + * offered in checkout at all. */ 'login_route' => 'login', + /* + * Name of the storefront's product-listing route — where confirmation() + * redirects a visit with no placed order to look at (session expired, + * direct navigation, a bookmark). route($this, $locale) must resolve. + */ + 'products_route' => 'products', + + /* + * ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden + * field, forced server-side — no country picker shown at all). null (the + * default) gives the full country/region picker, for a multi-country + * store. 3dealer is Greece-only for now. + */ + 'store_country_iso3' => 'GRC', + + /* + * The `purpose` tag CartController expects a product custom field's + * `file` answer to already carry (see Modules\Core\File\Models\File) — + * matches whatever purpose string the host's own upload endpoint + * (extending Modules\Core\File\Http\Controllers\UploadFileController) + * tags its stored files with. This module never reaches into that + * host controller directly; this config value is the one shared + * source of truth between the two. + */ + 'custom_field_upload_purpose' => 'custom-field-upload', + ]; diff --git a/config/core.php b/config/core.php index 5e0f027..83ca001 100644 --- a/config/core.php +++ b/config/core.php @@ -16,4 +16,126 @@ 'auto_create_customer_for_user' => true, + /* + |-------------------------------------------------------------------------- + | Privacy / GDPR data-subject requests + |-------------------------------------------------------------------------- + | + | 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider + | that should be consulted for right-of-access/right-of-erasure requests. A + | module never needs to be known to core in advance — it just adds its own + | provider class here, the same way config('lunar.search.indexers') maps a + | model to its indexer. See docs/privacy.md. + | + | 'grace_period_days' is how long an erasure request stays cancellable + | (account deactivated, not yet erased) before it's actually processed by + | the privacy:process-erasure-requests scheduled command. + | + */ + + 'privacy' => [ + 'providers' => [ + // ActivityLogDataProvider MUST run before AddressDataProvider — + // it resolves which activity_log rows belong to this customer + // (including ones keyed by an Address id) before + // AddressDataProvider hard-deletes those Address rows. See that + // provider's own class docblock. + \Modules\Core\Logging\Privacy\ActivityLogDataProvider::class, + \Modules\Core\Customer\Privacy\CustomerDataProvider::class, + \Modules\Core\Customer\Privacy\AddressDataProvider::class, + \Modules\Core\Order\Privacy\OrderDataProvider::class, + \Modules\Core\Cart\Privacy\CartDataProvider::class, + \Modules\Core\Review\Privacy\ReviewDataProvider::class, + \Modules\Core\Payment\Privacy\PaymentDataProvider::class, + \Modules\Core\Auth\Privacy\UserSessionDataProvider::class, + ], + + 'grace_period_days' => 30, + ], + + /* + |-------------------------------------------------------------------------- + | Cart Abandonment Threshold + |-------------------------------------------------------------------------- + | + | How long a cart (that hasn't converted to a placed order) can go without + | activity before Modules\Core\Cart\Filament\Resources\CartResource treats + | it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString() + | accepts works, e.g. '1 hour', '30 minutes', '2 days'. + | + */ + + 'cart' => [ + 'abandoned_after' => '1 hour', + + /* + |---------------------------------------------------------------------- + | Unrecoverable Cap + |---------------------------------------------------------------------- + | + | Beyond this age, a stale cart stops being treated as an active + | "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\ + | CartLifecycleService) — too old to be a realistic recovery target + | (pricing/stock/tax likely stale by then). This is about the + | abandoned-cart pipeline only, not data retention — no rows are + | deleted or pruned based on this value. + | + */ + + 'unrecoverable_after' => '90 days', + ], + + /* + |-------------------------------------------------------------------------- + | Order Return Window + |-------------------------------------------------------------------------- + | + | How many days after a carrier order is delivered (Order::fulfillment_status + | becomes 'return_window_open') before Modules\Core\Order\Commands\ + | CloseExpiredReturnWindows auto-completes it, if no return was requested. + | Store-pickup orders have no return-window step and are unaffected by + | this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp). + | + */ + + 'order' => [ + 'return_window_days' => 14, + ], + + /* + |-------------------------------------------------------------------------- + | Storefront OTP Login + |-------------------------------------------------------------------------- + | + | Modules\Core\Auth\Services\UserOtpService's passwordless login. + | max_attempts caps how many wrong codes a shopper can guess against ONE + | generated code before it's invalidated outright. generation_limit/ + | generation_decay_minutes cap how often a NEW code can be requested for + | the same email — independent of max_attempts, since generating a fresh + | code also resets the guess count, so an attempt cap alone doesn't stop + | an attacker from just requesting a new code every few tries. This same + | limit is also what stands between a malicious/careless caller and + | mail-bombing one inbox. + | + */ + + 'auth' => [ + 'otp' => [ + 'max_attempts' => 5, + 'generation_limit' => 3, + 'generation_decay_minutes' => 10, + ], + + // Modules\Core\Customer\Services\CustomerEmailChangeService — same + // shape/reasoning as auth.otp above, independent limits since this + // is a separate flow (changing an existing account's login email, + // not logging in). + 'email_change' => [ + 'max_attempts' => 5, + 'generation_limit' => 3, + 'generation_decay_minutes' => 10, + 'expiry_minutes' => 10, + ], + ], + ]; diff --git a/config/shippingCarriers/acs.php b/config/shippingCarriers/acs.php new file mode 100644 index 0000000..cea2928 --- /dev/null +++ b/config/shippingCarriers/acs.php @@ -0,0 +1,50 @@ + env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'), + + 'api_key' => env('ACS_API_KEY'), + + 'company_id' => env('ACS_COMPANY_ID'), + 'company_password' => env('ACS_COMPANY_PASSWORD'), + 'user_id' => env('ACS_USER_ID'), + 'user_password' => env('ACS_USER_PASSWORD'), + + 'billing_code' => env('ACS_BILLING_CODE'), + + 'sender' => [ + 'name' => env('ACS_SENDER_NAME'), + 'address' => env('ACS_SENDER_ADDRESS'), + 'zip_code' => env('ACS_SENDER_ZIP'), + 'phone' => env('ACS_SENDER_PHONE'), + ], + + 'timeout' => env('ACS_HTTP_TIMEOUT', 10), + +]; diff --git a/config/shippingCarriers/boxnow.php b/config/shippingCarriers/boxnow.php new file mode 100644 index 0000000..9a011ad --- /dev/null +++ b/config/shippingCarriers/boxnow.php @@ -0,0 +1,61 @@ + env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'), + 'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'), + + 'client_id' => env('BOXNOW_CLIENT_ID'), + 'client_secret' => env('BOXNOW_CLIENT_SECRET'), + 'partner_id' => env('BOXNOW_PARTNER_ID'), + + 'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'), + + 'sender' => [ + 'name' => env('BOXNOW_SENDER_NAME'), + 'email' => env('BOXNOW_SENDER_EMAIL'), + 'phone' => env('BOXNOW_SENDER_PHONE'), + ], + + 'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10), + +]; diff --git a/database/seeders/CheckoutTranslationsSeeder.php b/database/seeders/CheckoutTranslationsSeeder.php deleted file mode 100644 index 965a15b..0000000 --- a/database/seeders/CheckoutTranslationsSeeder.php +++ /dev/null @@ -1,183 +0,0 @@ -lines() as $key => [$en, $el]) { - $exists = LanguageLine::query() - ->where('group', 'checkout') - ->where('key', $key) - ->exists(); - - if ($exists) { - $this->command?->warn("checkout.{$key} already exists — skipped"); - - continue; - } - - $translations->create('checkout', $key, ['en' => $en, 'el' => $el]); - $this->command?->info("checkout.{$key} added"); - } - } - - /** - * key => [English, Greek]. - * - * @return array - */ - private function lines(): array - { - return [ - // ── Cart drawer + order summary ────────────────────────────── - 'cart.title' => ['Your cart', 'Το καλάθι σου'], - 'cart.close' => ['Close', 'Κλείσιμο'], - 'cart.empty' => ['Your cart is empty', 'Το καλάθι σου είναι άδειο'], - 'cart.quantity' => ['Quantity', 'Ποσότητα'], - 'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'], - 'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'], - 'cart.remove' => ['Remove', 'Αφαίρεση'], - 'cart.subtotal' => ['Subtotal', 'Υποσύνολο'], - 'cart.discount' => ['Discount', 'Έκπτωση'], - 'cart.shipping' => ['Shipping', 'Μεταφορικά'], - 'cart.shipping_pending' => ['Not selected yet', 'Δεν έχει επιλεγεί ακόμη'], - 'cart.tax' => ['VAT', 'ΦΠΑ'], - 'cart.total' => ['Total', 'Σύνολο'], - 'cart.checkout' => ['Checkout', 'Ολοκλήρωση παραγγελίας'], - 'cart.coupon_label' => ['Coupon code', 'Κωδικός κουπονιού'], - 'cart.coupon_placeholder' => ['Coupon code', 'Κωδικός κουπονιού'], - 'cart.coupon_apply' => ['Apply', 'Εφαρμογή'], - 'cart.coupon_remove' => ['Remove', 'Αφαίρεση'], - 'cart.coupon_invalid' => ["That coupon code isn't valid", 'Ο κωδικός κουπονιού δεν είναι έγκυρος'], - - // ── Checkout page ──────────────────────────────────────────── - 'page.title' => ['Checkout', 'Ολοκλήρωση παραγγελίας'], - 'page.contact_heading' => ['Contact', 'Στοιχεία επικοινωνίας'], - 'page.guest_tab' => ['Guest', 'Ως επισκέπτης'], - 'page.login_tab' => ['Log in', 'Σύνδεση'], - 'page.email_label' => ['Email', 'Email'], - 'page.recovery_consent' => [ - "Email me a reminder if I don't finish my order", - 'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου', - ], - 'page.login_email_label' => ['Email', 'Email'], - 'page.send_code' => ['Send code', 'Αποστολή κωδικού'], - 'page.login_coming_soon' => [ - 'Login is coming soon — continue as a guest for now.', - 'Η σύνδεση θα είναι διαθέσιμη σύντομα — προς το παρόν συνέχισε ως επισκέπτης.', - ], - 'page.billing_heading' => ['Billing information', 'Στοιχεία τιμολόγησης'], - 'page.shipping_heading' => ['Shipping information', 'Στοιχεία αποστολής'], - 'page.same_as_billing' => ['Same as billing address', 'Ίδια με τη διεύθυνση τιμολόγησης'], - 'page.first_name' => ['First name', 'Όνομα'], - 'page.last_name' => ['Last name', 'Επώνυμο'], - 'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'], - 'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'], - 'page.address_line_one' => ['Address', 'Διεύθυνση'], - 'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'], - 'page.city' => ['City', 'Πόλη'], - 'page.state' => ['Region / Prefecture', 'Νομός / Περιοχή'], - 'page.state_placeholder' => ['Select a region', 'Επίλεξε νομό'], - 'page.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'], - 'page.country' => ['Country', 'Χώρα'], - 'page.country_placeholder' => ['Select a country', 'Επίλεξε χώρα'], - 'page.phone' => ['Phone', 'Τηλέφωνο'], - 'page.delivery_instructions' => ['Delivery notes', 'Σχόλια για την παράδοση'], - 'page.save_address' => ['Save and continue', 'Αποθήκευση και συνέχεια'], - 'page.saving' => ['Saving…', 'Αποθήκευση…'], - 'page.saved' => ['Saved', 'Αποθηκεύτηκε'], - 'page.save_error' => ["Couldn't save — check your connection", 'Δεν αποθηκεύτηκε — έλεγξε τη σύνδεσή σου'], - 'page.shipping_method_heading' => ['Shipping method', 'Τρόπος αποστολής'], - 'page.shipping_method_empty' => [ - 'Add your shipping address to see delivery options.', - 'Συμπλήρωσε τη διεύθυνση αποστολής για να δεις τις διαθέσιμες επιλογές.', - ], - 'page.shipping_method_none' => [ - 'No delivery options are available for this address.', - 'Δεν υπάρχουν διαθέσιμες επιλογές αποστολής για αυτή τη διεύθυνση.', - ], - 'page.select_shipping_method' => ['Continue', 'Συνέχεια'], - 'page.shipping_option_invalid' => [ - 'That shipping option is no longer available.', - 'Αυτός ο τρόπος αποστολής δεν είναι πλέον διαθέσιμος.', - ], - '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.shipping_method_required' => [ - 'Choose a shipping method below to continue.', - 'Επίλεξε τρόπο αποστολής παρακάτω για να συνεχίσεις.', - ], - '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/views/checkout/components/add-to-cart.blade.php b/resources/views/checkout/components/add-to-cart.blade.php deleted file mode 100644 index 3f39e61..0000000 --- a/resources/views/checkout/components/add-to-cart.blade.php +++ /dev/null @@ -1,44 +0,0 @@ -{{-- - - - A self-contained add-to-cart form. Posts the line via bbk-add-to-cart-controller - (fetch) and hands the rendered cart body to the drawer over the - `bbk-cart:changed` window event. - - Props: - purchasable ProductVariant id. Omit to render no hidden id field — the host - must then supply [data-bbk-purchasable-input] itself (e.g. a - variant picker writing the selected id into it). - quantity Integer for the hidden quantity field, or false to omit it - (the host then puts its own name="quantity" control in the slot). - - The button and any quantity control come from the slot, so the host owns all - appearance. Extra attributes (class, etc.) land on the
. ---}} -@props([ - 'purchasable' => null, - 'quantity' => 1, - 'action' => null, -]) - -class('bbk-add-to-cart') }} -> - @csrf - - @if (! is_null($purchasable)) - - @endif - - @if ($quantity !== false) - - @endif - - {{ $slot }} - - -
diff --git a/resources/views/checkout/components/address-lines.blade.php b/resources/views/checkout/components/address-lines.blade.php deleted file mode 100644 index 957989d..0000000 --- a/resources/views/checkout/components/address-lines.blade.php +++ /dev/null @@ -1,15 +0,0 @@ -{{-- - Read-only formatted address. $address is any Lunar address model - (OrderAddress / CartAddress) — same column names on both. ---}} -@props(['address']) - -
- {{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }} - @if ($address->company_name){{ $address->company_name }}@endif - {{ $address->line_one }} - @if ($address->line_two){{ $address->line_two }}@endif - {{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }} - @if ($address->state){{ $address->state }}@endif - @if ($address->contact_phone){{ $address->contact_phone }}@endif -
diff --git a/resources/views/checkout/components/field.blade.php b/resources/views/checkout/components/field.blade.php deleted file mode 100644 index 368b894..0000000 --- a/resources/views/checkout/components/field.blade.php +++ /dev/null @@ -1,29 +0,0 @@ -{{-- - - - Generic labelled text input with old-input repopulation and validation - error display — the module's own equivalent of a host x-ui.field, used - instead of it per the module's independence rule. All styling is .bbk-field* - (resources/css/checkout.css); no host classes. ---}} -@props([ - 'name', - 'label', - 'type' => 'text', - 'value' => null, - 'required' => false, -]) - -
- - class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }} - > - {{-- Always present so bbk-checkout-form can fill it live on an autosave. --}} -

has($name)) hidden @endunless>{{ $errors->first($name) }}

-
diff --git a/resources/views/checkout/components/region-country.blade.php b/resources/views/checkout/components/region-country.blade.php deleted file mode 100644 index 7e1e006..0000000 --- a/resources/views/checkout/components/region-country.blade.php +++ /dev/null @@ -1,52 +0,0 @@ -{{-- - The state/region + country pair for one address (billing or shipping). - - Single-country store ($storeCountry set): region is a , as before. ---}} -@props([ - 'prefix', - 'storeCountry' => null, - 'regions' => [], - 'countries' => [], - 'address' => null, -]) - -
- @if ($storeCountry) - - -
- {{ __('checkout.page.country') }} -

- {{ \Illuminate\Support\Facades\Lang::has("core::countries.{$storeCountry->name}") - ? __("core::countries.{$storeCountry->name}") - : $storeCountry->name }} -

- -
- @else - - - - @endif -
diff --git a/resources/views/checkout/components/select.blade.php b/resources/views/checkout/components/select.blade.php deleted file mode 100644 index 3474c67..0000000 --- a/resources/views/checkout/components/select.blade.php +++ /dev/null @@ -1,62 +0,0 @@ -{{-- - - - - `options` is an iterable of models/objects; `label` is always read from - `->name`, the submitted value from `->{$valueField}` (default `id`, but e.g. - `name` for Lunar states — table-rate-shipping resolves those with - State::whereName(), so the address must carry the exact name string). - - `translationGroup` (optional, e.g. "countries"/"states") looks the raw - `->name` up in boboko-core's `core::{group}.{name}` lang file (see - boboko-core's lang/el/countries.php, lang/el/states.php) for the - DISPLAYED label only — the submitted `value` is always the untranslated - `->{$valueField}`, since table-rate-shipping/Lunar's Country lookups key - off the original English name. Falls back to the raw name when no - translation exists for the current locale (e.g. English, or a country - outside the covered set). ---}} -@props([ - 'name', - 'label', - 'options' => [], - 'value' => null, - 'placeholder' => null, - 'required' => false, - 'valueField' => 'id', - 'translationGroup' => null, -]) - -@php - $optionLabel = function ($option) use ($translationGroup) { - if (! $translationGroup) { - return $option->name; - } - - $key = "core::{$translationGroup}.{$option->name}"; - - return \Illuminate\Support\Facades\Lang::has($key) ? __($key) : $option->name; - }; -@endphp - -@php($selected = old($name, $value)) - -
- - -

has($name)) hidden @endunless>{{ $errors->first($name) }}

-
diff --git a/resources/views/checkout/components/textarea.blade.php b/resources/views/checkout/components/textarea.blade.php deleted file mode 100644 index 82beae1..0000000 --- a/resources/views/checkout/components/textarea.blade.php +++ /dev/null @@ -1,18 +0,0 @@ -@props([ - 'name', - 'label', - 'value' => null, - 'required' => false, -]) - -
- - -

has($name)) hidden @endunless>{{ $errors->first($name) }}

-
diff --git a/resources/views/checkout/confirmation.blade.php b/resources/views/checkout/confirmation.blade.php deleted file mode 100644 index ae97d87..0000000 --- a/resources/views/checkout/confirmation.blade.php +++ /dev/null @@ -1,132 +0,0 @@ -{{-- - Order confirmation. Reached only via a session flash of the placed order id - (CheckoutController::confirmation) — not deep-linkable. $order is a - Lunar\Models\Order with lines + shipping/billing addresses eager-loaded. ---}} -@extends('layouts.app') - -@section('title', __('checkout.page.confirmation_title') . ' — ' . config('app.name')) - -@section('content') -
-

{{ __('checkout.page.confirmation_heading') }}

- -
-
-
{{ __('checkout.page.confirmation_order_number') }}
-
{{ $order->reference }}
-
- - @if ($order->billingAddress?->contact_email) -
-
{{ __('checkout.page.email_label') }}
-
{{ $order->billingAddress->contact_email }}
-
- @endif - - @if ($paymentMethodName) -
-
{{ __('checkout.page.payment_heading') }}
-
{{ $paymentMethodName }}
-
- @endif - - @if ($shippingLine = $order->lines->firstWhere('type', 'shipping')) -
-
{{ __('checkout.page.shipping_method_heading') }}
-
{{ $shippingLine->description }}
-
- @endif -
- -

{{ __('checkout.page.confirmation_email_note') }}

- - {{-- Guests: logging in with the order's email attaches it to an account - (boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin), - so it shows in their history. --}} - @guest - @if ($loginRoute = config('checkout.login_route')) -

- {{ __('checkout.page.confirmation_login_hint') }} - {{ __('checkout.page.login_link') }} -

- @endif - @endguest - -
-
- @foreach ($order->lines->where('type', '!=', 'shipping') as $line) -
-
- @if ($thumb = $line->purchasable?->getThumbnailImage()) - {{ $line->description }} - @endif -
- -
- - {{ $line->description }} - × {{ $line->quantity }} - - - @if ($line->option) -

{{ $line->option }}

- @endif - - @include('checkout::partials.line-custom-fields', ['line' => $line]) -
- - {{ $line->sub_total?->formatted() }} -
- @endforeach - -
-
- {{ __('checkout.cart.subtotal') }} - {{ $order->sub_total?->formatted() }} -
- - @if ($order->discount_total?->value > 0) -
- {{ __('checkout.cart.discount') }} - −{{ $order->discount_total->formatted() }} -
- @endif - -
- {{ __('checkout.cart.shipping') }} - {{ $order->shipping_total?->formatted() }} -
- - @if ($order->tax_total?->value > 0) -
- {{ __('checkout.cart.tax') }} - {{ $order->tax_total->formatted() }} -
- @endif - -
- {{ __('checkout.cart.total') }} - {{ $order->total?->formatted() }} -
-
-
- -
- @if ($order->shippingAddress) -
-

{{ __('checkout.page.confirmation_shipping_to') }}

- -
- @endif - - @if ($order->billingAddress) -
-

{{ __('checkout.page.confirmation_billing') }}

- -
- @endif -
-
-
-@endsection diff --git a/resources/views/checkout/drawer.blade.php b/resources/views/checkout/drawer.blade.php deleted file mode 100644 index 24a98ed..0000000 --- a/resources/views/checkout/drawer.blade.php +++ /dev/null @@ -1,33 +0,0 @@ -{{-- - Slide-in cart drawer. Rendered once, globally, from the app layout - (@include('checkout::drawer')). Structure only — all styling lives in - resources/css/checkout.css under @layer bbk-checkout; the host restyles the - .bbk-* classes from its own stylesheet. No host components, no Tailwind. ---}} - diff --git a/resources/views/checkout/page.blade.php b/resources/views/checkout/page.blade.php deleted file mode 100644 index 68a44e6..0000000 --- a/resources/views/checkout/page.blade.php +++ /dev/null @@ -1,279 +0,0 @@ -{{-- - The checkout page. Two columns: left is contact + billing + shipping + - shipping method, right is the order summary (the same cart-body partial the - drawer uses, minus its own "Checkout" CTA — see .bbk-checkout-summary in - checkout.css). Stops short of payment for this slice — see - CheckoutController's class docblock. - - $cart, $lines, $billingAddress, $shippingAddress, $shippingOptions, - $countries come from CheckoutController::show(). ---}} -@extends('layouts.app') - -@section('title', __('checkout.page.title') . ' — ' . config('app.name')) - -@section('content') -
-

{{ __('checkout.page.title') }}

- -
-
- - {{-- Contact. Logged in: the order email is the account's (forced - server-side in saveAddress()), so there's no field. Guests type - their email, plus a login link when config('checkout.login_route') - is set; the storefront's login page sends them back here and Lunar - merges the guest cart into the account. --}} - @php($loginRoute = config('checkout.login_route')) -
- @auth -

- {{ __('checkout.page.logged_in_as') }} {{ auth()->user()->email }} -

- @else - @if ($loginRoute) - - @endif - - - @endauth - - {{-- Abandoned-cart-recovery opt-in. Optional, unticked, never - required — direct marketing under ePrivacy (GR L. 3471/2006 - art. 11), so it needs an explicit opt-in and checkout can't be - gated on it. Narrow scope by design (boboko-core's - setRecoveryConsent) — a general newsletter opt-in, if wanted, - is a separate checkbox. --}} - -
- - {{-- Autosaves — no submit button. Any `change` inside .bbk-checkout-main - (this form, plus the contact email/consent which sit outside it but - link via form="bbk-address-form") is debounced and POSTed as the whole - form; the shipping-method radios are excluded in scheduleSave(). --}} -
- @csrf - - {{-- Billing --}} -
-

{{ __('checkout.page.billing_heading') }}

- -
- - -
- - {{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS - (:has on the checkbox), saved/cleared by saveAddress(), and - required at place-order. --}} -
- - -
- - -
-
- - - -
- - -
- - - - -
- - {{-- Shipping --}} -
-

{{ __('checkout.page.shipping_heading') }}

- - - -
-
- - -
- - - -
- - -
- - - - -
- - -
-
- - - - {{-- Shipping method — resolves from the saved shipping address; - re-rendered as a fragment by bbk-checkout-form after each - autosave / option change. --}} -
-

{{ __('checkout.page.shipping_method_heading') }}

- -
- @include('checkout::partials.shipping-options', [ - 'shippingAddress' => $shippingAddress, - 'shippingOptions' => $shippingOptions, - ]) -
-
- - {{-- Payment --}} -
-

{{ __('checkout.page.payment_heading') }}

- -
- @include('checkout::partials.payment-methods', [ - 'paymentMethods' => $paymentMethods, - 'cart' => $cart, - ]) -
- - {{-- Stripe Payment Element mounts here when a Stripe method is picked. --}} - - - - -

- {!! __('checkout.page.withdrawal_notice', [ - 'link' => route('legal.shipping-returns', app()->getLocale()), - ]) !!} -

- - - - -
- - {{-- Fixed overlay while a payment is confirming (3-D Secure / webhook - poll). Inside .bbk-checkout-main so bbk-payment can target it. --}} - - -
- - -
-
-@endsection diff --git a/resources/views/checkout/partials/cart-body.blade.php b/resources/views/checkout/partials/cart-body.blade.php deleted file mode 100644 index 1bfdb95..0000000 --- a/resources/views/checkout/partials/cart-body.blade.php +++ /dev/null @@ -1,121 +0,0 @@ -{{-- - Server-rendered cart contents. Rendered inline on first page load inside - checkout/drawer.blade.php, and re-fetched + swapped into the drawer by - bbk-cart-controller after every mutation. $cart / $lines come from the view - composer in CheckoutModuleServiceProvider. - - The data-bbk-cart-* attributes on the root are the module's read API for the - host (e.g. the header bag-icon count) — bbk-cart-controller reads them after - each swap and re-emits them on the `bbk-cart:updated` window event. ---}} -@php($count = $lines->sum('quantity')) - -{{-- @dump($lines) --}} - -
- @if ($lines->isEmpty()) -

{{ __('checkout.cart.empty') }}

- @else -
    - @each('checkout::partials.cart-line', $lines, 'line') -
- -
-
- @if ($cart?->coupon_code) -
- {{ $cart->coupon_code }} - -
- @csrf - @method('DELETE') - -
-
- @else -
- @csrf - - - -
- - @if ($couponError ?? false) - - @endif - @endif -
- -
- {{ __('checkout.cart.subtotal') }} - {{ $cart?->subTotal?->formatted() }} -
- - @if ($cart?->discountTotal?->value > 0) -
- {{ __('checkout.cart.discount') }} - −{{ $cart->discountTotal->formatted() }} -
- @endif - - {{-- Shipping + tax appear once the shopper has a shipping address - (i.e. they're on the checkout page). In the drawer, where no - address is set yet, only subtotal + total show. --}} - @if ($cart?->shippingAddress) -
- {{ __('checkout.cart.shipping') }} - @if ($cart->shippingAddress->shipping_option) - {{ $cart->shippingTotal?->formatted() }} - @else - {{ __('checkout.cart.shipping_pending') }} - @endif -
- @endif - - @if ($cart?->taxTotal?->value > 0) -
- {{ __('checkout.cart.tax') }} - {{ $cart->taxTotal->formatted() }} -
- @endif - - {{-- Always shown — equals subtotal with nothing else applied, - diverges as discount / shipping / tax come in. --}} -
- {{ __('checkout.cart.total') }} - {{ $cart?->total?->formatted() }} -
- - - {{ __('checkout.cart.checkout') }} - -
- @endif -
diff --git a/resources/views/checkout/partials/cart-error.blade.php b/resources/views/checkout/partials/cart-error.blade.php deleted file mode 100644 index 20cc2a1..0000000 --- a/resources/views/checkout/partials/cart-error.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -{{-- - Shared error slot for any host wrapping cart-body in a bbk-cart controller - instance (the drawer, and the checkout page's own order summary) — - bbk-cart-controller.js#showError() writes into whichever one is present. - Without this element in a given host, a rejected quantity update (e.g. - over stock) still gets rejected server-side, but the shopper never sees - why. ---}} - diff --git a/resources/views/checkout/partials/cart-line.blade.php b/resources/views/checkout/partials/cart-line.blade.php deleted file mode 100644 index df76966..0000000 --- a/resources/views/checkout/partials/cart-line.blade.php +++ /dev/null @@ -1,103 +0,0 @@ -{{-- - One cart line. $line is a Lunar\Models\CartLine (iteration var set by - @each in cart-body). The two forms post through bbk-cart-controller - (fetch + method spoofing) and the response re-renders cart-body. ---}} -@php - $variant = $line->purchasable; - $product = $variant?->product; - $name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—'; - // The variant's own image (falls back to the product's thumbnail - // internally — see ProductVariant::getThumbnail()) — the specific option - // the shopper picked, not just the product in general. - $thumb = $variant?->getThumbnailImage() ?: null; - $variantLabel = $variant?->getOption(); - // Not routed through checkout::'s own locale-explicit convention — this - // is a storefront route, so it follows the storefront's own (implicit - // locale) call shape, same as App\Catalog\ProductCard. Carries the - // variant id along so the product page can restore the same option the - // shopper actually has in their cart, not just default to the first one - // (see product-form-controller.js reading ?variant= on connect()). - $productUrl = $product ? route('product.show', ['id' => $product->id, 'variant' => $variant?->id]) : null; -@endphp - -
  • -
    - @if ($thumb) - @if ($productUrl) - - @else - {{ $name }} - @endif - @endif -
    - -
    - @if ($productUrl) - {{ $name }} - @else -

    {{ $name }}

    - @endif - @if ($variantLabel) -

    {{ $variantLabel }}

    - @endif - @include('checkout::partials.line-custom-fields', ['line' => $line]) -

    {{ $line->unitPrice?->formatted() }}

    - -
    - @csrf - @method('PATCH') - - - - - -
    -
    - -
    -

    {{ $line->subTotal?->formatted() }}

    - -
    - @csrf - @method('DELETE') - -
    -
    -
  • diff --git a/resources/views/checkout/partials/line-custom-fields.blade.php b/resources/views/checkout/partials/line-custom-fields.blade.php deleted file mode 100644 index 3098b5c..0000000 --- a/resources/views/checkout/partials/line-custom-fields.blade.php +++ /dev/null @@ -1,49 +0,0 @@ -{{-- - @include('checkout::partials.line-custom-fields', ['line' => $line]) - - A cart or order line's custom-field answers (meta.custom_fields, written by - CartController::customFieldsMeta()). A file answer only carries a File id - (Modules\Core\File\Models\File is the source of truth for name/mime/disk/ - path — never duplicated into meta), resolved here and linked through - boboko-core's own signed download route (files.download), minted fresh on - every render, with a thumbnail when the browser can display the format - (HEIC can't be shown outside Safari, so it gets the name only). ---}} -@php - $fields = $line->meta['custom_fields'] ?? []; - $previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; -@endphp - -@if (! empty($fields)) -
    - @foreach ($fields as $field) -
    -
    {{ $field['label'] }}
    -
    - @if ($field['type'] === 'file') - @php - $file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null); - @endphp - @if ($file) - @php - $fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute( - 'files.download', - now()->addHours(2), - ['file' => $file->id], - ); - @endphp - - @if (in_array($file->mime, $previewable, true)) - - @endif - {{ $file->original_name }} - - @endif - @else - {{ $field['value'] }} - @endif -
    -
    - @endforeach -
    -@endif diff --git a/resources/views/checkout/partials/payment-methods.blade.php b/resources/views/checkout/partials/payment-methods.blade.php deleted file mode 100644 index ca239cf..0000000 --- a/resources/views/checkout/partials/payment-methods.blade.php +++ /dev/null @@ -1,30 +0,0 @@ -{{-- - Payment method radios. $paymentMethods is Collection from CheckoutService::getPaymentMethods() (already - filtered to enabled + driver-resolves + isConfigured()). Selecting one - autosaves via bbk-payment#selectMethod; `data-payment-driver` tells the - controller whether to mount the Stripe Element. - - $paymentMethods, $cart come from the page / controller. ---}} -@php($selected = $cart?->meta['payment_method'] ?? null) - -@if ($paymentMethods->isEmpty()) -

    {{ __('checkout.page.payment_method_none') }}

    -@else -
    - @foreach ($paymentMethods as $method) - - @endforeach -
    -@endif diff --git a/resources/views/checkout/partials/shipping-options.blade.php b/resources/views/checkout/partials/shipping-options.blade.php deleted file mode 100644 index 969dad6..0000000 --- a/resources/views/checkout/partials/shipping-options.blade.php +++ /dev/null @@ -1,50 +0,0 @@ -{{-- - Shipping methods for the checkout page. Rendered inline by page.blade.php on - load, and re-rendered as a fragment by CheckoutController after every - address save / option change (bbk-checkout-form swaps it in). Radios - autosave via bbk-checkout-form#selectShipping — no submit button. A single - resolved option is auto-selected server-side and shown as a fixed line. - - $shippingAddress, $shippingOptions come from the controller / page scope. ---}} -@php($selected = $shippingAddress?->shipping_option) - -{{-- Rate resolution needs country (always Greece here) + postcode; until a - postcode is saved there's nothing to quote against yet. --}} -@if (! $shippingAddress?->postcode) -

    {{ __('checkout.page.shipping_method_empty') }}

    -@elseif ($shippingOptions->isEmpty()) -

    {{ __('checkout.page.shipping_method_none') }}

    -@elseif ($shippingOptions->count() === 1) - @php($only = $shippingOptions->first()) -
    - - {{ $only->name }} - @if ($only->description) - {{ strip_tags($only->description) }} - @endif - - {{ $only->price->formatted() }} -
    -@else -
    - @foreach ($shippingOptions as $option) - - @endforeach -
    -@endif diff --git a/routes/checkout.php b/routes/checkout.php deleted file mode 100644 index 47f8f36..0000000 --- a/routes/checkout.php +++ /dev/null @@ -1,60 +0,0 @@ -middleware('locale') - ->group(function () { - // No standalone cart page — the drawer (checkout::drawer) is the cart. - Route::get('checkout', [CheckoutController::class, 'show']) - ->name('checkout.show'); - - Route::post('checkout/address', [CheckoutController::class, 'saveAddress']) - ->name('checkout.address.save'); - - Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption']) - ->name('checkout.shipping-option.select'); - - Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod']) - ->name('checkout.payment-method.select'); - - Route::post('checkout/place-order', [CheckoutController::class, 'placeOrder']) - ->name('checkout.place-order'); - - Route::get('checkout/order-status', [CheckoutController::class, 'orderStatus']) - ->name('checkout.order-status'); - - Route::get('checkout/confirmation', [CheckoutController::class, 'confirmation']) - ->name('checkout.confirmation'); - - Route::post('cart/lines', [CartController::class, 'add']) - ->name('checkout.cart.add'); - - Route::patch('cart/lines/{line}', [CartController::class, 'updateLine']) - ->whereNumber('line') - ->name('checkout.cart.update'); - - Route::delete('cart/lines/{line}', [CartController::class, 'remove']) - ->whereNumber('line') - ->name('checkout.cart.remove'); - - Route::post('cart/coupon', [CartController::class, 'applyCoupon']) - ->name('checkout.cart.coupon.apply'); - - Route::delete('cart/coupon', [CartController::class, 'removeCoupon']) - ->name('checkout.cart.coupon.remove'); - });