diff --git a/.env.example b/.env.example index 1e642cb..01acd1f 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,10 @@ MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +CONTACT_EMAIL= + +HCAPTCHA_SITEKEY= +HCAPTCHA_SECRET= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/README.md b/README.md new file mode 100644 index 0000000..c39a7d0 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# 3dealer + +Storefront for 3dealer.gr: Laravel 12 + Lunar PHP (headless), on top of `boboko/core`. Front-end conventions are in [CLAUDE.md](CLAUDE.md). + +## Scheduled jobs + +These run in the `scheduler` container (`php artisan schedule:work`). + +| Command | When | What it does | +|---|---|---| +| `custom-fields:prune-uploads` | Daily, 04:00 | Deletes custom-field photo uploads older than 24 h that no cart line or order line references. | +| `lunar:search:index` | Daily, 03:00 | Full product reindex. Registered by `boboko/core`. | + +## Product custom fields + +Admins can add custom fields to a product in the Lunar admin (the product's **Custom Fields** section, from `boboko/core`). The customer fills them in on the product page before adding the product to the cart. The answers are stored on the cart line (`meta.custom_fields`) and carried over to the order line. + +| Field type | Storefront input | Limit | +|---|---|---| +| Short text | text input | 255 characters | +| Long text | textarea | 2,000 characters | +| File upload | photo upload | JPG, PNG, WEBP, HEIC/HEIF, up to 10 MB | + +- **Photos upload as soon as they're picked.** They go to `POST /{locale}/custom-field-uploads`, which is limited to 20 per minute per client. They're stored on the private `local` disk under `storage/app/private/custom-field-uploads/`. Only an encrypted reference is sent with add-to-cart. The allowed types and size are set in `App\Http\Controllers\CustomFieldUploadController`. +- **Photos are never public.** The cart drawer, checkout summary and order confirmation link to a photo through a signed URL that expires after 2 hours. +- **Emails show text answers only**, never photos. +- **Photos are cleaned up automatically.** Photos never added to a cart are deleted by `custom-fields:prune-uploads` (see above). A photo on a cart line is kept as long as that cart line exists, and a photo on an order is kept indefinitely. +- **Products with custom fields can't be quick-added.** On product cards, the "add to cart" button becomes a link to the product page. +- Field labels are entered once in the admin and aren't translated, so they appear as entered in both `/el` and `/en`. diff --git a/app/Catalog/ProductCard.php b/app/Catalog/ProductCard.php index d0c00f2..7c2c053 100644 --- a/app/Catalog/ProductCard.php +++ b/app/Catalog/ProductCard.php @@ -19,7 +19,7 @@ final class ProductCard { /** * @param array $product one item from ProductService's localized array shape - * @return array{name: ?string, price: ?string, image: ?string, href: string} + * @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool, soldOut: bool} */ public static function fromIndexed(array $product): array { @@ -28,6 +28,20 @@ public static function fromIndexed(array $product): array 'price' => $product['price'], 'image' => $product['media'][0]['url'] ?? null, 'href' => route('product.show', ['id' => $product['id']]), + // The card's quick "Add to cart" always adds this variant, same + // default Modules\Core\Catalog\Services\ProductService:: + // variantSummaries() and product/show.blade.php both use — no + // picker at listing-grid scope, unlike the product page's own + // color swatches. + 'variantId' => $product['variants'][0]['id'] ?? null, + // A product with custom fields (photo upload, engraving text…) + // can't be quick-added from a card — the card links to the + // product page instead, even when every field is optional. + 'hasCustomFields' => ! empty($product['custom_fields']), + // Index-time stock (see boboko-core's ProductIndexer `in_stock`), + // so only as fresh as the last reindex. A document missing the + // field is treated as in stock rather than hiding its cart button. + 'soldOut' => ! ($product['in_stock'] ?? true), ]; } } diff --git a/app/Catalog/ProductListingPage.php b/app/Catalog/ProductListingPage.php index dcf322e..2b8a625 100644 --- a/app/Catalog/ProductListingPage.php +++ b/app/Catalog/ProductListingPage.php @@ -17,7 +17,14 @@ */ final class ProductListingPage { - private const PER_PAGE = 12; + private const PER_PAGE = 40; + + /** Category pages rarely have enough products to fill a page, so this + * ceiling is high enough to act as "no pagination" in practice — the + * pagination component self-hides via hasPages() when everything fits. + * If a category ever exceeds it, pagination reappears as a safety net + * rather than silently truncating results. */ + private const CATEGORY_PER_PAGE = 200; public function __construct( private readonly ProductService $products, @@ -33,6 +40,7 @@ public function __construct( public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array { $filters = $listing->filters($collectionId); + $perPage = $collectionId !== null ? self::CATEGORY_PER_PAGE : self::PER_PAGE; // Listing reads from the Meilisearch index via ProductService/ // ProductSearchService, not Eloquent. Both return a @@ -45,12 +53,12 @@ public function build(ProductListing $listing, Closure $url, ?int $collectionId query: $query, filters: $filters, sort: $listing->sort, - perPage: self::PER_PAGE, + perPage: $perPage, page: $listing->page, ) : $this->products->list( filters: $filters, - perPage: self::PER_PAGE, + perPage: $perPage, page: $listing->page, sort: $listing->sort, ); diff --git a/app/Http/Controllers/Account/AccountController.php b/app/Http/Controllers/Account/AccountController.php new file mode 100644 index 0000000..6a65ec0 --- /dev/null +++ b/app/Http/Controllers/Account/AccountController.php @@ -0,0 +1,156 @@ +user(); + $customer = $this->account->customer($user); + + return view('account.show', [ + 'user' => $user, + 'customer' => $customer, + 'address' => $this->defaultAddress($user), + 'regions' => State::where('country_id', $this->storeCountry()->id)->orderBy('name')->get(['id', 'name']), + ]); + } + + public function update(string $locale, Request $request): RedirectResponse + { + $user = $request->user(); + $country = $this->storeCountry(); + + // The address is all-or-nothing: typing any part of it makes the rest + // (and the name, which Lunar requires on every address) required. + $anyAddressField = implode(',', self::ADDRESS_FIELDS); + + $data = $request->validate([ + 'first_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField], + 'last_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField], + 'invoice' => ['boolean'], + 'company_name' => ['nullable', 'string', 'max:255', 'required_if_accepted:invoice'], + 'tax_identifier' => ['nullable', 'digits:9', 'required_if_accepted:invoice'], + 'line_one' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField], + 'city' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField], + 'postcode' => ['nullable', 'regex:/^\d{3}\s?\d{2}$/', 'required_with:'.$anyAddressField], + 'state' => [ + 'nullable', + 'string', + 'required_with:'.$anyAddressField, + Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id), + ], + 'contact_phone' => ['nullable', 'string', 'max:30'], + 'recovery_consent' => ['boolean'], + ]); + + $invoice = $request->boolean('invoice'); + + $this->account->updateProfile($user, [ + 'first_name' => $data['first_name'] ?? null, + 'last_name' => $data['last_name'] ?? null, + 'company_name' => $invoice ? $data['company_name'] : null, + 'tax_identifier' => $invoice ? $data['tax_identifier'] : null, + ]); + + if (filled($data['line_one'] ?? null)) { + $addressData = [ + ...collect($data)->only(self::ADDRESS_FIELDS)->all(), + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'country_id' => $country->id, + 'contact_email' => $user->email, + 'shipping_default' => true, + 'billing_default' => true, + ]; + + $existing = $this->defaultAddress($user); + + $existing + ? $this->account->updateAddress($user, $existing->id, $addressData) + : $this->account->createAddress($user, $addressData); + } + + $this->updateRecoveryConsent($user, $request->boolean('recovery_consent')); + + return redirect()->route('account')->with('status', __('storefront.account.saved')); + } + + /** + * "Email me a reminder if I don't finish my order", as a standing + * choice — stored on the customer via boboko-core's + * CustomerAccountService::setRecoveryConsent(), and applied to the + * current cart too, so opting out stops reminders for it right away. + */ + private function updateRecoveryConsent($user, bool $consent): void + { + $this->account->setRecoveryConsent($user, $consent); + + // Only an existing cart; never create one just to record this. + $cart = app(CartService::class)->current(); + + if ($cart && (bool) data_get($cart, 'meta.recovery_consent') !== $consent) { + app(CheckoutService::class)->setRecoveryConsent($consent); + } + } + + /** + * Self-service deletion: opens core's 30-day grace-period erasure request + * (which blocks the login right away) and logs out. Logging back in within + * the grace period cancels it; see core's docs/privacy.md. + */ + public function destroy(string $locale, Request $request, PrivacyService $privacy): RedirectResponse + { + $user = $request->user(); + + $privacy->requestErasureForUser($user, $user); + + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('login')->with('status', __('storefront.account.deletion_requested')); + } + + private function defaultAddress($user) + { + $addresses = collect($this->account->addresses($user)); + + return $addresses->firstWhere('shipping_default', true) ?? $addresses->first(); + } + + private function storeCountry(): Country + { + return Country::where('iso3', self::STORE_COUNTRY_ISO3)->firstOrFail(); + } +} diff --git a/app/Http/Controllers/Account/EmailController.php b/app/Http/Controllers/Account/EmailController.php new file mode 100644 index 0000000..df860e8 --- /dev/null +++ b/app/Http/Controllers/Account/EmailController.php @@ -0,0 +1,123 @@ + $request->user()]); + } + + public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse + { + $user = $request->user(); + + $request->merge(['email' => Str::lower(trim((string) $request->input('email')))]); + + $validated = $request->validate([ + 'email' => [ + 'required', + 'email', + 'max:255', + Rule::notIn([$user->email]), + ], + ], [ + 'email.not_in' => __('storefront.account.email_same'), + ]); + + try { + $emailChange->request($user, $validated['email']); + } catch (EmailAlreadyTakenException) { + return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]); + } catch (OtpThrottledException) { + return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]); + } + + $request->session()->put(self::SESSION_KEY, $validated['email']); + + return redirect()->route('account.email.code'); + } + + public function code(string $locale, Request $request): View|RedirectResponse + { + $pendingEmail = $request->session()->get(self::SESSION_KEY); + + if (! $pendingEmail) { + return redirect()->route('account.email.edit'); + } + + return view('account.email-code', ['email' => $pendingEmail]); + } + + public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse + { + $pendingEmail = $request->session()->get(self::SESSION_KEY); + + if (! $pendingEmail) { + return redirect()->route('account.email.edit'); + } + + try { + $emailChange->request($request->user(), $pendingEmail); + } catch (EmailAlreadyTakenException) { + $request->session()->forget(self::SESSION_KEY); + + return redirect()->route('account.email.edit') + ->withErrors(['email' => __('storefront.account.email_taken')]); + } catch (OtpThrottledException) { + return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]); + } + + return back()->with('status', __('storefront.auth.code_resent')); + } + + public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse + { + $pendingEmail = $request->session()->get(self::SESSION_KEY); + + if (! $pendingEmail) { + return redirect()->route('account.email.edit'); + } + + $validated = $request->validate(['code' => ['required', 'digits:6']]); + + try { + $emailChange->confirm($request->user(), $validated['code']); + } catch (EmailAlreadyTakenException) { + $request->session()->forget(self::SESSION_KEY); + + return redirect()->route('account.email.edit') + ->withErrors(['email' => __('storefront.account.email_taken')]); + } catch (InvalidEmailChangeCodeException) { + return back()->withErrors(['code' => __('storefront.auth.invalid_code')]); + } + + $request->session()->forget(self::SESSION_KEY); + + return redirect()->route('account')->with('status', __('storefront.account.email_changed')); + } +} diff --git a/app/Http/Controllers/Account/OrderController.php b/app/Http/Controllers/Account/OrderController.php new file mode 100644 index 0000000..a8802ae --- /dev/null +++ b/app/Http/Controllers/Account/OrderController.php @@ -0,0 +1,50 @@ + $this->account->orders($request->user(), 15), + ]); + } + + public function show(string $locale, Request $request, int $orderId): View + { + try { + $order = $this->account->order($request->user(), $orderId); + } catch (OrderNotFoundException) { + abort(404); + } + + // By type, like the checkout confirmation: the method may since have + // been disabled, but the order still shows what was used. + $paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null) + ->first() + ?->translate('name'); + + return view('account.orders.show', [ + 'order' => $order, + 'paymentMethodName' => $paymentMethodName, + 'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'), + ]); + } +} diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php new file mode 100644 index 0000000..6245d73 --- /dev/null +++ b/app/Http/Controllers/Auth/LoginController.php @@ -0,0 +1,135 @@ +query('redirect', ''); + + if (preg_match('#^/(?![/\\\\])#', $redirect)) { + $request->session()->put('url.intended', url($redirect)); + } + + return view('auth.login'); + } + + public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse + { + // Captcha only here: verify() and resend() need the email this step + // puts in the session, so they can't be reached without passing it. + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'h-captcha-response' => ['bail', 'required', new HCaptcha], + ], [ + 'h-captcha-response.required' => __('storefront.auth.captcha_failed'), + ]); + + $email = Str::lower(trim($validated['email'])); + + try { + $otp->generateAndSend($email); + } catch (OtpThrottledException) { + return back()->withInput()->withErrors([ + 'email' => __('storefront.auth.too_many_codes'), + ]); + } + + $request->session()->put('login.email', $email); + + return redirect()->route('login.code'); + } + + public function code(string $locale, Request $request): View|RedirectResponse + { + $email = $request->session()->get('login.email'); + + if (! $email) { + return redirect()->route('login'); + } + + return view('auth.login-code', ['email' => $email]); + } + + public function resend(string $locale, Request $request, UserOtpService $otp): RedirectResponse + { + $email = $request->session()->get('login.email'); + + if (! $email) { + return redirect()->route('login'); + } + + try { + $otp->generateAndSend($email); + } catch (OtpThrottledException) { + return back()->withErrors([ + 'code' => __('storefront.auth.too_many_codes'), + ]); + } + + return back()->with('status', __('storefront.auth.code_resent')); + } + + public function verify(string $locale, Request $request, UserOtpService $otp): RedirectResponse + { + $email = $request->session()->get('login.email'); + + if (! $email) { + return redirect()->route('login'); + } + + $validated = $request->validate([ + 'code' => ['required', 'digits:6'], + ]); + + // Wrong, expired, or locked out after too many guesses — core doesn't + // say which, so neither do we; the page offers "resend code" for all three. + if (! $otp->validate($email, $validated['code'], $request)) { + return back()->withErrors([ + 'code' => __('storefront.auth.invalid_code'), + ]); + } + + $request->session()->forget('login.email'); + + return redirect()->intended(route('home')); + } + + public function destroy(string $locale, Request $request): RedirectResponse + { + Auth::logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('home'); + } +} diff --git a/app/Http/Controllers/Checkout/CartController.php b/app/Http/Controllers/Checkout/CartController.php deleted file mode 100644 index 438195f..0000000 --- a/app/Http/Controllers/Checkout/CartController.php +++ /dev/null @@ -1,89 +0,0 @@ -validate([ - 'purchasable_id' => ['required', 'integer'], - 'quantity' => ['nullable', 'integer', 'min:1'], - ]); - - $variant = ProductVariant::findOrFail($data['purchasable_id']); - - $this->cart->addLine($variant, $data['quantity'] ?? 1); - - return view('checkout::partials.cart-body'); - } - - public function updateLine(string $locale, Request $request, int $line): View - { - $quantity = (int) $request->validate([ - 'quantity' => ['required', 'integer', 'min:0'], - ])['quantity']; - - $quantity === 0 - ? $this->cart->removeLine($line) - : $this->cart->updateLine($line, $quantity); - - return view('checkout::partials.cart-body'); - } - - 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 1e391c2..0000000 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ /dev/null @@ -1,531 +0,0 @@ -cart->current(); - $lines = $cart ? $this->cart->activeLines($cart) : collect(); - $storeCountry = $this->storeCountry(); - - $shippingOptions = collect(); - - if ($cart?->shippingAddress) { - // Nothing recreates the address row in this path — its own current - // value is the correct "previous" to carry forward if still valid. - $shippingOptions = $this->syncShipping($cart, $cart->shippingAddress->shipping_option); - - // 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(); - } - - return view('checkout::page', [ - 'cart' => $cart, - 'lines' => $lines, - '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 - ? 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_line_two' => ['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_company_name' => ['nullable', 'string', 'max:255'], - 'shipping_line_one' => ['nullable', 'string', 'max:255'], - 'shipping_line_two' => ['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(); - - $billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null); - $shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId); - - $billing = [ - 'first_name' => $data['billing_first_name'] ?? null, - 'last_name' => $data['billing_last_name'] ?? null, - 'company_name' => $data['billing_company_name'] ?? null, - 'tax_identifier' => $data['billing_tax_identifier'] ?? null, - 'line_one' => $data['billing_line_one'] ?? null, - 'line_two' => $data['billing_line_two'] ?? 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 - ? [...$billing, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null] - : [ - 'first_name' => $data['shipping_first_name'] ?? null, - 'last_name' => $data['shipping_last_name'] ?? null, - 'company_name' => $data['shipping_company_name'] ?? null, - 'line_one' => $data['shipping_line_one'] ?? null, - 'line_two' => $data['shipping_line_two'] ?? 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]; - $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')); - - $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); - } - - /** - * A plain, own-hosted stand-in for Box Now's Destination Map widget — - * that widget only talks to their Production environment (see their - * Partner API manual §4.1), which is useless while developing against - * Stage credentials. Same underlying data (GET /destinations), no map. - */ - public function boxNowLockers(string $locale, BoxNowClient $boxNow): JsonResponse - { - $lockers = collect($boxNow->destinations()) - // Drops entries with a blank `name` (e.g. id 8288, "Virtual - // Locker" in Sudan at lat 12.3/lng 25.3) — a real, in-range - // coordinate, but sandbox test fixture noise rather than an - // actual pickup point, and it alone was enough to make - // fitBounds() below zoom the map out to the whole Balkans/ - // Middle East to fit every marker's cluster in Greece. - ->filter(fn (array $destination) => filled($destination['name'] ?? null)) - ->map(fn (array $destination) => [ - 'id' => $destination['id'], - 'name' => $destination['name'] ?? $destination['title'] ?? $destination['id'], - 'addressLine1' => $destination['addressLine1'] ?? null, - 'addressLine2' => $destination['addressLine2'] ?? null, - 'postalCode' => $destination['postalCode'] ?? null, - 'country' => $destination['country'] ?? null, - 'note' => $destination['note'] ?? null, - 'image' => $destination['image'] ?? null, - 'lat' => isset($destination['lat']) ? (float) $destination['lat'] : null, - 'lng' => isset($destination['lng']) ? (float) $destination['lng'] : null, - ]) - // Box Now's own Stage/sandbox data has at least one malformed - // entry observed in practice (locker id 47: lat/lng as huge - // integers with the decimal point apparently dropped, e.g. - // 96065874308606 instead of ~37.96) — a single such point blows - // out L.featureGroup().getBounds() on the frontend, zooming the - // map out to near-nothing with every real marker imperceptible - // at that scale. Valid latitude/longitude ranges are absolute, - // not guesswork, so filtering on them is safe regardless of - // what BoxNow's API does or doesn't fix upstream. - ->filter(fn (array $locker) => $locker['lat'] !== null && $locker['lng'] !== null - && abs($locker['lat']) <= 90 && abs($locker['lng']) <= 180) - ->values(); - - return response()->json(['lockers' => $lockers]); - } - - /** - * Persists the shopper's chosen locker (radio/select change, same - * autosave shape as selectShippingOption()) via - * CheckoutService::selectBoxNowLocker() onto the cart's shipping - * address meta. - */ - public function selectBoxNowLocker(string $locale, Request $request): JsonResponse - { - $locationId = (string) $request->input('locker_id'); - - if ($locationId === '') { - return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422); - } - - try { - $this->checkout->selectBoxNowLocker([ - 'locationId' => $locationId, - 'name' => (string) $request->input('locker_name'), - 'addressLine1' => (string) $request->input('locker_address'), - ]); - } catch (NoShippingAddressException) { - return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422); - } - - return response()->json(['ok' => true]); - } - - /** - * 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(); - - // 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); - } - - 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, 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(), - ]); - } - - 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/Http/Controllers/ContactController.php b/app/Http/Controllers/ContactController.php index dbad520..bcb5ca4 100644 --- a/app/Http/Controllers/ContactController.php +++ b/app/Http/Controllers/ContactController.php @@ -2,10 +2,71 @@ namespace App\Http\Controllers; +use App\Http\Requests\ContactRequest; +use App\Mail\ContactConfirmationMail; +use App\Mail\ContactMessageMail; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\View\View; +use Throwable; + +/** + * Contact form. Nothing is stored: the message is emailed to the store + * (CONTACT_EMAIL) and the sender gets a generic confirmation. Both are sent + * synchronously so a failed store email can be reported back on the form. + * + * Limited per IP in here rather than with throttle middleware, so the limit + * shows as a message on the form instead of a bare 429 page. + */ class ContactController extends Controller { - public function index(string $locale) + private const SEND_LIMIT = 3; + private const SEND_DECAY_SECONDS = 600; + + public function index(string $locale): View { return view('contact'); } + + public function send(string $locale, ContactRequest $request): RedirectResponse + { + $key = 'contact:'.$request->ip(); + + if (RateLimiter::tooManyAttempts($key, self::SEND_LIMIT)) { + return back()->withInput()->with('contact_error', __('storefront.contact.too_many')); + } + + RateLimiter::hit($key, self::SEND_DECAY_SECONDS); + + $data = $request->validated(); + $to = config('services.contact.email'); + + try { + if (blank($to)) { + throw new \RuntimeException('CONTACT_EMAIL is not set.'); + } + + Mail::to($to)->send(new ContactMessageMail( + $data['name'], + $data['email'], + $data['message'], + $locale, + )); + } catch (Throwable $e) { + Log::error('Contact form: store email failed', ['exception' => $e]); + + return back()->withInput()->with('contact_error', __('storefront.contact.send_failed')); + } + + // The store already has the message, so a failed confirmation is only logged. + try { + Mail::to($data['email'])->locale($locale)->send(new ContactConfirmationMail); + } catch (Throwable $e) { + Log::warning('Contact form: confirmation email failed', ['exception' => $e]); + } + + return redirect()->route('contact')->with('status', __('storefront.contact.sent')); + } } diff --git a/app/Http/Controllers/CustomFieldUploadController.php b/app/Http/Controllers/CustomFieldUploadController.php new file mode 100644 index 0000000..4a47653 --- /dev/null +++ b/app/Http/Controllers/CustomFieldUploadController.php @@ -0,0 +1,62 @@ + ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES], + ]; + } + + // `label` is the admin-authored field label, only used as the + // :attribute in the validation message shown next to that field. + protected function validationAttributes(Request $request): array + { + return ['file' => (string) $request->input('label', 'file')]; + } +} diff --git a/app/Http/Controllers/ProductController.php b/app/Http/Controllers/ProductController.php index e390712..011bf62 100644 --- a/app/Http/Controllers/ProductController.php +++ b/app/Http/Controllers/ProductController.php @@ -4,8 +4,15 @@ use App\Catalog\ProductListing; use App\Catalog\ProductListingPage; +use Illuminate\Http\JsonResponse; +use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\Validator; +use Lunar\Models\Product; +use Lunar\Models\ProductVariant; use Modules\Core\Catalog\Services\ProductService; +use Modules\Core\Review\Models\ProductReview; class ProductController extends Controller { @@ -35,18 +42,208 @@ public function show(string $locale, int $id) $product = $this->products->getById($id); abort_if($product === null, Response::HTTP_NOT_FOUND); + $product = $this->mergeJustSubmittedReview($product); + $collection = $product['collections'][0] ?? null; - - $variantsData = $this->products->variantSummaries($product); - - $firstVariant = $product['variants'][0] ?? null; - $option = $firstVariant['options'][0]['option'] ?? null; + // dd($product); + [$productOptions, $variantsData] = $this->buildOptionPicker($id); return view('product.show', [ 'collection' => $collection, 'product' => $product, - 'option' => $option, + 'productOptions' => $productOptions, 'variantsData' => $variantsData, ]); } + + /** + * One button/swatch group per product option (a product can have several + * — e.g. a custom-photo product with size + style + person-count, each + * combination resolved client-side to one exact ProductVariant, see + * product-form-controller.js) plus the per-variant data the picker + * resolves a selection against. + * + * Reads live Eloquent rather than the Meilisearch-indexed $product array + * the rest of the page uses (same reasoning as checkStock() below): the + * index has no concept of option/value display order (Lunar's + * `position` column) or a stable value id, both of which the picker + * needs — to render values in the merchant's intended order, and to + * match a combination back to one exact variant without relying on + * translated label strings staying unique. + * + * @return array{0: array, 1: array} + */ + private function buildOptionPicker(int $productId): array + { + $variants = ProductVariant::query() + ->where('product_id', $productId) + ->with(['values.option', 'prices', 'images']) + ->get(); + + $productOptions = $variants + ->flatMap(fn (ProductVariant $variant) => $variant->values) + ->unique('id') + ->groupBy(fn ($value) => $value->option->handle) + ->map(function ($values, $handle) { + $sorted = $values->sortBy([['position', 'asc'], ['id', 'asc']]); + + return [ + 'handle' => $handle, + 'label' => $sorted->first()->option->translate('name'), + // The "Color" option type is the only one that writes a + // hex code into meta (see boboko/core's ColorOptionType) + // — its presence is how we tell a color option (swatches) + // from any other option (buttons). + 'isColor' => $sorted->contains(fn ($v) => !empty($v->meta['hex'] ?? null)), + 'values' => $sorted->map(fn ($v) => [ + 'id' => $v->id, + 'label' => $v->translate('name'), + 'hex' => $v->meta['hex'] ?? null, + ])->values()->all(), + ]; + }) + ->values() + ->all(); + + $variantsData = $variants->map(fn (ProductVariant $variant) => [ + 'id' => $variant->id, + 'price' => $variant->prices->first()?->price?->decimal(), + 'image' => $variant->images->first()?->getUrl(), + // Live from the DB (not the index's in_stock), with Lunar's own + // purchasability rule — drives the disabled add-to-cart button. + 'inStock' => $variant->canBeFulfilledAtQuantity(1), + // handle => selected value id, for matching a combination of + // selections back to this variant — see selectVariant() in + // product-form-controller.js. + 'options' => $variant->values->mapWithKeys(fn ($v) => [$v->option->handle => $v->id])->all(), + ])->values()->all(); + + return [$productOptions, $variantsData]; + } + + /** + * Meilisearch's own write API is itself async — addDocuments() enqueues an + * indexing task and returns immediately, and Laravel\Scout\Engines\ + * MeilisearchEngine::update() never waits on that task, so even + * $product->searchableSync() (which only skips OUR queue) can still land + * the shopper back on this page before Meilisearch has actually processed + * the write. storeReview() flashes the review it just created for exactly + * this one next request; splice it in here rather than trust the index is + * already caught up. Guarded by id so a race the other way — the index + * DID catch up in time — doesn't show the same review twice. + */ + private function mergeJustSubmittedReview(array $product): array + { + $justSubmitted = session('justSubmittedReview'); + + if (! $justSubmitted || (string) ($justSubmitted['product_id'] ?? null) !== (string) $product['id']) { + return $product; + } + + $items = $product['reviews']['items'] ?? []; + + if (collect($items)->contains('id', $justSubmitted['id'])) { + return $product; + } + + $items = [$justSubmitted, ...$items]; + + $product['reviews']['items'] = $items; + $product['reviews']['count'] = count($items); + $product['reviews']['average_rating'] = round(collect($items)->avg('rating'), 1); + + return $product; + } + + /** + * A storefront-owned, checkout-module-independent stock check — the + * product page's "Add to cart" calls this first and only submits to the + * checkout module's own add-to-cart endpoint once this says `ok`. Reads + * the live Eloquent ProductVariant directly (not the Meilisearch index + * ProductService otherwise reads from, which can lag behind an actual + * sale until the next reindex) via the SAME method Lunar's own + * CartLineStock validator calls, so this can never disagree with what + * the module's own server-side check would decide. + */ + public function checkStock(string $locale, Request $request): JsonResponse + { + $data = $request->validate([ + 'variant' => ['required', 'integer'], + 'quantity' => ['nullable', 'integer', 'min:1'], + ]); + + $variant = ProductVariant::find($data['variant']); + $quantity = $data['quantity'] ?? 1; + + if ($variant === null) { + return response()->json(['ok' => true]); + } + + return response()->json([ + 'ok' => $variant->canBeFulfilledAtQuantity($quantity), + 'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(), + ]); + } + + /** + * boboko/core's product_reviews table has no moderation/status column, so + * this goes live immediately — no approval queue to land in. + */ + public function storeReview(string $locale, Request $request, Product $product): RedirectResponse + { + $reviewsUrl = route('product.show', [ + 'locale' => $locale, + 'id' => $product->id, + 'tab' => 'reviews', + ]).'#product-tabs'; + + $validator = Validator::make($request->all(), [ + 'rating' => ['required', 'integer', 'between:1,5'], + 'content' => ['required', 'string'], + 'name' => ['nullable', 'string', 'max:255'], + 'email' => ['required', 'email'], + ]); + + if ($validator->fails()) { + return redirect($reviewsUrl)->withErrors($validator)->withInput(); + } + + $data = $validator->validated(); + + // Not $product->reviews()->create(...): that relation only exists via a + // Product::macro() registered in CorePlugin::register(Panel $panel), which + // Filament calls solely when the /boboko admin panel boots — never on a + // plain storefront request, where the macro is simply undefined. + $review = ProductReview::create([ + 'product_id' => $product->id, + 'rating' => $data['rating'], + 'body' => $data['content'], + 'reviewer_name' => $data['name'] ?? null, + 'reviewer_email' => $data['email'], + 'reviewed_at' => now(), + 'source' => 'storefront', + ]); + + // ReviewServiceProvider also reindexes on the model's `created` event, but + // queued (SCOUT_QUEUE=true) — it wouldn't land before this redirect's page + // load. Syncing here skips our queue too, but Meilisearch's own write API + // is itself async on top of that (see mergeJustSubmittedReview()), so this + // alone still isn't a guarantee — it's the flash below that actually is. + $product->searchableSync(); + + return redirect($reviewsUrl) + ->with('reviewSubmitted', true) + ->with('justSubmittedReview', [ + 'id' => $review->id, + 'product_id' => $review->product_id, + 'title' => $review->title, + 'body' => $review->body, + 'rating' => $review->rating, + 'reviewed_at' => $review->reviewed_at?->timestamp, + 'reviewer_name' => $review->reviewer_name, + 'reply' => null, + 'replied_at' => null, + 'media' => [], + ]); + } } diff --git a/app/Http/Controllers/WishlistController.php b/app/Http/Controllers/WishlistController.php new file mode 100644 index 0000000..5dcbc8f --- /dev/null +++ b/app/Http/Controllers/WishlistController.php @@ -0,0 +1,72 @@ + $this->products($products)]); + } + + /** + * The same list for a guest, from their cookie. Logged-in users are sent + * to the account version. + */ + public function guest(string $locale, ProductService $products): View|RedirectResponse + { + if (auth()->check()) { + return redirect()->route('account.wishlist'); + } + + return view('wishlist.guest', ['products' => $this->products($products)]); + } + + /** + * Adds or removes a product, for guests and logged-in shoppers alike. The + * heart button's Stimulus controller asks for JSON; without JS the form + * posts normally and comes back to the same page. + */ + public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse + { + abort_unless(Product::whereKey($productId)->exists(), 404); + + $active = $this->wishlist->toggle($productId); + + if ($request->expectsJson()) { + return response()->json(['active' => $active]); + } + + return back(); + } + + /** + * Product cards for the current wishlist, newest first. Products no longer + * in the search index (deleted, unpublished) are simply skipped. + */ + private function products(ProductService $products): Collection + { + return collect($this->wishlist->ids()) + ->map(fn (int $id) => $products->getById($id)) + ->filter() + ->map(fn (array $product) => ['id' => $product['id'], ...ProductCard::fromIndexed($product)]) + ->values(); + } +} diff --git a/app/Http/Requests/ContactRequest.php b/app/Http/Requests/ContactRequest.php new file mode 100644 index 0000000..80cb496 --- /dev/null +++ b/app/Http/Requests/ContactRequest.php @@ -0,0 +1,31 @@ + ['required', 'string', 'max:100'], + 'email' => ['required', 'email', 'max:255'], + 'message' => ['required', 'string', 'max:5000'], + 'h-captcha-response' => ['bail', 'required', new HCaptcha], + ]; + } + + public function messages(): array + { + return [ + 'h-captcha-response.required' => __('storefront.auth.captcha_failed'), + ]; + } +} diff --git a/app/Listeners/MergeGuestWishlistOnLogin.php b/app/Listeners/MergeGuestWishlistOnLogin.php new file mode 100644 index 0000000..5d1b90d --- /dev/null +++ b/app/Listeners/MergeGuestWishlistOnLogin.php @@ -0,0 +1,23 @@ +wishlist->mergeGuestInto($event->user); + } +} diff --git a/app/Listeners/SaveAddressFromFirstOrder.php b/app/Listeners/SaveAddressFromFirstOrder.php new file mode 100644 index 0000000..75b6b3a --- /dev/null +++ b/app/Listeners/SaveAddressFromFirstOrder.php @@ -0,0 +1,57 @@ +order; + $user = $order->user; + $shipping = $order->shippingAddress; + + if (! $user || ! $shipping || ! $shipping->line_one) { + return; + } + + $customer = $this->account->customer($user); + + if (! $customer) { + return; + } + + if (! $customer->first_name && ! $customer->last_name) { + $this->account->updateProfile($user, [ + 'first_name' => $shipping->first_name, + 'last_name' => $shipping->last_name, + ]); + } + + if (collect($this->account->addresses($user))->isNotEmpty()) { + return; + } + + $this->account->createAddress($user, [ + ...$shipping->only(['first_name', 'last_name', 'line_one', 'city', 'state', 'postcode', 'country_id', 'contact_phone']), + 'contact_email' => $user->email, + 'shipping_default' => true, + 'billing_default' => true, + ]); + } +} diff --git a/app/Mail/ContactConfirmationMail.php b/app/Mail/ContactConfirmationMail.php new file mode 100644 index 0000000..4f3828e --- /dev/null +++ b/app/Mail/ContactConfirmationMail.php @@ -0,0 +1,25 @@ +senderEmail, $this->senderName)], + subject: "Νέο μήνυμα επικοινωνίας από {$this->senderName}", + ); + } + + public function content(): Content + { + return new Content(view: 'emails.contact-message'); + } +} diff --git a/app/Models/WishlistItem.php b/app/Models/WishlistItem.php new file mode 100644 index 0000000..2276309 --- /dev/null +++ b/app/Models/WishlistItem.php @@ -0,0 +1,14 @@ +app->scoped(\App\Services\Wishlist::class); + } + public function boot(): void { Telemetry::optOut(); 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/app/Rules/HCaptcha.php b/app/Rules/HCaptcha.php new file mode 100644 index 0000000..9061b82 --- /dev/null +++ b/app/Rules/HCaptcha.php @@ -0,0 +1,66 @@ + ['required', new HCaptcha]`. + * + * Fails closed: if hCaptcha can't be reached the submission is rejected, since + * letting it through would reopen the hole this exists to close (bots making + * us send email to arbitrary addresses). + */ +class HCaptcha implements ValidationRule +{ + public function validate(string $attribute, mixed $value, Closure $fail): void + { + if (! is_string($value) || $value === '') { + $fail(__('storefront.auth.captcha_failed')); + + return; + } + + try { + $response = Http::asForm() + ->timeout(5) + ->post('https://api.hcaptcha.com/siteverify', [ + 'secret' => config('services.hcaptcha.secret'), + 'response' => $value, + // Rejects tokens solved against someone else's sitekey. + 'sitekey' => config('services.hcaptcha.sitekey'), + 'remoteip' => request()->ip(), + ]); + } catch (ConnectionException $e) { + Log::warning('hCaptcha siteverify unreachable', ['error' => $e->getMessage()]); + $fail(__('storefront.auth.captcha_failed')); + + return; + } + + if (! $response->successful() || $response->json('success') !== true) { + // A bad/missing secret or sitekey would otherwise look like every + // shopper failing the captcha. + $configErrors = array_intersect((array) $response->json('error-codes'), [ + 'missing-input-secret', + 'invalid-input-secret', + 'sitekey-secret-mismatch', + 'invalid-sitekey', + ]); + + if ($response->failed() || $configErrors) { + Log::warning('hCaptcha siteverify error', [ + 'status' => $response->status(), + 'error-codes' => $response->json('error-codes'), + ]); + } + + $fail(__('storefront.auth.captcha_failed')); + } + } +} diff --git a/app/Services/Wishlist.php b/app/Services/Wishlist.php new file mode 100644 index 0000000..e9d8c95 --- /dev/null +++ b/app/Services/Wishlist.php @@ -0,0 +1,126 @@ +|null ids for this request, including a toggle just made */ + private ?array $guestIds = null; + + /** @return array newest first */ + public function ids(): array + { + if ($user = Auth::user()) { + return WishlistItem::where('user_id', $user->id) + ->latest('id') + ->pluck('product_id') + ->all(); + } + + return $this->guestIds(); + } + + public function has(int $productId): bool + { + return in_array($productId, $this->ids(), true); + } + + /** + * @return bool whether the product is on the wishlist afterwards + */ + public function toggle(int $productId): bool + { + if ($user = Auth::user()) { + $deleted = WishlistItem::where('user_id', $user->id)->where('product_id', $productId)->delete(); + + if ($deleted) { + return false; + } + + WishlistItem::create(['user_id' => $user->id, 'product_id' => $productId]); + + return true; + } + + $ids = $this->guestIds(); + + if (in_array($productId, $ids, true)) { + $this->storeGuestIds(array_values(array_diff($ids, [$productId]))); + + return false; + } + + $this->storeGuestIds(array_slice([$productId, ...$ids], 0, self::GUEST_MAX)); + + return true; + } + + public function remove(int $productId): void + { + if ($this->has($productId)) { + $this->toggle($productId); + } + } + + /** + * Moves the guest cookie's products onto $user's wishlist and clears it. + */ + public function mergeGuestInto(Authenticatable $user): void + { + $ids = $this->guestIds(); + + if ($ids === []) { + return; + } + + // Oldest first, so the newest cookie item also ends up newest here. + foreach (array_reverse($ids) as $productId) { + WishlistItem::firstOrCreate(['user_id' => $user->id, 'product_id' => $productId]); + } + + $this->guestIds = []; + Cookie::queue(Cookie::forget(self::COOKIE)); + } + + /** @return array */ + private function guestIds(): array + { + if ($this->guestIds !== null) { + return $this->guestIds; + } + + $decoded = json_decode((string) request()->cookie(self::COOKIE), true); + + return $this->guestIds = is_array($decoded) + ? array_values(array_unique(array_filter(array_map('intval', $decoded)))) + : []; + } + + /** @param array $ids */ + private function storeGuestIds(array $ids): void + { + $this->guestIds = $ids; + + Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c183276..2957643 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,7 +11,18 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + // Laravel's priority list would otherwise run `auth` before core's + // `locale` middleware, so the redirects below would build URLs before + // URL::defaults(['locale' => …]) is set, throwing a missing-parameter error. + $middleware->prependToPriorityList( + before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class, + prepend: \Modules\Core\Localization\Middleware\LocaleMiddleware::class, + ); + + // Both resolve inside the {locale} group, after the `locale` middleware + // has set URL::defaults(['locale' => …]), so route() needs no locale arg. + $middleware->redirectGuestsTo(fn () => route('login')); + $middleware->redirectUsersTo(fn () => route('home')); }) ->withExceptions(function (Exceptions $exceptions): void { // 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 @@ =0.8.16 <=0.18", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -7978,9 +7916,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.3" + "source": "https://github.com/ramsey/uuid/tree/4.9.4" }, - "time": "2026-06-18T03:57:49+00:00" + "time": "2026-09-16T11:39:30+00:00" }, { "name": "ryangjchandler/blade-capture-directive", @@ -8062,35 +8000,33 @@ }, { "name": "sabberworm/php-css-parser", - "version": "v9.4.0", + "version": "v9.5.0", "source": { "type": "git", "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", - "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", - "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/f284e63b6e891e0c28631e54ba06c3ed102a9ef3", + "reference": "f284e63b6e891e0c28631e54ba06c3ed102a9ef3", "shasum": "" }, "require": { "ext-iconv": "*", - "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "1.4.0", "phpstan/extension-installer": "1.4.3", - "phpstan/phpstan": "1.12.33 || 2.2.2", - "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", - "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", - "phpunit/phpunit": "8.5.52", + "phpstan/phpstan": "1.12.33 || 2.2.9", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.18", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.12", + "phpunit/phpunit": "8.5.54", "rawr/phpunit-data-provider": "3.3.1", - "rector/rector": "1.2.10 || 2.4.6", - "rector/type-perfect": "1.0.0 || 2.1.3", - "squizlabs/php_codesniffer": "4.0.1", - "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + "rector/rector": "1.2.10 || 2.6.2", + "rector/type-perfect": "1.0.0 || 2.1.4", + "squizlabs/php_codesniffer": "4.0.4" }, "suggest": { "ext-mbstring": "for parsing UTF-8 CSS" @@ -8098,7 +8034,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "9.5.x-dev" + "dev-main": "9.6.x-dev" } }, "autoload": { @@ -8136,9 +8072,9 @@ ], "support": { "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", - "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.5.0" }, - "time": "2026-06-18T15:10:53+00:00" + "time": "2026-09-20T15:02:00+00:00" }, { "name": "scrivo/highlight.php", @@ -12665,16 +12601,16 @@ }, { "name": "technikermathe/blade-lucide-icons", - "version": "v3.180.0", + "version": "v3.182.0", "source": { "type": "git", "url": "https://github.com/PascaleBeier/blade-lucide-icons.git", - "reference": "adac9da3a65910fedc271129eea5340093910203" + "reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/adac9da3a65910fedc271129eea5340093910203", - "reference": "adac9da3a65910fedc271129eea5340093910203", + "url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/11253aa7d9aa7430da61d3534c62dc69a95b703b", + "reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b", "shasum": "" }, "require": { @@ -12724,152 +12660,9 @@ ], "support": { "issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues", - "source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.180.0" + "source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.182.0" }, - "time": "2026-09-15T02:25:56+00:00" - }, - { - "name": "thecodingmachine/safe", - "version": "v3.4.0", - "source": { - "type": "git", - "url": "https://github.com/thecodingmachine/safe.git", - "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", - "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", - "shasum": "" - }, - "require": { - "php": "^8.1" - }, - "require-dev": { - "php-parallel-lint/php-parallel-lint": "^1.4", - "phpstan/phpstan": "^2", - "phpunit/phpunit": "^10", - "squizlabs/php_codesniffer": "^3.2" - }, - "type": "library", - "autoload": { - "files": [ - "lib/special_cases.php", - "generated/apache.php", - "generated/apcu.php", - "generated/array.php", - "generated/bzip2.php", - "generated/calendar.php", - "generated/classobj.php", - "generated/com.php", - "generated/cubrid.php", - "generated/curl.php", - "generated/datetime.php", - "generated/dir.php", - "generated/eio.php", - "generated/errorfunc.php", - "generated/exec.php", - "generated/fileinfo.php", - "generated/filesystem.php", - "generated/filter.php", - "generated/fpm.php", - "generated/ftp.php", - "generated/funchand.php", - "generated/gettext.php", - "generated/gmp.php", - "generated/gnupg.php", - "generated/hash.php", - "generated/ibase.php", - "generated/ibmDb2.php", - "generated/iconv.php", - "generated/image.php", - "generated/imap.php", - "generated/info.php", - "generated/inotify.php", - "generated/json.php", - "generated/ldap.php", - "generated/libxml.php", - "generated/lzf.php", - "generated/mailparse.php", - "generated/mbstring.php", - "generated/misc.php", - "generated/mysql.php", - "generated/mysqli.php", - "generated/network.php", - "generated/oci8.php", - "generated/opcache.php", - "generated/openssl.php", - "generated/outcontrol.php", - "generated/pcntl.php", - "generated/pcre.php", - "generated/pgsql.php", - "generated/posix.php", - "generated/ps.php", - "generated/pspell.php", - "generated/readline.php", - "generated/rnp.php", - "generated/rpminfo.php", - "generated/rrd.php", - "generated/sem.php", - "generated/session.php", - "generated/shmop.php", - "generated/sockets.php", - "generated/sodium.php", - "generated/solr.php", - "generated/spl.php", - "generated/sqlsrv.php", - "generated/ssdeep.php", - "generated/ssh2.php", - "generated/stream.php", - "generated/strings.php", - "generated/swoole.php", - "generated/uodbc.php", - "generated/uopz.php", - "generated/url.php", - "generated/var.php", - "generated/xdiff.php", - "generated/xml.php", - "generated/xmlrpc.php", - "generated/yaml.php", - "generated/yaz.php", - "generated/zip.php", - "generated/zlib.php" - ], - "classmap": [ - "lib/DateTime.php", - "lib/DateTimeImmutable.php", - "lib/Exceptions/", - "generated/Exceptions/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHP core functions that throw exceptions instead of returning FALSE on error", - "support": { - "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" - }, - "funding": [ - { - "url": "https://github.com/OskarStark", - "type": "github" - }, - { - "url": "https://github.com/shish", - "type": "github" - }, - { - "url": "https://github.com/silasjoisten", - "type": "github" - }, - { - "url": "https://github.com/staabm", - "type": "github" - } - ], - "time": "2026-02-04T18:08:13+00:00" + "time": "2026-09-25T02:29:00+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", diff --git a/config/catalog.php b/config/catalog.php new file mode 100644 index 0000000..14af9e4 --- /dev/null +++ b/config/catalog.php @@ -0,0 +1,25 @@ + [ + SameCategoryRule::class, + RandomRule::class, + ], +]; diff --git a/config/checkout.php b/config/checkout.php new file mode 100644 index 0000000..54589b3 --- /dev/null +++ b/config/checkout.php @@ -0,0 +1,44 @@ +`, so the + * 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/lunar/search.php b/config/lunar/search.php index e885456..8e38315 100644 --- a/config/lunar/search.php +++ b/config/lunar/search.php @@ -20,7 +20,17 @@ Lunar\Models\Collection::class, Lunar\Models\Customer::class, Lunar\Models\Order::class, - Lunar\Models\Product::class, + // Modules\Core\Catalog\Models\Product, not Lunar\Models\Product — + // both share the same underlying Meilisearch index name, so + // listing the base class here too would make every reindex + // (this default list is always merged in, even when a specific + // model is passed on the CLI — see Lunar\Console\Commands\ + // ScoutIndexerCommand::handle()) run the base class's indexing a + // second time right after the subclass's, silently overwriting + // every document with one missing custom_fields/order_count + // (the whole reason Modules\Core\Catalog\Models\Product exists — + // see its own docblock). Caught in practice. + Modules\Core\Catalog\Models\Product::class, Lunar\Models\ProductOption::class, /* @@ -49,6 +59,20 @@ Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class, Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class, Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class, + // Lunar\Models\Product::class, NOT Modules\Core\Catalog\Models\ + // Product::class — Lunar\Base\Traits\Searchable::indexer() (and + // getFilterableAttributes()/getSortableAttributes(), same trait) + // reads `$config[self::class]`, and `self::class` inside a TRAIT + // METHOD is a compile-time literal bound to whichever class first + // `use`s the trait — Lunar\Models\Product, since the subclass + // never re-declares indexer() itself — regardless of which + // instance actually calls the method at runtime. Keying this by + // the subclass here made the lookup miss entirely, silently + // falling back to Lunar\Search\ScoutIndexer's own near-empty + // filterable/sortable field list — confirmed live: it wiped every + // real filterable/sortable attribute the index had (including + // ones that already worked, like collection_ids), not just the + // new order_count one. Caught in practice, reverted. Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class, Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class, ], diff --git a/config/services.php b/config/services.php index 7c13ec1..f5d90d3 100644 --- a/config/services.php +++ b/config/services.php @@ -35,6 +35,20 @@ ], ], + // Where contact-form messages are sent (ContactController). + 'contact' => [ + 'email' => env('CONTACT_EMAIL'), + ], + + // Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For + // local dev use hCaptcha's test keys, the real ones reject localhost: + // sitekey 10000000-ffff-ffff-ffff-000000000001, + // secret 0x0000000000000000000000000000000000000000. + 'hcaptcha' => [ + 'sitekey' => env('HCAPTCHA_SITEKEY'), + 'secret' => env('HCAPTCHA_SECRET'), + ], + 'stoic' => [ 'sso_secret' => env('STOIC_SSO_SECRET'), 'host' => env('STOIC_HOST'), 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/migrations/2026_09_24_000001_create_wishlist_items_table.php b/database/migrations/2026_09_24_000001_create_wishlist_items_table.php new file mode 100644 index 0000000..d830309 --- /dev/null +++ b/database/migrations/2026_09_24_000001_create_wishlist_items_table.php @@ -0,0 +1,25 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('lunar_products')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['user_id', 'product_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('wishlist_items'); + } +}; diff --git a/database/seeders/CheckoutTranslationsSeeder.php b/database/seeders/CheckoutTranslationsSeeder.php deleted file mode 100644 index b1f60c7..0000000 --- a/database/seeders/CheckoutTranslationsSeeder.php +++ /dev/null @@ -1,211 +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.box_now_locker_label' => [ - 'Choose a Box Now locker', - 'Επίλεξε Box Now locker', - ], - 'page.box_now_locker_loading' => [ - 'Loading lockers…', - 'Φόρτωση lockers…', - ], - 'page.box_now_locker_required' => [ - 'Choose a Box Now locker to continue.', - 'Επίλεξε ένα Box Now locker για να συνεχίσεις.', - ], - 'page.box_now_locker_select' => [ - 'Select this locker', - 'Επιλογή αυτού του locker', - ], - 'page.box_now_locker_selected' => [ - 'Selected', - 'Επιλέχθηκε', - ], - 'page.box_now_locker_search' => [ - 'Search by area or address…', - 'Αναζήτηση με περιοχή ή διεύθυνση…', - ], - 'page.box_now_locker_no_results' => [ - 'No lockers match your search.', - 'Δεν βρέθηκαν lockers για αυτή την αναζήτηση.', - ], - '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/database/seeders/ValidationTranslationsSeeder.php b/database/seeders/ValidationTranslationsSeeder.php new file mode 100644 index 0000000..acfd193 --- /dev/null +++ b/database/seeders/ValidationTranslationsSeeder.php @@ -0,0 +1,115 @@ +validate()` call in the app + * (see CheckoutController::saveAddress(), CartController, ProductController), + * not just the checkout module. + * + * Spatie's DB loader (spatie/laravel-translation-loader, wired in boboko-core's + * LocalizationServiceProvider) merges this group over Laravel's file-based + * validation.php, which the project doesn't ship a `lang/` copy of — so without + * this, Greek requests fall back to Laravel's untranslated English defaults. + * Only the rule keys and field attributes actually in use are seeded; add more + * as new rules/fields show up. + * + * Additive and idempotent: a key that already exists is left untouched, so + * anything edited in the Filament Language Lines UI wins on a re-run. Runs + * explicitly — `php artisan db:seed --class=ValidationTranslationsSeeder` — it + * is not wired into DatabaseSeeder. + * + * Greek copy uses the project's informal register (εσύ/σου). + */ +class ValidationTranslationsSeeder extends Seeder +{ + public function run(): void + { + $translations = app(TranslationService::class); + + foreach ($this->lines() as $key => [$en, $el]) { + $exists = LanguageLine::query() + ->where('group', 'validation') + ->where('key', $key) + ->exists(); + + if ($exists) { + $this->command?->warn("validation.{$key} already exists — skipped"); + + continue; + } + + $translations->create('validation', $key, ['en' => $en, 'el' => $el]); + $this->command?->info("validation.{$key} added"); + } + } + + /** + * key => [English, Greek]. + * + * @return array + */ + private function lines(): array + { + return [ + // ── Rule messages ───────────────────────────────────────────── + 'required' => ['The :attribute field is required.', 'Το πεδίο :attribute είναι υποχρεωτικό.'], + 'email' => ['The :attribute field must be a valid email address.', 'Το πεδίο :attribute πρέπει να είναι έγκυρη διεύθυνση email.'], + 'string' => ['The :attribute field must be a string.', 'Το πεδίο :attribute πρέπει να είναι κείμενο.'], + 'integer' => ['The :attribute field must be an integer.', 'Το πεδίο :attribute πρέπει να είναι ακέραιος αριθμός.'], + 'boolean' => ['The :attribute field must be true or false.', 'Το πεδίο :attribute πρέπει να είναι true ή false.'], + 'min.numeric' => ['The :attribute field must be at least :min.', 'Το πεδίο :attribute πρέπει να είναι τουλάχιστον :min.'], + 'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'], + 'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'], + 'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'], + // Product custom-field photo uploads (CustomFieldUploadController, CartController). + 'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'], + 'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'], + 'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'], + 'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :attribute απέτυχε.'], + + // ── Field names (checkout: billing/shipping address) ────────── + 'attributes.contact_email' => ['email', 'email'], + 'attributes.billing_first_name' => ['first name', 'όνομα'], + 'attributes.billing_last_name' => ['last name', 'επώνυμο'], + 'attributes.billing_company_name' => ['company name', 'επωνυμία εταιρείας'], + 'attributes.billing_tax_identifier' => ['tax ID', 'ΑΦΜ'], + 'attributes.billing_line_one' => ['address', 'διεύθυνση'], + 'attributes.billing_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'], + 'attributes.billing_city' => ['city', 'πόλη'], + 'attributes.billing_state' => ['region', 'νομό / περιοχή'], + 'attributes.billing_postcode' => ['postcode', 'ταχυδρομικό κώδικα'], + 'attributes.billing_country_id' => ['country', 'χώρα'], + 'attributes.billing_contact_phone' => ['phone', 'τηλέφωνο'], + 'attributes.shipping_first_name' => ['first name', 'όνομα'], + 'attributes.shipping_last_name' => ['last name', 'επώνυμο'], + 'attributes.shipping_company_name' => ['company name', 'επωνυμία εταιρείας'], + 'attributes.shipping_line_one' => ['address', 'διεύθυνση'], + 'attributes.shipping_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'], + 'attributes.shipping_city' => ['city', 'πόλη'], + 'attributes.shipping_state' => ['region', 'νομό / περιοχή'], + 'attributes.shipping_postcode' => ['postcode', 'ταχυδρομικό κώδικα'], + 'attributes.shipping_country_id' => ['country', 'χώρα'], + 'attributes.shipping_contact_phone' => ['phone', 'τηλέφωνο'], + 'attributes.shipping_delivery_instructions' => ['delivery notes', 'σχόλια για την παράδοση'], + + // ── Field names (cart) ───────────────────────────────────────── + 'attributes.purchasable_id' => ['product', 'προϊόν'], + 'attributes.quantity' => ['quantity', 'ποσότητα'], + 'attributes.code' => ['coupon code', 'κωδικό κουπονιού'], + + // ── Field names (product reviews / stock check) ──────────────── + 'attributes.variant' => ['variant', 'παραλλαγή'], + 'attributes.rating' => ['rating', 'βαθμολογία'], + 'attributes.content' => ['review text', 'κείμενο κριτικής'], + 'attributes.name' => ['name', 'όνομα'], + 'attributes.email' => ['email', 'email'], + ]; + } +} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..0ef35a4 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/css/lunarphp/panel/lunar-panel.css b/public/css/lunarphp/panel/lunar-panel.css old mode 100644 new mode 100755 diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png new file mode 100644 index 0000000..c735163 Binary files /dev/null and b/public/favicon-96x96.png differ diff --git a/public/favicon.ico b/public/favicon.ico index e69de29..a0b1834 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..158f136 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/js/app/components/apexcharts.js b/public/js/app/components/apexcharts.js old mode 100644 new mode 100755 diff --git a/public/site.webmanifest b/public/site.webmanifest new file mode 100644 index 0000000..fb45f79 --- /dev/null +++ b/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "3dealer", + "short_name": "3dealer", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/public/web-app-manifest-192x192.png b/public/web-app-manifest-192x192.png new file mode 100644 index 0000000..2b8edbf Binary files /dev/null and b/public/web-app-manifest-192x192.png differ diff --git a/public/web-app-manifest-512x512.png b/public/web-app-manifest-512x512.png new file mode 100644 index 0000000..7e51b9f Binary files /dev/null and b/public/web-app-manifest-512x512.png differ diff --git a/resources/css/app.css b/resources/css/app.css index d76c503..29633d1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -331,7 +331,7 @@ @layer components { transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86); } - .btn-primary:hover::after { + .btn-primary:not(:disabled):hover::after { transform: translate(0, 0); } diff --git a/resources/css/checkout.css b/resources/css/checkout.css index 6cb8dc5..7f77fd3 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -200,8 +200,51 @@ .bbk-cart-item-media img { .bbk-cart-item-detail { min-width: 0; } .bbk-cart-item-title { + display: block; margin: 0 0 0.25rem; font-weight: 600; + color: inherit; + text-decoration: none; +} + +a.bbk-cart-item-title:hover { text-decoration: underline; } + +.bbk-cart-item-variant { + margin: 0 0 0.25rem; + font-size: 0.8125rem; + color: var(--bbk-color-muted); +} + +/* A line's custom-field answers (checkout::partials.line-custom-fields). */ +.bbk-line-fields { + display: grid; + gap: 0.25rem; + margin: 0 0 0.5rem; + font-size: 0.8125rem; +} + +.bbk-line-field dt { + color: var(--bbk-color-muted); +} + +.bbk-line-field dd { + margin: 0; + white-space: pre-line; + overflow-wrap: anywhere; +} + +.bbk-line-field-file { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: inherit; +} + +.bbk-line-field-file img { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 0; } .bbk-cart-item-unit { @@ -361,6 +404,19 @@ .bbk-cart-coupon-error { color: var(--bbk-color-danger); } +.bbk-cart-error { + margin: 0; + padding: 0.75rem 1.5rem 0; + font-size: 0.8125rem; + color: var(--bbk-color-danger); +} + +.bbk-add-to-cart-error { + margin: 0.375rem 0 0; + font-size: 0.8125rem; + color: var(--bbk-color-danger); +} + .bbk-cart-checkout { display: block; width: 100%; @@ -448,35 +504,12 @@ .bbk-checkout-note { font-size: 0.875rem; } -/* Contact tabs */ +/* Contact: "logged in as" line, or the guest login prompt */ -.bbk-checkout-tabs { display: flex; flex-direction: column; gap: 1rem; } +.bbk-checkout-logged-in, +.bbk-checkout-login-prompt { margin: 0; } -.bbk-checkout-tab { - display: inline-flex; - width: fit-content; - margin-right: 0.5rem; - padding: 0.5rem 1rem; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius-sm); - background: var(--bbk-color-bg); - font: inherit; - font-weight: 600; - color: var(--bbk-color-muted); - cursor: pointer; - transition: background-color 0.15s ease, color 0.15s ease; -} - -.bbk-checkout-tab[aria-selected="true"] { - background: var(--bbk-color-text); - border-color: var(--bbk-color-text); - color: var(--bbk-color-bg); -} - -.bbk-checkout-tab:focus-visible { - outline: 2px solid var(--bbk-color-accent); - outline-offset: 2px; -} +.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; } /* Fields */ @@ -546,6 +579,12 @@ .bbk-checkbox { } /* For a full-sentence label that can wrap — align the box to the first line. */ +/* "I want an invoice": company/ΑΦΜ only while ticked */ + +.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; } + +.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; } + .bbk-checkbox--stacked { display: flex; align-items: flex-start; @@ -765,6 +804,8 @@ .bbk-checkout-continue { font: inherit; font-weight: 600; text-align: center; + text-decoration: none; + box-sizing: border-box; cursor: pointer; transition: opacity 0.15s ease; } @@ -882,6 +923,23 @@ .bbk-confirmation-heading { .bbk-confirmation-ref { margin: 0 0 0.25rem; } +.bbk-confirmation-meta { + margin: 0 0 1rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.bbk-confirmation-meta-row { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.9375rem; +} + +.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); } +.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; } + .bbk-confirmation-body { margin: 2rem 0; display: grid; @@ -899,11 +957,14 @@ .bbk-confirmation-lines { } .bbk-confirmation-line { - display: flex; - justify-content: space-between; - gap: 1rem; + display: grid; + grid-template-columns: 72px 1fr auto; + align-items: start; + gap: 0.875rem; } +.bbk-confirmation-line-detail { min-width: 0; } + .bbk-confirmation-line-qty { color: var(--bbk-color-muted); } .bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; } @@ -928,8 +989,3 @@ .bbk-address-lines { 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-add-to-cart-controller.js b/resources/js/checkout/bbk-add-to-cart-controller.js index 49abb02..1f1cdf2 100644 --- a/resources/js/checkout/bbk-add-to-cart-controller.js +++ b/resources/js/checkout/bbk-add-to-cart-controller.js @@ -6,12 +6,15 @@ import { csrfToken } from './csrf' // `bbk-cart:changed` window event. No DOM building here — the drawer // (bbk-cart-controller) owns rendering. export default class extends Controller { + static targets = ['error'] + async add(event) { event.preventDefault() const form = this.element const submit = form.querySelector('[type="submit"]') + this.clearError() form.setAttribute('data-bbk-add-to-cart-state', 'loading') if (submit) submit.disabled = true @@ -21,11 +24,16 @@ export default class extends Controller { headers: { 'X-CSRF-TOKEN': csrfToken(), 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', }, body: new FormData(form), }) - if (!response.ok) return + if (!response.ok) { + const data = await response.json().catch(() => null) + this.showError(data?.error) + return + } window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: await response.text() }, @@ -35,4 +43,15 @@ export default class extends Controller { if (submit) submit.disabled = false } } + + showError(message) { + if (!this.hasErrorTarget || !message) return + this.errorTarget.textContent = message + this.errorTarget.hidden = false + } + + clearError() { + if (!this.hasErrorTarget) return + this.errorTarget.hidden = true + } } diff --git a/resources/js/checkout/bbk-cart-controller.js b/resources/js/checkout/bbk-cart-controller.js index 465e7c4..dfe5901 100644 --- a/resources/js/checkout/bbk-cart-controller.js +++ b/resources/js/checkout/bbk-cart-controller.js @@ -13,11 +13,12 @@ import { csrfToken } from './csrf' // Appearance is entirely CSS-driven: open state is the data-bbk-cart-state // attribute on the root, nothing here touches styles or class lists. export default class extends Controller { - static targets = ['panel', 'body'] + static targets = ['panel', 'body', 'error'] connect() { this.onChanged = this.onChanged.bind(this) this.onKeydown = this.onKeydown.bind(this) + this.updateTimers = new Map() // line id -> pending debounce timer window.addEventListener('bbk-cart:changed', this.onChanged) window.addEventListener('bbk-cart:open', this.open.bind(this)) @@ -30,6 +31,7 @@ export default class extends Controller { disconnect() { window.removeEventListener('bbk-cart:changed', this.onChanged) document.removeEventListener('keydown', this.onKeydown) + this.updateTimers.forEach((timer) => clearTimeout(timer)) } onChanged(event) { @@ -63,7 +65,11 @@ export default class extends Controller { submit(event) { event.preventDefault() const form = event.target.closest('form') - if (form) this.send(form) + if (!form) return + + // A remove is a deliberate, one-shot action — only the quantity form + // (typing, or the +/- stepper below) benefits from debouncing. + form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form) } // +/- stepper buttons inside a line @@ -73,11 +79,27 @@ export default class extends Controller { const input = form.querySelector('input[type="number"]') const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir)) input.value = String(next) - this.send(form) + this.scheduleSend(form) + } + + // Repeated clicks (or spinner nudges) update the input instantly but only + // send once they settle for 300ms — sending on every single click was + // firing overlapping requests that raced each other and made the drawer + // visibly flicker/lag under quick clicking. + scheduleSend(form) { + const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId + if (!lineId) return this.send(form) + + clearTimeout(this.updateTimers.get(lineId)) + this.updateTimers.set(lineId, setTimeout(() => { + this.updateTimers.delete(lineId) + this.send(form) + }, 300)) } async send(form) { this.bodyTarget.setAttribute('aria-busy', 'true') + this.clearError() try { const response = await fetch(form.action, { @@ -85,16 +107,42 @@ export default class extends Controller { headers: { 'X-CSRF-TOKEN': csrfToken(), 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', }, body: new FormData(form), }) - if (response.ok) this.replaceBody(await response.text()) + if (response.ok) { + this.replaceBody(await response.text()) + return + } + + const data = await response.json().catch(() => null) + this.showError(data?.error) + + // The rejected quantity (typed, or from a +/- click) is left + // sitting in the input with nothing to correct it — the update + // never reached the cart, so the input must be put back to what + // the cart actually still holds, not just left showing whatever + // was rejected. + const input = form.querySelector('[data-bbk-cart-confirmed-quantity]') + if (input) input.value = input.dataset.bbkCartConfirmedQuantity } finally { this.bodyTarget.removeAttribute('aria-busy') } } + showError(message) { + if (!this.hasErrorTarget || !message) return + this.errorTarget.textContent = message + this.errorTarget.hidden = false + } + + clearError() { + if (!this.hasErrorTarget) return + this.errorTarget.hidden = true + } + replaceBody(html) { this.bodyTarget.innerHTML = html this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]')) diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js index d2a7f0f..aa310ec 100644 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ b/resources/js/checkout/bbk-checkout-form-controller.js @@ -13,7 +13,6 @@ import { csrfToken } from './csrf' // already uses). Shipping-method radios post to selectShippingUrl the same way. export default class extends Controller { static targets = [ - 'guestTab', 'loginTab', 'guestPanel', 'loginPanel', 'sameAsBilling', 'shippingFields', 'form', 'shippingOptions', 'status', ] @@ -41,22 +40,6 @@ export default class extends Controller { this.saveController?.abort() } - // ── Contact tabs ──────────────────────────────────────────────────── - - showGuest() { - this.guestPanelTarget.hidden = false - this.loginPanelTarget.hidden = true - this.guestTabTarget.setAttribute('aria-selected', 'true') - this.loginTabTarget.setAttribute('aria-selected', 'false') - } - - showLogin() { - this.guestPanelTarget.hidden = true - this.loginPanelTarget.hidden = false - this.guestTabTarget.setAttribute('aria-selected', 'false') - this.loginTabTarget.setAttribute('aria-selected', 'true') - } - // ── Same as billing ──────────────────────────────────────────────── toggleSameAsBilling() { @@ -93,7 +76,6 @@ export default class extends Controller { // react to fields that actually belong to the address form. const el = event.target const belongsToForm = el.form?.id === 'bbk-address-form' - || el.closest('[data-bbk-checkout-form-target="guestPanel"]') if (!belongsToForm) return // No status during the wait — it only shows once the request is in flight, diff --git a/resources/js/checkout/bbk-payment-controller.js b/resources/js/checkout/bbk-payment-controller.js index 96a43e7..9cc59a5 100644 --- a/resources/js/checkout/bbk-payment-controller.js +++ b/resources/js/checkout/bbk-payment-controller.js @@ -43,6 +43,16 @@ export default class extends Controller { this.amountValue = total this.elements.update({ amount: Math.max(total, 1) }) } + + // Removing the last line while sitting on the checkout page (via + // the order summary's own remove form) must not leave "place + // order" clickable with nothing left to charge for — this fires + // from both the drawer and the checkout page's own summary + // instance, whichever the shopper actually used. + const count = event.detail?.count + if (typeof count === 'number' && this.hasSubmitTarget) { + this.submitTarget.disabled = count === 0 + } } window.addEventListener('bbk-cart:updated', this.onSummaryUpdate) diff --git a/resources/js/stimulus/custom-field-upload-controller.js b/resources/js/stimulus/custom-field-upload-controller.js new file mode 100644 index 0000000..c1dd2d2 --- /dev/null +++ b/resources/js/stimulus/custom-field-upload-controller.js @@ -0,0 +1,108 @@ +import { Controller } from '@hotwired/stimulus' + +// One product custom field of type `file` (see x-product-custom-fields). +// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController) +// as soon as it's picked, then writes the returned File row's id (boboko-core's +// Modules\Core\File\Models\File) into the hidden input the add-to-cart form +// actually submits — the checkout module never receives the file itself. +// +// While uploading, the file input is marked invalid via setCustomValidity(), +// so the browser's own form validation blocks add-to-cart until the id is in +// place. A failed upload clears the input, so `required` blocks it too. +export default class extends Controller { + static targets = ['file', 'reference', 'preview', 'error'] + static values = { + url: String, + label: String, + uploadingMessage: String, + failedMessage: String, + } + + disconnect() { + this.abortController?.abort() + this.revokePreview() + } + + async upload() { + this.reset() + + const file = this.fileTarget.files[0] + if (!file) return + + const abortController = new AbortController() + this.abortController = abortController + + this.fileTarget.setCustomValidity(this.uploadingMessageValue) + this.fileTarget.setAttribute('aria-busy', 'true') + + const body = new FormData() + body.append('file', file) + body.append('label', this.labelValue) + + try { + const response = await fetch(this.urlValue, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + body, + signal: abortController.signal, + }) + const data = await response.json().catch(() => null) + + if (!response.ok || !data?.file_id) { + this.fail(data?.error) + return + } + + this.referenceTarget.value = data.file_id + this.showPreview(file) + } catch (error) { + // A newer pick superseded this upload — reset() already handled it. + if (error.name === 'AbortError') return + this.fail() + } finally { + if (!abortController.signal.aborted) this.markIdle() + } + } + + reset() { + this.abortController?.abort() + this.referenceTarget.value = '' + this.errorTarget.hidden = true + this.markIdle() + this.revokePreview() + } + + fail(message) { + this.fileTarget.value = '' + this.errorTarget.textContent = message || this.failedMessageValue + this.errorTarget.hidden = false + } + + markIdle() { + this.fileTarget.setCustomValidity('') + this.fileTarget.removeAttribute('aria-busy') + } + + showPreview(file) { + this.previewUrl = URL.createObjectURL(file) + this.previewTarget.src = this.previewUrl + this.previewTarget.hidden = false + } + + // Formats the browser can't render (HEIC outside Safari) — the file + // input's own filename is enough there. + hidePreview() { + this.previewTarget.hidden = true + } + + revokePreview() { + if (this.previewUrl) URL.revokeObjectURL(this.previewUrl) + this.previewUrl = null + this.previewTarget.removeAttribute('src') + this.previewTarget.hidden = true + } +} diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index d9341e6..669dd2a 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -8,6 +8,7 @@ import AutoSubmitController from './auto-submit-controller' import BackToTopController from './back-to-top-controller' import CartCountController from './cart-count-controller' import CarouselController from './carousel-controller' +import CustomFieldUploadController from './custom-field-upload-controller' import DropdownController from './dropdown-controller' import FrameScrollController from './frame-scroll-controller' import NavSearchController from './nav-search-controller' @@ -16,7 +17,9 @@ import ProductGalleryController from './product-gallery-controller' import QuantityController from './quantity-controller' import RangeSliderController from './range-slider-controller' import StarRatingController from './star-rating-controller' +import TabLinkController from './tab-link-controller' import TabsController from './tabs-controller' +import WishlistController from './wishlist-controller' export function registerControllers(application) { application.register('appear', AppearController) @@ -24,6 +27,7 @@ export function registerControllers(application) { application.register('back-to-top', BackToTopController) application.register('cart-count', CartCountController) application.register('carousel', CarouselController) + application.register('custom-field-upload', CustomFieldUploadController) application.register('dropdown', DropdownController) application.register('frame-scroll', FrameScrollController) application.register('nav-search', NavSearchController) @@ -32,5 +36,7 @@ export function registerControllers(application) { application.register('quantity', QuantityController) application.register('range-slider', RangeSliderController) application.register('star-rating', StarRatingController) + application.register('tab-link', TabLinkController) application.register('tabs', TabsController) + application.register('wishlist', WishlistController) } diff --git a/resources/js/stimulus/product-form-controller.js b/resources/js/stimulus/product-form-controller.js index 767b0a1..d8e6194 100644 --- a/resources/js/stimulus/product-form-controller.js +++ b/resources/js/stimulus/product-form-controller.js @@ -2,25 +2,138 @@ import { Controller } from '@hotwired/stimulus' import { formatPrice } from '../utils/format-price' export default class extends Controller { - static targets = ['price', 'image', 'swatch', 'colorName'] - static values = { variants: Array, selected: Number } + static targets = ['price', 'image', 'swatch', 'colorName', 'stockError', 'submit'] + static values = { + variants: Array, + selected: Number, + stockCheckUrl: String, + // Two pre-rendered translated templates (see product/show.blade.php) + // rather than one — this controller doesn't reimplement Laravel's + // pluralization rules, it just picks whichever of these two the + // count actually needs and fills in the number. + stockErrorOne: String, + stockErrorMany: String, + } connect() { - const params = new URLSearchParams(window.location.search) - const urlId = parseInt(params.get('variant')) - const defaultId = this.variantsValue[0]?.id + const params = new URLSearchParams(window.location.search) + const urlId = parseInt(params.get('variant')) + const urlVariant = this.variantsValue.find(v => v.id === urlId) + const initial = urlVariant ?? this.variantsValue[0] - this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId) - ? urlId - : defaultId + // A product can have several independent options (e.g. size + style + // + person-count) — this tracks the currently-picked value id per + // option handle, and selectVariant() below resolves the full + // combination back to one exact variant on every change. + this.selections = { ...initial?.options } + this.selectedValue = initial?.id + + // Capture phase, on this controller's own root element (an ancestor + // of the checkout module's add-to-cart
) — runs BEFORE that + // form's own bubble-phase submit handler (bbk-add-to-cart#add), so a + // failed check can stop it from ever reaching the module at all. The + // module itself is never touched or modified for this: it keeps + // validating server-side regardless, this is purely an up-front, + // storefront-owned check (see [[project_checkout_module]] for why + // that split matters — stock UX is a catalog concern, not something + // the portable checkout module should own) — and a REAL, live check + // against the backend (ProductController::checkStock(), reading the + // Eloquent model directly), not page-load data that can go stale. + this.onSubmitCapture = this.checkStock.bind(this) + this.element.addEventListener('submit', this.onSubmitCapture, true) + } + + disconnect() { + this.element.removeEventListener('submit', this.onSubmitCapture, true) + } + + checkStock(event) { + const form = event.target + if (!form.matches('.bbk-add-to-cart')) return + + // The re-submit this itself triggers below, once the backend has + // confirmed the quantity is fine — let that one through to the + // module's own submit handler instead of checking a second time. + if (form.dataset.bbkStockChecked) { + delete form.dataset.bbkStockChecked + return + } + + event.preventDefault() + event.stopPropagation() + this.verifyStock(form) + } + + async verifyStock(form) { + this.clearStockError() + + const submit = form.querySelector('[type="submit"]') + if (submit) submit.disabled = true + + const purchasableId = form.querySelector('[data-bbk-purchasable-input]')?.value + const quantity = form.querySelector('[name="quantity"]')?.value || '1' + + try { + const url = new URL(this.stockCheckUrlValue, window.location.origin) + url.searchParams.set('variant', purchasableId) + url.searchParams.set('quantity', quantity) + + const response = await fetch(url, { headers: { Accept: 'application/json' } }) + const data = await response.json() + + if (!data.ok) { + this.showStockError(data.stock) + return + } + } catch { + // Network hiccup — fall through and let the checkout module's + // own server-side check have the final word rather than + // silently blocking the shopper here. + } finally { + if (submit) submit.disabled = !this.selectedVariant?.inStock + } + + form.dataset.bbkStockChecked = 'true' + form.requestSubmit() + } + + showStockError(available) { + if (!this.hasStockErrorTarget) return + + this.stockErrorTarget.textContent = available === 1 + ? this.stockErrorOneValue + : this.stockErrorManyValue.replace(':count', String(available)) + this.stockErrorTarget.hidden = false + } + + clearStockError() { + if (!this.hasStockErrorTarget) return + this.stockErrorTarget.hidden = true + } + + get selectedVariant() { + return this.variantsValue.find(v => v.id === this.selectedValue) } selectVariant(event) { - const id = parseInt(event.currentTarget.dataset.variantId) - this.selectedValue = id + const option = event.currentTarget.dataset.option + const valueId = parseInt(event.currentTarget.dataset.valueId) + this.selections = { ...this.selections, [option]: valueId } + + const match = this.variantsValue.find(variant => + Object.keys(this.selections).every(key => variant.options?.[key] === this.selections[key]) + ) + + // No variant exists for this combination (e.g. an option value that + // isn't offered together with another currently-selected value) — + // leave the previous selection in place rather than pointing the + // add-to-cart form at nothing. + if (!match) return + + this.selectedValue = match.id const url = new URL(window.location) - url.searchParams.set('variant', id) + url.searchParams.set('variant', match.id) window.history.pushState({}, '', url) } @@ -30,6 +143,8 @@ export default class extends Controller { const variant = this.variantsValue.find(v => v.id === id) if (!variant) return + this.clearStockError() + if (this.hasPriceTarget && variant.price !== null) { this.priceTarget.textContent = formatPrice(variant.price) } @@ -45,13 +160,20 @@ export default class extends Controller { const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]') if (purchasableInput) purchasableInput.value = id + // Out-of-stock variant (as of page load) can't be added at all. + if (this.hasSubmitTarget) this.submitTarget.disabled = !variant.inStock + this.swatchTargets.forEach(swatch => { - const isSelected = parseInt(swatch.dataset.variantId) === id + const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId) swatch.classList.toggle('is-selected', isSelected) swatch.setAttribute('aria-pressed', String(isSelected)) - if (isSelected && this.hasColorNameTarget) { - this.colorNameTarget.textContent = swatch.getAttribute('aria-label') + if (isSelected) { + // Each color-option group has its own colorName echo (see + // x-ui.color-swatch) — matched by option handle so a swatch + // in one group never overwrites another group's label. + const colorName = this.colorNameTargets.find(target => target.dataset.option === swatch.dataset.option) + if (colorName) colorName.textContent = swatch.getAttribute('aria-label') } }) } diff --git a/resources/js/stimulus/star-rating-controller.js b/resources/js/stimulus/star-rating-controller.js index e7ceb14..bdea748 100644 --- a/resources/js/stimulus/star-rating-controller.js +++ b/resources/js/stimulus/star-rating-controller.js @@ -1,9 +1,25 @@ import { Controller } from '@hotwired/stimulus' export default class extends Controller { - static targets = ['star', 'input'] + static targets = ['star', 'input', 'error'] static values = { rating: { type: Number, default: 0 } } + connect() { + this.#fill(this.ratingValue) + } + + // Bound to the form's submit event (this controller sits on the + // itself, not just the star widget) — a plain `required` on the hidden + // rating input would never surface: browsers exclude type="hidden" from + // constraint validation entirely, so there'd be nothing to see or hear. + validate(event) { + if (this.ratingValue < 1) { + event.preventDefault() + this.errorTarget.hidden = false + this.starTargets[0]?.focus() + } + } + hover(event) { this.#fill(parseInt(event.currentTarget.dataset.value)) } @@ -16,6 +32,7 @@ export default class extends Controller { const val = parseInt(event.currentTarget.dataset.value) this.ratingValue = val this.inputTarget.value = val + this.errorTarget.hidden = true this.starTargets.forEach(star => { star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val)) diff --git a/resources/js/stimulus/tab-link-controller.js b/resources/js/stimulus/tab-link-controller.js new file mode 100644 index 0000000..9bfe268 --- /dev/null +++ b/resources/js/stimulus/tab-link-controller.js @@ -0,0 +1,15 @@ +import { Controller } from '@hotwired/stimulus' + +// Jumps to a tab panel from an element outside the tabs' own markup — the +// review count and the "read more" link both sit elsewhere in the DOM, too +// far apart from the tabs for a plain data-action, hence the outlet. +export default class extends Controller { + static outlets = ['tabs'] + static values = { panel: String } + + activate(event) { + event?.preventDefault() + this.tabsOutlet.activate(this.panelValue) + this.tabsOutlet.element.scrollIntoView({ block: 'start', behavior: 'smooth' }) + } +} diff --git a/resources/js/stimulus/tabs-controller.js b/resources/js/stimulus/tabs-controller.js index 019ae22..2907c6b 100644 --- a/resources/js/stimulus/tabs-controller.js +++ b/resources/js/stimulus/tabs-controller.js @@ -4,10 +4,10 @@ export default class extends Controller { static targets = ['button', 'panel'] show(event) { - this.#activate(event.currentTarget.dataset.panel) + this.activate(event.currentTarget.dataset.panel) } - #activate(panelId) { + activate(panelId) { this.buttonTargets.forEach(btn => { const active = btn.dataset.panel === panelId btn.classList.toggle('is-active', active) diff --git a/resources/js/stimulus/wishlist-controller.js b/resources/js/stimulus/wishlist-controller.js new file mode 100644 index 0000000..160e977 --- /dev/null +++ b/resources/js/stimulus/wishlist-controller.js @@ -0,0 +1,42 @@ +import { Controller } from '@hotwired/stimulus' + +// Heart toggle (x-wishlist-button). Posts the form with fetch and reflects the +// server's answer on aria-pressed, which the CSS uses to swap the outline and +// filled heart. If the request fails, falls back to a normal form submit. +export default class extends Controller { + static targets = ['button', 'status'] + + static values = { + addLabel: String, + removeLabel: String, + addedMessage: String, + removedMessage: String, + } + + async toggle(event) { + event.preventDefault() + + if (this.busy) return + this.busy = true + + try { + const response = await fetch(this.element.action, { + method: 'POST', + headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, + body: new FormData(this.element), + }) + + if (!response.ok) throw new Error(`Wishlist toggle failed: ${response.status}`) + + const { active } = await response.json() + + this.buttonTarget.setAttribute('aria-pressed', active ? 'true' : 'false') + this.buttonTarget.setAttribute('aria-label', active ? this.removeLabelValue : this.addLabelValue) + this.statusTarget.textContent = active ? this.addedMessageValue : this.removedMessageValue + } catch { + this.element.submit() + } finally { + this.busy = false + } + } +} diff --git a/resources/views/account/email-code.blade.php b/resources/views/account/email-code.blade.php new file mode 100644 index 0000000..1e4ff5d --- /dev/null +++ b/resources/views/account/email-code.blade.php @@ -0,0 +1,65 @@ +@extends('layouts.account') + +@section('title', __('storefront.auth.enter_code')) + +@section('account') + +
+ +
+

