Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Http\Controllers\CustomFieldUploadController;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Lunar\Models\CartLine;
|
||||
use Lunar\Models\OrderLine;
|
||||
|
||||
/**
|
||||
* Custom-field photos are uploaded the moment a shopper picks them (see
|
||||
* CustomFieldUploadController), before add-to-cart — so a shopper who picks a
|
||||
* photo and then leaves, or picks a different one, leaves a file nobody
|
||||
* references. This deletes those: any upload older than the grace period that
|
||||
* no cart line or order line's meta.custom_fields points to.
|
||||
*
|
||||
* The grace period covers a shopper still on the product page with a picked
|
||||
* but not-yet-added photo. A file referenced by a cart line is kept for as
|
||||
* long as that cart line exists; once the line (or its cart) is removed, the
|
||||
* next run deletes the file. Order line references are kept indefinitely.
|
||||
*/
|
||||
class PruneCustomFieldUploads extends Command
|
||||
{
|
||||
protected $signature = 'custom-fields:prune-uploads {--hours=24 : Only delete unreferenced uploads older than this}';
|
||||
|
||||
protected $description = 'Delete custom-field photo uploads not referenced by any cart or order line';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$disk = Storage::disk(CustomFieldUploadController::DISK);
|
||||
$referenced = $this->referencedPaths();
|
||||
$cutoff = now()->subHours((int) $this->option('hours'))->getTimestamp();
|
||||
$deleted = 0;
|
||||
|
||||
foreach ($disk->files(CustomFieldUploadController::DIRECTORY) as $path) {
|
||||
if (isset($referenced[$path]) || $disk->lastModified($path) > $cutoff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk->delete($path);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$this->info("Deleted {$deleted} unreferenced custom-field upload(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private function referencedPaths(): array
|
||||
{
|
||||
$paths = [];
|
||||
|
||||
foreach ([CartLine::class, OrderLine::class] as $model) {
|
||||
$model::query()
|
||||
->where('meta', 'like', '%custom_fields%')
|
||||
->select('meta')
|
||||
->cursor()
|
||||
->each(function ($line) use (&$paths) {
|
||||
foreach ($line->meta['custom_fields'] ?? [] as $field) {
|
||||
if (! empty($field['path'])) {
|
||||
$paths[$field['path']] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?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');
|
||||
|
||||
$customer = $this->account->updateProfile($user, [
|
||||
'first_name' => $data['first_name'] ?? null,
|
||||
'last_name' => $data['last_name'] ?? null,
|
||||
'company_name' => $invoice ? $data['company_name'] : null,
|
||||
]);
|
||||
|
||||
// Written directly: core's updateProfile() allowlists `vat_no`, but
|
||||
// Lunar's column is `tax_identifier`, so it can't go through there yet.
|
||||
$customer->update(['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($customer, $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 in the same meta shape the checkout writes (see
|
||||
* CheckoutController::rememberRecoveryConsent()), and applied to the
|
||||
* current cart too, so opting out stops reminders for it right away.
|
||||
*/
|
||||
private function updateRecoveryConsent($customer, bool $consent): void
|
||||
{
|
||||
if ((bool) data_get($customer, 'meta.recovery_consent') !== $consent) {
|
||||
$customer->meta = [
|
||||
...($customer->meta?->toArray() ?? []),
|
||||
'recovery_consent' => $consent,
|
||||
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
|
||||
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
|
||||
];
|
||||
$customer->save();
|
||||
}
|
||||
|
||||
// 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,169 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\EmailChangeCodeMail;
|
||||
use App\Mail\EmailChangedNoticeMail;
|
||||
use App\Services\GuestOrderClaimer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Changing the login email: new email → 6-digit code sent to that address →
|
||||
* switched. The email is the login, so it only changes once the shopper has
|
||||
* proved they can receive mail there; a typo can never lock them out.
|
||||
*
|
||||
* Once switched, the OLD address gets a notice (EmailChangedNoticeMail).
|
||||
*
|
||||
* Core has no email-change flow, so the pending change lives in the session
|
||||
* (the new address, a hash of the code, expiry and wrong-guess count). Limits
|
||||
* mirror core's login OTP: 3 codes per 10 minutes, 5 guesses per code.
|
||||
*/
|
||||
class EmailController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'email_change';
|
||||
private const EXPIRY_MINUTES = 10;
|
||||
private const MAX_ATTEMPTS = 5;
|
||||
private const SEND_LIMIT = 3;
|
||||
private const SEND_DECAY_SECONDS = 600;
|
||||
|
||||
public function edit(string $locale, Request $request): View
|
||||
{
|
||||
return view('account.email', ['user' => $request->user()]);
|
||||
}
|
||||
|
||||
public function send(string $locale, Request $request): 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]),
|
||||
Rule::unique($user->getTable(), 'email')->ignore($user->id),
|
||||
],
|
||||
], [
|
||||
'email.not_in' => __('storefront.account.email_same'),
|
||||
'email.unique' => __('storefront.account.email_taken'),
|
||||
]);
|
||||
|
||||
if (! $this->sendCode($request, $validated['email'])) {
|
||||
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
|
||||
}
|
||||
|
||||
return redirect()->route('account.email.code');
|
||||
}
|
||||
|
||||
public function code(string $locale, Request $request): View|RedirectResponse
|
||||
{
|
||||
$pending = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pending) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
return view('account.email-code', ['email' => $pending['email']]);
|
||||
}
|
||||
|
||||
public function resend(string $locale, Request $request): RedirectResponse
|
||||
{
|
||||
$pending = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pending) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
if (! $this->sendCode($request, $pending['email'])) {
|
||||
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
|
||||
}
|
||||
|
||||
return back()->with('status', __('storefront.auth.code_resent'));
|
||||
}
|
||||
|
||||
public function verify(string $locale, Request $request, GuestOrderClaimer $orders): RedirectResponse
|
||||
{
|
||||
$pending = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pending) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
||||
|
||||
$valid = $pending['code_hash'] !== null
|
||||
&& now()->timestamp < $pending['expires_at']
|
||||
&& Hash::check($validated['code'], $pending['code_hash']);
|
||||
|
||||
if (! $valid) {
|
||||
// Too many wrong guesses burns the code; only "resend" helps then.
|
||||
$pending['attempts']++;
|
||||
|
||||
if ($pending['attempts'] >= self::MAX_ATTEMPTS) {
|
||||
$pending['code_hash'] = null;
|
||||
}
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, $pending);
|
||||
|
||||
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Someone may have signed up with this address since the code was sent.
|
||||
if ($user->newQuery()->where('email', $pending['email'])->whereKeyNot($user->id)->exists()) {
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return redirect()->route('account.email.edit')
|
||||
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||
}
|
||||
|
||||
$oldEmail = $user->email;
|
||||
|
||||
$user->forceFill(['email' => $pending['email'], 'email_verified_at' => now()])->save();
|
||||
|
||||
// Lets the owner notice if someone else changed it from a hijacked session.
|
||||
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($pending['email']));
|
||||
|
||||
// The code just proved they own the new address too.
|
||||
$orders->claim($user);
|
||||
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
|
||||
}
|
||||
|
||||
private function sendCode(Request $request, string $email): bool
|
||||
{
|
||||
$limiterKey = 'email-change:'.$request->user()->id;
|
||||
|
||||
if (RateLimiter::tooManyAttempts($limiterKey, self::SEND_LIMIT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
RateLimiter::hit($limiterKey, self::SEND_DECAY_SECONDS);
|
||||
|
||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, [
|
||||
'email' => $email,
|
||||
'code_hash' => Hash::make($code),
|
||||
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
|
||||
'attempts' => 0,
|
||||
]);
|
||||
|
||||
Mail::to($email)->send(new EmailChangeCodeMail($code));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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,233 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
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 Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
|
||||
/**
|
||||
* 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 opaque reference returned by the host's upload
|
||||
* endpoint: an encrypted JSON {disk, path, name, mime}. The module doesn't
|
||||
* decide which files are acceptable (that's the host's upload endpoint) —
|
||||
* it only checks the reference is genuine and the file still exists.
|
||||
*
|
||||
* 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');
|
||||
|
||||
if ($fields->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$validated = Validator::make(
|
||||
$input,
|
||||
$fields->map(fn (array $field) => [
|
||||
($field['required'] ?? false) ? 'required' : 'nullable',
|
||||
'string',
|
||||
match ($field['type']) {
|
||||
'textarea' => 'max:2000',
|
||||
'file' => function (string $attribute, mixed $value, Closure $fail) {
|
||||
if ($this->decodeUpload($value) === null) {
|
||||
$fail('validation.uploaded')->translate();
|
||||
}
|
||||
},
|
||||
default => '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'
|
||||
? $this->fileAnswer($this->decodeUpload($validated[$field['key']]))
|
||||
: ['value' => $validated[$field['key']]]),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $answers === [] ? [] : ['custom_fields' => $answers];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{disk: string, path: string, name: string, mime: ?string}|null
|
||||
*/
|
||||
private function decodeUpload(string $reference): ?array
|
||||
{
|
||||
try {
|
||||
$upload = json_decode(Crypt::decryptString($reference), true);
|
||||
} catch (DecryptException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_array($upload) || ! isset($upload['disk'], $upload['path'], $upload['name'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Storage::disk($upload['disk'])->exists($upload['path']) ? $upload : null;
|
||||
}
|
||||
|
||||
private function fileAnswer(array $upload): array
|
||||
{
|
||||
return [
|
||||
'value' => $upload['name'],
|
||||
'disk' => $upload['disk'],
|
||||
'path' => $upload['path'],
|
||||
'mime' => $upload['mime'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
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,699 @@
|
||||
<?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->rememberRecoveryConsent($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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shopper's latest reminder choice, kept on their customer record
|
||||
* (meta, same shape CheckoutService::setRecoveryConsent() writes on the
|
||||
* cart) so their next checkout starts from it. The storefront's account
|
||||
* page reads/writes the same keys. Candidate for a boboko-core method.
|
||||
*/
|
||||
private function rememberRecoveryConsent(bool $consent): void
|
||||
{
|
||||
$customer = $this->account->customer(Auth::user());
|
||||
|
||||
if (! $customer || (bool) data_get($customer, 'meta.recovery_consent') === $consent) {
|
||||
return;
|
||||
}
|
||||
|
||||
$customer->meta = [
|
||||
...($customer->meta?->toArray() ?? []),
|
||||
'recovery_consent' => $consent,
|
||||
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
|
||||
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
|
||||
];
|
||||
$customer->save();
|
||||
}
|
||||
|
||||
private function storeCountry(): ?Country
|
||||
{
|
||||
if (self::STORE_COUNTRY_ISO3 === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Serves a file answer to a product custom field (a cart/order line's
|
||||
* meta.custom_fields, see CartController::customFieldsMeta()) from its private
|
||||
* disk. Only reachable through a temporary signed URL — generated per render
|
||||
* by checkout::partials.line-custom-fields — so disk and path in the query
|
||||
* can't be tampered with, and a link stops working once it expires.
|
||||
*/
|
||||
class CustomFieldFileController extends Controller
|
||||
{
|
||||
public function __invoke(string $locale, Request $request): StreamedResponse
|
||||
{
|
||||
$disk = Storage::disk((string) $request->query('disk'));
|
||||
$path = (string) $request->query('path');
|
||||
|
||||
abort_unless($disk->exists($path), 404);
|
||||
|
||||
return $disk->response($path, null, ['Cache-Control' => 'private, max-age=3600']);
|
||||
}
|
||||
}
|
||||
@@ -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,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* 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 is a per-site decision (this site: photographs),
|
||||
* so it lives here in the storefront, not in the checkout module. The module's
|
||||
* add-to-cart endpoint only ever receives the opaque `reference` returned
|
||||
* below — an encrypted {disk, path, name, mime} payload, so a shopper can
|
||||
* neither forge a reference to some other private file nor read the path —
|
||||
* and copies it onto the cart line's meta (see Checkout\CartController::
|
||||
* customFieldsMeta()).
|
||||
*
|
||||
* Stored on the private `local` disk: these are customers' personal photos,
|
||||
* never reachable by a public URL. Uploads nobody added to a cart are removed
|
||||
* by the custom-fields:prune-uploads command (see PruneCustomFieldUploads).
|
||||
*/
|
||||
class CustomFieldUploadController extends Controller
|
||||
{
|
||||
public const DISK = 'local';
|
||||
|
||||
public const DIRECTORY = 'custom-field-uploads';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public function store(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
// `label` is the admin-authored field label, only used as the
|
||||
// :attribute in the validation message shown next to that field.
|
||||
$validator = Validator::make(
|
||||
$request->all(),
|
||||
['file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES]],
|
||||
[],
|
||||
['file' => (string) $request->input('label', 'file')],
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['error' => $validator->errors()->first('file')], 422);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$path = $file->store(self::DIRECTORY, self::DISK);
|
||||
|
||||
abort_if($path === false, 500);
|
||||
|
||||
return response()->json([
|
||||
'reference' => Crypt::encryptString(json_encode([
|
||||
'disk' => self::DISK,
|
||||
'path' => $path,
|
||||
'name' => $file->getClientOriginalName(),
|
||||
'mime' => $file->getMimeType(),
|
||||
])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
[$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,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Services\GuestOrderClaimer;
|
||||
use Modules\Core\Auth\Events\UserAuthenticated;
|
||||
|
||||
/**
|
||||
* Picked up by Laravel's listener discovery (app/Listeners), no manual
|
||||
* registration. UserAuthenticated only fires after a valid login code.
|
||||
*/
|
||||
class ClaimGuestOrdersOnLogin
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GuestOrderClaimer $claimer,
|
||||
) {}
|
||||
|
||||
public function handle(UserAuthenticated $event): void
|
||||
{
|
||||
$this->claimer->claim($event->user);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* The code that confirms a new login email — see Account\EmailController.
|
||||
*/
|
||||
class EmailChangeCodeMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $code,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: 'Επιβεβαίωσε το νέο σου email');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.email-change-code');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Sent to the OLD address once the login email has changed (see
|
||||
* Account\EmailController), so the owner notices a takeover. The new address
|
||||
* is shown masked, e.g. "n•••@example.com".
|
||||
*/
|
||||
class EmailChangedNoticeMail extends Mailable
|
||||
{
|
||||
public readonly string $maskedEmail;
|
||||
|
||||
public function __construct(string $newEmail)
|
||||
{
|
||||
[$local, $domain] = explode('@', $newEmail, 2);
|
||||
|
||||
$this->maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain;
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: 'Το email του λογαριασμού σου άλλαξε');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.email-changed-notice');
|
||||
}
|
||||
}
|
||||
@@ -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,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Attaches placed guest orders to an account when their billing email matches
|
||||
* the account's email. Only ever called right after the shopper has proved
|
||||
* they own that email (a login code, or the code confirming an email change),
|
||||
* which is what makes matching on email safe.
|
||||
*
|
||||
* Orders already belonging to any customer or user are never touched.
|
||||
*/
|
||||
class GuestOrderClaimer
|
||||
{
|
||||
public function claim(Authenticatable $user): int
|
||||
{
|
||||
$customer = $user->latestCustomer();
|
||||
|
||||
if (! $customer || ! $user->email) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Order::query()
|
||||
->whereNotNull('placed_at')
|
||||
->whereNull('customer_id')
|
||||
->whereNull('user_id')
|
||||
->whereHas('billingAddress', fn ($query) => $query
|
||||
->whereRaw('lower(contact_email) = ?', [strtolower($user->email)]))
|
||||
->update([
|
||||
'customer_id' => $customer->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 opaque reference 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 reference
|
||||
// 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?.reference) {
|
||||
this.fail(data?.error)
|
||||
return
|
||||
}
|
||||
|
||||
this.referenceTarget.value = data.reference
|
||||
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,131 @@
|
||||
{{--
|
||||
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
|
||||
(App\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,42 @@
|
||||
{{--
|
||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
||||
|
||||
A cart or order line's custom-field answers (meta.custom_fields, written by
|
||||
CartController::customFieldsMeta()) — label/value pairs. A file answer links
|
||||
to the file through a temporary signed URL (CustomFieldFileController),
|
||||
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
|
||||
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
|
||||
'checkout.custom-field-file',
|
||||
now()->addHours(2),
|
||||
['locale' => app()->getLocale(), 'disk' => $field['disk'], 'path' => $field['path']],
|
||||
);
|
||||
@endphp
|
||||
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
|
||||
@if (in_array($field['mime'] ?? null, $previewable, true))
|
||||
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
|
||||
@endif
|
||||
<span>{{ $field['value'] }}</span>
|
||||
</a>
|
||||
@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,76 @@
|
||||
{{--
|
||||
<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 returned reference 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>
|
||||
|
||||