Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43b615297 | ||
|
|
7f6c1e6307 | ||
|
|
5bbf0aabcd | ||
|
|
fa172860db | ||
|
|
a51e9456d6 | ||
|
|
540da1af24 | ||
|
|
e28faea546 | ||
|
|
8055c003ac | ||
|
|
94803bfb67 | ||
|
|
cf3681260b | ||
|
|
580adac33a | ||
|
|
a4240b6474 | ||
|
|
326e1fac54 | ||
|
|
e3a6267059 | ||
|
|
24851184c4 | ||
|
|
a107184010 | ||
|
|
af0245de43 | ||
|
|
afa1993c53 | ||
|
|
fe55cb5f33 | ||
|
|
038ea05d56 | ||
|
|
ba6d68c5b9 | ||
|
|
1598f053ee | ||
|
|
279339a095 | ||
|
|
df9374a070 | ||
|
|
97c12efdb1 | ||
|
|
2cac70c53a | ||
|
|
46b3f29674 | ||
|
|
7a8b9cf4c0 | ||
|
|
0fb606ff3f | ||
|
|
2d85591a43 | ||
|
|
7577426f49 | ||
|
|
c5f7b28aa0 | ||
|
|
6c7d11ca78 |
@@ -59,6 +59,7 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
CONTACT_EMAIL=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -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`.
|
||||
@@ -19,7 +19,7 @@ final class ProductCard
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $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}
|
||||
*/
|
||||
public static function fromIndexed(array $product): array
|
||||
{
|
||||
@@ -28,6 +28,16 @@ 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Models\Country;
|
||||
use Lunar\Models\State;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
use Modules\Core\Privacy\Services\PrivacyService;
|
||||
|
||||
/**
|
||||
* The profile page: name, invoice details, the one address, and account
|
||||
* deletion. The customer keeps a single address, flagged as both the
|
||||
* shipping and billing default, so allowing several later is a UI change
|
||||
* only (the data is already Lunar's own addresses table).
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
// Single-country store, same as CheckoutController::STORE_COUNTRY_ISO3.
|
||||
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||
|
||||
private const ADDRESS_FIELDS = ['line_one', 'city', 'postcode', 'state', 'contact_phone'];
|
||||
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function show(string $locale, Request $request): View
|
||||
{
|
||||
$user = $request->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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
|
||||
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
|
||||
use Modules\Core\Customer\Services\CustomerEmailChangeService;
|
||||
|
||||
/**
|
||||
* Changing the login email — a thin wrapper over boboko-core's
|
||||
* Customer\Services\CustomerEmailChangeService, which owns the actual
|
||||
* request/confirm mechanics, throttling, pending-change storage, and
|
||||
* mailables. This controller's own job is just the storefront's session-
|
||||
* scoped "which email did I just ask to switch to" UI state (so the
|
||||
* .code/.resend pages know which address to show/resend to) and
|
||||
* translating the service's exceptions into the flash-message flow the
|
||||
* views expect.
|
||||
*/
|
||||
class EmailController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'pending_email_change';
|
||||
|
||||
public function edit(string $locale, Request $request): View
|
||||
{
|
||||
return view('account.email', ['user' => $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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
|
||||
/**
|
||||
* Order history. Every lookup goes through CustomerAccountService, which only
|
||||
* ever returns the logged-in user's own placed orders, so an order id from
|
||||
* the URL can't reach someone else's order.
|
||||
*/
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function index(string $locale, Request $request): View
|
||||
{
|
||||
return view('account.orders.index', [
|
||||
'orders' => $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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||
use Modules\Core\Auth\Services\UserOtpService;
|
||||
|
||||
/**
|
||||
* Passwordless customer login: email → emailed 6-digit code → logged in.
|
||||
* Login and registration are the same flow — UserOtpService::generateAndSend()
|
||||
* find-or-creates the user (and its Customer).
|
||||
*
|
||||
* All the security lives in UserOtpService: per-email code-request throttling
|
||||
* (OtpThrottledException), wrong-guess lockout, the Auth::login() itself (which
|
||||
* also regenerates the session and merges the guest cart via Lunar's Login
|
||||
* listener) and the session-registry record. This controller only moves the
|
||||
* shopper between the two steps, carrying the email in the session rather
|
||||
* than the URL.
|
||||
*/
|
||||
class LoginController extends Controller
|
||||
{
|
||||
/**
|
||||
* `?redirect=/el/checkout` (e.g. from the checkout's login tab) becomes the
|
||||
* intended URL that verify() returns to. Only a same-site path is accepted:
|
||||
* no scheme, no protocol-relative `//host`, so it can't redirect off-site.
|
||||
*/
|
||||
public function create(string $locale, Request $request): View
|
||||
{
|
||||
$redirect = (string) $request->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
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
$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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\CustomFieldUploadController;
|
||||
use Closure;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Models\CartLine;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\File\Models\File;
|
||||
use Modules\Core\File\Services\FileService;
|
||||
use Modules\Core\Localization\Services\LanguageCache;
|
||||
|
||||
/**
|
||||
* Thin storefront cart endpoints for the checkout module. Every action mutates
|
||||
* the session cart via boboko-core's CartService and returns the same
|
||||
* server-rendered `cart-body` partial — the drawer's Stimulus controller swaps
|
||||
* that fragment in place (no JSON, no client-side templating). $cart / $lines
|
||||
* for the partial come from the view composer in CheckoutModuleServiceProvider.
|
||||
*/
|
||||
class CartController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CartService $cart,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* CartException here is Lunar's own add_to_cart validation pipeline
|
||||
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
|
||||
* "not enough stock at this quantity" for a tracked (purchasable =
|
||||
* in_stock) variant. Its own message is an untranslated, hardcoded
|
||||
* English string not meant for storefront display, so this returns our
|
||||
* own translated one instead rather than passing it through — a
|
||||
* storefront.* key rather than checkout.*, since this is a catalog/stock
|
||||
* concern the storefront owns, not something specific to the portable
|
||||
* checkout module.
|
||||
*/
|
||||
public function add(string $locale, Request $request): View|JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchasable_id' => ['required', 'integer'],
|
||||
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||
'custom_fields' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
||||
|
||||
try {
|
||||
$meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []);
|
||||
} catch (ValidationException $e) {
|
||||
return response()->json(['error' => collect($e->errors())->flatten()->first()], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->cart->addLine($variant, $data['quantity'] ?? 1, $meta);
|
||||
} catch (CartException) {
|
||||
return $this->stockError($variant);
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* The shopper's answers to the product's custom fields (boboko-core's
|
||||
* Product::$custom_fields — {key, type: text|textarea|file, label,
|
||||
* required}), as cart line meta. Lunar copies CartLine.meta onto the
|
||||
* OrderLine at order creation, so this is also what the order keeps.
|
||||
*
|
||||
* Only keys the product actually defines are kept, nested under
|
||||
* `custom_fields` — line meta also carries behavior flags (core's
|
||||
* `saved_for_later` zeroes the line's price), so shopper input must never
|
||||
* be merged into it directly. Label and type are snapshotted alongside
|
||||
* each value so the cart/order still reads correctly if the product's
|
||||
* fields are edited later.
|
||||
*
|
||||
* A `file` answer is the id of a File row the host's own upload endpoint
|
||||
* (CustomFieldUploadController) already created via boboko-core's
|
||||
* FileService — never the file's bytes, disk, or path, all of which
|
||||
* FileService alone is the source of truth for. A shopper can't point
|
||||
* this at someone else's file: the id must resolve to a File that is
|
||||
* BOTH unowned (isFileAnswerValid()) and tagged with
|
||||
* CustomFieldUploadController::PURPOSE, so an id belonging to an
|
||||
* already-ordered file (owned, and/or a different purpose entirely) is
|
||||
* rejected. Attaching the File to the real CartLine it belongs to
|
||||
* happens afterward, in boboko-core's own Modules\Core\File\Listeners\
|
||||
* AttachCustomFieldFileToCartLine (listening for Cart\Events\
|
||||
* CartLineAdded) — not here, since this method only builds the meta
|
||||
* $this->cart->addLine() is about to receive, before any CartLine
|
||||
* actually exists to own anything.
|
||||
*
|
||||
* Two adds with identical answers merge into one line (Lunar matches
|
||||
* existing lines on meta); different answers stay separate lines.
|
||||
*/
|
||||
private function customFieldsMeta(ProductVariant $variant, array $input): array
|
||||
{
|
||||
$fields = collect($variant->product?->custom_fields ?? [])
|
||||
->keyBy('key')
|
||||
->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]);
|
||||
|
||||
if ($fields->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$validated = Validator::make(
|
||||
$input,
|
||||
$fields->map(fn (array $field) => [
|
||||
($field['required'] ?? false) ? 'required' : 'nullable',
|
||||
...match ($field['type']) {
|
||||
'textarea' => ['string', 'max:2000'],
|
||||
'file' => [function (string $attribute, mixed $value, Closure $fail) {
|
||||
if (! $this->isFileAnswerValid($value)) {
|
||||
$fail('validation.uploaded')->translate();
|
||||
}
|
||||
}],
|
||||
default => ['string', 'max:255'],
|
||||
},
|
||||
])->all(),
|
||||
[],
|
||||
$fields->map(fn (array $field) => $field['label'])->all(),
|
||||
)->validate();
|
||||
|
||||
$answers = $fields
|
||||
->filter(fn (array $field) => filled($validated[$field['key']] ?? null))
|
||||
->map(fn (array $field) => [
|
||||
'key' => $field['key'],
|
||||
'label' => $field['label'],
|
||||
'type' => $field['type'],
|
||||
...($field['type'] === 'file'
|
||||
? ['file_id' => (int) $validated[$field['key']]]
|
||||
: ['value' => $validated[$field['key']]]),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $answers === [] ? [] : ['custom_fields' => $answers];
|
||||
}
|
||||
|
||||
/**
|
||||
* Product::$custom_fields stores `label` as {locale: string} (see
|
||||
* boboko-core's Catalog\Filament\Pages\ManageProductCustomFields) — this
|
||||
* resolves it to the single current-locale string cart/order line meta
|
||||
* actually needs, the same filled()-over-?? fallback boboko-core's
|
||||
* ProductDocumentLocalizer uses for every other translated field (an
|
||||
* empty string for the current locale still falls through to the
|
||||
* store's default language, rather than showing blank). A product
|
||||
* saved before labels became translatable still has a plain string
|
||||
* here, returned as-is.
|
||||
*/
|
||||
private function resolveLabel(mixed $label): string
|
||||
{
|
||||
if (! is_array($label)) {
|
||||
return (string) $label;
|
||||
}
|
||||
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = app(LanguageCache::class)->defaultLocale();
|
||||
|
||||
return filled($label[$locale] ?? null)
|
||||
? $label[$locale]
|
||||
: ($label[$fallbackLocale] ?? '');
|
||||
}
|
||||
|
||||
private function isFileAnswerValid(mixed $fileId): bool
|
||||
{
|
||||
$file = File::find($fileId);
|
||||
|
||||
return $file !== null
|
||||
&& $file->purpose === CustomFieldUploadController::PURPOSE
|
||||
&& $file->owner_id === null
|
||||
&& app(FileService::class)->exists($file);
|
||||
}
|
||||
|
||||
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
|
||||
{
|
||||
$quantity = (int) $request->validate([
|
||||
'quantity' => ['required', 'integer', 'min:0'],
|
||||
])['quantity'];
|
||||
|
||||
try {
|
||||
$quantity === 0
|
||||
? $this->cart->removeLine($line)
|
||||
: $this->cart->updateLine($line, $quantity);
|
||||
} catch (CartException) {
|
||||
$variant = CartLine::find($line)?->purchasable;
|
||||
|
||||
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
|
||||
* checked against (stock, for a tracked in_stock variant) — telling the
|
||||
* shopper how many are actually left beats a generic "not enough stock"
|
||||
* they'd otherwise have to guess around by trial and error.
|
||||
*/
|
||||
private function stockError(?ProductVariant $variant): JsonResponse
|
||||
{
|
||||
$available = $variant?->getTotalInventory() ?? 0;
|
||||
|
||||
return response()->json([
|
||||
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
|
||||
], 422);
|
||||
}
|
||||
|
||||
public function remove(string $locale, int $line): View
|
||||
{
|
||||
$this->cart->removeLine($line);
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* A bad code is a normal, expected outcome here (typo, expired code), not
|
||||
* an error state for the request — it re-renders the same cart-body
|
||||
* partial with $couponError set, rather than a 4xx/redirect, so the fetch
|
||||
* + swap in bbk-cart-controller stays the one code path for every cart
|
||||
* mutation.
|
||||
*/
|
||||
public function applyCoupon(string $locale, Request $request): View
|
||||
{
|
||||
$code = $request->validate([
|
||||
'code' => ['required', 'string'],
|
||||
])['code'];
|
||||
|
||||
$couponError = false;
|
||||
|
||||
try {
|
||||
$this->cart->applyCoupon($code);
|
||||
} catch (InvalidCouponException) {
|
||||
$couponError = true;
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body', ['couponError' => $couponError]);
|
||||
}
|
||||
|
||||
public function removeCoupon(string $locale): View
|
||||
{
|
||||
$this->cart->removeCoupon();
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Exceptions\Carts\CartException;
|
||||
use Lunar\Exceptions\FingerprintMismatchException;
|
||||
use Lunar\Facades\CartSession;
|
||||
use Lunar\Models\Cart;
|
||||
use Lunar\Models\Country;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\State;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
|
||||
/**
|
||||
* The checkout page — one page, sections (contact / billing / shipping /
|
||||
* shipping method), reviewed against boboko-core's CheckoutService. Stops
|
||||
* short of payment for this slice (see project memory).
|
||||
*
|
||||
* The address form and the shipping-method radios **autosave** — no submit
|
||||
* buttons. `saveAddress()` / `selectShippingOption()` are called by
|
||||
* bbk-checkout-form (debounced fetch) and return a JSON envelope of
|
||||
* server-rendered fragments (shipping options + order summary) plus any
|
||||
* field errors, rather than redirecting. Address validation is deliberately
|
||||
* lenient — nothing is rejected mid-typing; required-field enforcement is the
|
||||
* job of the (not-yet-built) "Continue to payment" gate.
|
||||
*
|
||||
* Guests type their email; the login tab links to the storefront's own
|
||||
* `login` route and back. Logged in: the email is the account's (forced in
|
||||
* saveAddress()), the first visit prefills addresses from the account
|
||||
* (prefillFromAccount()), and Lunar's Login listener has already attached the
|
||||
* cart, so the placed order lands in the account's history.
|
||||
*
|
||||
* Single-country store: STORE_COUNTRY_ISO3 fixes the country to Greece (hidden
|
||||
* field, forced server-side). Set it to null for the full country picker (the
|
||||
* portable path for a multi-country boboko store).
|
||||
*/
|
||||
class CheckoutController extends Controller
|
||||
{
|
||||
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||
|
||||
public function __construct(
|
||||
private readonly CartService $cart,
|
||||
private readonly CheckoutService $checkout,
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function show(string $locale): View
|
||||
{
|
||||
$cart = $this->cart->current();
|
||||
$lines = $cart ? $this->cart->activeLines($cart) : collect();
|
||||
$storeCountry = $this->storeCountry();
|
||||
|
||||
$shippingOptions = collect();
|
||||
|
||||
// Captured before prefillFromAccount(), which may recreate the address
|
||||
// row (dropping its shipping_option) — same reason as in saveAddress().
|
||||
$previousOption = $cart?->shippingAddress?->shipping_option;
|
||||
|
||||
if ($cart && Auth::check()) {
|
||||
$cart = $this->prefillFromAccount($cart);
|
||||
|
||||
// Nothing chosen on this cart yet: carry over the account's standing
|
||||
// opt-in (an explicit earlier choice, recorded with its own
|
||||
// timestamp/policy version). Never opts anyone in by default.
|
||||
if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? [])
|
||||
&& data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) {
|
||||
$cart = $this->checkout->setRecoveryConsent(true);
|
||||
}
|
||||
}
|
||||
|
||||
if ($cart?->shippingAddress) {
|
||||
$shippingOptions = $this->syncShipping($cart, $previousOption);
|
||||
|
||||
// Cart's CachesProperties::refresh() explicitly nulls total/
|
||||
// subTotal/shippingTotal/etc. back to their defaults — every
|
||||
// Lunar call site pairs it with recalculate() for exactly that
|
||||
// reason. Bare refresh() here was leaving $cart->total null on
|
||||
// reload, which fed a 0 amount straight into the Stripe Element.
|
||||
$cart->refresh()->recalculate();
|
||||
}
|
||||
|
||||
$paymentMethods = $this->checkout->getPaymentMethods();
|
||||
|
||||
// Nothing checked yet (fresh cart), or the shopper's earlier pick is
|
||||
// no longer offered (method disabled/removed since) — auto-select
|
||||
// the first one, same as a manual click would, so the payment
|
||||
// section (and the Stripe Element mounting under it) isn't sitting
|
||||
// inert behind an unchecked radio. A still-valid previous choice is
|
||||
// left alone.
|
||||
$firstMethod = $paymentMethods->first();
|
||||
|
||||
if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) {
|
||||
$cart = $this->checkout->selectPaymentMethod($firstMethod->type);
|
||||
}
|
||||
|
||||
return view('checkout::page', [
|
||||
'cart' => $cart,
|
||||
'lines' => $lines,
|
||||
'billingAddress' => $cart?->billingAddress,
|
||||
'shippingAddress' => $cart?->shippingAddress,
|
||||
'shippingOptions' => $shippingOptions,
|
||||
'paymentMethods' => $paymentMethods,
|
||||
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
|
||||
'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false),
|
||||
'storeCountry' => $storeCountry,
|
||||
'countries' => $storeCountry
|
||||
? collect()
|
||||
: Country::orderBy('name')->get(['id', 'name']),
|
||||
'regions' => $storeCountry
|
||||
? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name'])
|
||||
: collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveAddress(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$storeCountry = $this->storeCountry();
|
||||
$sameAsBilling = $request->boolean('same_as_billing');
|
||||
|
||||
// Only the fields shipping rates resolve against — if none of these
|
||||
// changed (shopper edited their name, phone, email, …) there's no point
|
||||
// re-quoting shipping or re-rendering the summary.
|
||||
$addressBefore = $this->cart->current()?->shippingAddress;
|
||||
$rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']);
|
||||
|
||||
// setShippingAddress() below always deletes + recreates this row (see
|
||||
// syncShipping()'s docblock) — capture what was selected NOW, before
|
||||
// it's gone, so it can be carried forward onto the fresh row.
|
||||
$previousOption = $addressBefore?->shipping_option;
|
||||
|
||||
$stateRule = $storeCountry
|
||||
? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)]
|
||||
: ['nullable', 'string', 'max:255'];
|
||||
$countryRule = $storeCountry
|
||||
? ['nullable']
|
||||
: ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id'];
|
||||
|
||||
// Lenient — only format checks. Anything that fails is simply left out
|
||||
// of what gets persisted, and reported back for inline display.
|
||||
$validator = Validator::make($request->all(), [
|
||||
'contact_email' => ['nullable', 'email'],
|
||||
|
||||
'billing_first_name' => ['nullable', 'string', 'max:255'],
|
||||
'billing_last_name' => ['nullable', 'string', 'max:255'],
|
||||
'billing_company_name' => ['nullable', 'string', 'max:255'],
|
||||
'billing_tax_identifier' => ['nullable', 'string', 'max:255'],
|
||||
'billing_line_one' => ['nullable', 'string', 'max:255'],
|
||||
'billing_city' => ['nullable', 'string', 'max:255'],
|
||||
'billing_state' => $stateRule,
|
||||
'billing_postcode' => ['nullable', 'string', 'max:20'],
|
||||
'billing_country_id' => $countryRule,
|
||||
'billing_contact_phone' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
'shipping_first_name' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_last_name' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_line_one' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_city' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_state' => $stateRule,
|
||||
'shipping_postcode' => ['nullable', 'string', 'max:20'],
|
||||
'shipping_country_id' => $countryRule,
|
||||
'shipping_contact_phone' => ['nullable', 'string', 'max:50'],
|
||||
'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$errors = $validator->errors()->toArray();
|
||||
$data = $validator->valid();
|
||||
|
||||
// Logged in: the order email is always the account's. It isn't a field
|
||||
// on the page then, and a submitted value isn't trusted.
|
||||
if ($user = Auth::user()) {
|
||||
$data['contact_email'] = $user->email;
|
||||
}
|
||||
|
||||
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
|
||||
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
|
||||
|
||||
// Company/ΑΦΜ only count when "I want an invoice" is ticked; the fields
|
||||
// stay in the DOM (just hidden) when it isn't, so ignore what they send.
|
||||
$wantsInvoice = $request->boolean('wants_invoice');
|
||||
|
||||
$billing = [
|
||||
'first_name' => $data['billing_first_name'] ?? null,
|
||||
'last_name' => $data['billing_last_name'] ?? null,
|
||||
'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null,
|
||||
'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null,
|
||||
'line_one' => $data['billing_line_one'] ?? null,
|
||||
'city' => $data['billing_city'] ?? null,
|
||||
'state' => $data['billing_state'] ?? null,
|
||||
'postcode' => $data['billing_postcode'] ?? null,
|
||||
'country_id' => $billingCountryId,
|
||||
'contact_email' => $data['contact_email'] ?? null,
|
||||
'contact_phone' => $data['billing_contact_phone'] ?? null,
|
||||
];
|
||||
|
||||
$shipping = $sameAsBilling
|
||||
? [
|
||||
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
||||
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
||||
]
|
||||
: [
|
||||
'first_name' => $data['shipping_first_name'] ?? null,
|
||||
'last_name' => $data['shipping_last_name'] ?? null,
|
||||
'line_one' => $data['shipping_line_one'] ?? null,
|
||||
'city' => $data['shipping_city'] ?? null,
|
||||
'state' => $data['shipping_state'] ?? null,
|
||||
'postcode' => $data['shipping_postcode'] ?? null,
|
||||
'country_id' => $shippingCountryId,
|
||||
'contact_email' => $data['contact_email'] ?? null,
|
||||
'contact_phone' => $data['shipping_contact_phone'] ?? null,
|
||||
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
||||
];
|
||||
|
||||
$this->checkout->setBillingAddress($billing);
|
||||
$cart = $this->checkout->setShippingAddress($shipping);
|
||||
|
||||
$cart->meta = [
|
||||
...($cart->meta?->toArray() ?? []),
|
||||
'ship_to_billing' => $sameAsBilling,
|
||||
'wants_invoice' => $wantsInvoice,
|
||||
];
|
||||
$cart->save();
|
||||
|
||||
// Abandoned-cart-recovery opt-in — boboko-core owns the record (bool +
|
||||
// timestamp + policy version on Cart::meta, RecoveryConsentSet event).
|
||||
// Deliberately its own scope, not merged with any future newsletter opt-in.
|
||||
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
|
||||
|
||||
if (Auth::check()) {
|
||||
$this->account->setRecoveryConsent(Auth::user(), $request->boolean('recovery_consent'));
|
||||
}
|
||||
|
||||
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
|
||||
$rateChanged = $rateKeyAfter != $rateKeyBefore;
|
||||
|
||||
// setShippingAddress() above always deletes and recreates the
|
||||
// CartAddress row (Lunar's AddAddress action), which drops whatever
|
||||
// shipping_option was previously selected — regardless of whether the
|
||||
// rate-determining fields actually changed. So this always has to run
|
||||
// to restore/re-validate it, even on a save that only touched e.g. the
|
||||
// phone number. Only the fragment RE-RENDER is skippable when nothing
|
||||
// rate-relevant moved — the re-select itself is not optional.
|
||||
$options = $this->syncShipping($cart, $previousOption);
|
||||
|
||||
if (! $rateChanged) {
|
||||
return $this->fragments($cart, null, $errors);
|
||||
}
|
||||
|
||||
return $this->fragments($cart, $options, $errors);
|
||||
}
|
||||
|
||||
public function selectShippingOption(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$identifier = (string) $request->input('shipping_option');
|
||||
|
||||
try {
|
||||
$this->checkout->selectShippingOption($identifier);
|
||||
} catch (InvalidShippingOptionException) {
|
||||
// Re-render with whatever is currently valid; no hard error surfaced.
|
||||
}
|
||||
|
||||
$cart = $this->cart->current();
|
||||
$options = $cart?->shippingAddress
|
||||
? $this->checkout->getShippingOptions()
|
||||
: collect();
|
||||
|
||||
return $this->fragments($cart, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Autosave-select a payment method (radio change). Persists it via
|
||||
* CheckoutService (which also records it on Cart::meta and re-snapshots
|
||||
* the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary.
|
||||
*/
|
||||
public function selectPaymentMethod(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$type = (string) $request->input('payment_type');
|
||||
|
||||
try {
|
||||
$this->checkout->selectPaymentMethod($type);
|
||||
} catch (UnknownPaymentTypeException) {
|
||||
// Radio value out of sync with what's offered — ignore, the summary
|
||||
// just won't reflect a method fee. place-order re-checks properly.
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'summaryHtml' => view('checkout::partials.cart-body')->render(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The real submit — the hard gate. Re-selects the payment method (fresh
|
||||
* fingerprint), then hands off to CheckoutService::initiatePayment(), which
|
||||
* creates the draft order, records terms acceptance, and charges the driver.
|
||||
* Returns JSON the bbk-payment controller routes on:
|
||||
* { redirect } — placed, go to confirmation
|
||||
* { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls
|
||||
* { status: 'failed', message } — declined
|
||||
* { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection
|
||||
*/
|
||||
public function placeOrder(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
if (! $request->boolean('terms_accepted')) {
|
||||
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->checkout->selectPaymentMethod((string) $request->input('payment_type'));
|
||||
} catch (UnknownPaymentTypeException) {
|
||||
return response()->json(['error' => __('checkout.page.choose_payment_method')], 422);
|
||||
}
|
||||
|
||||
$cart = $this->cart->current();
|
||||
|
||||
// Captured now, before initiatePayment() can place the order — Lunar's
|
||||
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
|
||||
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
|
||||
// (i.e. has an order with placed_at set), which happens synchronously
|
||||
// for an immediately-captured payment. Any later $this->cart->current()
|
||||
// call in this same flow (here, or in a subsequent orderStatus() poll
|
||||
// once the 3-D Secure webhook sets placed_at) would then resolve to
|
||||
// that fresh, order-less cart instead of the one that was just placed.
|
||||
// Storing the real cart id ourselves, under our own session key,
|
||||
// sidesteps CartSession entirely for the rest of the placement flow.
|
||||
session(['checkout.cart_id' => $cart?->id]);
|
||||
|
||||
// Lunar's own ValidateCartForOrderCreation (order_create validator)
|
||||
// never checks for this — an empty cart with a valid billing address
|
||||
// sails straight through it and would place a real, zero-line order.
|
||||
// The disabled "place order" button is only the client-side half of
|
||||
// this fix; this is the half that actually matters.
|
||||
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.cart_empty'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Lenient autosave never requires these; this is the gate.
|
||||
if (data_get($cart, 'meta.wants_invoice')
|
||||
&& (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.invoice_required'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Same check Lunar's own ValidateCartForOrderCreation runs inside
|
||||
// initiatePayment() (a product unpublished/deleted after it was
|
||||
// added to the cart) — checked here first so the shopper is told
|
||||
// which product is the problem, rather than falling into the
|
||||
// catch-all "complete your billing/shipping details" message below,
|
||||
// which is what actually happened and is generic to every
|
||||
// CartException reason, misleading when the real cause is a line,
|
||||
// not an address.
|
||||
$unavailableLines = $this->cart->activeLines($cart)->filter(
|
||||
fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(),
|
||||
);
|
||||
|
||||
if ($unavailableLines->isNotEmpty()) {
|
||||
$names = $unavailableLines
|
||||
->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier())
|
||||
->filter()
|
||||
->implode(', ');
|
||||
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]),
|
||||
], 422);
|
||||
}
|
||||
|
||||
// The one incomplete-cart case worth a specific message + pointing the
|
||||
// shopper at the right section: a region resolving 2+ methods needs an
|
||||
// explicit pick (no auto-select), easy to miss since nothing else on
|
||||
// the page demands it. Everything else CartException catches below.
|
||||
if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.shipping_method_required'),
|
||||
'field' => 'shipping_option',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? '');
|
||||
|
||||
$data = $request->filled('payment_method')
|
||||
? ['payment_method' => (string) $request->input('payment_method')]
|
||||
: [];
|
||||
|
||||
try {
|
||||
$result = $this->checkout->initiatePayment(
|
||||
$fingerprint,
|
||||
termsAccepted: true,
|
||||
policyVersion: (string) config('legal.terms_version'),
|
||||
data: $data,
|
||||
);
|
||||
} catch (FingerprintMismatchException) {
|
||||
return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409);
|
||||
} catch (CartException $e) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.payment_incomplete_details'),
|
||||
'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(),
|
||||
], 422);
|
||||
} catch (TermsNotAcceptedException) {
|
||||
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
||||
}
|
||||
|
||||
// Pending with no continuation (cash-on-delivery, or any other
|
||||
// deferred/offline method) means CheckoutService::initiatePayment()
|
||||
// already created the placed order — money just hasn't changed
|
||||
// hands yet. Only a Pending WITH a continuation (Stripe's client
|
||||
// secret) means the shopper still has something to do before the
|
||||
// order exists as far as the storefront is concerned.
|
||||
return match (true) {
|
||||
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
|
||||
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
|
||||
$result->status === PaymentResultStatus::Pending => response()->json([
|
||||
'status' => 'pending',
|
||||
'clientSecret' => $result->continuation?->value,
|
||||
]),
|
||||
default => response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
|
||||
'retriable' => $result->retriable,
|
||||
], 422),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll target for the 3-D Secure path: has the webhook placed the order yet?
|
||||
* boboko-core's StripeWebhookController -> handleCallback -> PaymentCaptured
|
||||
* -> ApplyResolvedPaymentStatus sets placed_at.
|
||||
*/
|
||||
public function orderStatus(string $locale): JsonResponse
|
||||
{
|
||||
$order = $this->placedOrder();
|
||||
|
||||
if (! $order) {
|
||||
return response()->json(['placed' => false]);
|
||||
}
|
||||
|
||||
session(['checkout.order_id' => $order->id]);
|
||||
CartSession::forget();
|
||||
|
||||
return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]);
|
||||
}
|
||||
|
||||
public function confirmation(string $locale): View|RedirectResponse
|
||||
{
|
||||
$orderId = session('checkout.order_id');
|
||||
|
||||
$order = $orderId
|
||||
? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
|
||||
: null;
|
||||
|
||||
if (! $order) {
|
||||
return redirect()->route('products', $locale);
|
||||
}
|
||||
|
||||
// Looked up by type rather than a stored relation — the method may since
|
||||
// have been disabled/deleted, but the order still needs to show what was
|
||||
// actually used at the time.
|
||||
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||
->first()
|
||||
?->translate('name');
|
||||
|
||||
return view('checkout::confirmation', [
|
||||
'order' => $order,
|
||||
'paymentMethodName' => $paymentMethodName,
|
||||
]);
|
||||
}
|
||||
|
||||
private function orderPlacedResponse(string $locale): JsonResponse
|
||||
{
|
||||
if ($order = $this->placedOrder()) {
|
||||
session(['checkout.order_id' => $order->id]);
|
||||
}
|
||||
|
||||
CartSession::forget();
|
||||
|
||||
return response()->json(['redirect' => route('checkout.confirmation', $locale)]);
|
||||
}
|
||||
|
||||
private function placedOrder(): ?Order
|
||||
{
|
||||
$cartId = session('checkout.cart_id');
|
||||
|
||||
if ($cartId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Order::where('cart_id', $cartId)
|
||||
->whereNotNull('placed_at')
|
||||
->latest('placed_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolve shipping options for the cart's current address and keep the
|
||||
* selection sane: auto-select when exactly one resolves, or carry a
|
||||
* previous pick forward when it's still among the resolved options.
|
||||
*
|
||||
* $previousOption must be captured by the CALLER before setShippingAddress()
|
||||
* runs — Lunar's AddAddress action always deletes and recreates the
|
||||
* CartAddress row on every save (see saveAddress()), so by the time this
|
||||
* runs, $address->shipping_option is unconditionally null regardless of
|
||||
* what was selected a moment ago. There is nothing meaningful left to read
|
||||
* off $address itself; $previousOption is the only source of truth for
|
||||
* "what was chosen before this save wiped the row." show() passes the
|
||||
* address's own (not-just-wiped) current value, since nothing recreated
|
||||
* anything in that path.
|
||||
*
|
||||
* Always (re-)applies the resolved target via selectShippingOption() rather
|
||||
* than comparing against the (always-blank, post-recreation) current value
|
||||
* — the fresh row needs the write regardless of whether the decision
|
||||
* "which option" actually changed.
|
||||
*
|
||||
* @return Collection<int, \Lunar\DataTypes\ShippingOption>
|
||||
*/
|
||||
private function syncShipping(Cart $cart, ?string $previousOption): Collection
|
||||
{
|
||||
if (! $cart->shippingAddress) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$options = $this->checkout->getShippingOptions();
|
||||
|
||||
$target = match (true) {
|
||||
$options->count() === 1 => $options->first()->identifier,
|
||||
$previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($target !== null) {
|
||||
try {
|
||||
$this->checkout->selectShippingOption($target);
|
||||
} catch (InvalidShippingOptionException) {
|
||||
// $target came from $options itself — shouldn't happen, stay defensive
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* $options === null means "nothing money-relevant changed" — acknowledge the
|
||||
* save (and any field errors) without re-rendering the shipping options or
|
||||
* the order summary, so a plain name/phone edit is a cheap round-trip.
|
||||
*/
|
||||
private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'errors' => collect($errors)
|
||||
->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages)
|
||||
->all(),
|
||||
'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [
|
||||
'shippingAddress' => $cart?->shippingAddress,
|
||||
'shippingOptions' => $options,
|
||||
])->render(),
|
||||
// Composer (CheckoutModuleServiceProvider) fills $cart / $lines.
|
||||
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logged-in shopper: fills any BLANK cart address field from the account
|
||||
* (name, saved default address, phone, email), on every checkout load, so
|
||||
* an account filled in after checkout started still shows up. Never
|
||||
* overwrites anything already in the cart.
|
||||
*
|
||||
* Company/ΑΦΜ (and ticking "I want an invoice") only on the first pass
|
||||
* (meta.account_prefilled): someone who then clears them or unticks the
|
||||
* box for this order shouldn't get them back on the next reload.
|
||||
*
|
||||
* Writes only when something actually changes, so a normal reload costs
|
||||
* nothing extra.
|
||||
*/
|
||||
private function prefillFromAccount(Cart $cart): Cart
|
||||
{
|
||||
$user = Auth::user();
|
||||
$customer = $this->account->customer($user);
|
||||
$addresses = collect($this->account->addresses($user));
|
||||
$saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||
$firstPass = ! data_get($cart, 'meta.account_prefilled');
|
||||
|
||||
$fromAccount = array_filter([
|
||||
'first_name' => $customer?->first_name ?: $saved?->first_name,
|
||||
'last_name' => $customer?->last_name ?: $saved?->last_name,
|
||||
'line_one' => $saved?->line_one,
|
||||
'city' => $saved?->city,
|
||||
'state' => $saved?->state,
|
||||
'postcode' => $saved?->postcode,
|
||||
'country_id' => $this->storeCountry()?->id ?? $saved?->country_id,
|
||||
'contact_email' => $user->email,
|
||||
'contact_phone' => $saved?->contact_phone,
|
||||
], 'filled');
|
||||
|
||||
$invoice = $firstPass
|
||||
? array_filter([
|
||||
'company_name' => $customer?->company_name,
|
||||
'tax_identifier' => $customer?->tax_identifier,
|
||||
], 'filled')
|
||||
: [];
|
||||
|
||||
$fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city',
|
||||
'state', 'postcode', 'country_id', 'contact_email', 'contact_phone'];
|
||||
|
||||
$fillBlanks = function (?array $current, array $values) {
|
||||
$current ??= [];
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
if (blank($current[$key] ?? null)) {
|
||||
$current[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
};
|
||||
|
||||
$billingBefore = $cart->billingAddress?->only($fields);
|
||||
$billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]);
|
||||
|
||||
// Shipping has no company/ΑΦΜ (same shape saveAddress() writes).
|
||||
$shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true);
|
||||
$shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions'];
|
||||
|
||||
$shippingBefore = $cart->shippingAddress?->only($shippingFields);
|
||||
$shipping = $shipToBilling
|
||||
? [
|
||||
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
||||
'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null,
|
||||
]
|
||||
: $fillBlanks($shippingBefore, $fromAccount);
|
||||
|
||||
if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) {
|
||||
$this->checkout->setBillingAddress($billing);
|
||||
$cart = $this->checkout->setShippingAddress($shipping);
|
||||
}
|
||||
|
||||
if ($firstPass) {
|
||||
$cart->meta = [
|
||||
...($cart->meta?->toArray() ?? []),
|
||||
'account_prefilled' => true,
|
||||
'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [],
|
||||
];
|
||||
$cart->save();
|
||||
}
|
||||
|
||||
return $cart;
|
||||
}
|
||||
|
||||
private function storeCountry(): ?Country
|
||||
{
|
||||
if (self::STORE_COUNTRY_ISO3 === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Core\File\Http\Controllers\UploadFileController;
|
||||
|
||||
/**
|
||||
* Stores the shopper's photo for a product custom field of type `file` (see
|
||||
* boboko-core's Product::$custom_fields) the moment it's picked on the product
|
||||
* page — before add-to-cart, see custom-field-upload-controller.js.
|
||||
*
|
||||
* Which files are acceptable (extensions, size) is a per-site decision — this
|
||||
* site: photographs — so it lives here as this app's own policy, extending
|
||||
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
|
||||
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
|
||||
* endpoint only ever receives the stored File row's own `id` — FileService is
|
||||
* the single source of truth for disk/path/name/mime, never duplicated into
|
||||
* cart/order line meta (see Checkout\CartController::customFieldsMeta()). A
|
||||
* shopper can't point a cart line at someone else's file: CartController only
|
||||
* accepts an id that is both unowned and tagged with this exact PURPOSE.
|
||||
*
|
||||
* Stored on the private `local` disk: these are customers' personal photos,
|
||||
* never reachable by a public URL except through FileService's own signed
|
||||
* download route. Uploads nobody adds to a cart are removed by core's
|
||||
* `boboko:file:prune-unowned custom-field-upload` command.
|
||||
*/
|
||||
class CustomFieldUploadController extends UploadFileController
|
||||
{
|
||||
public const PURPOSE = 'custom-field-upload';
|
||||
|
||||
public const MAX_KILOBYTES = 10240;
|
||||
|
||||
public const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'];
|
||||
|
||||
/**
|
||||
* For the file input's `accept` attribute — same list the server enforces.
|
||||
*/
|
||||
public static function accept(): string
|
||||
{
|
||||
return '.'.implode(',.', self::EXTENSIONS);
|
||||
}
|
||||
|
||||
protected function purpose(): string
|
||||
{
|
||||
return self::PURPOSE;
|
||||
}
|
||||
|
||||
protected function validationRules(Request $request): array
|
||||
{
|
||||
return [
|
||||
'file' => ['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')];
|
||||
}
|
||||
}
|
||||
@@ -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,205 @@ 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(),
|
||||
// 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' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\ProductCard;
|
||||
use App\Services\Wishlist;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
|
||||
class WishlistController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Wishlist $wishlist,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The account's wishlist page.
|
||||
*/
|
||||
public function index(string $locale, ProductService $products): View
|
||||
{
|
||||
return view('account.wishlist', ['products' => $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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'message' => ['required', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Services\Wishlist;
|
||||
use Modules\Core\Auth\Events\UserAuthenticated;
|
||||
|
||||
/**
|
||||
* Picked up by Laravel's listener discovery (app/Listeners), no manual
|
||||
* registration. Runs inside the login request, so it can read the guest
|
||||
* wishlist cookie and queue its removal.
|
||||
*/
|
||||
class MergeGuestWishlistOnLogin
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Wishlist $wishlist,
|
||||
) {}
|
||||
|
||||
public function handle(UserAuthenticated $event): void
|
||||
{
|
||||
$this->wishlist->mergeGuestInto($event->user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
|
||||
/**
|
||||
* When a logged-in shopper places an order and their account has no saved
|
||||
* address yet, the order's shipping address (and their name, if the profile
|
||||
* has none) becomes the account's. Never overwrites anything already saved.
|
||||
*
|
||||
* Uses the order's own user, not Auth: OrderPlaced can fire from a payment
|
||||
* webhook, where nobody is logged in. Picked up by listener discovery.
|
||||
*/
|
||||
class SaveAddressFromFirstOrder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function handle(OrderPlaced $event): void
|
||||
{
|
||||
$order = $event->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Sent to whoever submitted the contact form. Deliberately generic: it never
|
||||
* repeats what they wrote, so the form can't be used to deliver arbitrary text
|
||||
* to someone else's inbox.
|
||||
*/
|
||||
class ContactConfirmationMail extends Mailable
|
||||
{
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: __('storefront.contact.confirmation_subject'));
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.contact-confirmation');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* A contact-form message, sent to the store's CONTACT_EMAIL. Reply-To is the
|
||||
* sender, so replying from the inbox goes straight to them.
|
||||
*/
|
||||
class ContactMessageMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $senderName,
|
||||
public readonly string $senderEmail,
|
||||
public readonly string $body,
|
||||
public readonly string $senderLocale,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
replyTo: [new Address($this->senderEmail, $this->senderName)],
|
||||
subject: "Νέο μήνυμα επικοινωνίας από {$this->senderName}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.contact-message');
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,6 @@ class User extends Authenticatable implements LunarUserInterface
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $dispatchesEvents = [
|
||||
'created' => UserCreated::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* One product on a logged-in user's wishlist. Guests' wishlists live in a
|
||||
* cookie instead — see App\Services\Wishlist.
|
||||
*/
|
||||
class WishlistItem extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'product_id'];
|
||||
}
|
||||
@@ -17,6 +17,13 @@
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
// One instance per request: it caches the guest cookie's ids, so a
|
||||
// toggle and a later has() in the same request agree.
|
||||
$this->app->scoped(\App\Services\Wishlist::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Telemetry::optOut();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\View\View as ViewInstance;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
|
||||
/**
|
||||
* The seam for the (currently in-repo) cart + checkout module.
|
||||
*
|
||||
* Everything the module needs to boot inside a host app lives here: its view /
|
||||
* component namespaces, its routes, and the composer that feeds the
|
||||
* always-present cart drawer. Strings (__('checkout.cart.*')) are NOT wired up
|
||||
* here — same as storefront.* elsewhere in this app, they resolve through
|
||||
* Lunar's DB-backed translation UI (spatie/laravel-translation-loader), added
|
||||
* manually there rather than shipped as lang/ files. The module's own files
|
||||
* (resources/views/checkout/**, resources/js/checkout/**, resources/css/checkout.css,
|
||||
* routes/checkout.php, app/Http/Controllers/Checkout/**) contain nothing
|
||||
* 3dealer-specific.
|
||||
*
|
||||
* When the module is extracted to boboko-core, this class is deleted and its
|
||||
* body becomes the package's own ServiceProvider. The only other host wiring
|
||||
* is the one registerCheckout() call in resources/js/app.js and the two lines
|
||||
* in the layout (the checkout.css @vite entry and @include('checkout::drawer')).
|
||||
*/
|
||||
class CheckoutModuleServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
$this->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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\WishlistItem;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
/**
|
||||
* The current shopper's wishlist, product ids only.
|
||||
*
|
||||
* Logged in: rows in wishlist_items. Guest: a 1-year cookie holding the ids
|
||||
* (encrypted like every cookie, by the web group's EncryptCookies), so nothing
|
||||
* is written to the database for anonymous visitors. On login the cookie is
|
||||
* merged into the account and cleared (MergeGuestWishlistOnLogin).
|
||||
*/
|
||||
class Wishlist
|
||||
{
|
||||
public const COOKIE = 'wishlist';
|
||||
|
||||
private const COOKIE_MINUTES = 60 * 24 * 365;
|
||||
|
||||
// Keeps the cookie well under the 4KB browser limit.
|
||||
private const GUEST_MAX = 100;
|
||||
|
||||
/** @var array<int>|null ids for this request, including a toggle just made */
|
||||
private ?array $guestIds = null;
|
||||
|
||||
/** @return array<int> 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<int> */
|
||||
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<int> $ids */
|
||||
private function storeGuestIds(array $ids): void
|
||||
{
|
||||
$this->guestIds = $ids;
|
||||
|
||||
Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
//
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\CheckoutModuleServiceProvider;
|
||||
use App\Providers\PanelServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
PanelServiceProvider::class,
|
||||
CheckoutModuleServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Per-site settings for the cart + checkout module (see
|
||||
* App\Providers\CheckoutModuleServiceProvider). Becomes the package's
|
||||
* publishable config when the module moves to boboko-core.
|
||||
*/
|
||||
return [
|
||||
|
||||
/*
|
||||
* Name of the storefront's login route. The checkout's login tab and the
|
||||
* confirmation page link to it with `?redirect=<checkout path>`, so the
|
||||
* login page must send the shopper back there afterwards (3dealer's
|
||||
* Auth\LoginController does). null: no login offered in checkout at all.
|
||||
*/
|
||||
'login_route' => 'login',
|
||||
|
||||
];
|
||||
@@ -58,11 +58,23 @@
|
||||
| make sense for the store you're building.
|
||||
|
|
||||
*/
|
||||
'draft_status' => 'awaiting-payment',
|
||||
'draft_status' => 'awaiting_payment',
|
||||
|
||||
// Two sequences — carrier and store-pickup — see Modules\Core\Order\
|
||||
// Services\OrderStatusFlow, the single source of truth for which
|
||||
// transitions are actually offered from a given status. Payment
|
||||
// timing (prepaid vs. cash-on-delivery) does not affect this
|
||||
// sequence at all — see Order::paid/paid_at instead, an entirely
|
||||
// independent field with no status value of its own.
|
||||
//
|
||||
// 'favourite' controls whether a status gets its own tab in the order
|
||||
// list topbar (Lunar\Admin\...\ListOrders::getDefaultTabs()) — set
|
||||
// only on the main-journey statuses so the topbar isn't cluttered
|
||||
// with every branch/exception value; every status (favourite or not)
|
||||
// remains reachable via the table's own status filter/search.
|
||||
'statuses' => [
|
||||
|
||||
'awaiting-payment' => [
|
||||
'awaiting_payment' => [
|
||||
'label' => 'Awaiting Payment',
|
||||
'color' => '#848a8c',
|
||||
'mailers' => [],
|
||||
@@ -70,17 +82,25 @@
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'payment-offline' => [
|
||||
'label' => 'Payment Offline',
|
||||
'processing' => [
|
||||
'label' => 'Processing',
|
||||
'color' => '#0A81D7',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'payment-received' => [
|
||||
'label' => 'Payment Received',
|
||||
'color' => '#6a67ce',
|
||||
'ready_for_dispatch' => [
|
||||
'label' => 'Ready for Dispatch',
|
||||
'color' => '#c98a2e',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'ready_for_pickup' => [
|
||||
'label' => 'Ready for Pickup',
|
||||
'color' => '#c98a2e',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
@@ -88,11 +108,78 @@
|
||||
|
||||
'dispatched' => [
|
||||
'label' => 'Dispatched',
|
||||
'color' => '#0A81D7',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'delivery_failed' => [
|
||||
'label' => 'Delivery Failed',
|
||||
'color' => '#c0392b',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => false,
|
||||
],
|
||||
|
||||
'picked_up' => [
|
||||
'label' => 'Picked Up',
|
||||
'color' => '#3f7a5c',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
// The parcel arriving and the return window opening are the same
|
||||
// moment — one status value covers both.
|
||||
'delivered' => [
|
||||
'label' => 'Delivered',
|
||||
'color' => '#c98a2e',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'completed' => [
|
||||
'label' => 'Completed',
|
||||
'color' => '#3f7a5c',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'return_requested' => [
|
||||
'label' => 'Return Requested',
|
||||
'color' => '#c98a2e',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => false,
|
||||
],
|
||||
|
||||
'returned' => [
|
||||
'label' => 'Returned',
|
||||
'color' => '#c0392b',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => false,
|
||||
],
|
||||
|
||||
'partially_refunded' => [
|
||||
'label' => 'Partially Refunded',
|
||||
'color' => '#c98a2e',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => false,
|
||||
],
|
||||
|
||||
'refunded' => [
|
||||
'label' => 'Refunded',
|
||||
'color' => '#c0392b',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@@ -46,6 +46,6 @@
|
||||
| to include in the count below.
|
||||
|
|
||||
*/
|
||||
'order_count_statuses' => ['payment-received'],
|
||||
'order_count_statuses' => ['processing'],
|
||||
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -35,9 +35,27 @@
|
||||
],
|
||||
],
|
||||
|
||||
// Where contact-form messages are sent (ContactController).
|
||||
'contact' => [
|
||||
'email' => env('CONTACT_EMAIL'),
|
||||
],
|
||||
|
||||
'stoic' => [
|
||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||
'host' => env('STOIC_HOST'),
|
||||
],
|
||||
|
||||
// Keys read by lunarphp/stripe + Modules\Core\Payment\Drivers\StripePaymentDriver.
|
||||
// `key` is the SECRET key (this ecosystem's convention — StripeManager calls
|
||||
// Stripe::setApiKey(config('services.stripe.key'))); `public_key` is the
|
||||
// publishable key for Stripe.js; `webhooks.lunar` is the signing secret the
|
||||
// webhook route verifies against.
|
||||
'stripe' => [
|
||||
'key' => env('STRIPE_SECRET'),
|
||||
'public_key' => env('STRIPE_PUBLIC_KEY'),
|
||||
'webhooks' => [
|
||||
'lunar' => env('STRIPE_WEBHOOK_SECRET'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('wishlist_items', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Modules\Core\Localization\Services\TranslationService;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
/**
|
||||
* Default `checkout` translation lines for the cart drawer and the checkout
|
||||
* page (see the checkout module under resources/views/checkout).
|
||||
*
|
||||
* Additive and idempotent: a group/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=CheckoutTranslationsSeeder` — it is
|
||||
* not wired into DatabaseSeeder.
|
||||
*
|
||||
* Greek copy uses the project's informal register (εσύ/σου). When the checkout
|
||||
* module moves to boboko-core this becomes the module's own default-strings
|
||||
* seeder.
|
||||
*/
|
||||
class CheckoutTranslationsSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$translations = app(TranslationService::class);
|
||||
|
||||
foreach ($this->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<string, array{0: string, 1: string}>
|
||||
*/
|
||||
private function lines(): array
|
||||
{
|
||||
return [
|
||||
// ── Cart drawer + order summary ──────────────────────────────
|
||||
'cart.title' => ['Your cart', 'Το καλάθι σου'],
|
||||
'cart.close' => ['Close', 'Κλείσιμο'],
|
||||
'cart.empty' => ['Your cart is empty', 'Το καλάθι σου είναι άδειο'],
|
||||
'cart.quantity' => ['Quantity', 'Ποσότητα'],
|
||||
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
|
||||
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
|
||||
'cart.remove' => ['Remove', 'Αφαίρεση'],
|
||||
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
|
||||
'cart.discount' => ['Discount', 'Έκπτωση'],
|
||||
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
|
||||
'cart.shipping_pending' => ['Not selected yet', 'Δεν έχει επιλεγεί ακόμη'],
|
||||
'cart.tax' => ['VAT', 'ΦΠΑ'],
|
||||
'cart.total' => ['Total', 'Σύνολο'],
|
||||
'cart.checkout' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
|
||||
'cart.coupon_label' => ['Coupon code', 'Κωδικός κουπονιού'],
|
||||
'cart.coupon_placeholder' => ['Coupon code', 'Κωδικός κουπονιού'],
|
||||
'cart.coupon_apply' => ['Apply', 'Εφαρμογή'],
|
||||
'cart.coupon_remove' => ['Remove', 'Αφαίρεση'],
|
||||
'cart.coupon_invalid' => ["That coupon code isn't valid", 'Ο κωδικός κουπονιού δεν είναι έγκυρος'],
|
||||
|
||||
// ── Checkout page ────────────────────────────────────────────
|
||||
'page.title' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
|
||||
'page.contact_heading' => ['Contact', 'Στοιχεία επικοινωνίας'],
|
||||
'page.guest_tab' => ['Guest', 'Ως επισκέπτης'],
|
||||
'page.login_tab' => ['Log in', 'Σύνδεση'],
|
||||
'page.email_label' => ['Email', 'Email'],
|
||||
'page.recovery_consent' => [
|
||||
"Email me a reminder if I don't finish my order",
|
||||
'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
|
||||
],
|
||||
'page.login_email_label' => ['Email', 'Email'],
|
||||
'page.send_code' => ['Send code', 'Αποστολή κωδικού'],
|
||||
'page.login_coming_soon' => [
|
||||
'Login is coming soon — continue as a guest for now.',
|
||||
'Η σύνδεση θα είναι διαθέσιμη σύντομα — προς το παρόν συνέχισε ως επισκέπτης.',
|
||||
],
|
||||
'page.billing_heading' => ['Billing information', 'Στοιχεία τιμολόγησης'],
|
||||
'page.shipping_heading' => ['Shipping information', 'Στοιχεία αποστολής'],
|
||||
'page.same_as_billing' => ['Same as billing address', 'Ίδια με τη διεύθυνση τιμολόγησης'],
|
||||
'page.first_name' => ['First name', 'Όνομα'],
|
||||
'page.last_name' => ['Last name', 'Επώνυμο'],
|
||||
'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
|
||||
'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'],
|
||||
'page.address_line_one' => ['Address', 'Διεύθυνση'],
|
||||
'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'],
|
||||
'page.city' => ['City', 'Πόλη'],
|
||||
'page.state' => ['Region / Prefecture', 'Νομός / Περιοχή'],
|
||||
'page.state_placeholder' => ['Select a region', 'Επίλεξε νομό'],
|
||||
'page.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'],
|
||||
'page.country' => ['Country', 'Χώρα'],
|
||||
'page.country_placeholder' => ['Select a country', 'Επίλεξε χώρα'],
|
||||
'page.phone' => ['Phone', 'Τηλέφωνο'],
|
||||
'page.delivery_instructions' => ['Delivery notes', 'Σχόλια για την παράδοση'],
|
||||
'page.save_address' => ['Save and continue', 'Αποθήκευση και συνέχεια'],
|
||||
'page.saving' => ['Saving…', 'Αποθήκευση…'],
|
||||
'page.saved' => ['Saved', 'Αποθηκεύτηκε'],
|
||||
'page.save_error' => ["Couldn't save — check your connection", 'Δεν αποθηκεύτηκε — έλεγξε τη σύνδεσή σου'],
|
||||
'page.shipping_method_heading' => ['Shipping method', 'Τρόπος αποστολής'],
|
||||
'page.shipping_method_empty' => [
|
||||
'Add your shipping address to see delivery options.',
|
||||
'Συμπλήρωσε τη διεύθυνση αποστολής για να δεις τις διαθέσιμες επιλογές.',
|
||||
],
|
||||
'page.shipping_method_none' => [
|
||||
'No delivery options are available for this address.',
|
||||
'Δεν υπάρχουν διαθέσιμες επιλογές αποστολής για αυτή τη διεύθυνση.',
|
||||
],
|
||||
'page.select_shipping_method' => ['Continue', 'Συνέχεια'],
|
||||
'page.shipping_option_invalid' => [
|
||||
'That shipping option is no longer available.',
|
||||
'Αυτός ο τρόπος αποστολής δεν είναι πλέον διαθέσιμος.',
|
||||
],
|
||||
'page.continue_to_payment' => ['Continue to payment', 'Συνέχεια στην πληρωμή'],
|
||||
'page.order_summary_heading' => ['Order summary', 'Σύνοψη παραγγελίας'],
|
||||
|
||||
// ── Payment step ────────────────────────────────────────────
|
||||
'page.payment_heading' => ['Payment', 'Πληρωμή'],
|
||||
'page.payment_method_none' => [
|
||||
'No payment methods are available right now.',
|
||||
'Δεν υπάρχουν διαθέσιμοι τρόποι πληρωμής αυτή τη στιγμή.',
|
||||
],
|
||||
'page.terms_accept' => [
|
||||
"I accept the <a href=':terms' target='_blank'>Terms of Sale</a> and the <a href=':privacy' target='_blank'>Privacy Policy</a>",
|
||||
"Αποδέχομαι τους <a href=':terms' target='_blank'>Όρους Πώλησης</a> και την <a href=':privacy' target='_blank'>Πολιτική Απορρήτου</a>",
|
||||
],
|
||||
'page.terms_required' => [
|
||||
'You must accept the terms to place your order.',
|
||||
'Πρέπει να αποδεχτείς τους όρους για να ολοκληρώσεις την παραγγελία.',
|
||||
],
|
||||
'page.withdrawal_notice' => [
|
||||
"You have a 14-day right of withdrawal. <a href=':link' target='_blank'>See details</a>.",
|
||||
"Έχεις δικαίωμα υπαναχώρησης εντός 14 ημερών. <a href=':link' target='_blank'>Δες λεπτομέρειες</a>.",
|
||||
],
|
||||
'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', 'Συνέχεια αγορών'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Modules\Core\Localization\Services\TranslationService;
|
||||
use Spatie\TranslationLoader\LanguageLine;
|
||||
|
||||
/**
|
||||
* Default `validation` translation lines — Laravel's own error-message group
|
||||
* (`validation.required`, `validation.email`, `validation.attributes.*`, …),
|
||||
* resolved by every `Validator::make()`/`$request->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<string, array{0: string, 1: string}>
|
||||
*/
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -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"
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -1,6 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "./fonts.css";
|
||||
@import "./dropdown.css";
|
||||
|
||||
/* Note for anyone theming the checkout module (resources/css/checkout.css,
|
||||
.bbk-* classes): it's deliberately plain, unlayered CSS, not inside any
|
||||
@layer — so override it with plain rules here too, not from inside
|
||||
@layer components/utilities, which would lose to it. See checkout.css's
|
||||
file-level comment for why. */
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
/*
|
||||
* Cart + checkout module — generic default styling.
|
||||
*
|
||||
* Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version
|
||||
* put these rules in `@layer bbk-checkout`, positioned (via a cross-file
|
||||
* @layer ordering statement) to sit between Tailwind's `base` and
|
||||
* `components` — in theory enough to beat Preflight's element resets while
|
||||
* still losing to a host override. In practice a build tool processing each
|
||||
* CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the
|
||||
* cross-file ordering information that trick depends on, so it silently
|
||||
* didn't work: Preflight's `button { background-color: transparent }`,
|
||||
* `* { border-width: 0 }` etc. (layered, in `base`) were beating every
|
||||
* `.bbk-*` rule below regardless of specificity — buttons with no
|
||||
* background, no border, wrong font-size.
|
||||
*
|
||||
* Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always
|
||||
* beats ANY layered rule (Preflight included), full stop, no ordering tricks,
|
||||
* nothing a bundler can silently invalidate. This file is loaded BEFORE the
|
||||
* host's own stylesheet (see the @vite call in the layout <head>), so:
|
||||
*
|
||||
* - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins —
|
||||
* same specificity, later in source order
|
||||
* - a later host rule with a MORE specific selector wins regardless
|
||||
* - a host rule inside `@layer components`/`@layer utilities` does NOT
|
||||
* win — unlayered always beats layered. Theme this module from plain
|
||||
* rules in app.css, not from inside a Tailwind layer.
|
||||
*
|
||||
* Two ways to theme this, cheapest first:
|
||||
*
|
||||
* 1. Redefine the --bbk-* custom properties below (from :root, or scoped to
|
||||
* .bbk-cart for a cart-only override) — covers colour, radius, shadow,
|
||||
* font without touching a single selector below.
|
||||
*
|
||||
* :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; }
|
||||
*
|
||||
* 2. Override individual `.bbk-*` rules directly (as plain rules, per
|
||||
* above) for anything structural (spacing, layout) the variables don't
|
||||
* cover.
|
||||
*
|
||||
* This file's own look is a deliberately neutral placeholder — inoffensive,
|
||||
* not "designed" — so a project always has something reasonable before it
|
||||
* themes; it is not meant to be edited per project.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
|
||||
--bbk-color-text: #18181b;
|
||||
--bbk-color-muted: #71717a;
|
||||
--bbk-color-bg: #ffffff;
|
||||
--bbk-color-bg-muted: #f4f4f5;
|
||||
--bbk-color-border: #e4e4e7;
|
||||
--bbk-color-accent: #18181b;
|
||||
--bbk-color-accent-text: #ffffff;
|
||||
--bbk-color-danger: #dc2626;
|
||||
|
||||
--bbk-radius: 8px;
|
||||
--bbk-radius-sm: 4px;
|
||||
--bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.bbk-cart[hidden] { display: none; }
|
||||
|
||||
.bbk-cart {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
font-family: var(--bbk-font);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
color: var(--bbk-color-text);
|
||||
}
|
||||
|
||||
.bbk-cart-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; }
|
||||
|
||||
.bbk-cart-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(420px, 100vw);
|
||||
height: 100%;
|
||||
background: var(--bbk-color-bg);
|
||||
box-shadow: var(--bbk-shadow);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); }
|
||||
|
||||
.bbk-cart-panel-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 1.5rem 1.25rem;
|
||||
border-bottom: 1px solid var(--bbk-color-border);
|
||||
}
|
||||
|
||||
.bbk-cart-heading {
|
||||
margin: 0;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-cart-dismiss,
|
||||
.bbk-cart-item-remove,
|
||||
.bbk-cart-qty-btn {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
color: var(--bbk-color-muted);
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-cart-dismiss {
|
||||
font-size: 1.75rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); }
|
||||
|
||||
.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); }
|
||||
|
||||
.bbk-cart-dismiss:focus-visible,
|
||||
.bbk-cart-item-remove:focus-visible,
|
||||
.bbk-cart-qty-btn:focus-visible,
|
||||
.bbk-cart-qty-input:focus-visible,
|
||||
.bbk-cart-checkout:focus-visible,
|
||||
.bbk-cart-coupon-input:focus-visible,
|
||||
.bbk-cart-coupon-submit:focus-visible,
|
||||
.bbk-cart-coupon-remove:focus-visible {
|
||||
outline: 2px solid var(--bbk-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bbk-visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.bbk-cart-panel-body {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.bbk-cart-items {
|
||||
list-style: none;
|
||||
margin: 0 0 2rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bbk-cart-item {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr auto;
|
||||
gap: 0.875rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.bbk-cart-item-media img {
|
||||
display: block;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: var(--bbk-color-bg-muted);
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin: 0 0 0.625rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-cart-item-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bbk-cart-item-total { margin: 0; font-weight: 600; }
|
||||
|
||||
.bbk-cart-item-remove {
|
||||
font-size: 1.125rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bbk-cart-qty {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bbk-cart-qty-btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
background: var(--bbk-color-bg-muted);
|
||||
}
|
||||
|
||||
.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); }
|
||||
|
||||
.bbk-cart-qty-input {
|
||||
width: 2.25rem;
|
||||
height: 1.75rem;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--bbk-color-border);
|
||||
border-right: 1px solid var(--bbk-color-border);
|
||||
text-align: center;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bbk-color-bg);
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.bbk-cart-qty-input::-webkit-outer-spin-button,
|
||||
.bbk-cart-qty-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bbk-cart-summary {
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--bbk-color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.bbk-cart-summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
|
||||
|
||||
.bbk-cart-summary-pending {
|
||||
color: var(--bbk-color-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.bbk-cart-summary-row--total {
|
||||
margin-top: 0.375rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--bbk-color-border);
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bbk-color-bg);
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-submit {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: var(--bbk-color-bg-muted);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); }
|
||||
|
||||
.bbk-cart-coupon-applied {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: var(--bbk-color-bg-muted);
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-code {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-remove {
|
||||
flex: 0 0 auto;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-muted);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); }
|
||||
|
||||
.bbk-cart-coupon-error {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
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%;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border: 1px solid var(--bbk-color-accent);
|
||||
border-radius: var(--bbk-radius);
|
||||
background: var(--bbk-color-accent);
|
||||
color: var(--bbk-color-accent-text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-cart-checkout:hover { opacity: 0.85; }
|
||||
|
||||
.bbk-cart-checkout:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.bbk-cart-empty {
|
||||
text-align: center;
|
||||
color: var(--bbk-color-muted);
|
||||
padding: 2.5rem 0;
|
||||
}
|
||||
|
||||
/* ───────────────────────────────────────────────────────────────────
|
||||
Checkout page — two columns: fields on the left, order summary (the
|
||||
same cart-body partial the drawer uses) on the right.
|
||||
─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.bbk-checkout-page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 5rem;
|
||||
font-family: var(--bbk-font);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
color: var(--bbk-color-text);
|
||||
}
|
||||
|
||||
.bbk-checkout-heading {
|
||||
margin: 0 0 2rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-checkout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 3rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.bbk-checkout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.bbk-checkout-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-section {
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--bbk-color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-section-heading {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-checkout-note {
|
||||
margin: 0;
|
||||
color: var(--bbk-color-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Contact: "logged in as" line, or the guest login prompt */
|
||||
|
||||
.bbk-checkout-logged-in,
|
||||
.bbk-checkout-login-prompt { margin: 0; }
|
||||
|
||||
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
|
||||
|
||||
/* Fields */
|
||||
|
||||
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
|
||||
.bbk-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bbk-field-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.bbk-field-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-field-input {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--bbk-color-bg);
|
||||
}
|
||||
|
||||
.bbk-field-input:focus-visible {
|
||||
outline: 2px solid var(--bbk-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bbk-field-input:disabled {
|
||||
background: var(--bbk-color-bg-muted);
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
|
||||
|
||||
.bbk-field-error {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-danger);
|
||||
}
|
||||
|
||||
/* A fixed, non-editable field value (e.g. the store's single country). */
|
||||
.bbk-field-static {
|
||||
margin: 0;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: var(--bbk-color-bg-muted);
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
textarea.bbk-field-input { resize: vertical; }
|
||||
|
||||
.bbk-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
margin-top: 0.75rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
|
||||
|
||||
.bbk-checkout-shipping-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Shipping method */
|
||||
|
||||
.bbk-checkout-shipping-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-shipping-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
||||
|
||||
.bbk-checkout-shipping-option-detail {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-shipping-option-name { font-weight: 600; }
|
||||
|
||||
.bbk-checkout-shipping-option-description {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-checkout-shipping-option-price { font-weight: 600; }
|
||||
|
||||
/* The single auto-selected option — a fixed line, not a choosable radio. */
|
||||
.bbk-checkout-shipping-confirmed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--bbk-color-accent);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
}
|
||||
|
||||
/* Autosave status line under the address form. */
|
||||
.bbk-checkout-status {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
||||
|
||||
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
||||
|
||||
.bbk-checkout-continue {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border: 1px solid var(--bbk-color-accent);
|
||||
border-radius: var(--bbk-radius);
|
||||
background: var(--bbk-color-accent);
|
||||
color: var(--bbk-color-accent-text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-checkout-continue:hover { opacity: 0.85; }
|
||||
|
||||
.bbk-checkout-continue:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Order summary column */
|
||||
|
||||
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
|
||||
|
||||
.bbk-checkout-summary {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius);
|
||||
background: var(--bbk-color-bg);
|
||||
}
|
||||
|
||||
.bbk-checkout-summary-heading {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
|
||||
nowhere further to send you from here. */
|
||||
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
|
||||
|
||||
/* ── Payment ───────────────────────────────────────────────────────── */
|
||||
|
||||
.bbk-checkout-payment-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-payment-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
||||
|
||||
.bbk-checkout-payment-option-name { font-weight: 600; }
|
||||
|
||||
.bbk-payment-element { margin: 0.25rem 0; }
|
||||
|
||||
.bbk-checkout-withdrawal {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-checkout-withdrawal a { color: inherit; }
|
||||
|
||||
.bbk-checkout-error {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bbk-color-danger);
|
||||
}
|
||||
|
||||
/* Processing overlay — fixed, covers the page while a payment confirms. */
|
||||
.bbk-checkout-processing {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.bbk-spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--bbk-color-border);
|
||||
border-top-color: var(--bbk-color-accent);
|
||||
border-radius: 50%;
|
||||
animation: bbk-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes bbk-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Confirmation page ─────────────────────────────────────────────── */
|
||||
|
||||
.bbk-confirmation {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem 5rem;
|
||||
font-family: var(--bbk-font);
|
||||
color: var(--bbk-color-text);
|
||||
}
|
||||
|
||||
.bbk-confirmation-heading {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
|
||||
|
||||
.bbk-confirmation-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;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
.bbk-confirmation-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-line {
|
||||
display: 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; }
|
||||
|
||||
.bbk-confirmation-addresses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-address-heading {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-address-lines {
|
||||
font-style: normal;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
@@ -10,8 +10,13 @@ window.Turbo.session.drive = false;
|
||||
|
||||
import { Application } from "@hotwired/stimulus";
|
||||
import { registerControllers } from "./stimulus/index";
|
||||
import { registerCheckout } from "./checkout";
|
||||
|
||||
const application = Application.start();
|
||||
application.debug = false;
|
||||
|
||||
registerControllers(application);
|
||||
|
||||
// Portable cart + checkout module (destined for boboko-core). Owns its own
|
||||
// bbk-* Stimulus controllers; this is the only wiring line it needs here.
|
||||
registerCheckout(application);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { csrfToken } from './csrf'
|
||||
|
||||
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
|
||||
// via fetch and hands the server-rendered cart body to the drawer through the
|
||||
// `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
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new FormData(form),
|
||||
})
|
||||
|
||||
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() },
|
||||
}))
|
||||
} finally {
|
||||
form.removeAttribute('data-bbk-add-to-cart-state')
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { csrfToken } from './csrf'
|
||||
|
||||
// Drives the slide-in cart drawer. One instance, on the drawer root in
|
||||
// checkout/drawer.blade.php.
|
||||
//
|
||||
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
|
||||
// this drawer's own line forms) and swaps in the server-rendered cart body
|
||||
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
|
||||
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
|
||||
// (e.g. the header bag icon) can react
|
||||
//
|
||||
// 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', '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))
|
||||
document.addEventListener('keydown', this.onKeydown)
|
||||
|
||||
// Prime the host with the count rendered server-side on page load.
|
||||
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener('bbk-cart:changed', this.onChanged)
|
||||
document.removeEventListener('keydown', this.onKeydown)
|
||||
this.updateTimers.forEach((timer) => clearTimeout(timer))
|
||||
}
|
||||
|
||||
onChanged(event) {
|
||||
if (event.detail?.html) this.replaceBody(event.detail.html)
|
||||
this.open()
|
||||
}
|
||||
|
||||
onKeydown(event) {
|
||||
if (event.key === 'Escape' && !this.element.hidden) this.close()
|
||||
}
|
||||
|
||||
open() {
|
||||
if (!this.element.hidden) return
|
||||
this.element.hidden = false
|
||||
// Next frame, so the panel transitions from its off-canvas start.
|
||||
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.removeAttribute('data-bbk-cart-state')
|
||||
|
||||
const panel = this.panelTarget
|
||||
const done = () => {
|
||||
this.element.hidden = true
|
||||
panel.removeEventListener('transitionend', done)
|
||||
}
|
||||
panel.addEventListener('transitionend', done)
|
||||
}
|
||||
|
||||
// change on a line quantity input, or submit of a line's remove form
|
||||
submit(event) {
|
||||
event.preventDefault()
|
||||
const form = event.target.closest('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
|
||||
step(event) {
|
||||
event.preventDefault()
|
||||
const form = event.target.closest('form')
|
||||
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.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, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new FormData(form),
|
||||
})
|
||||
|
||||
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]'))
|
||||
}
|
||||
|
||||
emitUpdated(node) {
|
||||
if (!node) return
|
||||
|
||||
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
|
||||
detail: {
|
||||
count: parseInt(node.dataset.bbkCartCount || '0', 10),
|
||||
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { csrfToken } from './csrf'
|
||||
|
||||
// Drives the checkout page's left column: contact tabs, the same-as-billing
|
||||
// toggle, and — the bulk of it — autosaving the address form and the shipping
|
||||
// method with no submit buttons.
|
||||
//
|
||||
// Flow: any `change` in the address form is debounced ~400ms, then the whole
|
||||
// form is POSTed to saveUrl. The server persists leniently and returns
|
||||
// { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options
|
||||
// block in place and hand the summary fragment to the drawer's bbk-cart
|
||||
// controller via the `bbk-cart:changed` window event (same mechanism the drawer
|
||||
// already uses). Shipping-method radios post to selectShippingUrl the same way.
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
'sameAsBilling', 'shippingFields',
|
||||
'form', 'shippingOptions', 'status',
|
||||
]
|
||||
|
||||
static values = {
|
||||
saveUrl: String,
|
||||
selectShippingUrl: String,
|
||||
statusSaving: String,
|
||||
statusSaved: String,
|
||||
statusError: String,
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.saveTimer = null
|
||||
this.saveController = null
|
||||
this.statusTimer = null
|
||||
this.shippingPromise = null
|
||||
|
||||
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearTimeout(this.saveTimer)
|
||||
clearTimeout(this.statusTimer)
|
||||
this.saveController?.abort()
|
||||
}
|
||||
|
||||
// ── Same as billing ────────────────────────────────────────────────
|
||||
|
||||
toggleSameAsBilling() {
|
||||
this.applySameAsBilling()
|
||||
}
|
||||
|
||||
applySameAsBilling() {
|
||||
const on = this.sameAsBillingTarget.checked
|
||||
|
||||
// Checked: shipping *is* billing — copy every value across, then hide +
|
||||
// disable so the browser doesn't submit them; the server reuses billing.
|
||||
// Unchecked: reveal them pre-filled from billing wherever still empty.
|
||||
this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => {
|
||||
const shippingField = this.element.querySelector(
|
||||
`[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`,
|
||||
)
|
||||
if (shippingField && (on || !shippingField.value)) {
|
||||
shippingField.value = billingField.value
|
||||
}
|
||||
})
|
||||
|
||||
this.shippingFieldsTarget.hidden = on
|
||||
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
|
||||
field.disabled = on
|
||||
})
|
||||
}
|
||||
|
||||
// ── Autosave ───────────────────────────────────────────────────────
|
||||
|
||||
scheduleSave(event) {
|
||||
// The shipping-method and payment radios live inside this controller's
|
||||
// element too, and this action is bound on .bbk-checkout-main to also
|
||||
// catch the contact email/consent that sit outside the <form>. Only
|
||||
// react to fields that actually belong to the address form.
|
||||
const el = event.target
|
||||
const belongsToForm = el.form?.id === 'bbk-address-form'
|
||||
if (!belongsToForm) return
|
||||
|
||||
// No status during the wait — it only shows once the request is in flight,
|
||||
// so the indicator isn't flickering "saving" on every keystroke.
|
||||
clearTimeout(this.saveTimer)
|
||||
this.saveTimer = setTimeout(() => this.save(), 700)
|
||||
}
|
||||
|
||||
// Called by bbk-payment right before place-order — a debounced save (and
|
||||
// the shipping-option auto-select that happens as part of it) might still
|
||||
// be pending when the shopper clicks "place order"; this guarantees the
|
||||
// server has processed the current form state first.
|
||||
async flush() {
|
||||
clearTimeout(this.saveTimer)
|
||||
await this.save()
|
||||
// A shipping-method radio click fires its own (undebounced) request —
|
||||
// still async, still racy against an immediate "place order" click.
|
||||
if (this.shippingPromise) await this.shippingPromise
|
||||
}
|
||||
|
||||
async save() {
|
||||
this.saveController?.abort()
|
||||
this.saveController = new AbortController()
|
||||
this.setStatus('saving')
|
||||
|
||||
try {
|
||||
const response = await fetch(this.saveUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new FormData(this.formTarget),
|
||||
signal: this.saveController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) return this.setStatus('error')
|
||||
|
||||
this.applyResult(await response.json())
|
||||
this.setStatus('saved')
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') this.setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
async selectShipping(event) {
|
||||
// Tracked so flush() can await it — nothing else stops "place order"
|
||||
// (a separate, unrelated click) from racing ahead of this request.
|
||||
this.shippingPromise = this.doSelectShipping(event.target.value)
|
||||
await this.shippingPromise
|
||||
}
|
||||
|
||||
async doSelectShipping(value) {
|
||||
this.saveController?.abort()
|
||||
this.setStatus('saving')
|
||||
|
||||
const body = new FormData()
|
||||
body.append('shipping_option', value)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.selectShippingUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
|
||||
if (!response.ok) return this.setStatus('error')
|
||||
|
||||
this.applyResult(await response.json())
|
||||
this.setStatus('saved')
|
||||
} catch {
|
||||
this.setStatus('error')
|
||||
} finally {
|
||||
this.shippingPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
applyResult(data) {
|
||||
this.applyErrors(data.errors || {})
|
||||
|
||||
if (data.shippingOptionsHtml != null) {
|
||||
this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml
|
||||
}
|
||||
|
||||
if (data.summaryHtml != null) {
|
||||
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
|
||||
detail: { html: data.summaryHtml },
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
applyErrors(errors) {
|
||||
this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => {
|
||||
const message = errors[el.dataset.bbkFieldError]
|
||||
el.textContent = message || ''
|
||||
el.hidden = !message
|
||||
|
||||
const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`)
|
||||
field?.classList.toggle('bbk-field-input--error', Boolean(message))
|
||||
})
|
||||
}
|
||||
|
||||
setStatus(state) {
|
||||
if (!this.hasStatusTarget) return
|
||||
|
||||
const text = {
|
||||
saving: this.statusSavingValue,
|
||||
saved: this.statusSavedValue,
|
||||
error: this.statusErrorValue,
|
||||
}[state]
|
||||
|
||||
this.statusTarget.textContent = text
|
||||
this.statusTarget.hidden = false
|
||||
this.statusTarget.dataset.state = state
|
||||
|
||||
clearTimeout(this.statusTimer)
|
||||
if (state === 'saved') {
|
||||
this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import { csrfToken } from './csrf'
|
||||
|
||||
const STRIPE_JS = 'https://js.stripe.com/v3/'
|
||||
const POLL_INTERVAL = 1500
|
||||
const POLL_TIMEOUT = 30000
|
||||
|
||||
// The payment step of the checkout page. Sits alongside bbk-checkout-form on
|
||||
// .bbk-checkout-main.
|
||||
//
|
||||
// - selectMethod: radio change -> persist via /payment-method, refresh the
|
||||
// summary (COD fee), mount/unmount the Stripe Payment Element
|
||||
// - placeOrder: the real submit. For Stripe, builds a PaymentMethod client-side
|
||||
// and POSTs it to /place-order, then routes on the JSON result:
|
||||
// { redirect } -> order placed, go to confirmation
|
||||
// { status:'pending', clientSecret } -> 3-D Secure: handleNextAction, then
|
||||
// poll /order-status until the webhook places it
|
||||
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
|
||||
export default class extends Controller {
|
||||
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
|
||||
|
||||
static values = {
|
||||
selectUrl: String,
|
||||
placeOrderUrl: String,
|
||||
orderStatusUrl: String,
|
||||
stripeKey: String,
|
||||
amount: Number,
|
||||
currency: String,
|
||||
termsRequired: String,
|
||||
chooseMethod: String,
|
||||
genericError: String,
|
||||
processingSlow: String,
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.stripe = null
|
||||
this.elements = null
|
||||
this.paymentElement = null
|
||||
|
||||
this.onSummaryUpdate = (event) => {
|
||||
const total = event.detail?.total
|
||||
if (typeof total === 'number' && this.elements) {
|
||||
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)
|
||||
|
||||
if (this.selectedIsStripe()) this.mountStripe()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
|
||||
this.unmountStripe()
|
||||
}
|
||||
|
||||
// ── Method selection ──────────────────────────────────────────────
|
||||
|
||||
async selectMethod(event) {
|
||||
const isStripe = event.target.dataset.paymentDriver === 'stripe'
|
||||
|
||||
try {
|
||||
const response = await fetch(this.selectUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({ payment_type: event.target.value }),
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.summaryHtml != null) {
|
||||
window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } }))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// summary just won't refresh — non-fatal
|
||||
}
|
||||
|
||||
isStripe ? this.mountStripe() : this.unmountStripe()
|
||||
}
|
||||
|
||||
selectedRadio() {
|
||||
return this.element.querySelector('input[name="payment_type"]:checked')
|
||||
}
|
||||
|
||||
selectedIsStripe() {
|
||||
return this.selectedRadio()?.dataset.paymentDriver === 'stripe'
|
||||
}
|
||||
|
||||
// ── Stripe Payment Element ────────────────────────────────────────
|
||||
|
||||
async loadStripe() {
|
||||
if (window.Stripe) return window.Stripe
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector(`script[src="${STRIPE_JS}"]`)
|
||||
if (existing) {
|
||||
existing.addEventListener('load', resolve)
|
||||
existing.addEventListener('error', reject)
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
script.src = STRIPE_JS
|
||||
script.onload = resolve
|
||||
script.onerror = reject
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return window.Stripe
|
||||
}
|
||||
|
||||
async mountStripe() {
|
||||
if (this.paymentElement || !this.stripeKeyValue) return
|
||||
|
||||
const Stripe = await this.loadStripe()
|
||||
this.stripe = this.stripe || Stripe(this.stripeKeyValue)
|
||||
|
||||
this.elements = this.stripe.elements({
|
||||
mode: 'payment',
|
||||
amount: Math.max(this.amountValue, 1),
|
||||
currency: this.currencyValue,
|
||||
paymentMethodCreation: 'manual',
|
||||
// Card only — matches the server confirming with
|
||||
// automatic_payment_methods.allow_redirects = 'never' (no
|
||||
// return_url in our flow: 3-D Secure resolves in-page via
|
||||
// handleNextAction, never a full-page redirect).
|
||||
paymentMethodTypes: ['card'],
|
||||
})
|
||||
this.paymentElement = this.elements.create('payment')
|
||||
this.paymentElement.mount(this.elementTarget)
|
||||
this.elementTarget.hidden = false
|
||||
}
|
||||
|
||||
unmountStripe() {
|
||||
this.paymentElement?.unmount()
|
||||
this.paymentElement = null
|
||||
this.elements = null
|
||||
|
||||
if (this.hasElementTarget) {
|
||||
this.elementTarget.innerHTML = ''
|
||||
this.elementTarget.hidden = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Place order ──────────────────────────────────────────────────
|
||||
|
||||
// Sibling controller on the same element (.bbk-checkout-main) — used to
|
||||
// flush a pending debounced address autosave before placing the order.
|
||||
get checkoutForm() {
|
||||
return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form')
|
||||
}
|
||||
|
||||
async placeOrder() {
|
||||
this.clearError()
|
||||
this.submitTarget.disabled = true
|
||||
|
||||
// A debounced address save (and the shipping-option auto-select that
|
||||
// happens as part of it) might still be pending — make sure the
|
||||
// server has the latest state before we ask it to place the order.
|
||||
await this.checkoutForm?.flush()
|
||||
|
||||
if (!this.termsTarget.checked) {
|
||||
this.submitTarget.disabled = false
|
||||
this.showError(this.termsRequiredValue)
|
||||
return
|
||||
}
|
||||
|
||||
const radio = this.selectedRadio()
|
||||
if (!radio) {
|
||||
this.submitTarget.disabled = false
|
||||
this.showError(this.chooseMethodValue)
|
||||
return
|
||||
}
|
||||
|
||||
let paymentMethodId = null
|
||||
if (radio.dataset.paymentDriver === 'stripe') {
|
||||
const { error: submitError } = await this.elements.submit()
|
||||
if (submitError) return this.fail(submitError.message)
|
||||
|
||||
const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
|
||||
if (pmError) return this.fail(pmError.message)
|
||||
paymentMethodId = paymentMethod.id
|
||||
}
|
||||
|
||||
let data
|
||||
try {
|
||||
const response = await fetch(this.placeOrderUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
payment_type: radio.value,
|
||||
payment_method: paymentMethodId ?? '',
|
||||
terms_accepted: '1',
|
||||
}),
|
||||
})
|
||||
data = await response.json()
|
||||
} catch {
|
||||
return this.fail(this.genericErrorValue)
|
||||
}
|
||||
|
||||
if (data.redirect) {
|
||||
window.location.assign(data.redirect)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === 'pending' && data.clientSecret) {
|
||||
await this.resolvePending(data.clientSecret)
|
||||
return
|
||||
}
|
||||
|
||||
// Points at the section that actually needs attention, rather than
|
||||
// leaving a generic error and making the shopper hunt for it — e.g. a
|
||||
// region with 2+ shipping methods needs an explicit pick, easy to miss.
|
||||
if (data.field === 'shipping_option') {
|
||||
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
|
||||
return
|
||||
}
|
||||
|
||||
this.fail(data.message || data.error || this.genericErrorValue)
|
||||
}
|
||||
|
||||
async resolvePending(clientSecret) {
|
||||
this.processingTarget.hidden = false
|
||||
|
||||
const { error } = await this.stripe.handleNextAction({ clientSecret })
|
||||
if (error) {
|
||||
this.processingTarget.hidden = true
|
||||
return this.fail(error.message)
|
||||
}
|
||||
|
||||
// 3-D Secure cleared client-side — the webhook places the order. Poll.
|
||||
const startedAt = Date.now()
|
||||
const tick = async () => {
|
||||
try {
|
||||
const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } })
|
||||
const data = await response.json()
|
||||
if (data.placed && data.redirect) {
|
||||
window.location.assign(data.redirect)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// keep polling
|
||||
}
|
||||
|
||||
if (Date.now() - startedAt > POLL_TIMEOUT) {
|
||||
this.processingTextTarget.textContent = this.processingSlowValue
|
||||
return
|
||||
}
|
||||
setTimeout(tick, POLL_INTERVAL)
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────
|
||||
|
||||
fail(message, { scroll = true } = {}) {
|
||||
this.showError(message, { scroll })
|
||||
this.submitTarget.disabled = false
|
||||
}
|
||||
|
||||
showError(message, { scroll = true } = {}) {
|
||||
this.errorTarget.textContent = message
|
||||
this.errorTarget.hidden = false
|
||||
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}
|
||||
|
||||
clearError() {
|
||||
this.errorTarget.textContent = ''
|
||||
this.errorTarget.hidden = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
|
||||
// boboko host renders in its layout <head>. Kept as its own module so both
|
||||
// checkout controllers share one source.
|
||||
export function csrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
||||
import BbkCartController from './bbk-cart-controller'
|
||||
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
||||
import BbkPaymentController from './bbk-payment-controller'
|
||||
|
||||
// Registers the checkout module's Stimulus controllers onto the host app's
|
||||
// Stimulus application. Call once from the host's JS entry point:
|
||||
//
|
||||
// import { registerCheckout } from './checkout'
|
||||
// registerCheckout(application)
|
||||
//
|
||||
// When this module moves to boboko-core this file ships with it unchanged;
|
||||
// only that one import line in the host entry point differs per project.
|
||||
export function registerCheckout(application) {
|
||||
application.register('bbk-add-to-cart', BbkAddToCartController)
|
||||
application.register('bbk-cart', BbkCartController)
|
||||
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
||||
application.register('bbk-payment', BbkPaymentController)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// 3dealer-side glue for the checkout module. The module owns the cart and emits
|
||||
// `bbk-cart:updated` {count, total} on window after every change; this reflects
|
||||
// the line count on the header bag icon. How (or whether) that count is shown
|
||||
// is the host's call — hence this lives here, not in the module.
|
||||
export default class extends Controller {
|
||||
static targets = ['badge']
|
||||
|
||||
connect() {
|
||||
this.onUpdate = (event) => this.render(event.detail?.count ?? 0)
|
||||
window.addEventListener('bbk-cart:updated', this.onUpdate)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener('bbk-cart:updated', this.onUpdate)
|
||||
}
|
||||
|
||||
// Header cart icon click — there's no separate cart page, the drawer IS
|
||||
// the cart. `bbk-cart:open` is the module's own event, already listened
|
||||
// for by bbk-cart-controller.
|
||||
open() {
|
||||
window.dispatchEvent(new CustomEvent('bbk-cart:open'))
|
||||
}
|
||||
|
||||
render(count) {
|
||||
if (!this.hasBadgeTarget) return
|
||||
this.badgeTarget.textContent = String(count)
|
||||
this.badgeTarget.hidden = count < 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
import AppearController from './appear-controller'
|
||||
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'
|
||||
@@ -15,13 +17,17 @@ 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)
|
||||
application.register('auto-submit', AutoSubmitController)
|
||||
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)
|
||||
@@ -30,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)
|
||||
}
|
||||
|
||||
@@ -2,25 +2,134 @@ 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']
|
||||
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 <form>) — 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 = false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 +139,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)
|
||||
}
|
||||
@@ -38,13 +149,24 @@ export default class extends Controller {
|
||||
this.imageTarget.src = variant.image
|
||||
}
|
||||
|
||||
// Keep the checkout module's add-to-cart form pointed at the chosen
|
||||
// variant. [data-bbk-purchasable-input] is that module's documented
|
||||
// hook (see resources/views/checkout/components/add-to-cart.blade.php);
|
||||
// this is the one place the two touch.
|
||||
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
|
||||
if (purchasableInput) purchasableInput.value = id
|
||||
|
||||
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')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 <form>
|
||||
// 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))
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
@extends('layouts.account')
|
||||
|
||||
@section('title', __('storefront.auth.enter_code'))
|
||||
|
||||
@section('account')
|
||||
|
||||
<div class="flex max-w-2xl flex-col gap-12">
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||
{{ __('storefront.auth.enter_code') }}
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
{{ __('storefront.auth.code_sent_to') }} <strong class="break-all">{{ $email }}</strong>
|
||||
</p>
|
||||
|
||||
<x-ui.status />
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('account.email.verify') }}" class="flex flex-col gap-8">
|
||||
@csrf
|
||||
|
||||
<x-ui.field
|
||||
:label="__('storefront.auth.code')"
|
||||
for="account-email-code"
|
||||
:required="true"
|
||||
:error="$errors->first('code')"
|
||||
>
|
||||
<x-ui.input
|
||||
id="account-email-code"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxlength="6"
|
||||
:required="true"
|
||||
autofocus
|
||||
class="text-2xl font-bold tracking-[0.5em]"
|
||||
:aria-invalid="$errors->has('code') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('code') ? 'account-email-code-error' : null"
|
||||
/>
|
||||
</x-ui.field>
|
||||
|
||||
<div>
|
||||
<x-ui.button type="submit">{{ __('storefront.account.email_confirm') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-4 text-sm">
|
||||
<form method="POST" action="{{ route('account.email.resend') }}">
|
||||
@csrf
|
||||
<button type="submit" class="cursor-pointer underline hover:no-underline">
|
||||
{{ __('storefront.auth.resend_code') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<a href="{{ route('account.email.edit') }}" class="underline hover:no-underline">
|
||||
{{ __('storefront.auth.change_email') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -0,0 +1,53 @@
|
||||
@extends('layouts.account')
|
||||
|
||||
@section('title', __('storefront.account.email_change_heading'))
|
||||
|
||||
@section('account')
|
||||
|
||||
<div class="flex max-w-2xl flex-col gap-12">
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||
{{ __('storefront.account.email_change_heading') }}
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
{{ __('storefront.account.email_current') }} <strong class="break-all">{{ $user->email }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('account.email.send') }}" class="flex flex-col gap-8">
|
||||
@csrf
|
||||
|
||||
<x-ui.field
|
||||
:label="__('storefront.account.email_new')"
|
||||
for="account-new-email"
|
||||
:required="true"
|
||||
:description="__('storefront.account.email_new_hint')"
|
||||
:error="$errors->first('email')"
|
||||
>
|
||||
<x-ui.input
|
||||
id="account-new-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:value="old('email')"
|
||||
:required="true"
|
||||
autofocus
|
||||
:aria-invalid="$errors->has('email') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('email') ? 'account-new-email-error' : 'account-new-email-description'"
|
||||
/>
|
||||
</x-ui.field>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-8">
|
||||
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
|
||||
|
||||
<a href="{{ route('account') }}" class="text-sm underline hover:no-underline">
|
||||
{{ __('storefront.account.delete_cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -0,0 +1,67 @@
|
||||
@extends('layouts.account')
|
||||
|
||||
@section('title', __('storefront.account.nav_orders'))
|
||||
|
||||
@section('account')
|
||||
|
||||
<div class="flex max-w-4xl flex-col gap-12">
|
||||
|
||||
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||
{{ __('storefront.account.nav_orders') }}
|
||||
</h1>
|
||||
|
||||
@if ($orders->isEmpty())
|
||||
<div class="flex flex-col items-start gap-8">
|
||||
<p>{{ __('storefront.orders.empty') }}</p>
|
||||
<x-ui.button :href="route('products')" size="md">{{ __('storefront.orders.shop_now') }}</x-ui.button>
|
||||
</div>
|
||||
@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. --}}
|
||||
<div>
|
||||
<div class="hidden grid-cols-[1fr_1fr_1.5fr_1fr_auto] gap-6 border-b border-black pb-3 text-sm uppercase font-display font-extrabold sm:grid" aria-hidden="true">
|
||||
<span>{{ __('storefront.orders.date') }}</span>
|
||||
<span>{{ __('storefront.orders.number') }}</span>
|
||||
<span>{{ __('storefront.orders.status') }}</span>
|
||||
<span>{{ __('storefront.orders.total') }}</span>
|
||||
<span class="w-24"></span>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
@foreach ($orders as $order)
|
||||
<li class="grid grid-cols-2 gap-x-6 gap-y-2 border-b border-black py-5 sm:grid-cols-[1fr_1fr_1.5fr_1fr_auto] sm:items-center">
|
||||
<span>
|
||||
<span class="sr-only">{{ __('storefront.orders.date') }}:</span>
|
||||
{{ $order->placed_at->format('d/m/Y') }}
|
||||
</span>
|
||||
<span class="text-right font-bold sm:text-left">
|
||||
<span class="sr-only">{{ __('storefront.orders.number') }}:</span>
|
||||
#{{ $order->reference }}
|
||||
</span>
|
||||
<span>
|
||||
<span class="sr-only">{{ __('storefront.orders.status') }}:</span>
|
||||
<x-order-status :status="$order->status" />
|
||||
</span>
|
||||
<span class="text-right sm:text-left">
|
||||
<span class="sr-only">{{ __('storefront.orders.total') }}:</span>
|
||||
{{ $order->total?->formatted() }}
|
||||
</span>
|
||||
<a
|
||||
href="{{ route('account.orders.show', ['orderId' => $order->id]) }}"
|
||||
class="col-span-2 mt-2 inline-flex w-24 items-center gap-2 font-bold underline hover:no-underline sm:col-span-1 sm:mt-0"
|
||||
aria-label="{{ __('storefront.orders.view_order', ['number' => $order->reference]) }}"
|
||||
>
|
||||
{{ __('storefront.orders.view') }}
|
||||
<x-ui.icon name="arrow-right" :size="16" />
|
||||
</a>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<x-ui.pagination :paginator="$orders" />
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -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')
|
||||
|
||||
<div class="flex max-w-4xl flex-col gap-12">
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<a href="{{ route('account.orders') }}" class="inline-flex items-center gap-2 self-start text-sm underline hover:no-underline">
|
||||
<x-ui.icon name="arrow-left" :size="16" />
|
||||
{{ __('storefront.orders.back') }}
|
||||
</a>
|
||||
|
||||
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||
{{ __('storefront.orders.order_title', ['number' => $order->reference]) }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{{-- Summary --}}
|
||||
<dl class="grid grid-cols-1 gap-6 border-y border-black py-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.date') }}</dt>
|
||||
<dd>{{ $order->placed_at->format('d/m/Y') }}</dd>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.status') }}</dt>
|
||||
<dd class="font-bold"><x-order-status :status="$order->status" /></dd>
|
||||
</div>
|
||||
|
||||
@if ($paymentMethodName)
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.payment') }}</dt>
|
||||
<dd>{{ $paymentMethodName }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($shippingLine)
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.shipping_method') }}</dt>
|
||||
<dd>{{ $shippingLine->description }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@foreach ($shipments as $shipment)
|
||||
<div class="flex flex-col gap-1">
|
||||
<dt class="text-sm text-neutral-500">{{ __('storefront.orders.tracking') }}</dt>
|
||||
<dd class="break-all">{{ $shipment->tracking_reference }}</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
|
||||
{{-- Items + totals --}}
|
||||
<section class="flex flex-col gap-6" aria-labelledby="order-items-heading">
|
||||
<h2 id="order-items-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.orders.items') }}</h2>
|
||||
|
||||
<ul>
|
||||
@foreach ($productLines as $line)
|
||||
<li class="flex gap-4 border-b border-black py-5 sm:gap-6">
|
||||
<div class="size-18 shrink-0 bg-neutral-300 sm:size-24">
|
||||
@if ($thumb = $line->purchasable?->getThumbnailImage())
|
||||
<img src="{{ $thumb }}" alt="" aria-hidden="true" width="96" height="96" loading="lazy" class="size-full object-cover">
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{{-- 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)
|
||||
<p class="font-bold">
|
||||
@if ($lineProduct?->status === 'published')
|
||||
<a href="{{ route('product.show', ['id' => $lineProduct->id]) }}" class="underline hover:no-underline">{{ $line->description }}</a>
|
||||
@else
|
||||
{{ $line->description }}
|
||||
@endif
|
||||
<span class="font-normal">× {{ $line->quantity }}</span>
|
||||
</p>
|
||||
|
||||
@if ($line->option)
|
||||
<p class="text-sm text-neutral-500">{{ $line->option }}</p>
|
||||
@endif
|
||||
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
</div>
|
||||
|
||||
<p class="shrink-0 font-bold">{{ $line->sub_total?->formatted() }}</p>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<dl class="ml-auto flex w-full max-w-sm flex-col gap-2">
|
||||
<div class="flex justify-between gap-6">
|
||||
<dt>{{ __('storefront.orders.subtotal') }}</dt>
|
||||
<dd>{{ $order->sub_total?->formatted() }}</dd>
|
||||
</div>
|
||||
|
||||
@if ($order->discount_total?->value > 0)
|
||||
<div class="flex justify-between gap-6">
|
||||
<dt>{{ __('storefront.orders.discount') }}</dt>
|
||||
<dd>−{{ $order->discount_total->formatted() }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-between gap-6">
|
||||
<dt>{{ __('storefront.orders.shipping') }}</dt>
|
||||
<dd>{{ $order->shipping_total?->formatted() }}</dd>
|
||||
</div>
|
||||
|
||||
@if ($order->tax_total?->value > 0)
|
||||
<div class="flex justify-between gap-6 text-sm text-neutral-500">
|
||||
<dt>{{ __('storefront.orders.tax') }}</dt>
|
||||
<dd>{{ $order->tax_total->formatted() }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-between gap-6 border-t border-black pt-3 font-display text-h4 font-extrabold">
|
||||
<dt>{{ __('storefront.orders.total') }}</dt>
|
||||
<dd>{{ $order->total?->formatted() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{{-- Addresses --}}
|
||||
<div class="grid grid-cols-1 gap-12 sm:grid-cols-2">
|
||||
@foreach ([
|
||||
'shipping_to' => $order->shippingAddress,
|
||||
'billing' => $order->billingAddress,
|
||||
] as $heading => $address)
|
||||
@if ($address)
|
||||
<section class="flex flex-col gap-4" aria-labelledby="order-{{ $heading }}-heading">
|
||||
<h2 id="order-{{ $heading }}-heading" class="font-display text-h4 font-extrabold">{{ __("storefront.orders.{$heading}") }}</h2>
|
||||
|
||||
<address class="flex flex-col not-italic">
|
||||
<span>{{ trim($address->first_name.' '.$address->last_name) }}</span>
|
||||
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
|
||||
@if ($address->tax_identifier)<span>{{ __('storefront.account.tax_identifier') }}: {{ $address->tax_identifier }}</span>@endif
|
||||
<span>{{ $address->line_one }}</span>
|
||||
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
|
||||
<span>{{ trim($address->postcode.' '.$address->city) }}</span>
|
||||
@if ($address->state)
|
||||
<span>{{ Lang::has("core::states.{$address->state}") ? __("core::states.{$address->state}") : $address->state }}</span>
|
||||
@endif
|
||||
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
|
||||
</address>
|
||||
</section>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -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')
|
||||
|
||||
<div class="flex max-w-2xl flex-col gap-16">
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||
{{ __('storefront.account.nav_profile') }}
|
||||
</h1>
|
||||
|
||||
<x-ui.status />
|
||||
</div>
|
||||
|
||||
{{-- Email: the login itself, so it's changed through its own verified flow --}}
|
||||
<section class="flex flex-col gap-6" aria-labelledby="account-email-heading">
|
||||
<h2 id="account-email-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.email_heading') }}</h2>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-x-8 gap-y-2 border-b border-black py-2">
|
||||
<span class="break-all">{{ $user->email }}</span>
|
||||
<a href="{{ route('account.email.edit') }}" class="text-sm underline hover:no-underline">
|
||||
{{ __('storefront.account.email_change') }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form method="POST" action="{{ route('account.update') }}" class="flex flex-col gap-16">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
{{-- Details --}}
|
||||
<section class="flex flex-col gap-8" aria-labelledby="account-details-heading">
|
||||
<h2 id="account-details-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.details_heading') }}</h2>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
<x-ui.field :label="__('storefront.account.first_name')" for="account-first-name" :error="$errors->first('first_name')">
|
||||
<x-ui.input id="account-first-name" name="first_name" autocomplete="given-name"
|
||||
:value="old('first_name', $customer?->first_name)"
|
||||
:aria-invalid="$errors->has('first_name') ? 'true' : null" :aria-describedby="$errors->has('first_name') ? 'account-first-name-error' : null" />
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.field :label="__('storefront.account.last_name')" for="account-last-name" :error="$errors->first('last_name')">
|
||||
<x-ui.input id="account-last-name" name="last_name" autocomplete="family-name"
|
||||
:value="old('last_name', $customer?->last_name)"
|
||||
:aria-invalid="$errors->has('last_name') ? 'true' : null" :aria-describedby="$errors->has('last_name') ? 'account-last-name-error' : null" />
|
||||
</x-ui.field>
|
||||
</div>
|
||||
|
||||
{{-- 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. --}}
|
||||
<div class="group/invoice flex flex-col gap-8">
|
||||
<input type="hidden" name="invoice" value="0">
|
||||
<x-ui.checkbox
|
||||
id="account-invoice"
|
||||
name="invoice"
|
||||
:checked="$wantsInvoice"
|
||||
:label="__('storefront.account.invoice')"
|
||||
aria-controls="account-invoice-fields"
|
||||
/>
|
||||
|
||||
<div id="account-invoice-fields" class="hidden grid-cols-1 gap-8 sm:grid-cols-2 group-has-[#account-invoice:checked]/invoice:grid">
|
||||
<x-ui.field :label="__('storefront.account.company_name')" for="account-company" :error="$errors->first('company_name')">
|
||||
<x-ui.input id="account-company" name="company_name" autocomplete="organization"
|
||||
:value="old('company_name', $customer?->company_name)"
|
||||
:aria-invalid="$errors->has('company_name') ? 'true' : null" :aria-describedby="$errors->has('company_name') ? 'account-company-error' : null" />
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.field :label="__('storefront.account.tax_identifier')" for="account-tax-id" :error="$errors->first('tax_identifier')">
|
||||
<x-ui.input id="account-tax-id" name="tax_identifier" inputmode="numeric" maxlength="9" pattern="[0-9]{9}"
|
||||
:value="old('tax_identifier', $customer?->tax_identifier)"
|
||||
:aria-invalid="$errors->has('tax_identifier') ? 'true' : null" :aria-describedby="$errors->has('tax_identifier') ? 'account-tax-id-error' : null" />
|
||||
</x-ui.field>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Address: one, used as both shipping and billing default --}}
|
||||
<section class="flex flex-col gap-8" aria-labelledby="account-address-heading">
|
||||
<h2 id="account-address-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.address_heading') }}</h2>
|
||||
|
||||
<x-ui.field :label="__('storefront.account.line_one')" for="account-line-one" :error="$errors->first('line_one')">
|
||||
<x-ui.input id="account-line-one" name="line_one" autocomplete="address-line1"
|
||||
:value="old('line_one', $address?->line_one)"
|
||||
:aria-invalid="$errors->has('line_one') ? 'true' : null" :aria-describedby="$errors->has('line_one') ? 'account-line-one-error' : null" />
|
||||
</x-ui.field>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
<x-ui.field :label="__('storefront.account.city')" for="account-city" :error="$errors->first('city')">
|
||||
<x-ui.input id="account-city" name="city" autocomplete="address-level2"
|
||||
:value="old('city', $address?->city)"
|
||||
:aria-invalid="$errors->has('city') ? 'true' : null" :aria-describedby="$errors->has('city') ? 'account-city-error' : null" />
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.field :label="__('storefront.account.postcode')" for="account-postcode" :error="$errors->first('postcode')">
|
||||
<x-ui.input id="account-postcode" name="postcode" autocomplete="postal-code" inputmode="numeric"
|
||||
:value="old('postcode', $address?->postcode)"
|
||||
:aria-invalid="$errors->has('postcode') ? 'true' : null" :aria-describedby="$errors->has('postcode') ? 'account-postcode-error' : null" />
|
||||
</x-ui.field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||
<x-ui.field :label="__('storefront.account.state')" for="account-state" :error="$errors->first('state')">
|
||||
<x-ui.select
|
||||
id="account-state"
|
||||
name="state"
|
||||
:block="true"
|
||||
:options="$regionOptions"
|
||||
:value="old('state', $address?->state)"
|
||||
:placeholder="__('storefront.account.state_placeholder')"
|
||||
:aria-invalid="$errors->has('state') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('state') ? 'account-state-error' : null"
|
||||
/>
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.field :label="__('storefront.account.phone')" for="account-phone" :error="$errors->first('contact_phone')">
|
||||
<x-ui.input id="account-phone" name="contact_phone" type="tel" autocomplete="tel"
|
||||
:value="old('contact_phone', $address?->contact_phone)"
|
||||
:aria-invalid="$errors->has('contact_phone') ? 'true' : null" :aria-describedby="$errors->has('contact_phone') ? 'account-phone-error' : null" />
|
||||
</x-ui.field>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Standing opt-in for abandoned-cart reminders (explicit, off by
|
||||
default); checkout starts from it and can change it again. --}}
|
||||
<section class="flex flex-col gap-8" aria-labelledby="account-emails-heading">
|
||||
<h2 id="account-emails-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.emails_heading') }}</h2>
|
||||
|
||||
<div>
|
||||
<input type="hidden" name="recovery_consent" value="0">
|
||||
<x-ui.checkbox
|
||||
id="account-recovery-consent"
|
||||
name="recovery_consent"
|
||||
:checked="(bool) old('recovery_consent', data_get($customer, 'meta.recovery_consent'))"
|
||||
:label="__('storefront.account.recovery_consent')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<x-ui.button type="submit">{{ __('storefront.account.save') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{-- Delete account --}}
|
||||
<section class="flex flex-col gap-6 border-t border-black pt-12" aria-labelledby="account-delete-heading">
|
||||
<h2 id="account-delete-heading" class="font-display text-h4 font-extrabold">{{ __('storefront.account.delete_heading') }}</h2>
|
||||
|
||||
<p>{{ __('storefront.account.delete_text') }}</p>
|
||||
|
||||
<div>
|
||||
<x-ui.button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
popovertarget="account-delete-dialog"
|
||||
aria-haspopup="dialog"
|
||||
>{{ __('storefront.account.delete') }}</x-ui.button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="account-delete-dialog"
|
||||
popover
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="account-delete-dialog-heading"
|
||||
class="m-auto w-[calc(100%-2rem)] max-w-lg border border-black bg-neutral-200 p-8 backdrop:bg-black/50"
|
||||
>
|
||||
<h3 id="account-delete-dialog-heading" class="mb-4 font-display text-h4 font-extrabold">
|
||||
{{ __('storefront.account.delete_confirm_heading') }}
|
||||
</h3>
|
||||
|
||||
<p class="mb-8">{{ __('storefront.account.delete_confirm_text') }}</p>
|
||||
|
||||
<form method="POST" action="{{ route('account.destroy') }}" class="flex flex-wrap gap-4">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
|
||||
<x-ui.button type="submit" size="md">{{ __('storefront.account.delete_confirm') }}</x-ui.button>
|
||||
|
||||
<x-ui.button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
popovertarget="account-delete-dialog"
|
||||
popovertargetaction="hide"
|
||||
>{{ __('storefront.account.delete_cancel') }}</x-ui.button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -0,0 +1,17 @@
|
||||
@extends('layouts.account')
|
||||
|
||||
@section('title', __('storefront.account.nav_wishlist'))
|
||||
|
||||
@section('account')
|
||||
|
||||
<div class="flex flex-col gap-12">
|
||||
|
||||
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||
{{ __('storefront.account.nav_wishlist') }}
|
||||
</h1>
|
||||
|
||||
@include('wishlist.list')
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
@@ -0,0 +1,67 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', __('storefront.auth.enter_code'))
|
||||
|
||||
@push('seo')
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
|
||||
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
|
||||
|
||||
<h1 class="mb-6 font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||
{{ __('storefront.auth.enter_code') }}
|
||||
</h1>
|
||||
|
||||
<p class="mb-12">
|
||||
{{ __('storefront.auth.code_sent_to') }} <strong class="break-all">{{ $email }}</strong>
|
||||
</p>
|
||||
|
||||
<x-ui.status class="mb-8" />
|
||||
|
||||
<form method="POST" action="{{ route('login.verify') }}" class="flex flex-col gap-8">
|
||||
@csrf
|
||||
|
||||
<x-ui.field
|
||||
:label="__('storefront.auth.code')"
|
||||
for="login-code"
|
||||
:required="true"
|
||||
:error="$errors->first('code')"
|
||||
>
|
||||
<x-ui.input
|
||||
id="login-code"
|
||||
name="code"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxlength="6"
|
||||
:required="true"
|
||||
autofocus
|
||||
class="text-2xl font-bold tracking-[0.5em]"
|
||||
:aria-invalid="$errors->has('code') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('code') ? 'login-code-error' : null"
|
||||
/>
|
||||
</x-ui.field>
|
||||
|
||||
<div>
|
||||
<x-ui.button type="submit">{{ __('storefront.auth.login') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-12 flex flex-wrap items-center gap-x-8 gap-y-4 text-sm">
|
||||
<form method="POST" action="{{ route('login.resend') }}">
|
||||
@csrf
|
||||
<button type="submit" class="cursor-pointer underline hover:no-underline">
|
||||
{{ __('storefront.auth.resend_code') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<a href="{{ route('login') }}" class="underline hover:no-underline">
|
||||
{{ __('storefront.auth.change_email') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
@endsection
|
||||
@@ -0,0 +1,62 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', __('storefront.auth.login'))
|
||||
|
||||
@push('seo')
|
||||
<meta name="robots" content="noindex, follow">
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
|
||||
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
|
||||
|
||||
<h1 class="mb-6 font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||
{{ __('storefront.auth.login') }}
|
||||
</h1>
|
||||
|
||||
{{-- Unescaped: the label holds a <br /> (edited in Filament › Language Lines, staff only). --}}
|
||||
<p class="mb-12">{!! __('storefront.auth.login_intro') !!}</p>
|
||||
|
||||
{{-- e.g. "account deletion scheduled", after AccountController::destroy() --}}
|
||||
<x-ui.status class="mb-12" />
|
||||
|
||||
<form method="POST" action="{{ route('login.send') }}" class="flex flex-col gap-8">
|
||||
@csrf
|
||||
|
||||
<x-ui.field
|
||||
:label="__('storefront.auth.email')"
|
||||
for="login-email"
|
||||
:required="true"
|
||||
:error="$errors->first('email')"
|
||||
>
|
||||
<x-ui.input
|
||||
id="login-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:value="old('email')"
|
||||
:required="true"
|
||||
autofocus
|
||||
:aria-invalid="$errors->has('email') ? 'true' : null"
|
||||
:aria-describedby="$errors->has('email') ? 'login-email-error' : null"
|
||||
/>
|
||||
</x-ui.field>
|
||||
|
||||
{{-- 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). --}}
|
||||
<p class="text-sm text-neutral-500 [&_a]:underline [&_a:hover]:no-underline">
|
||||
{!! __('storefront.auth.terms_notice', [
|
||||
'terms' => route('legal.terms'),
|
||||
'privacy' => route('legal.privacy'),
|
||||
]) !!}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</section>
|
||||
|
||||
@endsection
|
||||
@@ -14,7 +14,8 @@
|
||||
@endpush
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-5xl mx-auto pt-26 pb-12">
|
||||
{{-- <div class="max-w-5xl mx-auto pt-26 pb-12"> --}}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-8 pt-26 pb-12">
|
||||
|
||||
<div class="flex items-end justify-between gap-6 flex-wrap mb-16">
|
||||
<h1 class="font-medium text-h2">{{ $collection['name'] }}</h1>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{{--
|
||||
<x-checkout::add-to-cart :purchasable="$variantId" />
|
||||
|
||||
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 <form>.
|
||||
--}}
|
||||
@props([
|
||||
'purchasable' => null,
|
||||
'quantity' => 1,
|
||||
'action' => null,
|
||||
])
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ $action ?? route('checkout.cart.add', app()->getLocale()) }}"
|
||||
data-controller="bbk-add-to-cart"
|
||||
data-action="bbk-add-to-cart#add"
|
||||
{{ $attributes->class('bbk-add-to-cart') }}
|
||||
>
|
||||
@csrf
|
||||
|
||||
@if (! is_null($purchasable))
|
||||
<input type="hidden" name="purchasable_id" value="{{ $purchasable }}" data-bbk-purchasable-input>
|
||||
@endif
|
||||
|
||||
@if ($quantity !== false)
|
||||
<input type="hidden" name="quantity" value="{{ $quantity }}">
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
<p class="bbk-add-to-cart-error" data-bbk-add-to-cart-target="error" hidden role="alert"></p>
|
||||
</form>
|
||||
@@ -0,0 +1,15 @@
|
||||
{{--
|
||||
Read-only formatted address. $address is any Lunar address model
|
||||
(OrderAddress / CartAddress) — same column names on both.
|
||||
--}}
|
||||
@props(['address'])
|
||||
|
||||
<address class="bbk-address-lines">
|
||||
<span>{{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }}</span>
|
||||
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
|
||||
<span>{{ $address->line_one }}</span>
|
||||
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
|
||||
<span>{{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }}</span>
|
||||
@if ($address->state)<span>{{ $address->state }}</span>@endif
|
||||
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
|
||||
</address>
|
||||
@@ -0,0 +1,29 @@
|
||||
{{--
|
||||
<x-checkout::field name="billing_first_name" label="First name" required />
|
||||
|
||||
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,
|
||||
])
|
||||
|
||||
<div class="bbk-field">
|
||||
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
|
||||
<input
|
||||
type="{{ $type }}"
|
||||
name="{{ $name }}"
|
||||
id="bbk-{{ $name }}"
|
||||
value="{{ old($name, $value) }}"
|
||||
@if ($required) required @endif
|
||||
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
|
||||
>
|
||||
{{-- Always present so bbk-checkout-form can fill it live on an autosave. --}}
|
||||
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
{{--
|
||||
The state/region + country pair for one address (billing or shipping).
|
||||
|
||||
Single-country store ($storeCountry set): region is a <select> of that
|
||||
country's Lunar states, submitting `->name` (table-rate-shipping resolves
|
||||
zones with State::whereName()), and country is a fixed hidden field + label.
|
||||
Otherwise: free-text region + full country <select>, as before.
|
||||
--}}
|
||||
@props([
|
||||
'prefix',
|
||||
'storeCountry' => null,
|
||||
'regions' => [],
|
||||
'countries' => [],
|
||||
'address' => null,
|
||||
])
|
||||
|
||||
<div class="bbk-field-row">
|
||||
@if ($storeCountry)
|
||||
<x-checkout::select
|
||||
:name="$prefix . '_state'"
|
||||
label="{{ __('checkout.page.state') }}"
|
||||
:options="$regions"
|
||||
value-field="name"
|
||||
translation-group="states"
|
||||
:value="$address?->state"
|
||||
placeholder="{{ __('checkout.page.state_placeholder') }}"
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="bbk-field">
|
||||
<span class="bbk-field-label">{{ __('checkout.page.country') }}</span>
|
||||
<p class="bbk-field-static">
|
||||
{{ \Illuminate\Support\Facades\Lang::has("core::countries.{$storeCountry->name}")
|
||||
? __("core::countries.{$storeCountry->name}")
|
||||
: $storeCountry->name }}
|
||||
</p>
|
||||
<input type="hidden" name="{{ $prefix }}_country_id" value="{{ $storeCountry->id }}">
|
||||
</div>
|
||||
@else
|
||||
<x-checkout::field :name="$prefix . '_state'" label="{{ __('checkout.page.state') }}" :value="$address?->state" />
|
||||
|
||||
<x-checkout::select
|
||||
:name="$prefix . '_country_id'"
|
||||
label="{{ __('checkout.page.country') }}"
|
||||
:options="$countries"
|
||||
translation-group="countries"
|
||||
:value="$address?->country_id"
|
||||
placeholder="{{ __('checkout.page.country_placeholder') }}"
|
||||
required
|
||||
/>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,62 @@
|
||||
{{--
|
||||
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
|
||||
<x-checkout::select name="shipping_state" label="Region" :options="$regions" value-field="name" translation-group="states" required />
|
||||
|
||||
`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))
|
||||
|
||||
<div class="bbk-field">
|
||||
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
|
||||
<select
|
||||
name="{{ $name }}"
|
||||
id="bbk-{{ $name }}"
|
||||
@if ($required) required @endif
|
||||
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
|
||||
>
|
||||
@if ($placeholder)
|
||||
<option value="" @selected(! $selected)>{{ $placeholder }}</option>
|
||||
@endif
|
||||
@foreach ($options as $option)
|
||||
<option value="{{ $option->{$valueField} }}" @selected((string) $selected === (string) $option->{$valueField})>
|
||||
{{ $optionLabel($option) }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
@props([
|
||||
'name',
|
||||
'label',
|
||||
'value' => null,
|
||||
'required' => false,
|
||||
])
|
||||
|
||||
<div class="bbk-field">
|
||||
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
|
||||
<textarea
|
||||
name="{{ $name }}"
|
||||
id="bbk-{{ $name }}"
|
||||
rows="3"
|
||||
@if ($required) required @endif
|
||||
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
|
||||
>{{ old($name, $value) }}</textarea>
|
||||
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,132 @@
|
||||
{{--
|
||||
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')
|
||||
<div class="bbk-confirmation">
|
||||
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
|
||||
|
||||
<dl class="bbk-confirmation-meta">
|
||||
<div class="bbk-confirmation-meta-row">
|
||||
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
|
||||
<dd>{{ $order->reference }}</dd>
|
||||
</div>
|
||||
|
||||
@if ($order->billingAddress?->contact_email)
|
||||
<div class="bbk-confirmation-meta-row">
|
||||
<dt>{{ __('checkout.page.email_label') }}</dt>
|
||||
<dd>{{ $order->billingAddress->contact_email }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($paymentMethodName)
|
||||
<div class="bbk-confirmation-meta-row">
|
||||
<dt>{{ __('checkout.page.payment_heading') }}</dt>
|
||||
<dd>{{ $paymentMethodName }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($shippingLine = $order->lines->firstWhere('type', 'shipping'))
|
||||
<div class="bbk-confirmation-meta-row">
|
||||
<dt>{{ __('checkout.page.shipping_method_heading') }}</dt>
|
||||
<dd>{{ $shippingLine->description }}</dd>
|
||||
</div>
|
||||
@endif
|
||||
</dl>
|
||||
|
||||
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
|
||||
|
||||
{{-- Guests: logging in with the order's email attaches it to an account
|
||||
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
|
||||
so it shows in their history. --}}
|
||||
@guest
|
||||
@if ($loginRoute = config('checkout.login_route'))
|
||||
<p class="bbk-checkout-note">
|
||||
{{ __('checkout.page.confirmation_login_hint') }}
|
||||
<a href="{{ route($loginRoute) }}">{{ __('checkout.page.login_link') }}</a>
|
||||
</p>
|
||||
@endif
|
||||
@endguest
|
||||
|
||||
<div class="bbk-confirmation-body">
|
||||
<div class="bbk-confirmation-lines">
|
||||
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
|
||||
<div class="bbk-confirmation-line">
|
||||
<div class="bbk-cart-item-media">
|
||||
@if ($thumb = $line->purchasable?->getThumbnailImage())
|
||||
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bbk-confirmation-line-detail">
|
||||
<span class="bbk-confirmation-line-name">
|
||||
{{ $line->description }}
|
||||
<span class="bbk-confirmation-line-qty">× {{ $line->quantity }}</span>
|
||||
</span>
|
||||
|
||||
@if ($line->option)
|
||||
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
|
||||
@endif
|
||||
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
</div>
|
||||
|
||||
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
<div class="bbk-cart-summary">
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.subtotal') }}</span>
|
||||
<span>{{ $order->sub_total?->formatted() }}</span>
|
||||
</div>
|
||||
|
||||
@if ($order->discount_total?->value > 0)
|
||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
||||
<span>{{ __('checkout.cart.discount') }}</span>
|
||||
<span>−{{ $order->discount_total->formatted() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.shipping') }}</span>
|
||||
<span>{{ $order->shipping_total?->formatted() }}</span>
|
||||
</div>
|
||||
|
||||
@if ($order->tax_total?->value > 0)
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.tax') }}</span>
|
||||
<span>{{ $order->tax_total->formatted() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
||||
<span>{{ __('checkout.cart.total') }}</span>
|
||||
<span>{{ $order->total?->formatted() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bbk-confirmation-addresses">
|
||||
@if ($order->shippingAddress)
|
||||
<div class="bbk-confirmation-address">
|
||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
|
||||
<x-checkout::address-lines :address="$order->shippingAddress" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($order->billingAddress)
|
||||
<div class="bbk-confirmation-address">
|
||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
|
||||
<x-checkout::address-lines :address="$order->billingAddress" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,33 @@
|
||||
{{--
|
||||
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.
|
||||
--}}
|
||||
<div class="bbk-cart" data-controller="bbk-cart" hidden>
|
||||
<div class="bbk-cart-backdrop" data-action="click->bbk-cart#close"></div>
|
||||
|
||||
<aside
|
||||
class="bbk-cart-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bbk-cart-heading"
|
||||
data-bbk-cart-target="panel"
|
||||
>
|
||||
<header class="bbk-cart-panel-header">
|
||||
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-cart-dismiss"
|
||||
data-action="bbk-cart#close"
|
||||
aria-label="{{ __('checkout.cart.close') }}"
|
||||
>×</button>
|
||||
</header>
|
||||
|
||||
@include('checkout::partials.cart-error')
|
||||
|
||||
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
|
||||
@include('checkout::partials.cart-body')
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -0,0 +1,279 @@
|
||||
{{--
|
||||
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')
|
||||
<div class="bbk-checkout-page">
|
||||
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
|
||||
|
||||
<div class="bbk-checkout">
|
||||
<div
|
||||
class="bbk-checkout-main"
|
||||
data-controller="bbk-checkout-form bbk-payment"
|
||||
data-action="input->bbk-checkout-form#scheduleSave"
|
||||
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
|
||||
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
|
||||
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
|
||||
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
|
||||
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
|
||||
data-bbk-payment-select-url-value="{{ route('checkout.payment-method.select', app()->getLocale()) }}"
|
||||
data-bbk-payment-place-order-url-value="{{ route('checkout.place-order', app()->getLocale()) }}"
|
||||
data-bbk-payment-order-status-url-value="{{ route('checkout.order-status', app()->getLocale()) }}"
|
||||
data-bbk-payment-stripe-key-value="{{ config('services.stripe.public_key') }}"
|
||||
data-bbk-payment-amount-value="{{ $cart?->total?->value ?? 0 }}"
|
||||
data-bbk-payment-currency-value="{{ strtolower($cart?->total?->currency?->code ?? 'eur') }}"
|
||||
data-bbk-payment-terms-required-value="{{ __('checkout.page.terms_required') }}"
|
||||
data-bbk-payment-choose-method-value="{{ __('checkout.page.choose_payment_method') }}"
|
||||
data-bbk-payment-generic-error-value="{{ __('checkout.page.payment_failed') }}"
|
||||
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
|
||||
>
|
||||
|
||||
{{-- Contact. Logged in: the order email is the account's (forced
|
||||
server-side in saveAddress()), so there's no field. Guests type
|
||||
their email, plus a login link when config('checkout.login_route')
|
||||
is set; the storefront's login page sends them back here and Lunar
|
||||
merges the guest cart into the account. --}}
|
||||
@php($loginRoute = config('checkout.login_route'))
|
||||
<section class="bbk-checkout-section">
|
||||
@auth
|
||||
<p class="bbk-checkout-logged-in">
|
||||
{{ __('checkout.page.logged_in_as') }} <strong>{{ auth()->user()->email }}</strong>
|
||||
</p>
|
||||
@else
|
||||
@if ($loginRoute)
|
||||
<p class="bbk-checkout-login-prompt">
|
||||
{{ __('checkout.page.login_prompt') }}
|
||||
<a href="{{ route($loginRoute, ['redirect' => route('checkout.show', app()->getLocale(), false)]) }}">{{ __('checkout.page.login_link') }}</a>
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<x-checkout::field
|
||||
name="contact_email"
|
||||
label="{{ __('checkout.page.email_label') }}"
|
||||
type="email"
|
||||
:value="$shippingAddress?->contact_email ?? $billingAddress?->contact_email"
|
||||
required
|
||||
form="bbk-address-form"
|
||||
/>
|
||||
@endauth
|
||||
|
||||
{{-- Abandoned-cart-recovery opt-in. Optional, unticked, never
|
||||
required — direct marketing under ePrivacy (GR L. 3471/2006
|
||||
art. 11), so it needs an explicit opt-in and checkout can't be
|
||||
gated on it. Narrow scope by design (boboko-core's
|
||||
setRecoveryConsent) — a general newsletter opt-in, if wanted,
|
||||
is a separate checkbox. --}}
|
||||
<label class="bbk-checkbox bbk-checkbox--stacked">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="recovery_consent"
|
||||
value="1"
|
||||
form="bbk-address-form"
|
||||
{{ old('recovery_consent', data_get($cart, 'meta.recovery_consent')) ? 'checked' : '' }}
|
||||
>
|
||||
{{ __('checkout.page.recovery_consent') }}
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{{-- 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(). --}}
|
||||
<form
|
||||
id="bbk-address-form"
|
||||
method="POST"
|
||||
action="{{ route('checkout.address.save', app()->getLocale()) }}"
|
||||
data-bbk-checkout-form-target="form"
|
||||
>
|
||||
@csrf
|
||||
|
||||
{{-- Billing --}}
|
||||
<section class="bbk-checkout-section">
|
||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.billing_heading') }}</h2>
|
||||
|
||||
<div class="bbk-field-row">
|
||||
<x-checkout::field name="billing_first_name" label="{{ __('checkout.page.first_name') }}" :value="$billingAddress?->first_name" required />
|
||||
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
|
||||
</div>
|
||||
|
||||
{{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS
|
||||
(:has on the checkbox), saved/cleared by saveAddress(), and
|
||||
required at place-order. --}}
|
||||
<div class="bbk-invoice">
|
||||
<label class="bbk-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="wants_invoice"
|
||||
value="1"
|
||||
aria-controls="bbk-invoice-fields"
|
||||
@checked($wantsInvoice)
|
||||
>
|
||||
{{ __('checkout.page.wants_invoice') }}
|
||||
</label>
|
||||
|
||||
<div class="bbk-field-row bbk-invoice-fields" id="bbk-invoice-fields">
|
||||
<x-checkout::field name="billing_company_name" label="{{ __('checkout.page.company_name') }}" :value="$billingAddress?->company_name" />
|
||||
<x-checkout::field name="billing_tax_identifier" label="{{ __('checkout.page.tax_identifier') }}" :value="$billingAddress?->tax_identifier" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
|
||||
|
||||
<div class="bbk-field-row">
|
||||
<x-checkout::field name="billing_city" label="{{ __('checkout.page.city') }}" :value="$billingAddress?->city" required />
|
||||
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
|
||||
</div>
|
||||
|
||||
<x-checkout::region-country
|
||||
prefix="billing"
|
||||
:store-country="$storeCountry"
|
||||
:regions="$regions"
|
||||
:countries="$countries"
|
||||
:address="$billingAddress"
|
||||
/>
|
||||
|
||||
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
|
||||
</section>
|
||||
|
||||
{{-- Shipping --}}
|
||||
<section class="bbk-checkout-section">
|
||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_heading') }}</h2>
|
||||
|
||||
<label class="bbk-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="same_as_billing"
|
||||
value="1"
|
||||
data-bbk-checkout-form-target="sameAsBilling"
|
||||
data-action="bbk-checkout-form#toggleSameAsBilling"
|
||||
@checked($shipToBilling)
|
||||
>
|
||||
{{ __('checkout.page.same_as_billing') }}
|
||||
</label>
|
||||
|
||||
<div class="bbk-checkout-shipping-fields" data-bbk-checkout-form-target="shippingFields">
|
||||
<div class="bbk-field-row">
|
||||
<x-checkout::field name="shipping_first_name" label="{{ __('checkout.page.first_name') }}" :value="$shippingAddress?->first_name" required />
|
||||
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
|
||||
</div>
|
||||
|
||||
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
|
||||
|
||||
<div class="bbk-field-row">
|
||||
<x-checkout::field name="shipping_city" label="{{ __('checkout.page.city') }}" :value="$shippingAddress?->city" required />
|
||||
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
|
||||
</div>
|
||||
|
||||
<x-checkout::region-country
|
||||
prefix="shipping"
|
||||
:store-country="$storeCountry"
|
||||
:regions="$regions"
|
||||
:countries="$countries"
|
||||
:address="$shippingAddress"
|
||||
/>
|
||||
|
||||
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
|
||||
</div>
|
||||
|
||||
<x-checkout::textarea
|
||||
name="shipping_delivery_instructions"
|
||||
label="{{ __('checkout.page.delivery_instructions') }}"
|
||||
:value="$shippingAddress?->delivery_instructions"
|
||||
/>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<p
|
||||
class="bbk-checkout-status"
|
||||
data-bbk-checkout-form-target="status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
></p>
|
||||
|
||||
{{-- Shipping method — resolves from the saved shipping address;
|
||||
re-rendered as a fragment by bbk-checkout-form after each
|
||||
autosave / option change. --}}
|
||||
<section class="bbk-checkout-section">
|
||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
|
||||
|
||||
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
|
||||
@include('checkout::partials.shipping-options', [
|
||||
'shippingAddress' => $shippingAddress,
|
||||
'shippingOptions' => $shippingOptions,
|
||||
])
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- Payment --}}
|
||||
<section class="bbk-checkout-section">
|
||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
|
||||
|
||||
<div id="bbk-payment-methods">
|
||||
@include('checkout::partials.payment-methods', [
|
||||
'paymentMethods' => $paymentMethods,
|
||||
'cart' => $cart,
|
||||
])
|
||||
</div>
|
||||
|
||||
{{-- Stripe Payment Element mounts here when a Stripe method is picked. --}}
|
||||
<div class="bbk-payment-element" data-bbk-payment-target="element" hidden></div>
|
||||
|
||||
<label class="bbk-checkbox bbk-checkbox--stacked">
|
||||
<input type="checkbox" data-bbk-payment-target="terms">
|
||||
{!! __('checkout.page.terms_accept', [
|
||||
'terms' => route('legal.terms', app()->getLocale()),
|
||||
'privacy' => route('legal.privacy', app()->getLocale()),
|
||||
]) !!}
|
||||
</label>
|
||||
|
||||
<p class="bbk-checkout-withdrawal">
|
||||
{!! __('checkout.page.withdrawal_notice', [
|
||||
'link' => route('legal.shipping-returns', app()->getLocale()),
|
||||
]) !!}
|
||||
</p>
|
||||
|
||||
<p class="bbk-checkout-error" data-bbk-payment-target="error" role="alert" hidden></p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-checkout-continue"
|
||||
data-bbk-payment-target="submit"
|
||||
data-action="bbk-payment#placeOrder"
|
||||
@disabled($lines->isEmpty())
|
||||
>
|
||||
{{ __('checkout.page.place_order') }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{{-- Fixed overlay while a payment is confirming (3-D Secure / webhook
|
||||
poll). Inside .bbk-checkout-main so bbk-payment can target it. --}}
|
||||
<div class="bbk-checkout-processing" data-bbk-payment-target="processing" hidden>
|
||||
<span class="bbk-spinner" aria-hidden="true"></span>
|
||||
<p data-bbk-payment-target="processingText">{{ __('checkout.page.payment_processing') }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<aside class="bbk-checkout-aside">
|
||||
<div class="bbk-checkout-summary" data-controller="bbk-cart">
|
||||
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
|
||||
@include('checkout::partials.cart-error')
|
||||
<div data-bbk-cart-target="body" aria-live="polite">
|
||||
@include('checkout::partials.cart-body')
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,121 @@
|
||||
{{--
|
||||
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) --}}
|
||||
|
||||
<div
|
||||
class="bbk-cart-content"
|
||||
data-bbk-cart-count="{{ $count }}"
|
||||
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
|
||||
>
|
||||
@if ($lines->isEmpty())
|
||||
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
|
||||
@else
|
||||
<ul class="bbk-cart-items">
|
||||
@each('checkout::partials.cart-line', $lines, 'line')
|
||||
</ul>
|
||||
|
||||
<div class="bbk-cart-summary">
|
||||
<div class="bbk-cart-coupon">
|
||||
@if ($cart?->coupon_code)
|
||||
<div class="bbk-cart-coupon-applied">
|
||||
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
|
||||
data-action="submit->bbk-cart#submit"
|
||||
>
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="bbk-cart-coupon-remove">
|
||||
{{ __('checkout.cart.coupon_remove') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<form
|
||||
class="bbk-cart-coupon-form"
|
||||
method="POST"
|
||||
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
|
||||
data-action="submit->bbk-cart#submit"
|
||||
>
|
||||
@csrf
|
||||
<label class="bbk-visually-hidden" for="bbk-coupon-code">
|
||||
{{ __('checkout.cart.coupon_label') }}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code"
|
||||
id="bbk-coupon-code"
|
||||
class="bbk-cart-coupon-input"
|
||||
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
<button type="submit" class="bbk-cart-coupon-submit">
|
||||
{{ __('checkout.cart.coupon_apply') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if ($couponError ?? false)
|
||||
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.subtotal') }}</span>
|
||||
<span>{{ $cart?->subTotal?->formatted() }}</span>
|
||||
</div>
|
||||
|
||||
@if ($cart?->discountTotal?->value > 0)
|
||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
||||
<span>{{ __('checkout.cart.discount') }}</span>
|
||||
<span>−{{ $cart->discountTotal->formatted() }}</span>
|
||||
</div>
|
||||
@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)
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.shipping') }}</span>
|
||||
@if ($cart->shippingAddress->shipping_option)
|
||||
<span>{{ $cart->shippingTotal?->formatted() }}</span>
|
||||
@else
|
||||
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($cart?->taxTotal?->value > 0)
|
||||
<div class="bbk-cart-summary-row">
|
||||
<span>{{ __('checkout.cart.tax') }}</span>
|
||||
<span>{{ $cart->taxTotal->formatted() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Always shown — equals subtotal with nothing else applied,
|
||||
diverges as discount / shipping / tax come in. --}}
|
||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
||||
<span>{{ __('checkout.cart.total') }}</span>
|
||||
<span>{{ $cart?->total?->formatted() }}</span>
|
||||
</div>
|
||||
|
||||
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
|
||||
{{ __('checkout.cart.checkout') }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
{{--
|
||||
Shared error slot for any host wrapping cart-body in a bbk-cart controller
|
||||
instance (the drawer, and the checkout page's own order summary) —
|
||||
bbk-cart-controller.js#showError() writes into whichever one is present.
|
||||
Without this element in a given host, a rejected quantity update (e.g.
|
||||
over stock) still gets rejected server-side, but the shopper never sees
|
||||
why.
|
||||
--}}
|
||||
<p class="bbk-cart-error" data-bbk-cart-target="error" hidden role="alert"></p>
|
||||
@@ -0,0 +1,103 @@
|
||||
{{--
|
||||
One cart line. $line is a Lunar\Models\CartLine (iteration var set by
|
||||
@each in cart-body). The two forms post through bbk-cart-controller
|
||||
(fetch + method spoofing) and the response re-renders cart-body.
|
||||
--}}
|
||||
@php
|
||||
$variant = $line->purchasable;
|
||||
$product = $variant?->product;
|
||||
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
|
||||
// The variant's own image (falls back to the product's thumbnail
|
||||
// internally — see ProductVariant::getThumbnail()) — the specific option
|
||||
// the shopper picked, not just the product in general.
|
||||
$thumb = $variant?->getThumbnailImage() ?: null;
|
||||
$variantLabel = $variant?->getOption();
|
||||
// Not routed through checkout::'s own locale-explicit convention — this
|
||||
// is a storefront route, so it follows the storefront's own (implicit
|
||||
// locale) call shape, same as App\Catalog\ProductCard. Carries the
|
||||
// variant id along so the product page can restore the same option the
|
||||
// shopper actually has in their cart, not just default to the first one
|
||||
// (see product-form-controller.js reading ?variant= on connect()).
|
||||
$productUrl = $product ? route('product.show', ['id' => $product->id, 'variant' => $variant?->id]) : null;
|
||||
@endphp
|
||||
|
||||
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
|
||||
<div class="bbk-cart-item-media">
|
||||
@if ($thumb)
|
||||
@if ($productUrl)
|
||||
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
|
||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
||||
</a>
|
||||
@else
|
||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="bbk-cart-item-detail">
|
||||
@if ($productUrl)
|
||||
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
|
||||
@else
|
||||
<p class="bbk-cart-item-title">{{ $name }}</p>
|
||||
@endif
|
||||
@if ($variantLabel)
|
||||
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
|
||||
@endif
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
||||
|
||||
<form
|
||||
class="bbk-cart-qty"
|
||||
method="POST"
|
||||
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
||||
>
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-cart-qty-btn"
|
||||
data-action="bbk-cart#step"
|
||||
data-bbk-cart-dir-param="-1"
|
||||
aria-label="{{ __('checkout.cart.decrease') }}"
|
||||
>−</button>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
name="quantity"
|
||||
value="{{ $line->quantity }}"
|
||||
min="0"
|
||||
inputmode="numeric"
|
||||
class="bbk-cart-qty-input"
|
||||
data-action="change->bbk-cart#submit"
|
||||
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
|
||||
aria-label="{{ __('checkout.cart.quantity') }}"
|
||||
>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-cart-qty-btn"
|
||||
data-action="bbk-cart#step"
|
||||
data-bbk-cart-dir-param="1"
|
||||
aria-label="{{ __('checkout.cart.increase') }}"
|
||||
>+</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bbk-cart-item-aside">
|
||||
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
||||
data-action="submit->bbk-cart#submit"
|
||||
>
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button
|
||||
type="submit"
|
||||
class="bbk-cart-item-remove"
|
||||
aria-label="{{ __('checkout.cart.remove') }}"
|
||||
>×</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
@@ -0,0 +1,49 @@
|
||||
{{--
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
|
||||
A cart or order line's custom-field answers (meta.custom_fields, written by
|
||||
CartController::customFieldsMeta()). A file answer only carries a File id
|
||||
(Modules\Core\File\Models\File is the source of truth for name/mime/disk/
|
||||
path — never duplicated into meta), resolved here and linked through
|
||||
boboko-core's own signed download route (files.download), minted fresh on
|
||||
every render, with a thumbnail when the browser can display the format
|
||||
(HEIC can't be shown outside Safari, so it gets the name only).
|
||||
--}}
|
||||
@php
|
||||
$fields = $line->meta['custom_fields'] ?? [];
|
||||
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
@endphp
|
||||
|
||||
@if (! empty($fields))
|
||||
<dl class="bbk-line-fields">
|
||||
@foreach ($fields as $field)
|
||||
<div class="bbk-line-field">
|
||||
<dt>{{ $field['label'] }}</dt>
|
||||
<dd>
|
||||
@if ($field['type'] === 'file')
|
||||
@php
|
||||
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
|
||||
@endphp
|
||||
@if ($file)
|
||||
@php
|
||||
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
|
||||
'files.download',
|
||||
now()->addHours(2),
|
||||
['file' => $file->id],
|
||||
);
|
||||
@endphp
|
||||
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
|
||||
@if (in_array($file->mime, $previewable, true))
|
||||
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
|
||||
@endif
|
||||
<span>{{ $file->original_name }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
{{ $field['value'] }}
|
||||
@endif
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
@endif
|
||||
@@ -0,0 +1,30 @@
|
||||
{{--
|
||||
Payment method radios. $paymentMethods is Collection<Modules\Core\Payment\
|
||||
Models\PaymentMethod> 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())
|
||||
<p class="bbk-checkout-note">{{ __('checkout.page.payment_method_none') }}</p>
|
||||
@else
|
||||
<div class="bbk-checkout-payment-options">
|
||||
@foreach ($paymentMethods as $method)
|
||||
<label class="bbk-checkout-payment-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="payment_type"
|
||||
value="{{ $method->type }}"
|
||||
data-payment-driver="{{ $method->driver }}"
|
||||
@checked($selected === $method->type)
|
||||
data-action="change->bbk-payment#selectMethod"
|
||||
>
|
||||
<span class="bbk-checkout-payment-option-name">{{ $method->translate('name') }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@@ -0,0 +1,50 @@
|
||||
{{--
|
||||
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)
|
||||
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
|
||||
@elseif ($shippingOptions->isEmpty())
|
||||
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
|
||||
@elseif ($shippingOptions->count() === 1)
|
||||
@php($only = $shippingOptions->first())
|
||||
<div class="bbk-checkout-shipping-confirmed">
|
||||
<span class="bbk-checkout-shipping-option-detail">
|
||||
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
|
||||
@if ($only->description)
|
||||
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
|
||||
@endif
|
||||
</span>
|
||||
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
|
||||
</div>
|
||||
@else
|
||||
<div class="bbk-checkout-shipping-options">
|
||||
@foreach ($shippingOptions as $option)
|
||||
<label class="bbk-checkout-shipping-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="shipping_option"
|
||||
value="{{ $option->identifier }}"
|
||||
@checked($selected === $option->identifier)
|
||||
data-action="change->bbk-checkout-form#selectShipping"
|
||||
>
|
||||
<span class="bbk-checkout-shipping-option-detail">
|
||||
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
|
||||
@if ($option->description)
|
||||
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
|
||||
@endif
|
||||
</span>
|
||||
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@@ -41,11 +41,42 @@
|
||||
</a>
|
||||
@endforeach
|
||||
|
||||
{{-- Cart --}}
|
||||
<a href="{{ url('/'.app()->getLocale().'/cart') }}" class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity" aria-label="Cart">
|
||||
<x-ui.icon name="bag" :size="40" />
|
||||
{{-- Wishlist — the account page, or the cookie-based guest page --}}
|
||||
<a
|
||||
href="{{ auth()->check() ? route('account.wishlist') : route('wishlist') }}"
|
||||
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
|
||||
aria-label="{{ __('storefront.account.nav_wishlist') }}"
|
||||
@if (request()->routeIs('account.wishlist', 'wishlist')) aria-current="page" @endif
|
||||
>
|
||||
<x-ui.icon name="heart" :size="40" />
|
||||
</a>
|
||||
|
||||
{{-- Account — the account page, or login for guests --}}
|
||||
<a
|
||||
href="{{ auth()->check() ? route('account') : route('login') }}"
|
||||
class="flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
|
||||
aria-label="{{ auth()->check() ? __('storefront.nav.account') : __('storefront.auth.login') }}"
|
||||
>
|
||||
<x-ui.icon name="user" :size="40" />
|
||||
</a>
|
||||
|
||||
{{-- Cart — opens the checkout module's drawer, no separate cart page --}}
|
||||
<button
|
||||
type="button"
|
||||
class="relative flex items-center justify-center w-10 h-10 hover:opacity-70 transition-opacity"
|
||||
aria-label="Cart"
|
||||
aria-haspopup="dialog"
|
||||
data-controller="cart-count"
|
||||
data-action="cart-count#open"
|
||||
>
|
||||
<x-ui.icon name="bag" :size="40" />
|
||||
<span
|
||||
data-cart-count-target="badge"
|
||||
class="absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full bg-black text-neutral-200 text-xs font-bold flex items-center justify-center"
|
||||
hidden
|
||||
>0</span>
|
||||
</button>
|
||||
|
||||
{{-- Search --}}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{{--
|
||||
An order status as text, e.g. "Σε επεξεργασία". Translated through
|
||||
storefront.order_status.{status}, falling back to Lunar's own (English)
|
||||
label from config('lunar.orders.statuses') when that key doesn't exist.
|
||||
--}}
|
||||
@props(['status'])
|
||||
|
||||
@php
|
||||
$key = "storefront.order_status.{$status}";
|
||||
$label = Lang::has($key) ? __($key) : config("lunar.orders.statuses.{$status}.label", $status);
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes }}>{{ $label }}</span>
|
||||
@@ -0,0 +1,77 @@
|
||||
{{--
|
||||
<x-product-custom-fields :fields="$product['custom_fields']" />
|
||||
|
||||
The product's custom fields (boboko-core's Product::$custom_fields), rendered
|
||||
inside the add-to-cart <form> so their values travel with it as
|
||||
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
|
||||
|
||||
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
|
||||
and only the resulting File row's id (boboko-core's Modules\Core\File\
|
||||
Models\File) is submitted with the form — the file input itself has no
|
||||
name. It still carries `required`, so the browser's own validation blocks
|
||||
add-to-cart until a photo is picked, and the controller marks it invalid
|
||||
(setCustomValidity) while the upload is in flight.
|
||||
--}}
|
||||
@props(['fields' => []])
|
||||
|
||||
@use('App\Http\Controllers\CustomFieldUploadController')
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
@foreach ($fields as $field)
|
||||
@php
|
||||
$id = 'custom-field-'.$field['key'];
|
||||
$name = 'custom_fields['.$field['key'].']';
|
||||
$required = (bool) ($field['required'] ?? false);
|
||||
@endphp
|
||||
|
||||
@switch($field['type'])
|
||||
@case('textarea')
|
||||
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
||||
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" />
|
||||
</x-ui.field>
|
||||
@break
|
||||
|
||||
@case('file')
|
||||
<x-ui.field
|
||||
:label="$field['label']"
|
||||
:for="$id"
|
||||
:required="$required"
|
||||
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
|
||||
data-controller="custom-field-upload"
|
||||
data-custom-field-upload-url-value="{{ route('custom-field-upload.store') }}"
|
||||
data-custom-field-upload-label-value="{{ $field['label'] }}"
|
||||
data-custom-field-upload-uploading-message-value="{{ __('storefront.product.custom_field_uploading') }}"
|
||||
data-custom-field-upload-failed-message-value="{{ __('storefront.product.custom_field_upload_failed') }}"
|
||||
>
|
||||
<input type="hidden" name="{{ $name }}" data-custom-field-upload-target="reference">
|
||||
|
||||
<x-ui.file-input
|
||||
:id="$id"
|
||||
:accept="CustomFieldUploadController::accept()"
|
||||
:required="$required"
|
||||
aria-describedby="{{ $id }}-description {{ $id }}-error"
|
||||
data-custom-field-upload-target="file"
|
||||
data-action="change->custom-field-upload#upload"
|
||||
/>
|
||||
|
||||
<img
|
||||
alt=""
|
||||
width="96"
|
||||
height="96"
|
||||
hidden
|
||||
class="w-24 h-24 object-cover border border-black"
|
||||
data-custom-field-upload-target="preview"
|
||||
data-action="error->custom-field-upload#hidePreview"
|
||||
>
|
||||
|
||||
<p id="{{ $id }}-error" class="text-sm text-red-600" role="alert" hidden data-custom-field-upload-target="error"></p>
|
||||
</x-ui.field>
|
||||
@break
|
||||
|
||||
@default
|
||||
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
||||
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" />
|
||||
</x-ui.field>
|
||||
@endswitch
|
||||
@endforeach
|
||||
</div>
|
||||
@@ -24,6 +24,8 @@
|
||||
:price="$product['price'] ?? null"
|
||||
:image="$product['image'] ?? null"
|
||||
:href="$product['href'] ?? '#'"
|
||||
:variant-id="$product['variantId'] ?? null"
|
||||
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||
/>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
@props(['review'])
|
||||
|
||||
<article class="flex flex-col gap-3 py-6 border-b border-black last:border-0">
|
||||
<article class="flex flex-col gap-3 py-6">
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<x-reviews-stars :rating="$review['rating']" :size="18" />
|
||||
<span class="font-bold">
|
||||
{{ $review['name'] ?? 'Ανώνυμος' }}
|
||||
</span>
|
||||
</div>
|
||||
<time class="text-sm text-neutral-500 shrink-0">{{ $review['date'] }}</time>
|
||||
<x-reviews-stars :rating="$review['rating']" :size="23" />
|
||||
|
||||
<div class="flex items-end gap-4">
|
||||
<span class="text-2xl font-bold leading-none">
|
||||
{{ $review['name'] ?? 'Ανώνυμος' }}
|
||||
</span>
|
||||
<time class="text-sm text-neutral-500 shrink-0 leading-none pb-0.5">{{ $review['date'] }}</time>
|
||||
</div>
|
||||
|
||||
<p class="leading-relaxed">{{ $review['text'] }}</p>
|
||||
|
||||
@if (!empty($review['image']))
|
||||
<div class="mt-1">
|
||||
<img
|
||||
src="{{ $review['image'] }}"
|
||||
alt="Φωτογραφία από τον αγοραστή"
|
||||
class="w-24 h-24 object-cover border border-black"
|
||||
>
|
||||
<a href="{{ $review['image'] }}" target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src="{{ $review['image'] }}"
|
||||
alt="Φωτογραφία από τον αγοραστή"
|
||||
class="w-24 h-24 object-cover border border-black"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (!empty($review['reply']))
|
||||
<div class="mt-2 pl-4 border-l-2 border-neutral-300 flex flex-col gap-1">
|
||||
<p class="text-sm font-bold">
|
||||
{{ __('storefront.review.reply') }}
|
||||
@if (!empty($review['replyDate']))
|
||||
<span class="font-normal text-neutral-500">— {{ $review['replyDate'] }}</span>
|
||||
@endif
|
||||
</p>
|
||||
<p class="leading-relaxed">{{ $review['reply'] }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
@props(['product'])
|
||||
|
||||
@php $oldRating = (int) old('rating', 0); @endphp
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="#"
|
||||
action="{{ route('product.reviews.store', ['locale' => app()->getLocale(), 'product' => $product['id']]) }}"
|
||||
class="flex flex-col gap-8 mt-10"
|
||||
data-controller="star-rating"
|
||||
data-star-rating-rating-value="{{ $oldRating }}"
|
||||
data-action="submit->star-rating#validate"
|
||||
>
|
||||
@csrf
|
||||
|
||||
{{-- Rating --}}
|
||||
<x-ui.field :label="__('storefront.review.rating')" for="rating" :required="true">
|
||||
<x-ui.field :label="__('storefront.review.rating')" for="rating" :required="true" :error="$errors->first('rating')">
|
||||
<div
|
||||
id="rating"
|
||||
data-controller="star-rating"
|
||||
class="flex items-center gap-1.5 text-brand"
|
||||
role="radiogroup"
|
||||
aria-label="{{ __('storefront.review.rating') }}"
|
||||
aria-required="true"
|
||||
aria-describedby="rating-client-error"
|
||||
>
|
||||
<input type="hidden" name="rating" value="0" data-star-rating-target="input">
|
||||
<input type="hidden" name="rating" value="{{ $oldRating }}" data-star-rating-target="input">
|
||||
@for($i = 1; $i <= 5; $i++)
|
||||
<button
|
||||
type="button"
|
||||
@@ -25,7 +30,7 @@ class="flex items-center gap-1.5 text-brand"
|
||||
data-value="{{ $i }}"
|
||||
data-action="click->star-rating#select mouseenter->star-rating#hover mouseleave->star-rating#leave"
|
||||
aria-label="{{ trans_choice('storefront.review.stars_count', $i, ['count' => $i]) }}"
|
||||
aria-pressed="false"
|
||||
aria-pressed="{{ $i === $oldRating ? 'true' : 'false' }}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -41,24 +46,21 @@ class="w-6 h-6"
|
||||
</button>
|
||||
@endfor
|
||||
</div>
|
||||
<p id="rating-client-error" data-star-rating-target="error" class="text-sm text-red-600" role="alert" hidden>{{ __('storefront.review.rating_required') }}</p>
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.field :label="__('storefront.review.write_label')" for="review-content" :required="true">
|
||||
<x-ui.textarea id="review-content" name="content" :required="true" />
|
||||
<x-ui.field :label="__('storefront.review.write_label')" for="review-content" :required="true" :error="$errors->first('content')">
|
||||
<x-ui.textarea id="review-content" name="content" :required="true" :rows="6">{{ old('content') }}</x-ui.textarea>
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.field :label="__('storefront.review.name')" for="review-name" :labelDescription="__('storefront.review.name_optional')">
|
||||
<x-ui.input id="review-name" name="name" autocomplete="name" />
|
||||
<x-ui.field :label="__('storefront.review.name')" for="review-name" :labelDescription="__('storefront.review.name_optional')" :error="$errors->first('name')">
|
||||
<x-ui.input id="review-name" name="name" autocomplete="name" :value="old('name')" />
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.field :label="__('storefront.review.email')" for="review-email" :required="true" :labelDescription="__('storefront.review.email_not_published')">
|
||||
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" />
|
||||
<x-ui.field :label="__('storefront.review.email')" for="review-email" :required="true" :labelDescription="__('storefront.review.email_not_published')" :error="$errors->first('email')">
|
||||
<x-ui.input id="review-email" name="email" type="email" :required="true" autocomplete="email" :value="old('email')" />
|
||||
</x-ui.field>
|
||||
|
||||
<x-ui.checkbox id="review-save-info" name="save_info" >
|
||||
<span class="text-sm">{{ __('storefront.review.save_info') }}</span>
|
||||
</x-ui.checkbox>
|
||||
|
||||
<div>
|
||||
<x-ui.button type="submit">{{ __('storefront.review.submit') }}</x-ui.button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
'count' => 0,
|
||||
'showCount' => false,
|
||||
'size' => 24,
|
||||
'linkable' => false,
|
||||
])
|
||||
|
||||
<div class="flex items-center gap-3" {{ $attributes }}>
|
||||
@@ -28,9 +29,17 @@ class="flex items-center gap-1.5 text-brand"
|
||||
</div>
|
||||
|
||||
@if ($showCount && $count > 0)
|
||||
<span class="text-sm text-neutral-500">
|
||||
({{ trans_choice('storefront.customer_reviews', $count, ['count' => $count]) }})
|
||||
</span>
|
||||
@php $countText = '(' . trans_choice('storefront.customer_reviews', $count, ['count' => $count]) . ')'; @endphp
|
||||
@if ($linkable)
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->tab-link#activate"
|
||||
class="text-sm text-neutral-500 hover:underline cursor-pointer"
|
||||
aria-controls="tab-panel-reviews"
|
||||
>{{ $countText }}</button>
|
||||
@else
|
||||
<span class="text-sm text-neutral-500">{{ $countText }}</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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 '.$variantClasses.' '.$sizeClasses);
|
||||
|
||||
$attrs = $href
|
||||
? $attributes->merge(['href' => $href, 'class' => $class])
|
||||
|
||||
@@ -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])
|
||||
|
||||
<div {{ $attributes }}>
|
||||
@if($option)
|
||||
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
|
||||
{{ $option }}: <span data-product-form-target="colorName" class="font-normal normal-case"></span>
|
||||
{{ $option }}: <span data-product-form-target="colorName" data-option="{{ $optionHandle }}" class="font-normal normal-case"></span>
|
||||
</p>
|
||||
@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)
|
||||
<button
|
||||
type="button"
|
||||
class="color-swatch"
|
||||
data-product-form-target="swatch"
|
||||
data-action="click->product-form#selectVariant"
|
||||
data-variant-id="{{ $variant['id'] }}"
|
||||
style="background-color: {{ $bg }};"
|
||||
aria-label="{{ $label }}"
|
||||
data-option="{{ $optionHandle }}"
|
||||
data-value-id="{{ $value['id'] }}"
|
||||
style="background-color: {{ $value['hex'] ?? '#cccccc' }};"
|
||||
aria-label="{{ $value['label'] }}"
|
||||
aria-pressed="false"
|
||||
title="{{ $label }}"
|
||||
></button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@props([
|
||||
'accept' => null,
|
||||
'required' => false,
|
||||
'disabled' => false,
|
||||
])
|
||||
|
||||
<input
|
||||
type="file"
|
||||
@if ($accept) accept="{{ $accept }}" @endif
|
||||
@required($required)
|
||||
@disabled($disabled)
|
||||
{{ $attributes->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',
|
||||
]) }}
|
||||
/>
|
||||
@@ -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' => '<path stroke-width=0.5 d="M20.0049 22H4.00488C3.4526 22 3.00488 21.5523 3.00488 21V3C3.00488 2.44772 3.4526 2 4.00488 2H20.0049C20.5572 2 21.0049 2.44772 21.0049 3V21C21.0049 21.5523 20.5572 22 20.0049 22ZM19.0049 20V4H5.00488V20H19.0049ZM9.00488 6V8C9.00488 9.65685 10.348 11 12.0049 11C13.6617 11 15.0049 9.65685 15.0049 8V6H17.0049V8C17.0049 10.7614 14.7663 13 12.0049 13C9.24346 13 7.00488 10.7614 7.00488 8V6H9.00488Z"/>',
|
||||
|
||||
'user' => '<path stroke-width=0.5 d="M4 22C4 17.5817 7.58172 14 12 14C16.4183 14 20 17.5817 20 22H18C18 18.6863 15.3137 16 12 16C8.68629 16 6 18.6863 6 22H4ZM12 13C8.685 13 6 10.315 6 7C6 3.685 8.685 1 12 1C15.315 1 18 3.685 18 7C18 10.315 15.315 13 12 13ZM12 11C14.21 11 16 9.21 16 7C16 4.79 14.21 3 12 3C9.79 3 8 4.79 8 7C8 9.21 9.79 11 12 11Z"/>',
|
||||
|
||||
'heart' => '<path stroke-width=0.5 d="M12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736C22.5053 7.02472 22.583 10.637 20.4786 12.993L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853ZM18.827 6.1701C17.3279 4.66794 14.9076 4.60701 13.337 6.01687L12.0019 7.21524L10.6661 6.01781C9.09098 4.60597 6.67506 4.66808 5.17157 6.17157C3.68183 7.66131 3.60704 10.0473 4.97993 11.6232L11.9999 18.6543L19.0201 11.6232C20.3935 10.0467 20.319 7.66525 18.827 6.1701Z"/>',
|
||||
|
||||
'heart-fill' => '<path stroke-width=2 d="M12.001 4.52853C14.35 2.42 17.98 2.49 20.2426 4.75736C22.5053 7.02472 22.583 10.637 20.4786 12.993L11.9999 21.485L3.52138 12.993C1.41705 10.637 1.49571 7.01901 3.75736 4.75736C6.02157 2.49315 9.64519 2.41687 12.001 4.52853Z"/>',
|
||||
|
||||
'close' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" d="M6 6L18 18M18 6L6 18"/>',
|
||||
|
||||
'arrow-left' => '<path fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" d="M19 12H5M5 12L11 6M5 12L11 18"/>',
|
||||
|
||||
@@ -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 <x-ui.color-swatch> for options that aren't a color (no hex),
|
||||
where a swatch dot has nothing meaningful to show. --}}
|
||||
@props(['values', 'option' => null, 'optionHandle' => null])
|
||||
|
||||
<div {{ $attributes }}>
|
||||
@if($option)
|
||||
<p class="font-bold mb-3 text-sm uppercase tracking-wide">
|
||||
{{ $option }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div
|
||||
class="flex flex-wrap gap-2"
|
||||
role="group"
|
||||
aria-label="{{ $option ?? 'Option' }}"
|
||||
>
|
||||
@foreach($values as $value)
|
||||
<x-ui.button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
data-product-form-target="swatch"
|
||||
data-action="click->product-form#selectVariant"
|
||||
data-option="{{ $optionHandle }}"
|
||||
data-value-id="{{ $value['id'] }}"
|
||||
aria-label="{{ $value['label'] }}"
|
||||
aria-pressed="false"
|
||||
>{{ $value['label'] }}</x-ui.button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +1,13 @@
|
||||
@props([
|
||||
'name' => '',
|
||||
'price' => null,
|
||||
'image' => null,
|
||||
'href' => '#',
|
||||
'name' => '',
|
||||
'price' => null,
|
||||
'image' => null,
|
||||
'href' => '#',
|
||||
'variantId' => null,
|
||||
'hasCustomFields' => 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 +32,35 @@ class="w-full h-auto block"
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<x-ui.button size="md" position="absolute" class="opacity-0 group-hover:opacity-100 transition-opacity duration-100">
|
||||
{{ __('storefront.product.add_to_cart') }}
|
||||
</x-ui.button>
|
||||
@if ($wishlist && $productId)
|
||||
<x-wishlist-button :product-id="$productId" class="absolute top-4 right-4 z-10" />
|
||||
@endif
|
||||
|
||||
@if ($hasCustomFields)
|
||||
{{-- Custom fields have to be filled in on the product page. --}}
|
||||
<x-ui.button
|
||||
:href="$href"
|
||||
size="md"
|
||||
position="absolute"
|
||||
data-turbo-frame="_top"
|
||||
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity duration-100"
|
||||
>
|
||||
{{ __('storefront.product.personalize') }}
|
||||
</x-ui.button>
|
||||
@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. --}}
|
||||
<x-checkout::add-to-cart
|
||||
:purchasable="$variantId"
|
||||
class="absolute opacity-0 group-hover:opacity-100 has-[.bbk-add-to-cart-error:not([hidden])]:opacity-100 transition-opacity duration-100"
|
||||
>
|
||||
<x-ui.button type="submit" size="md">
|
||||
{{ __('storefront.product.add_to_cart') }}
|
||||
</x-ui.button>
|
||||
</x-checkout::add-to-cart>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-baseline justify-between gap-4">
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
@props([
|
||||
'options' => [],
|
||||
'value' => null,
|
||||
'name' => null,
|
||||
'ariaLabel' => null,
|
||||
'options' => [],
|
||||
'value' => null,
|
||||
'name' => null,
|
||||
'ariaLabel' => null,
|
||||
'placeholder' => null,
|
||||
'block' => false,
|
||||
])
|
||||
|
||||
|
||||
<div class="relative inline-flex items-center">
|
||||
<div @class(['relative items-center', 'flex w-full' => $block, 'inline-flex' => ! $block])>
|
||||
<select
|
||||
@if($name) name="{{ $name }}" @endif
|
||||
@if($ariaLabel) aria-label="{{ $ariaLabel }}" @endif
|
||||
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none']) }}
|
||||
{{ $attributes->merge(['class' => 'appearance-none bg-transparent border-b border-black pr-10 py-2.5 cursor-pointer focus:outline-none'.($block ? ' w-full' : '')]) }}
|
||||
>
|
||||
@if($placeholder !== null)
|
||||
<option value="" @selected($value === null || $value === '')>{{ $placeholder }}</option>
|
||||
@endif
|
||||
@foreach($options as $option)
|
||||
<option value="{{ $option['value'] }}" @selected($value === $option['value'])>{{ $option['label'] }}</option>
|
||||
@endforeach
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{{--
|
||||
Flash confirmation box ("Changes saved." etc.). Renders nothing when there's
|
||||
no message; defaults to session('status'). Spacing comes from the caller.
|
||||
--}}
|
||||
@props([
|
||||
'message' => session('status'),
|
||||
])
|
||||
|
||||
@if (filled($message))
|
||||
<p role="status" aria-live="polite" {{ $attributes->merge(['class' => 'border border-black bg-brand p-6 text-black']) }}>{{ $message }}</p>
|
||||
@endif
|
||||