+ {{ __('storefront.auth.enter_code') }} +

+ +

+ {{ __('storefront.auth.code_sent_to') }} {{ $email }} +

+ + +
+ + + @csrf + + + + + +
+ {{ __('storefront.account.email_confirm') }} +
+ + +
+
+ @csrf + +
+ + + {{ __('storefront.auth.change_email') }} + +
+ +
+ +@endsection diff --git a/resources/views/account/email.blade.php b/resources/views/account/email.blade.php new file mode 100644 index 0000000..05326b3 --- /dev/null +++ b/resources/views/account/email.blade.php @@ -0,0 +1,53 @@ +@extends('layouts.account') + +@section('title', __('storefront.account.email_change_heading')) + +@section('account') + +
+ +
+

+ {{ __('storefront.account.email_change_heading') }} +

+ +

+ {{ __('storefront.account.email_current') }} {{ $user->email }} +

+
+ +
+ @csrf + + + + + +
+ {{ __('storefront.auth.send_code') }} + + + {{ __('storefront.account.delete_cancel') }} + +
+
+ +
+ +@endsection diff --git a/resources/views/account/orders/index.blade.php b/resources/views/account/orders/index.blade.php new file mode 100644 index 0000000..803d58d --- /dev/null +++ b/resources/views/account/orders/index.blade.php @@ -0,0 +1,67 @@ +@extends('layouts.account') + +@section('title', __('storefront.account.nav_orders')) + +@section('account') + +
+ +

+ {{ __('storefront.account.nav_orders') }} +

+ + @if ($orders->isEmpty()) +
+

{{ __('storefront.orders.empty') }}

+ {{ __('storefront.orders.shop_now') }} +
+ @else + {{-- A list, not a table, so each order can stack on mobile. The column + headings are visual only; each cell carries its own sr-only label. --}} +
+ + +
    + @foreach ($orders as $order) +
  • + + {{ __('storefront.orders.date') }}: + {{ $order->placed_at->format('d/m/Y') }} + + + {{ __('storefront.orders.number') }}: + #{{ $order->reference }} + + + {{ __('storefront.orders.status') }}: + + + + {{ __('storefront.orders.total') }}: + {{ $order->total?->formatted() }} + + + {{ __('storefront.orders.view') }} + + +
  • + @endforeach +
+
+ + + @endif + +
+ +@endsection diff --git a/resources/views/account/orders/show.blade.php b/resources/views/account/orders/show.blade.php new file mode 100644 index 0000000..5049a75 --- /dev/null +++ b/resources/views/account/orders/show.blade.php @@ -0,0 +1,158 @@ +@extends('layouts.account') + +@section('title', __('storefront.orders.order_title', ['number' => $order->reference])) + +@php + $productLines = $order->lines->where('type', '!=', 'shipping'); + $shippingLine = $order->lines->firstWhere('type', 'shipping'); +@endphp + +@section('account') + +
+ +
+ + + {{ __('storefront.orders.back') }} + + +

+ {{ __('storefront.orders.order_title', ['number' => $order->reference]) }} +

+
+ + {{-- Summary --}} +
+
+
{{ __('storefront.orders.date') }}
+
{{ $order->placed_at->format('d/m/Y') }}
+
+ +
+
{{ __('storefront.orders.status') }}
+
+
+ + @if ($paymentMethodName) +
+
{{ __('storefront.orders.payment') }}
+
{{ $paymentMethodName }}
+
+ @endif + + @if ($shippingLine) +
+
{{ __('storefront.orders.shipping_method') }}
+
{{ $shippingLine->description }}
+
+ @endif + + @foreach ($shipments as $shipment) +
+
{{ __('storefront.orders.tracking') }}
+
{{ $shipment->tracking_reference }}
+
+ @endforeach +
+ + {{-- Items + totals --}} +
+

{{ __('storefront.orders.items') }}

+ +
    + @foreach ($productLines as $line) +
  • +
    + @if ($thumb = $line->purchasable?->getThumbnailImage()) + + @endif +
    + +
    + {{-- Linked only while the product still exists and is published; + otherwise the name stays plain text (the order keeps it). --}} + @php($lineProduct = $line->purchasable?->product) +

    + @if ($lineProduct?->status === 'published') + {{ $line->description }} + @else + {{ $line->description }} + @endif + × {{ $line->quantity }} +

    + + @if ($line->option) +

    {{ $line->option }}

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

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

    +
  • + @endforeach +
+ +
+
+
{{ __('storefront.orders.subtotal') }}
+
{{ $order->sub_total?->formatted() }}
+
+ + @if ($order->discount_total?->value > 0) +
+
{{ __('storefront.orders.discount') }}
+
−{{ $order->discount_total->formatted() }}
+
+ @endif + +
+
{{ __('storefront.orders.shipping') }}
+
{{ $order->shipping_total?->formatted() }}
+
+ + @if ($order->tax_total?->value > 0) +
+
{{ __('storefront.orders.tax') }}
+
{{ $order->tax_total->formatted() }}
+
+ @endif + +
+
{{ __('storefront.orders.total') }}
+
{{ $order->total?->formatted() }}
+
+
+
+ + {{-- Addresses --}} +
+ @foreach ([ + 'shipping_to' => $order->shippingAddress, + 'billing' => $order->billingAddress, + ] as $heading => $address) + @if ($address) +
+

{{ __("storefront.orders.{$heading}") }}

+ +
+ {{ trim($address->first_name.' '.$address->last_name) }} + @if ($address->company_name){{ $address->company_name }}@endif + @if ($address->tax_identifier){{ __('storefront.account.tax_identifier') }}: {{ $address->tax_identifier }}@endif + {{ $address->line_one }} + @if ($address->line_two){{ $address->line_two }}@endif + {{ trim($address->postcode.' '.$address->city) }} + @if ($address->state) + {{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }} + @endif + @if ($address->contact_phone){{ $address->contact_phone }}@endif +
+
+ @endif + @endforeach +
+ +
+ +@endsection diff --git a/resources/views/account/show.blade.php b/resources/views/account/show.blade.php new file mode 100644 index 0000000..abe75a0 --- /dev/null +++ b/resources/views/account/show.blade.php @@ -0,0 +1,204 @@ +@extends('layouts.account') + +@section('title', __('storefront.account.nav_profile')) + +@php + $wantsInvoice = (bool) old('invoice', filled($customer?->company_name) || filled($customer?->tax_identifier)); + + $regionOptions = $regions->map(fn ($region) => [ + 'value' => $region->name, + 'label' => Lang::has("core::states.{$region->name}") ? __("core::states.{$region->name}") : $region->name, + ])->all(); + +@endphp + +@section('account') + +
+ +
+

+ {{ __('storefront.account.nav_profile') }} +

+ + +
+ + {{-- Email: the login itself, so it's changed through its own verified flow --}} +
+

{{ __('storefront.account.email_heading') }}

+ + +
+ +
+ @csrf + @method('PUT') + + {{-- Details --}} +
+

{{ __('storefront.account.details_heading') }}

+ +
+ + + + + + + +
+ + {{-- Invoice details, revealed by the checkbox (CSS :has(), no JS). Not + `required` in HTML: a hidden required field would block submit, so + the server requires them only when the box is ticked. --}} +
+ + + + +
+
+ + {{-- Address: one, used as both shipping and billing default --}} +
+

{{ __('storefront.account.address_heading') }}

+ + + + + +
+ + + + + + + +
+ +
+ + + + + + + +
+
+ + {{-- Standing opt-in for abandoned-cart reminders (explicit, off by + default); checkout starts from it and can change it again. --}} +
+

{{ __('storefront.account.emails_heading') }}

+ +
+ + +
+
+ +
+ {{ __('storefront.account.save') }} +
+
+ + {{-- Delete account --}} +
+

{{ __('storefront.account.delete_heading') }}

+ +

{{ __('storefront.account.delete_text') }}

+ +
+ {{ __('storefront.account.delete') }} +
+ + +
+ +
+ +@endsection diff --git a/resources/views/account/wishlist.blade.php b/resources/views/account/wishlist.blade.php new file mode 100644 index 0000000..32ea261 --- /dev/null +++ b/resources/views/account/wishlist.blade.php @@ -0,0 +1,17 @@ +@extends('layouts.account') + +@section('title', __('storefront.account.nav_wishlist')) + +@section('account') + +
+ +

+ {{ __('storefront.account.nav_wishlist') }} +

+ + @include('wishlist.list') + +
+ +@endsection diff --git a/resources/views/auth/login-code.blade.php b/resources/views/auth/login-code.blade.php new file mode 100644 index 0000000..fa57212 --- /dev/null +++ b/resources/views/auth/login-code.blade.php @@ -0,0 +1,67 @@ +@extends('layouts.app') + +@section('title', __('storefront.auth.enter_code')) + +@push('seo') + +@endpush + +@section('content') + +
+ +

+ {{ __('storefront.auth.enter_code') }} +

+ +

+ {{ __('storefront.auth.code_sent_to') }} {{ $email }} +

+ + + +
+ @csrf + + + + + +
+ {{ __('storefront.auth.login') }} +
+
+ +
+
+ @csrf + +
+ + + {{ __('storefront.auth.change_email') }} + +
+ +
+ +@endsection diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 0000000..da4a8f8 --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,79 @@ +@extends('layouts.app') + +@section('title', __('storefront.auth.login')) + +@push('seo') + +@endpush + +{{-- Only on pages with a captcha, not in the global layout. --}} +@push('scripts') + +@endpush + +@section('content') + +
+ +

+ {{ __('storefront.auth.login') }} +

+ + {{-- Unescaped: the label holds a
(edited in Filament › Language Lines, staff only). --}} +

{!! __('storefront.auth.login_intro') !!}

+ + {{-- e.g. "account deletion scheduled", after AccountController::destroy() --}} + + +
+ @csrf + + + + + + {{-- Shown to everyone. Recording acceptance for new accounts is a + boboko-core task (UserOtpService). Unescaped: the label holds the + two links (Language Lines, staff only). --}} +

+ {!! __('storefront.auth.terms_notice', [ + 'terms' => route('legal.terms'), + 'privacy' => route('legal.privacy'), + ]) !!} +

+ + {{-- hCaptcha checkbox, verified by App\Rules\HCaptcha. The widget adds + the `h-captcha-response` field itself. Fixed height reserves the + iframe's space so the button doesn't jump when it loads. --}} + +
has('h-captcha-response')) aria-describedby="login-captcha-error" @endif + >
+
+ +
+ {{ __('storefront.auth.send_code') }} +
+
+ +
+ +@endsection diff --git a/resources/views/category/show.blade.php b/resources/views/category/show.blade.php index 9b36b80..bf4f9b5 100644 --- a/resources/views/category/show.blade.php +++ b/resources/views/category/show.blade.php @@ -14,7 +14,8 @@ @endpush @section('content') -
+ {{--
--}} +

{{ $collection['name'] }}

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 bcd2b05..0000000 --- a/resources/views/checkout/components/add-to-cart.blade.php +++ /dev/null @@ -1,42 +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 baa7c50..0000000 --- a/resources/views/checkout/confirmation.blade.php +++ /dev/null @@ -1,84 +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 }} -

-

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

- -
-
- @foreach ($order->lines->where('type', '!=', 'shipping') as $line) -
- - {{ $line->description }} - × {{ $line->quantity }} - - {{ $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 -
-
- - - {{ __('checkout.page.confirmation_continue') }} - -
-@endsection diff --git a/resources/views/checkout/drawer.blade.php b/resources/views/checkout/drawer.blade.php deleted file mode 100644 index cd0ff6a..0000000 --- a/resources/views/checkout/drawer.blade.php +++ /dev/null @@ -1,31 +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 f3d344f..0000000 --- a/resources/views/checkout/page.blade.php +++ /dev/null @@ -1,287 +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 --}} -
-
- - - - -
- - - {{-- 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. --}} - -
- - {{-- Not wired yet — Modules\Core\Auth\Services\UserOtpService exists - (email + one-time code, passwordless) but nothing in the storefront - calls it yet. UI placeholder only; see project notes. --}} - -
-
- - {{-- 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') }}

- -
- - -
- -
- - -
- - - - -
- - -
- - - - -
- - {{-- 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-line.blade.php b/resources/views/checkout/partials/cart-line.blade.php deleted file mode 100644 index a78f0fb..0000000 --- a/resources/views/checkout/partials/cart-line.blade.php +++ /dev/null @@ -1,77 +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 ?? '—'; - $thumb = $product?->getThumbnailImage() ?: null; -@endphp - -
  • -
    - @if ($thumb) - {{ $name }} - @endif -
    - -
    -

    {{ $name }}

    -

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

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

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

    - -
    - @csrf - @method('DELETE') - -
    -
    -
  • 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 9030c5e..0000000 --- a/resources/views/checkout/partials/shipping-options.blade.php +++ /dev/null @@ -1,106 +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 - -{{-- - Dummy Box Now locker picker — a Leaflet map standing in for Box Now's own - Destination Map JS widget, which only talks to their Production API (not - Stage/sandbox — see their Partner API manual §4.1), making it useless - for local/staging development. Backed by the same GET /destinations data - (including lat/lng) via CheckoutController::boxNowLockers(). Only shown - once the "box-now" shipping option is selected (bbk-checkout-form - toggles [hidden] on shipping-option change; see bbk-box-now-locker - Stimulus controller). Persists the choice via a separate autosave POST - (checkout.box-now.locker.select) rather than piggybacking on the - shipping-option field, since the two are independent pieces of state - (method vs. destination) that CheckoutService models as two calls - (selectShippingOption() / selectBoxNowLocker()). ---}} - diff --git a/resources/views/components/header.blade.php b/resources/views/components/header.blade.php index f88d407..9e2164c 100644 --- a/resources/views/components/header.blade.php +++ b/resources/views/components/header.blade.php @@ -41,6 +41,25 @@ @endforeach + {{-- Wishlist — the account page, or the cookie-based guest page --}} + routeIs('account.wishlist', 'wishlist')) aria-current="page" @endif + > + + + + {{-- Account — the account page, or login for guests --}} + + + + {{-- Cart — opens the checkout module's drawer, no separate cart page --}}
    + - - + + {{ old('content') }} - - + + - - + + - - {{ __('storefront.review.save_info') }} - -
    {{ __('storefront.review.submit') }}
    diff --git a/resources/views/components/reviews-stars.blade.php b/resources/views/components/reviews-stars.blade.php index dc6b1d5..d7fcbbd 100644 --- a/resources/views/components/reviews-stars.blade.php +++ b/resources/views/components/reviews-stars.blade.php @@ -3,6 +3,7 @@ 'count' => 0, 'showCount' => false, 'size' => 24, + 'linkable' => false, ])
    @@ -28,9 +29,17 @@ class="flex items-center gap-1.5 text-brand"
    @if ($showCount && $count > 0) - - ({{ trans_choice('storefront.customer_reviews', $count, ['count' => $count]) }}) - + @php $countText = '(' . trans_choice('storefront.customer_reviews', $count, ['count' => $count]) . ')'; @endphp + @if ($linkable) + + @else + {{ $countText }} + @endif @endif
    diff --git a/resources/views/components/ui/button.blade.php b/resources/views/components/ui/button.blade.php index 54dc9a9..ceded73 100644 --- a/resources/views/components/ui/button.blade.php +++ b/resources/views/components/ui/button.blade.php @@ -3,6 +3,7 @@ 'href' => null, 'type' => 'button', 'size' => 'lg', + 'variant' => 'primary', 'position' => 'relative', ]) @@ -15,6 +16,16 @@ default => 'py-5 px-[46px] text-[19px]', }; + // 'primary' is the CTA look (offset-shadow via .btn-primary's ::before/ + // ::after, italic, uppercase). 'secondary' is a plain bordered toggle — + // no shadow layers, fills solid on hover/aria-pressed=true instead, used + // for option pickers (see x-ui.option-buttons) and anywhere else a + // secondary/toggle action shouldn't compete visually with the CTA. + $variantClasses = match($variant) { + 'secondary' => 'font-semibold hover:bg-black hover:text-neutral-200 aria-pressed:bg-black aria-pressed:text-neutral-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-black', + default => 'btn-primary font-bold italic uppercase', + }; + // $position defaults to 'relative' (needed so the ::before/::after layers // in .btn-primary position against the button itself), but callers that // need to place the button absolutely (e.g. a hover-reveal CTA over a @@ -22,7 +33,7 @@ // "absolute" via the class prop — Tailwind's generated stylesheet always // orders the "relative" utility after "absolute", so on a class clash // "relative" silently wins and the button never actually gets positioned. - $class = 'btn-primary '.$position.' isolate inline-flex items-center justify-center border border-black font-display font-bold italic text-black cursor-pointer no-underline uppercase ' . $sizeClasses; + $class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline disabled:cursor-not-allowed disabled:opacity-50 '.$variantClasses.' '.$sizeClasses); $attrs = $href ? $attributes->merge(['href' => $href, 'class' => $class]) diff --git a/resources/views/components/ui/color-swatch.blade.php b/resources/views/components/ui/color-swatch.blade.php index c9b171b..bfa6aa0 100644 --- a/resources/views/components/ui/color-swatch.blade.php +++ b/resources/views/components/ui/color-swatch.blade.php @@ -1,12 +1,11 @@ -{{-- $variants: array of Modules\Core\Catalog\Services\ProductIndexer's mapVariant() - shape (id, options: [{option, value, meta}], ...) — plain arrays, not Eloquent - models, since this is fed from Modules\Core\Catalog\Services\ProductService. --}} -@props(['variants', 'option' => null]) +{{-- $values: one product option's de-duplicated, ordered values, as built by + ProductController::buildOptionPicker() — [{id, label, hex}]. --}} +@props(['values', 'option' => null, 'optionHandle' => null])
    @if($option)

    - {{ $option }}: + {{ $option }}:

    @endif @@ -15,22 +14,17 @@ class="flex flex-wrap gap-2" role="group" aria-label="{{ $option ?? 'Color' }}" > - @foreach($variants as $variant) - @php - $value = $variant['options'][0] ?? null; - $label = $value['value'] ?? ''; - $bg = $value['meta']['hex'] ?? '#cccccc'; - @endphp + @foreach($values as $value) @endforeach
    diff --git a/resources/views/components/ui/file-input.blade.php b/resources/views/components/ui/file-input.blade.php new file mode 100644 index 0000000..a310119 --- /dev/null +++ b/resources/views/components/ui/file-input.blade.php @@ -0,0 +1,20 @@ +@props([ + 'accept' => null, + 'required' => false, + 'disabled' => false, +]) + +merge([ + 'class' => 'w-full py-2 text-sm + file:mr-4 file:py-2 file:px-4 file:rounded-none file:border file:border-black file:bg-transparent + file:font-semibold file:cursor-pointer file:transition-colors + hover:file:bg-black hover:file:text-neutral-200 + focus:outline-none focus-visible:ring-2 focus-visible:ring-black + disabled:cursor-not-allowed disabled:opacity-50', + ]) }} +/> diff --git a/resources/views/components/ui/icon.blade.php b/resources/views/components/ui/icon.blade.php index bb38b00..6894be3 100644 --- a/resources/views/components/ui/icon.blade.php +++ b/resources/views/components/ui/icon.blade.php @@ -6,7 +6,7 @@ ]) @php - $fillIcons = ['facebook', 'instagram', 'tiktok', 'search', 'bag']; + $fillIcons = ['facebook', 'instagram', 'tiktok', 'search', 'bag', 'user', 'heart', 'heart-fill']; $svgStroke = $stroke ?? (in_array($name, $fillIcons) ? 'none' : $color); @endphp @@ -16,6 +16,12 @@ 'bag' => '', + 'user' => '', + + 'heart' => '', + + 'heart-fill' => '', + 'close' => '', 'arrow-left' => '', diff --git a/resources/views/components/ui/option-buttons.blade.php b/resources/views/components/ui/option-buttons.blade.php new file mode 100644 index 0000000..578fced --- /dev/null +++ b/resources/views/components/ui/option-buttons.blade.php @@ -0,0 +1,32 @@ +{{-- $values: one product option's de-duplicated, ordered values, as built by + ProductController::buildOptionPicker() — [{id, label, hex}]. Use this + instead of for options that aren't a color (no hex), + where a swatch dot has nothing meaningful to show. --}} +@props(['values', 'option' => null, 'optionHandle' => null]) + +
    + @if($option) +

    + {{ $option }} +

    + @endif + +
    + @foreach($values as $value) + {{ $value['label'] }} + @endforeach +
    +
    diff --git a/resources/views/components/ui/product-card.blade.php b/resources/views/components/ui/product-card.blade.php index bf566c0..2e164a4 100644 --- a/resources/views/components/ui/product-card.blade.php +++ b/resources/views/components/ui/product-card.blade.php @@ -1,8 +1,15 @@ @props([ - 'name' => '', - 'price' => null, - 'image' => null, - 'href' => '#', + 'name' => '', + 'price' => null, + 'image' => null, + 'href' => '#', + 'variantId' => null, + 'hasCustomFields' => false, + // Shows the "sold" star and drops the quick add-to-cart/personalize button. + 'soldOut' => false, + // Heart toggle at the top right of the image; needs productId. + 'productId' => null, + 'wishlist' => false, ]) {{-- data-turbo-frame="_top" on the links: this card renders inside the @@ -27,9 +34,37 @@ class="w-full h-auto block" @endif - - {{ __('storefront.product.add_to_cart') }} - + @if ($wishlist && $productId) + + @endif + + @if ($soldOut) + + @elseif ($hasCustomFields) + {{-- Custom fields have to be filled in on the product page. --}} + + {{ __('storefront.product.personalize') }} + + @elseif ($variantId) + {{-- has-[...] forces the button visible while an add-to-cart error + is showing, so it isn't only readable on hover — a shopper who + already moved off the card (mouse or the click itself) must + still see why nothing happened. --}} + + + {{ __('storefront.product.add_to_cart') }} + + + @endif
    diff --git a/resources/views/components/ui/select.blade.php b/resources/views/components/ui/select.blade.php index d5bdda4..0a4ce46 100644 --- a/resources/views/components/ui/select.blade.php +++ b/resources/views/components/ui/select.blade.php @@ -1,17 +1,22 @@ @props([ - 'options' => [], - 'value' => null, - 'name' => null, - 'ariaLabel' => null, + 'options' => [], + 'value' => null, + 'name' => null, + 'ariaLabel' => null, + 'placeholder' => null, + 'block' => false, ]) -
    +
    $block, 'inline-flex' => ! $block])>