Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b522d60954 | ||
|
|
60ae6cf7a6 | ||
|
|
296d6001cd | ||
|
|
aa051525a1 | ||
|
|
b288513bb4 | ||
|
|
74930351f1 | ||
|
|
65d4603793 | ||
|
|
023c11019c | ||
|
|
88d8ae0954 | ||
|
|
ca362f81c8 | ||
|
|
4634e14924 | ||
|
|
69486f65ee | ||
|
|
060b6c647e | ||
|
|
36806cac5b | ||
|
|
b1162761de | ||
|
|
8b362c9436 | ||
|
|
cd45d8f578 | ||
|
|
8e186fc231 | ||
|
|
a51ce78538 | ||
|
|
ab7e560a3d | ||
|
|
081893ef57 | ||
|
|
e2d7bbb043 | ||
|
|
3347febb3c | ||
|
|
d43b615297 | ||
|
|
7f6c1e6307 | ||
|
|
5bbf0aabcd | ||
|
|
fa172860db | ||
|
|
a51e9456d6 | ||
|
|
540da1af24 | ||
|
|
e28faea546 | ||
|
|
8055c003ac | ||
|
|
94803bfb67 | ||
|
|
cf3681260b | ||
|
|
580adac33a | ||
|
|
a4240b6474 | ||
|
|
326e1fac54 | ||
|
|
e3a6267059 | ||
|
|
24851184c4 | ||
|
|
a107184010 | ||
|
|
af0245de43 | ||
|
|
afa1993c53 | ||
|
|
c7bd1efdaa |
@@ -31,7 +31,7 @@ DB_DATABASE=boboko
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=dev
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
@@ -58,8 +58,12 @@ MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
# Fallback only: the sender name comes from Store Details' "Mail from name" when set.
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
HCAPTCHA_SITEKEY=
|
||||
HCAPTCHA_SECRET=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
@@ -84,6 +88,9 @@ SCOUT_QUEUE=true
|
||||
MEILISEARCH_KEY=dev-meilisearch-key-change-me
|
||||
MEILISEARCH_HOST=http://meilisearch:7700
|
||||
|
||||
# Lunar sets all prices inclusive of tax with these values
|
||||
LUNAR_STORE_INCLUSIVE_OF_TAX=true
|
||||
|
||||
# Host-side ports — change these if another project on this machine already uses the defaults
|
||||
APP_PORT=8090
|
||||
STOIC_PORT=2727
|
||||
@@ -92,3 +99,17 @@ POSTGRES_HOST_PORT=5436
|
||||
MAILPIT_PORT=8025
|
||||
VITE_PORT=5173
|
||||
MEILISEARCH_PORT=7700
|
||||
|
||||
STRIPE_SECRET=secret
|
||||
STRIPE_PUBLIC_KEY=public_key
|
||||
STRIPE_WEBHOOK_SECRET=webhook_secret
|
||||
|
||||
BOXNOW_BASE_URL=https://api-stage.boxnow.gr/api/v1
|
||||
BOXNOW_LOCATION_API_URL=https://locationapi-stage.boxnow.gr/api/v1
|
||||
BOXNOW_CLIENT_ID=box-client-id
|
||||
BOXNOW_CLIENT_SECRET=box-client-secret
|
||||
BOXNOW_PARTNER_ID=box-partner-id
|
||||
BOXNOW_ORIGIN_LOCATION_ID=box-location-id
|
||||
BOXNOW_SENDER_NAME=test
|
||||
BOXNOW_SENDER_EMAIL=test@test.com
|
||||
BOXNOW_SENDER_PHONE=+306912345678
|
||||
|
||||
@@ -10,11 +10,19 @@ RUN composer install \
|
||||
--optimize-autoloader \
|
||||
--ignore-platform-reqs
|
||||
|
||||
# ── Stage 2a: Vite dev (node_modules only, no build) ─────────────────────────
|
||||
# ── Stage 2a: Vite dev (node_modules only, no build) ──────────────────────────
|
||||
FROM node:22-alpine AS vite-dev
|
||||
# git: needed for npm to install @boboko/core's git+https tagged-VCS form (see
|
||||
# package.json's _dependencies and docker/entrypoint-vite.sh). No build-time
|
||||
# `npm install` here — package.json's boboko/core path-repo form (../boboko-core)
|
||||
# isn't visible in the build context, only once bind-mounted at container start,
|
||||
# and entrypoint-vite.sh already runs npm install on every boot, so this would be
|
||||
# redundant even if it could work.
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
|
||||
RUN chmod +x /entrypoint-vite.sh
|
||||
ENTRYPOINT ["/entrypoint-vite.sh"]
|
||||
|
||||
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
||||
FROM node:22-alpine AS node-build
|
||||
@@ -94,7 +102,8 @@ RUN php artisan package:discover --ansi \
|
||||
&& php artisan filament:assets --ansi
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
|
||||
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
|
||||
|
||||
EXPOSE 9000
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
pull
|
||||
docker_build
|
||||
docker_up
|
||||
migrate
|
||||
artisan_optimize
|
||||
restart_workers
|
||||
stoic_generate_thumbs
|
||||
@@ -45,12 +44,6 @@
|
||||
{{ $compose }} up -d --remove-orphans
|
||||
@endtask
|
||||
|
||||
@task('migrate', ['on' => 'web'])
|
||||
echo ">>> Running migrations..."
|
||||
cd {{ $path }}
|
||||
{{ $compose }} exec -T app php artisan migrate --force < /dev/null
|
||||
@endtask
|
||||
|
||||
@task('artisan_optimize', ['on' => 'web'])
|
||||
echo ">>> Optimizing..."
|
||||
cd {{ $path }}
|
||||
@@ -61,9 +54,10 @@
|
||||
@endtask
|
||||
|
||||
@task('restart_workers', ['on' => 'web'])
|
||||
echo ">>> Restarting queue workers..."
|
||||
echo ">>> Restarting queue workers and scheduler..."
|
||||
cd {{ $path }}
|
||||
{{ $compose }} restart queue
|
||||
{{ $compose }} restart scheduler
|
||||
@endtask
|
||||
|
||||
@task('password_protect', ['on' => 'web'])
|
||||
|
||||
@@ -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, soldOut: bool}
|
||||
*/
|
||||
public static function fromIndexed(array $product): array
|
||||
{
|
||||
@@ -28,6 +28,20 @@ public static function fromIndexed(array $product): array
|
||||
'price' => $product['price'],
|
||||
'image' => $product['media'][0]['url'] ?? null,
|
||||
'href' => route('product.show', ['id' => $product['id']]),
|
||||
// The card's quick "Add to cart" always adds this variant, same
|
||||
// default Modules\Core\Catalog\Services\ProductService::
|
||||
// variantSummaries() and product/show.blade.php both use — no
|
||||
// picker at listing-grid scope, unlike the product page's own
|
||||
// color swatches.
|
||||
'variantId' => $product['variants'][0]['id'] ?? null,
|
||||
// A product with custom fields (photo upload, engraving text…)
|
||||
// can't be quick-added from a card — the card links to the
|
||||
// product page instead, even when every field is optional.
|
||||
'hasCustomFields' => ! empty($product['custom_fields']),
|
||||
// Index-time stock (see boboko-core's ProductIndexer `in_stock`),
|
||||
// so only as fresh as the last reindex. A document missing the
|
||||
// field is treated as in stock rather than hiding its cart button.
|
||||
'soldOut' => ! ($product['in_stock'] ?? true),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
*/
|
||||
final class ProductListingPage
|
||||
{
|
||||
private const PER_PAGE = 12;
|
||||
private const PER_PAGE = 40;
|
||||
|
||||
/** Category pages rarely have enough products to fill a page, so this
|
||||
* ceiling is high enough to act as "no pagination" in practice — the
|
||||
* pagination component self-hides via hasPages() when everything fits.
|
||||
* If a category ever exceeds it, pagination reappears as a safety net
|
||||
* rather than silently truncating results. */
|
||||
private const CATEGORY_PER_PAGE = 200;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProductService $products,
|
||||
@@ -33,6 +40,7 @@ public function __construct(
|
||||
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
|
||||
{
|
||||
$filters = $listing->filters($collectionId);
|
||||
$perPage = $collectionId !== null ? self::CATEGORY_PER_PAGE : self::PER_PAGE;
|
||||
|
||||
// Listing reads from the Meilisearch index via ProductService/
|
||||
// ProductSearchService, not Eloquent. Both return a
|
||||
@@ -45,12 +53,12 @@ public function build(ProductListing $listing, Closure $url, ?int $collectionId
|
||||
query: $query,
|
||||
filters: $filters,
|
||||
sort: $listing->sort,
|
||||
perPage: self::PER_PAGE,
|
||||
perPage: $perPage,
|
||||
page: $listing->page,
|
||||
)
|
||||
: $this->products->list(
|
||||
filters: $filters,
|
||||
perPage: self::PER_PAGE,
|
||||
perPage: $perPage,
|
||||
page: $listing->page,
|
||||
sort: $listing->sort,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Lunar\Models\Country;
|
||||
use Lunar\Models\State;
|
||||
use Modules\Core\Cart\Services\CartService;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
use Modules\Core\Privacy\Services\PrivacyService;
|
||||
|
||||
/**
|
||||
* The profile page: name, invoice details, the one address, and account
|
||||
* deletion. The customer keeps a single address, flagged as both the
|
||||
* shipping and billing default, so allowing several later is a UI change
|
||||
* only (the data is already Lunar's own addresses table).
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
// Single-country store, same as CheckoutController::STORE_COUNTRY_ISO3.
|
||||
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||
|
||||
private const ADDRESS_FIELDS = ['line_one', 'city', 'postcode', 'state', 'contact_phone'];
|
||||
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function show(string $locale, Request $request): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$customer = $this->account->customer($user);
|
||||
|
||||
return view('account.show', [
|
||||
'user' => $user,
|
||||
'customer' => $customer,
|
||||
'address' => $this->defaultAddress($user),
|
||||
'regions' => State::where('country_id', $this->storeCountry()->id)->orderBy('name')->get(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(string $locale, Request $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$country = $this->storeCountry();
|
||||
|
||||
// The address is all-or-nothing: typing any part of it makes the rest
|
||||
// (and the name, which Lunar requires on every address) required.
|
||||
$anyAddressField = implode(',', self::ADDRESS_FIELDS);
|
||||
|
||||
$data = $request->validate([
|
||||
'first_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'last_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'invoice' => ['boolean'],
|
||||
'company_name' => ['nullable', 'string', 'max:255', 'required_if_accepted:invoice'],
|
||||
'tax_identifier' => ['nullable', 'digits:9', 'required_if_accepted:invoice'],
|
||||
'line_one' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'city' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||
'postcode' => ['nullable', 'regex:/^\d{3}\s?\d{2}$/', 'required_with:'.$anyAddressField],
|
||||
'state' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'required_with:'.$anyAddressField,
|
||||
Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id),
|
||||
],
|
||||
'contact_phone' => ['nullable', 'string', 'max:30'],
|
||||
'recovery_consent' => ['boolean'],
|
||||
]);
|
||||
|
||||
$invoice = $request->boolean('invoice');
|
||||
|
||||
$this->account->updateProfile($user, [
|
||||
'first_name' => $data['first_name'] ?? null,
|
||||
'last_name' => $data['last_name'] ?? null,
|
||||
'company_name' => $invoice ? $data['company_name'] : null,
|
||||
'tax_identifier' => $invoice ? $data['tax_identifier'] : null,
|
||||
]);
|
||||
|
||||
if (filled($data['line_one'] ?? null)) {
|
||||
$addressData = [
|
||||
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'country_id' => $country->id,
|
||||
'contact_email' => $user->email,
|
||||
'shipping_default' => true,
|
||||
'billing_default' => true,
|
||||
];
|
||||
|
||||
$existing = $this->defaultAddress($user);
|
||||
|
||||
$existing
|
||||
? $this->account->updateAddress($user, $existing->id, $addressData)
|
||||
: $this->account->createAddress($user, $addressData);
|
||||
}
|
||||
|
||||
$this->updateRecoveryConsent($user, $request->boolean('recovery_consent'));
|
||||
|
||||
return redirect()->route('account')->with('status', __('storefront.account.saved'));
|
||||
}
|
||||
|
||||
/**
|
||||
* "Email me a reminder if I don't finish my order", as a standing
|
||||
* choice — stored on the customer via boboko-core's
|
||||
* CustomerAccountService::setRecoveryConsent(), and applied to the
|
||||
* current cart too, so opting out stops reminders for it right away.
|
||||
*/
|
||||
private function updateRecoveryConsent($user, bool $consent): void
|
||||
{
|
||||
$this->account->setRecoveryConsent($user, $consent);
|
||||
|
||||
// Only an existing cart; never create one just to record this.
|
||||
$cart = app(CartService::class)->current();
|
||||
|
||||
if ($cart && (bool) data_get($cart, 'meta.recovery_consent') !== $consent) {
|
||||
app(CheckoutService::class)->setRecoveryConsent($consent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-service deletion: opens core's 30-day grace-period erasure request
|
||||
* (which blocks the login right away) and logs out. Logging back in within
|
||||
* the grace period cancels it; see core's docs/privacy.md.
|
||||
*/
|
||||
public function destroy(string $locale, Request $request, PrivacyService $privacy): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$privacy->requestErasureForUser($user, $user);
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->with('status', __('storefront.account.deletion_requested'));
|
||||
}
|
||||
|
||||
private function defaultAddress($user)
|
||||
{
|
||||
$addresses = collect($this->account->addresses($user));
|
||||
|
||||
return $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||
}
|
||||
|
||||
private function storeCountry(): Country
|
||||
{
|
||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
|
||||
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
|
||||
use Modules\Core\Customer\Services\CustomerEmailChangeService;
|
||||
|
||||
/**
|
||||
* Changing the login email — a thin wrapper over boboko-core's
|
||||
* Customer\Services\CustomerEmailChangeService, which owns the actual
|
||||
* request/confirm mechanics, throttling, pending-change storage, and
|
||||
* mailables. This controller's own job is just the storefront's session-
|
||||
* scoped "which email did I just ask to switch to" UI state (so the
|
||||
* .code/.resend pages know which address to show/resend to) and
|
||||
* translating the service's exceptions into the flash-message flow the
|
||||
* views expect.
|
||||
*/
|
||||
class EmailController extends Controller
|
||||
{
|
||||
private const SESSION_KEY = 'pending_email_change';
|
||||
|
||||
public function edit(string $locale, Request $request): View
|
||||
{
|
||||
return view('account.email', ['user' => $request->user()]);
|
||||
}
|
||||
|
||||
public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$request->merge(['email' => Str::lower(trim((string) $request->input('email')))]);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
Rule::notIn([$user->email]),
|
||||
],
|
||||
], [
|
||||
'email.not_in' => __('storefront.account.email_same'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$emailChange->request($user, $validated['email']);
|
||||
} catch (EmailAlreadyTakenException) {
|
||||
return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||
} catch (OtpThrottledException) {
|
||||
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
|
||||
}
|
||||
|
||||
$request->session()->put(self::SESSION_KEY, $validated['email']);
|
||||
|
||||
return redirect()->route('account.email.code');
|
||||
}
|
||||
|
||||
public function code(string $locale, Request $request): View|RedirectResponse
|
||||
{
|
||||
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pendingEmail) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
return view('account.email-code', ['email' => $pendingEmail]);
|
||||
}
|
||||
|
||||
public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||
{
|
||||
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pendingEmail) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
try {
|
||||
$emailChange->request($request->user(), $pendingEmail);
|
||||
} catch (EmailAlreadyTakenException) {
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return redirect()->route('account.email.edit')
|
||||
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||
} catch (OtpThrottledException) {
|
||||
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
|
||||
}
|
||||
|
||||
return back()->with('status', __('storefront.auth.code_resent'));
|
||||
}
|
||||
|
||||
public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||
{
|
||||
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||
|
||||
if (! $pendingEmail) {
|
||||
return redirect()->route('account.email.edit');
|
||||
}
|
||||
|
||||
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
||||
|
||||
try {
|
||||
$emailChange->confirm($request->user(), $validated['code']);
|
||||
} catch (EmailAlreadyTakenException) {
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return redirect()->route('account.email.edit')
|
||||
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||
} catch (InvalidEmailChangeCodeException) {
|
||||
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
|
||||
}
|
||||
|
||||
$request->session()->forget(self::SESSION_KEY);
|
||||
|
||||
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Account;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Payment\Models\PaymentMethod;
|
||||
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
|
||||
/**
|
||||
* Order history. Every lookup goes through CustomerAccountService, which only
|
||||
* ever returns the logged-in user's own placed orders, so an order id from
|
||||
* the URL can't reach someone else's order.
|
||||
*/
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function index(string $locale, Request $request): View
|
||||
{
|
||||
return view('account.orders.index', [
|
||||
'orders' => $this->account->orders($request->user(), 15),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(string $locale, Request $request, int $orderId): View
|
||||
{
|
||||
try {
|
||||
$order = $this->account->order($request->user(), $orderId);
|
||||
} catch (OrderNotFoundException) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// By type, like the checkout confirmation: the method may since have
|
||||
// been disabled, but the order still shows what was used.
|
||||
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||
->first()
|
||||
?->translate('name');
|
||||
|
||||
return view('account.orders.show', [
|
||||
'order' => $order,
|
||||
'paymentMethodName' => $paymentMethodName,
|
||||
'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Rules\HCaptcha;
|
||||
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
|
||||
{
|
||||
// Captcha only here: verify() and resend() need the email this step
|
||||
// puts in the session, so they can't be reached without passing it.
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||
], [
|
||||
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||
]);
|
||||
|
||||
$email = Str::lower(trim($validated['email']));
|
||||
|
||||
try {
|
||||
$otp->generateAndSend($email);
|
||||
} catch (OtpThrottledException) {
|
||||
return back()->withInput()->withErrors([
|
||||
'email' => __('storefront.auth.too_many_codes'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->put('login.email', $email);
|
||||
|
||||
return redirect()->route('login.code');
|
||||
}
|
||||
|
||||
public function code(string $locale, Request $request): View|RedirectResponse
|
||||
{
|
||||
$email = $request->session()->get('login.email');
|
||||
|
||||
if (! $email) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
return view('auth.login-code', ['email' => $email]);
|
||||
}
|
||||
|
||||
public function resend(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||
{
|
||||
$email = $request->session()->get('login.email');
|
||||
|
||||
if (! $email) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
try {
|
||||
$otp->generateAndSend($email);
|
||||
} catch (OtpThrottledException) {
|
||||
return back()->withErrors([
|
||||
'code' => __('storefront.auth.too_many_codes'),
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('status', __('storefront.auth.code_resent'));
|
||||
}
|
||||
|
||||
public function verify(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||
{
|
||||
$email = $request->session()->get('login.email');
|
||||
|
||||
if (! $email) {
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'code' => ['required', 'digits:6'],
|
||||
]);
|
||||
|
||||
// Wrong, expired, or locked out after too many guesses — core doesn't
|
||||
// say which, so neither do we; the page offers "resend code" for all three.
|
||||
if (! $otp->validate($email, $validated['code'], $request)) {
|
||||
return back()->withErrors([
|
||||
'code' => __('storefront.auth.invalid_code'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->forget('login.email');
|
||||
|
||||
return redirect()->intended(route('home'));
|
||||
}
|
||||
|
||||
public function destroy(string $locale, Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('home');
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Checkout;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
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,
|
||||
) {}
|
||||
|
||||
public function add(string $locale, Request $request): View
|
||||
{
|
||||
$data = $request->validate([
|
||||
'purchasable_id' => ['required', 'integer'],
|
||||
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
||||
|
||||
$this->cart->addLine($variant, $data['quantity'] ?? 1);
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
public function updateLine(string $locale, Request $request, int $line): View
|
||||
{
|
||||
$quantity = (int) $request->validate([
|
||||
'quantity' => ['required', 'integer', 'min:0'],
|
||||
])['quantity'];
|
||||
|
||||
$quantity === 0
|
||||
? $this->cart->removeLine($line)
|
||||
: $this->cart->updateLine($line, $quantity);
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
public function remove(string $locale, int $line): View
|
||||
{
|
||||
$this->cart->removeLine($line);
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
|
||||
/**
|
||||
* A bad code is a normal, expected outcome here (typo, expired code), not
|
||||
* an error state for the request — it re-renders the same cart-body
|
||||
* partial with $couponError set, rather than a 4xx/redirect, so the fetch
|
||||
* + swap in bbk-cart-controller stays the one code path for every cart
|
||||
* mutation.
|
||||
*/
|
||||
public function applyCoupon(string $locale, Request $request): View
|
||||
{
|
||||
$code = $request->validate([
|
||||
'code' => ['required', 'string'],
|
||||
])['code'];
|
||||
|
||||
$couponError = false;
|
||||
|
||||
try {
|
||||
$this->cart->applyCoupon($code);
|
||||
} catch (InvalidCouponException) {
|
||||
$couponError = true;
|
||||
}
|
||||
|
||||
return view('checkout::partials.cart-body', ['couponError' => $couponError]);
|
||||
}
|
||||
|
||||
public function removeCoupon(string $locale): View
|
||||
{
|
||||
$this->cart->removeCoupon();
|
||||
|
||||
return view('checkout::partials.cart-body');
|
||||
}
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
<?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\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\NoShippingAddressException;
|
||||
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||
use Modules\Core\Checkout\Services\CheckoutService;
|
||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Guest-only for now — the Contact section's login tab is UI only.
|
||||
*
|
||||
* 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,
|
||||
) {}
|
||||
|
||||
public function show(string $locale): View
|
||||
{
|
||||
$cart = $this->cart->current();
|
||||
$lines = $cart ? $this->cart->activeLines($cart) : collect();
|
||||
$storeCountry = $this->storeCountry();
|
||||
|
||||
$shippingOptions = collect();
|
||||
|
||||
if ($cart?->shippingAddress) {
|
||||
// Nothing recreates the address row in this path — its own current
|
||||
// value is the correct "previous" to carry forward if still valid.
|
||||
$shippingOptions = $this->syncShipping($cart, $cart->shippingAddress->shipping_option);
|
||||
|
||||
// Cart's CachesProperties::refresh() explicitly nulls total/
|
||||
// subTotal/shippingTotal/etc. back to their defaults — every
|
||||
// Lunar call site pairs it with recalculate() for exactly that
|
||||
// reason. Bare refresh() here was leaving $cart->total null on
|
||||
// reload, which fed a 0 amount straight into the Stripe Element.
|
||||
$cart->refresh()->recalculate();
|
||||
}
|
||||
|
||||
return view('checkout::page', [
|
||||
'cart' => $cart,
|
||||
'lines' => $lines,
|
||||
'billingAddress' => $cart?->billingAddress,
|
||||
'shippingAddress' => $cart?->shippingAddress,
|
||||
'shippingOptions' => $shippingOptions,
|
||||
'paymentMethods' => $this->checkout->getPaymentMethods(),
|
||||
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
|
||||
'storeCountry' => $storeCountry,
|
||||
'countries' => $storeCountry
|
||||
? collect()
|
||||
: Country::orderBy('name')->get(['id', 'name']),
|
||||
'regions' => $storeCountry
|
||||
? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name'])
|
||||
: collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveAddress(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$storeCountry = $this->storeCountry();
|
||||
$sameAsBilling = $request->boolean('same_as_billing');
|
||||
|
||||
// Only the fields shipping rates resolve against — if none of these
|
||||
// changed (shopper edited their name, phone, email, …) there's no point
|
||||
// re-quoting shipping or re-rendering the summary.
|
||||
$addressBefore = $this->cart->current()?->shippingAddress;
|
||||
$rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']);
|
||||
|
||||
// setShippingAddress() below always deletes + recreates this row (see
|
||||
// syncShipping()'s docblock) — capture what was selected NOW, before
|
||||
// it's gone, so it can be carried forward onto the fresh row.
|
||||
$previousOption = $addressBefore?->shipping_option;
|
||||
|
||||
$stateRule = $storeCountry
|
||||
? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)]
|
||||
: ['nullable', 'string', 'max:255'];
|
||||
$countryRule = $storeCountry
|
||||
? ['nullable']
|
||||
: ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id'];
|
||||
|
||||
// Lenient — only format checks. Anything that fails is simply left out
|
||||
// of what gets persisted, and reported back for inline display.
|
||||
$validator = Validator::make($request->all(), [
|
||||
'contact_email' => ['nullable', 'email'],
|
||||
|
||||
'billing_first_name' => ['nullable', 'string', 'max:255'],
|
||||
'billing_last_name' => ['nullable', 'string', 'max:255'],
|
||||
'billing_company_name' => ['nullable', 'string', 'max:255'],
|
||||
'billing_tax_identifier' => ['nullable', 'string', 'max:255'],
|
||||
'billing_line_one' => ['nullable', 'string', 'max:255'],
|
||||
'billing_line_two' => ['nullable', 'string', 'max:255'],
|
||||
'billing_city' => ['nullable', 'string', 'max:255'],
|
||||
'billing_state' => $stateRule,
|
||||
'billing_postcode' => ['nullable', 'string', 'max:20'],
|
||||
'billing_country_id' => $countryRule,
|
||||
'billing_contact_phone' => ['nullable', 'string', 'max:50'],
|
||||
|
||||
'shipping_first_name' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_last_name' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_company_name' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_line_one' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_line_two' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_city' => ['nullable', 'string', 'max:255'],
|
||||
'shipping_state' => $stateRule,
|
||||
'shipping_postcode' => ['nullable', 'string', 'max:20'],
|
||||
'shipping_country_id' => $countryRule,
|
||||
'shipping_contact_phone' => ['nullable', 'string', 'max:50'],
|
||||
'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'],
|
||||
]);
|
||||
|
||||
$errors = $validator->errors()->toArray();
|
||||
$data = $validator->valid();
|
||||
|
||||
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
|
||||
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
|
||||
|
||||
$billing = [
|
||||
'first_name' => $data['billing_first_name'] ?? null,
|
||||
'last_name' => $data['billing_last_name'] ?? null,
|
||||
'company_name' => $data['billing_company_name'] ?? null,
|
||||
'tax_identifier' => $data['billing_tax_identifier'] ?? null,
|
||||
'line_one' => $data['billing_line_one'] ?? null,
|
||||
'line_two' => $data['billing_line_two'] ?? null,
|
||||
'city' => $data['billing_city'] ?? null,
|
||||
'state' => $data['billing_state'] ?? null,
|
||||
'postcode' => $data['billing_postcode'] ?? null,
|
||||
'country_id' => $billingCountryId,
|
||||
'contact_email' => $data['contact_email'] ?? null,
|
||||
'contact_phone' => $data['billing_contact_phone'] ?? null,
|
||||
];
|
||||
|
||||
$shipping = $sameAsBilling
|
||||
? [...$billing, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null]
|
||||
: [
|
||||
'first_name' => $data['shipping_first_name'] ?? null,
|
||||
'last_name' => $data['shipping_last_name'] ?? null,
|
||||
'company_name' => $data['shipping_company_name'] ?? null,
|
||||
'line_one' => $data['shipping_line_one'] ?? null,
|
||||
'line_two' => $data['shipping_line_two'] ?? null,
|
||||
'city' => $data['shipping_city'] ?? null,
|
||||
'state' => $data['shipping_state'] ?? null,
|
||||
'postcode' => $data['shipping_postcode'] ?? null,
|
||||
'country_id' => $shippingCountryId,
|
||||
'contact_email' => $data['contact_email'] ?? null,
|
||||
'contact_phone' => $data['shipping_contact_phone'] ?? null,
|
||||
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
||||
];
|
||||
|
||||
$this->checkout->setBillingAddress($billing);
|
||||
$cart = $this->checkout->setShippingAddress($shipping);
|
||||
|
||||
$cart->meta = [...($cart->meta?->toArray() ?? []), 'ship_to_billing' => $sameAsBilling];
|
||||
$cart->save();
|
||||
|
||||
// Abandoned-cart-recovery opt-in — boboko-core owns the record (bool +
|
||||
// timestamp + policy version on Cart::meta, RecoveryConsentSet event).
|
||||
// Deliberately its own scope, not merged with any future newsletter opt-in.
|
||||
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
|
||||
|
||||
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
|
||||
$rateChanged = $rateKeyAfter != $rateKeyBefore;
|
||||
|
||||
// setShippingAddress() above always deletes and recreates the
|
||||
// CartAddress row (Lunar's AddAddress action), which drops whatever
|
||||
// shipping_option was previously selected — regardless of whether the
|
||||
// rate-determining fields actually changed. So this always has to run
|
||||
// to restore/re-validate it, even on a save that only touched e.g. the
|
||||
// phone number. Only the fragment RE-RENDER is skippable when nothing
|
||||
// rate-relevant moved — the re-select itself is not optional.
|
||||
$options = $this->syncShipping($cart, $previousOption);
|
||||
|
||||
if (! $rateChanged) {
|
||||
return $this->fragments($cart, null, $errors);
|
||||
}
|
||||
|
||||
return $this->fragments($cart, $options, $errors);
|
||||
}
|
||||
|
||||
public function selectShippingOption(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$identifier = (string) $request->input('shipping_option');
|
||||
|
||||
try {
|
||||
$this->checkout->selectShippingOption($identifier);
|
||||
} catch (InvalidShippingOptionException) {
|
||||
// Re-render with whatever is currently valid; no hard error surfaced.
|
||||
}
|
||||
|
||||
$cart = $this->cart->current();
|
||||
$options = $cart?->shippingAddress
|
||||
? $this->checkout->getShippingOptions()
|
||||
: collect();
|
||||
|
||||
return $this->fragments($cart, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* A plain, own-hosted stand-in for Box Now's Destination Map widget —
|
||||
* that widget only talks to their Production environment (see their
|
||||
* Partner API manual §4.1), which is useless while developing against
|
||||
* Stage credentials. Same underlying data (GET /destinations), no map.
|
||||
*/
|
||||
public function boxNowLockers(string $locale, BoxNowClient $boxNow): JsonResponse
|
||||
{
|
||||
$lockers = collect($boxNow->destinations())
|
||||
// Drops entries with a blank `name` (e.g. id 8288, "Virtual
|
||||
// Locker" in Sudan at lat 12.3/lng 25.3) — a real, in-range
|
||||
// coordinate, but sandbox test fixture noise rather than an
|
||||
// actual pickup point, and it alone was enough to make
|
||||
// fitBounds() below zoom the map out to the whole Balkans/
|
||||
// Middle East to fit every marker's cluster in Greece.
|
||||
->filter(fn (array $destination) => filled($destination['name'] ?? null))
|
||||
->map(fn (array $destination) => [
|
||||
'id' => $destination['id'],
|
||||
'name' => $destination['name'] ?? $destination['title'] ?? $destination['id'],
|
||||
'addressLine1' => $destination['addressLine1'] ?? null,
|
||||
'addressLine2' => $destination['addressLine2'] ?? null,
|
||||
'postalCode' => $destination['postalCode'] ?? null,
|
||||
'country' => $destination['country'] ?? null,
|
||||
'note' => $destination['note'] ?? null,
|
||||
'image' => $destination['image'] ?? null,
|
||||
'lat' => isset($destination['lat']) ? (float) $destination['lat'] : null,
|
||||
'lng' => isset($destination['lng']) ? (float) $destination['lng'] : null,
|
||||
])
|
||||
// Box Now's own Stage/sandbox data has at least one malformed
|
||||
// entry observed in practice (locker id 47: lat/lng as huge
|
||||
// integers with the decimal point apparently dropped, e.g.
|
||||
// 96065874308606 instead of ~37.96) — a single such point blows
|
||||
// out L.featureGroup().getBounds() on the frontend, zooming the
|
||||
// map out to near-nothing with every real marker imperceptible
|
||||
// at that scale. Valid latitude/longitude ranges are absolute,
|
||||
// not guesswork, so filtering on them is safe regardless of
|
||||
// what BoxNow's API does or doesn't fix upstream.
|
||||
->filter(fn (array $locker) => $locker['lat'] !== null && $locker['lng'] !== null
|
||||
&& abs($locker['lat']) <= 90 && abs($locker['lng']) <= 180)
|
||||
->values();
|
||||
|
||||
return response()->json(['lockers' => $lockers]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the shopper's chosen locker (radio/select change, same
|
||||
* autosave shape as selectShippingOption()) via
|
||||
* CheckoutService::selectBoxNowLocker() onto the cart's shipping
|
||||
* address meta.
|
||||
*/
|
||||
public function selectBoxNowLocker(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$locationId = (string) $request->input('locker_id');
|
||||
|
||||
if ($locationId === '') {
|
||||
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->checkout->selectBoxNowLocker([
|
||||
'locationId' => $locationId,
|
||||
'name' => (string) $request->input('locker_name'),
|
||||
'addressLine1' => (string) $request->input('locker_address'),
|
||||
]);
|
||||
} catch (NoShippingAddressException) {
|
||||
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Autosave-select a payment method (radio change). Persists it via
|
||||
* CheckoutService (which also records it on Cart::meta and re-snapshots
|
||||
* the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary.
|
||||
*/
|
||||
public function selectPaymentMethod(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$type = (string) $request->input('payment_type');
|
||||
|
||||
try {
|
||||
$this->checkout->selectPaymentMethod($type);
|
||||
} catch (UnknownPaymentTypeException) {
|
||||
// Radio value out of sync with what's offered — ignore, the summary
|
||||
// just won't reflect a method fee. place-order re-checks properly.
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'summaryHtml' => view('checkout::partials.cart-body')->render(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The real submit — the hard gate. Re-selects the payment method (fresh
|
||||
* fingerprint), then hands off to CheckoutService::initiatePayment(), which
|
||||
* creates the draft order, records terms acceptance, and charges the driver.
|
||||
* Returns JSON the bbk-payment controller routes on:
|
||||
* { redirect } — placed, go to confirmation
|
||||
* { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls
|
||||
* { status: 'failed', message } — declined
|
||||
* { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection
|
||||
*/
|
||||
public function placeOrder(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
if (! $request->boolean('terms_accepted')) {
|
||||
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->checkout->selectPaymentMethod((string) $request->input('payment_type'));
|
||||
} catch (UnknownPaymentTypeException) {
|
||||
return response()->json(['error' => __('checkout.page.choose_payment_method')], 422);
|
||||
}
|
||||
|
||||
$cart = $this->cart->current();
|
||||
|
||||
// The one incomplete-cart case worth a specific message + pointing the
|
||||
// shopper at the right section: a region resolving 2+ methods needs an
|
||||
// explicit pick (no auto-select), easy to miss since nothing else on
|
||||
// the page demands it. Everything else CartException catches below.
|
||||
if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.shipping_method_required'),
|
||||
'field' => 'shipping_option',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? '');
|
||||
|
||||
$data = $request->filled('payment_method')
|
||||
? ['payment_method' => (string) $request->input('payment_method')]
|
||||
: [];
|
||||
|
||||
try {
|
||||
$result = $this->checkout->initiatePayment(
|
||||
$fingerprint,
|
||||
termsAccepted: true,
|
||||
policyVersion: (string) config('legal.terms_version'),
|
||||
data: $data,
|
||||
);
|
||||
} catch (FingerprintMismatchException) {
|
||||
return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409);
|
||||
} catch (CartException $e) {
|
||||
return response()->json([
|
||||
'status' => 'invalid',
|
||||
'message' => __('checkout.page.payment_incomplete_details'),
|
||||
'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(),
|
||||
], 422);
|
||||
} catch (TermsNotAcceptedException) {
|
||||
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
||||
}
|
||||
|
||||
return match ($result->status) {
|
||||
PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
|
||||
PaymentResultStatus::Pending => response()->json([
|
||||
'status' => 'pending',
|
||||
'clientSecret' => $result->continuation?->value,
|
||||
]),
|
||||
PaymentResultStatus::Failed => response()->json([
|
||||
'status' => 'failed',
|
||||
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
|
||||
'retriable' => $result->retriable,
|
||||
], 422),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll target for the 3-D Secure path: has the webhook placed the order yet?
|
||||
* boboko-core's StripeWebhookController -> handleCallback -> PaymentCaptured
|
||||
* -> ApplyResolvedPaymentStatus sets placed_at.
|
||||
*/
|
||||
public function orderStatus(string $locale): JsonResponse
|
||||
{
|
||||
$order = $this->placedOrder();
|
||||
|
||||
if (! $order) {
|
||||
return response()->json(['placed' => false]);
|
||||
}
|
||||
|
||||
session(['checkout.order_id' => $order->id]);
|
||||
CartSession::forget();
|
||||
|
||||
return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]);
|
||||
}
|
||||
|
||||
public function confirmation(string $locale): View|RedirectResponse
|
||||
{
|
||||
$orderId = session('checkout.order_id');
|
||||
|
||||
$order = $orderId
|
||||
? Order::with(['lines', 'shippingAddress', 'billingAddress'])->find($orderId)
|
||||
: null;
|
||||
|
||||
if (! $order) {
|
||||
return redirect()->route('products', $locale);
|
||||
}
|
||||
|
||||
return view('checkout::confirmation', ['order' => $order]);
|
||||
}
|
||||
|
||||
private function orderPlacedResponse(string $locale): JsonResponse
|
||||
{
|
||||
if ($order = $this->placedOrder()) {
|
||||
session(['checkout.order_id' => $order->id]);
|
||||
}
|
||||
|
||||
CartSession::forget();
|
||||
|
||||
return response()->json(['redirect' => route('checkout.confirmation', $locale)]);
|
||||
}
|
||||
|
||||
private function placedOrder(): ?Order
|
||||
{
|
||||
return $this->cart->current()
|
||||
?->orders()
|
||||
->whereNotNull('placed_at')
|
||||
->latest('placed_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-resolve shipping options for the cart's current address and keep the
|
||||
* selection sane: auto-select when exactly one resolves, or carry a
|
||||
* previous pick forward when it's still among the resolved options.
|
||||
*
|
||||
* $previousOption must be captured by the CALLER before setShippingAddress()
|
||||
* runs — Lunar's AddAddress action always deletes and recreates the
|
||||
* CartAddress row on every save (see saveAddress()), so by the time this
|
||||
* runs, $address->shipping_option is unconditionally null regardless of
|
||||
* what was selected a moment ago. There is nothing meaningful left to read
|
||||
* off $address itself; $previousOption is the only source of truth for
|
||||
* "what was chosen before this save wiped the row." show() passes the
|
||||
* address's own (not-just-wiped) current value, since nothing recreated
|
||||
* anything in that path.
|
||||
*
|
||||
* Always (re-)applies the resolved target via selectShippingOption() rather
|
||||
* than comparing against the (always-blank, post-recreation) current value
|
||||
* — the fresh row needs the write regardless of whether the decision
|
||||
* "which option" actually changed.
|
||||
*
|
||||
* @return Collection<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(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function storeCountry(): ?Country
|
||||
{
|
||||
if (self::STORE_COUNTRY_ISO3 === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,82 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ContactRequest;
|
||||
use App\Mail\ContactConfirmationMail;
|
||||
use App\Mail\ContactMessageMail;
|
||||
use App\Models\StoicPage;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Store\Services\StoreDetailsService;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Contact form. Nothing is stored: the message is emailed to the store
|
||||
* (Store Details' 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');
|
||||
$page = StoicPage::localized('contact');
|
||||
|
||||
if (! $page) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('contact', [
|
||||
'page' => $page,
|
||||
'store' => app(StoreDetailsService::class)->current(),
|
||||
]);
|
||||
}
|
||||
|
||||
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 = app(StoreDetailsService::class)->current()->contact_email;
|
||||
|
||||
try {
|
||||
if (blank($to)) {
|
||||
throw new \RuntimeException('Store Details has no contact email.');
|
||||
}
|
||||
|
||||
Mail::to($to)->send(new ContactMessageMail(
|
||||
$data['name'],
|
||||
$data['email'],
|
||||
$data['message'],
|
||||
$locale,
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Contact form: store email failed', ['exception' => $e]);
|
||||
|
||||
return back()->withInput()->with('contact_error', __('storefront.contact.send_failed'));
|
||||
}
|
||||
|
||||
// The store already has the message, so a failed confirmation is only logged.
|
||||
try {
|
||||
Mail::to($data['email'])->locale($locale)->send(new ContactConfirmationMail);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Contact form: confirmation email failed', ['exception' => $e]);
|
||||
}
|
||||
|
||||
return redirect()->route('contact')->with('status', __('storefront.contact.sent'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Core\File\Http\Controllers\UploadFileController;
|
||||
|
||||
/**
|
||||
* Stores the shopper's photo for a product custom field of type `file` (see
|
||||
* boboko-core's Product::$custom_fields) the moment it's picked on the product
|
||||
* page — before add-to-cart, see custom-field-upload-controller.js.
|
||||
*
|
||||
* Which files are acceptable (extensions, size) is a per-site decision — this
|
||||
* site: photographs — so it lives here as this app's own policy, extending
|
||||
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
|
||||
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
|
||||
* endpoint only ever receives the stored File row's own `id` — FileService is
|
||||
* the single source of truth for disk/path/name/mime, never duplicated into
|
||||
* cart/order line meta (see Checkout\CartController::customFieldsMeta()). A
|
||||
* shopper can't point a cart line at someone else's file: CartController only
|
||||
* accepts an id that is both unowned and tagged with this exact PURPOSE.
|
||||
*
|
||||
* Stored on the private `local` disk: these are customers' personal photos,
|
||||
* never reachable by a public URL except through FileService's own signed
|
||||
* download route. Uploads nobody adds to a cart are removed by core's
|
||||
* `boboko:file:prune-unowned custom-field-upload` command.
|
||||
*/
|
||||
class CustomFieldUploadController extends UploadFileController
|
||||
{
|
||||
public const PURPOSE = 'custom-field-upload';
|
||||
|
||||
public const MAX_KILOBYTES = 10240;
|
||||
|
||||
public const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'];
|
||||
|
||||
/**
|
||||
* For the file input's `accept` attribute — same list the server enforces.
|
||||
*/
|
||||
public static function accept(): string
|
||||
{
|
||||
return '.'.implode(',.', self::EXTENSIONS);
|
||||
}
|
||||
|
||||
protected function purpose(): string
|
||||
{
|
||||
return self::PURPOSE;
|
||||
}
|
||||
|
||||
protected function validationRules(Request $request): array
|
||||
{
|
||||
return [
|
||||
'file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES],
|
||||
];
|
||||
}
|
||||
|
||||
// `label` is the admin-authored field label, only used as the
|
||||
// :attribute in the validation message shown next to that field.
|
||||
protected function validationAttributes(Request $request): array
|
||||
{
|
||||
return ['file' => (string) $request->input('label', 'file')];
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,72 @@
|
||||
|
||||
use App\Catalog\ProductCard;
|
||||
use App\Models\StoicPage;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Services\CollectionService;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ProductService $products) {}
|
||||
/** Slug of the hero collection in the "Homepage" collection group. */
|
||||
private const HERO_COLLECTION_SLUG = 'hero';
|
||||
|
||||
private const HERO_FALLBACK_COUNT = 5;
|
||||
|
||||
private const CLASSICS_COUNT = 8;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProductService $products,
|
||||
private readonly CollectionService $collections,
|
||||
) {}
|
||||
|
||||
public function index(string $locale)
|
||||
{
|
||||
$page = StoicPage::firstWhere('slug', 'home');
|
||||
$page = StoicPage::localized('home');
|
||||
|
||||
if (! $page) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$products = collect($this->products->random(13))
|
||||
->map(fn (array $product) => ProductCard::fromIndexed($product));
|
||||
// Random pool for the classics carousel, and for the hero too when the
|
||||
// hero collection is missing or empty. Oversized so hero products can be
|
||||
// excluded from it and still leave enough.
|
||||
$random = collect($this->products->random(self::HERO_FALLBACK_COUNT + self::CLASSICS_COUNT));
|
||||
|
||||
$hero = collect($this->heroCollectionProducts());
|
||||
|
||||
if ($hero->isEmpty()) {
|
||||
$hero = $random->take(self::HERO_FALLBACK_COUNT);
|
||||
}
|
||||
|
||||
$heroIds = $hero->pluck('id')->all();
|
||||
|
||||
$classics = $random
|
||||
->reject(fn (array $product) => in_array($product['id'], $heroIds))
|
||||
->take(self::CLASSICS_COUNT);
|
||||
|
||||
return view('home', [
|
||||
'page' => $page,
|
||||
'heroProducts' => $products->take(5)->values(),
|
||||
'classicsProducts' => $products->slice(5)->values(),
|
||||
'heroProducts' => $hero->map(fn (array $product) => ProductCard::fromIndexed($product))->values(),
|
||||
'classicsProducts' => $classics->map(fn (array $product) => ProductCard::fromIndexed($product))->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Products the store owner picked for the hero, via the Lunar collection
|
||||
* with slug "hero". Not in the owner's drag order yet: the product index
|
||||
* doesn't carry per-collection position (pending a boboko-core task).
|
||||
*/
|
||||
private function heroCollectionProducts(): array
|
||||
{
|
||||
$collection = $this->collections->getBySlug(self::HERO_COLLECTION_SLUG);
|
||||
|
||||
if ($collection === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->products
|
||||
->list(new ProductFilters(collectionId: $collection['id']))
|
||||
->products
|
||||
->items();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,28 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\StoicPage;
|
||||
use Modules\Core\Store\Services\StoreDetailsService;
|
||||
|
||||
class LegalPageController extends Controller
|
||||
{
|
||||
/**
|
||||
* {placeholder} => StoreDetails column, usable in the Stoic legal page
|
||||
* markdown.
|
||||
*/
|
||||
private const PLACEHOLDERS = [
|
||||
'{store_name}' => 'name',
|
||||
'{store_address}' => 'address',
|
||||
'{store_phone}' => 'phone',
|
||||
'{store_contact_email}' => 'contact_email',
|
||||
'{store_legal_name}' => 'legal_name',
|
||||
'{store_tax_identifier}' => 'tax_identifier',
|
||||
'{store_registration_number}' => 'registration_number',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly StoreDetailsService $storeDetails,
|
||||
) {}
|
||||
|
||||
public function terms(string $locale)
|
||||
{
|
||||
return $this->show('terms-and-conditions');
|
||||
@@ -28,12 +47,30 @@ public function cookies(string $locale)
|
||||
|
||||
private function show(string $slug)
|
||||
{
|
||||
$page = StoicPage::firstWhere('slug', $slug);
|
||||
$page = StoicPage::localized($slug);
|
||||
|
||||
if (! $page) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('legal', ['page' => $page]);
|
||||
return view('legal', [
|
||||
'page' => $page,
|
||||
'content' => $this->fillPlaceholders($page->content ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swapped in before the markdown is rendered, so values are escaped: the
|
||||
* rendered markdown is printed unescaped. An empty field renders as
|
||||
* nothing rather than leaving the raw placeholder on the page.
|
||||
*/
|
||||
private function fillPlaceholders(string $content): string
|
||||
{
|
||||
$details = $this->storeDetails->current();
|
||||
|
||||
return strtr($content, array_map(
|
||||
fn (string $column) => e((string) ($details->translate($column) ?? '')),
|
||||
self::PLACEHOLDERS,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
|
||||
use App\Catalog\ProductListing;
|
||||
use App\Catalog\ProductListingPage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
@@ -35,18 +42,208 @@ public function show(string $locale, int $id)
|
||||
$product = $this->products->getById($id);
|
||||
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
||||
|
||||
$product = $this->mergeJustSubmittedReview($product);
|
||||
|
||||
$collection = $product['collections'][0] ?? null;
|
||||
|
||||
$variantsData = $this->products->variantSummaries($product);
|
||||
|
||||
$firstVariant = $product['variants'][0] ?? null;
|
||||
$option = $firstVariant['options'][0]['option'] ?? null;
|
||||
// dd($product);
|
||||
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
|
||||
|
||||
return view('product.show', [
|
||||
'collection' => $collection,
|
||||
'product' => $product,
|
||||
'option' => $option,
|
||||
'productOptions' => $productOptions,
|
||||
'variantsData' => $variantsData,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One button/swatch group per product option (a product can have several
|
||||
* — e.g. a custom-photo product with size + style + person-count, each
|
||||
* combination resolved client-side to one exact ProductVariant, see
|
||||
* product-form-controller.js) plus the per-variant data the picker
|
||||
* resolves a selection against.
|
||||
*
|
||||
* Reads live Eloquent rather than the Meilisearch-indexed $product array
|
||||
* the rest of the page uses (same reasoning as checkStock() below): the
|
||||
* index has no concept of option/value display order (Lunar's
|
||||
* `position` column) or a stable value id, both of which the picker
|
||||
* needs — to render values in the merchant's intended order, and to
|
||||
* match a combination back to one exact variant without relying on
|
||||
* translated label strings staying unique.
|
||||
*
|
||||
* @return array{0: array, 1: array}
|
||||
*/
|
||||
private function buildOptionPicker(int $productId): array
|
||||
{
|
||||
$variants = ProductVariant::query()
|
||||
->where('product_id', $productId)
|
||||
->with(['values.option', 'prices', 'images'])
|
||||
->get();
|
||||
|
||||
$productOptions = $variants
|
||||
->flatMap(fn (ProductVariant $variant) => $variant->values)
|
||||
->unique('id')
|
||||
->groupBy(fn ($value) => $value->option->handle)
|
||||
->map(function ($values, $handle) {
|
||||
$sorted = $values->sortBy([['position', 'asc'], ['id', 'asc']]);
|
||||
|
||||
return [
|
||||
'handle' => $handle,
|
||||
'label' => $sorted->first()->option->translate('name'),
|
||||
// The "Color" option type is the only one that writes a
|
||||
// hex code into meta (see boboko/core's ColorOptionType)
|
||||
// — its presence is how we tell a color option (swatches)
|
||||
// from any other option (buttons).
|
||||
'isColor' => $sorted->contains(fn ($v) => !empty($v->meta['hex'] ?? null)),
|
||||
'values' => $sorted->map(fn ($v) => [
|
||||
'id' => $v->id,
|
||||
'label' => $v->translate('name'),
|
||||
'hex' => $v->meta['hex'] ?? null,
|
||||
])->values()->all(),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$variantsData = $variants->map(fn (ProductVariant $variant) => [
|
||||
'id' => $variant->id,
|
||||
'price' => $variant->prices->first()?->price?->decimal(),
|
||||
'image' => $variant->images->first()?->getUrl(),
|
||||
// Live from the DB (not the index's in_stock), with Lunar's own
|
||||
// purchasability rule — drives the disabled add-to-cart button.
|
||||
'inStock' => $variant->canBeFulfilledAtQuantity(1),
|
||||
// handle => selected value id, for matching a combination of
|
||||
// selections back to this variant — see selectVariant() in
|
||||
// product-form-controller.js.
|
||||
'options' => $variant->values->mapWithKeys(fn ($v) => [$v->option->handle => $v->id])->all(),
|
||||
])->values()->all();
|
||||
|
||||
return [$productOptions, $variantsData];
|
||||
}
|
||||
|
||||
/**
|
||||
* Meilisearch's own write API is itself async — addDocuments() enqueues an
|
||||
* indexing task and returns immediately, and Laravel\Scout\Engines\
|
||||
* MeilisearchEngine::update() never waits on that task, so even
|
||||
* $product->searchableSync() (which only skips OUR queue) can still land
|
||||
* the shopper back on this page before Meilisearch has actually processed
|
||||
* the write. storeReview() flashes the review it just created for exactly
|
||||
* this one next request; splice it in here rather than trust the index is
|
||||
* already caught up. Guarded by id so a race the other way — the index
|
||||
* DID catch up in time — doesn't show the same review twice.
|
||||
*/
|
||||
private function mergeJustSubmittedReview(array $product): array
|
||||
{
|
||||
$justSubmitted = session('justSubmittedReview');
|
||||
|
||||
if (! $justSubmitted || (string) ($justSubmitted['product_id'] ?? null) !== (string) $product['id']) {
|
||||
return $product;
|
||||
}
|
||||
|
||||
$items = $product['reviews']['items'] ?? [];
|
||||
|
||||
if (collect($items)->contains('id', $justSubmitted['id'])) {
|
||||
return $product;
|
||||
}
|
||||
|
||||
$items = [$justSubmitted, ...$items];
|
||||
|
||||
$product['reviews']['items'] = $items;
|
||||
$product['reviews']['count'] = count($items);
|
||||
$product['reviews']['average_rating'] = round(collect($items)->avg('rating'), 1);
|
||||
|
||||
return $product;
|
||||
}
|
||||
|
||||
/**
|
||||
* A storefront-owned, checkout-module-independent stock check — the
|
||||
* product page's "Add to cart" calls this first and only submits to the
|
||||
* checkout module's own add-to-cart endpoint once this says `ok`. Reads
|
||||
* the live Eloquent ProductVariant directly (not the Meilisearch index
|
||||
* ProductService otherwise reads from, which can lag behind an actual
|
||||
* sale until the next reindex) via the SAME method Lunar's own
|
||||
* CartLineStock validator calls, so this can never disagree with what
|
||||
* the module's own server-side check would decide.
|
||||
*/
|
||||
public function checkStock(string $locale, Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'variant' => ['required', 'integer'],
|
||||
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
$variant = ProductVariant::find($data['variant']);
|
||||
$quantity = $data['quantity'] ?? 1;
|
||||
|
||||
if ($variant === null) {
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => $variant->canBeFulfilledAtQuantity($quantity),
|
||||
'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* boboko/core's product_reviews table has no moderation/status column, so
|
||||
* this goes live immediately — no approval queue to land in.
|
||||
*/
|
||||
public function storeReview(string $locale, Request $request, Product $product): RedirectResponse
|
||||
{
|
||||
$reviewsUrl = route('product.show', [
|
||||
'locale' => $locale,
|
||||
'id' => $product->id,
|
||||
'tab' => 'reviews',
|
||||
]).'#product-tabs';
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'rating' => ['required', 'integer', 'between:1,5'],
|
||||
'content' => ['required', 'string'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return redirect($reviewsUrl)->withErrors($validator)->withInput();
|
||||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
|
||||
// Not $product->reviews()->create(...): that relation only exists via a
|
||||
// Product::macro() registered in CorePlugin::register(Panel $panel), which
|
||||
// Filament calls solely when the /boboko admin panel boots — never on a
|
||||
// plain storefront request, where the macro is simply undefined.
|
||||
$review = ProductReview::create([
|
||||
'product_id' => $product->id,
|
||||
'rating' => $data['rating'],
|
||||
'body' => $data['content'],
|
||||
'reviewer_name' => $data['name'] ?? null,
|
||||
'reviewer_email' => $data['email'],
|
||||
'reviewed_at' => now(),
|
||||
'source' => 'storefront',
|
||||
]);
|
||||
|
||||
// ReviewServiceProvider also reindexes on the model's `created` event, but
|
||||
// queued (SCOUT_QUEUE=true) — it wouldn't land before this redirect's page
|
||||
// load. Syncing here skips our queue too, but Meilisearch's own write API
|
||||
// is itself async on top of that (see mergeJustSubmittedReview()), so this
|
||||
// alone still isn't a guarantee — it's the flash below that actually is.
|
||||
$product->searchableSync();
|
||||
|
||||
return redirect($reviewsUrl)
|
||||
->with('reviewSubmitted', true)
|
||||
->with('justSubmittedReview', [
|
||||
'id' => $review->id,
|
||||
'product_id' => $review->product_id,
|
||||
'title' => $review->title,
|
||||
'body' => $review->body,
|
||||
'rating' => $review->rating,
|
||||
'reviewed_at' => $review->reviewed_at?->timestamp,
|
||||
'reviewer_name' => $review->reviewer_name,
|
||||
'reply' => null,
|
||||
'replied_at' => null,
|
||||
'media' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\ProductCard;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
use Modules\Core\Wishlist\Services\WishlistService;
|
||||
|
||||
/**
|
||||
* Page rendering only — the toggle action itself lives in core
|
||||
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
|
||||
* `wishlist.toggle`), since it needs no app-specific presentation.
|
||||
*/
|
||||
class WishlistController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WishlistService $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)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Rules\HCaptcha;
|
||||
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'],
|
||||
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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 Store Details' 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');
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,32 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Stoic;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
|
||||
use JacobJoergensen\LaravelPaper\Attributes\Driver;
|
||||
use JacobJoergensen\LaravelPaper\Attributes\Timestamps;
|
||||
use JacobJoergensen\LaravelPaper\Paper;
|
||||
|
||||
// Points at the default locale (el), which Stoic writes every field to. Other
|
||||
// locales hold only the translatable fields — use localized() to get a page
|
||||
// with those layered on top for the current locale.
|
||||
#[Driver("markdown")]
|
||||
#[ContentPath("resources/stoic/content/pages")]
|
||||
#[ContentPath("resources/stoic/content/el/pages")]
|
||||
#[Timestamps]
|
||||
class StoicPage extends Model
|
||||
{
|
||||
use Paper;
|
||||
|
||||
/**
|
||||
* The page in the current locale: the default-locale entry, with the
|
||||
* current locale's non-empty fields (and body) swapped in. Untranslated
|
||||
* fields fall back to the default locale.
|
||||
*/
|
||||
public static function localized(string $slug): ?static
|
||||
{
|
||||
$page = static::firstWhere('slug', $slug);
|
||||
|
||||
return $page?->forceFill(Stoic::localeOverrides("pages/{$slug}.md"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,34 +4,80 @@
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Staff;
|
||||
use App\Services\StoicSettings;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Mail\Events\MessageSending;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\View\View as ViewInstance;
|
||||
use Lunar\Facades\ModelManifest;
|
||||
use Lunar\Facades\Telemetry;
|
||||
use Lunar\Models\CollectionGroup;
|
||||
use Modules\Core\Catalog\DTOs\CollectionFilters;
|
||||
use Modules\Core\Catalog\Enums\CollectionSort;
|
||||
use Modules\Core\Catalog\Services\CollectionService;
|
||||
use Modules\Core\Store\Services\StoreDetailsService;
|
||||
use Symfony\Component\Mime\Address;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->scoped(StoicSettings::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Telemetry::optOut();
|
||||
|
||||
// header.blade.php's category dropdown — root collections only, resolved
|
||||
// per-request so the composer runs after `locale` middleware has already
|
||||
// set App::getLocale(), which CollectionService's name resolution depends on.
|
||||
// header.blade.php's category dropdown — root collections of the catalog
|
||||
// group only, so merchandising groups (e.g. "Homepage") stay out of the
|
||||
// nav. Resolved per-request so the composer runs after `locale` middleware
|
||||
// has already set App::getLocale(), which CollectionService's name
|
||||
// resolution depends on. The group is looked up by handle, not id, since
|
||||
// ids can differ between environments.
|
||||
View::composer('components.header', function (ViewInstance $view) {
|
||||
$view->with('categories', app(CollectionService::class)->list(
|
||||
filters: new CollectionFilters(rootOnly: true),
|
||||
$groupId = CollectionGroup::where('handle', 'shopify')->value('id');
|
||||
|
||||
$view->with('categories', $groupId === null ? [] : app(CollectionService::class)->list(
|
||||
filters: new CollectionFilters(groupId: $groupId, rootOnly: true),
|
||||
perPage: 100,
|
||||
sort: CollectionSort::Position,
|
||||
)->items());
|
||||
});
|
||||
|
||||
// Site-wide settings edited in Stoic (settings.md): default meta
|
||||
// description, social links for the footer, emails and schema.org.
|
||||
View::composer(
|
||||
['layouts.app', 'components.footer', 'emails.layout', 'home'],
|
||||
fn (ViewInstance $view) => $view->with('stoicSettings', app(StoicSettings::class)),
|
||||
);
|
||||
|
||||
// INTERIM, belongs in boboko-core's Store module: the sender name on
|
||||
// outgoing emails comes from Store Details' "Mail from name", with
|
||||
// MAIL_FROM_NAME as the fallback when it's empty. Done per message
|
||||
// rather than by setting config at boot, so a long-running queue
|
||||
// worker picks up a changed name without a restart. Only the default
|
||||
// from address is renamed; a mailable with its own from is left alone.
|
||||
Event::listen(MessageSending::class, function (MessageSending $event) {
|
||||
$name = app(StoreDetailsService::class)->current()->mail_from_name;
|
||||
|
||||
if (blank($name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultAddress = config('mail.from.address');
|
||||
|
||||
$event->message->from(...array_map(
|
||||
fn (Address $from) => $from->getAddress() === $defaultAddress
|
||||
? new Address($from->getAddress(), $name)
|
||||
: $from,
|
||||
$event->message->getFrom(),
|
||||
));
|
||||
});
|
||||
|
||||
if ($this->app->environment('production')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?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,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Verifies the `h-captcha-response` token the hCaptcha widget adds to a form.
|
||||
* Use it as `'h-captcha-response' => ['required', new HCaptcha]`.
|
||||
*
|
||||
* Fails closed: if hCaptcha can't be reached the submission is rejected, since
|
||||
* letting it through would reopen the hole this exists to close (bots making
|
||||
* us send email to arbitrary addresses).
|
||||
*/
|
||||
class HCaptcha implements ValidationRule
|
||||
{
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
$fail(__('storefront.auth.captcha_failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->timeout(5)
|
||||
->post('https://api.hcaptcha.com/siteverify', [
|
||||
'secret' => config('services.hcaptcha.secret'),
|
||||
'response' => $value,
|
||||
// Rejects tokens solved against someone else's sitekey.
|
||||
'sitekey' => config('services.hcaptcha.sitekey'),
|
||||
'remoteip' => request()->ip(),
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('hCaptcha siteverify unreachable', ['error' => $e->getMessage()]);
|
||||
$fail(__('storefront.auth.captcha_failed'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $response->successful() || $response->json('success') !== true) {
|
||||
// A bad/missing secret or sitekey would otherwise look like every
|
||||
// shopper failing the captcha.
|
||||
$configErrors = array_intersect((array) $response->json('error-codes'), [
|
||||
'missing-input-secret',
|
||||
'invalid-input-secret',
|
||||
'sitekey-secret-mismatch',
|
||||
'invalid-sitekey',
|
||||
]);
|
||||
|
||||
if ($response->failed() || $configErrors) {
|
||||
Log::warning('hCaptcha siteverify error', [
|
||||
'status' => $response->status(),
|
||||
'error-codes' => $response->json('error-codes'),
|
||||
]);
|
||||
}
|
||||
|
||||
$fail(__('storefront.auth.captcha_failed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Spatie\YamlFrontMatter\YamlFrontMatter;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Stoic
|
||||
@@ -29,6 +30,42 @@ public static function presets(): array
|
||||
return static::$presets;
|
||||
}
|
||||
|
||||
// Stoic's default locale (first in i18n.locales), or null when i18n is off.
|
||||
// Its content folder holds every field; other locales only translatable ones.
|
||||
public static function defaultLocale(): ?string
|
||||
{
|
||||
static::loadConfig();
|
||||
return static::$config["i18n"]["locales"][0]["code"] ?? null;
|
||||
}
|
||||
|
||||
// The current locale's translated fields for a content file (path relative
|
||||
// to a locale folder, e.g. "pages/home.md"), with the markdown body as
|
||||
// `content`. Empty when the current locale is the default one (or i18n is
|
||||
// off), the file doesn't exist, or a field is left blank — so layering this
|
||||
// over the default-locale values falls back to them field by field.
|
||||
public static function localeOverrides(string $file): array
|
||||
{
|
||||
$default = static::defaultLocale();
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if ($default === null || $locale === $default) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$path = resource_path("stoic/content/{$locale}/{$file}");
|
||||
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$document = YamlFrontMatter::parse(file_get_contents($path));
|
||||
|
||||
return array_filter(
|
||||
[...$document->matter(), "content" => trim($document->body())],
|
||||
fn($value) => filled($value),
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the public URL for a stoic image at a given preset.
|
||||
public static function image(string $filename, string $preset): string
|
||||
{
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Spatie\YamlFrontMatter\YamlFrontMatter;
|
||||
|
||||
/**
|
||||
* Site-wide, marketing-editable settings from Stoic's `settings` singleton
|
||||
* (resources/stoic/content/{locale}/settings.md). Read directly rather than
|
||||
* through a Paper model: Paper models map to a folder of entries, and this is
|
||||
* one file per locale. Legal and transactional details (address, ΑΦΜ, phone)
|
||||
* live in boboko-core's StoreDetails instead.
|
||||
*
|
||||
* Stoic writes every field to the default locale's file and only the
|
||||
* translatable ones (e.g. meta_description) to the others, so the current
|
||||
* locale's file is layered over the default one; empty values fall through.
|
||||
*
|
||||
* Bound as scoped in AppServiceProvider, so files are parsed at most once per
|
||||
* locale per request (or queued job) and edits in Stoic show up on the next one.
|
||||
* The locale is read per call, not at construction, so a mail rendered with
|
||||
* ->locale() still gets its own locale's values.
|
||||
*/
|
||||
class StoicSettings
|
||||
{
|
||||
private const SOCIALS = [
|
||||
'facebook' => 'Facebook',
|
||||
'instagram' => 'Instagram',
|
||||
'tiktok' => 'TikTok',
|
||||
];
|
||||
|
||||
/** @var array<string, array<string, mixed>> keyed by locale */
|
||||
private array $data = [];
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$value = $this->data()[$key] ?? null;
|
||||
|
||||
return filled($value) ? $value : $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute og:image URL for a Stoic image field value (the page's own, else
|
||||
* the default from settings.md), or null. Image fields store a
|
||||
* comma-separated list; only the first is used.
|
||||
*/
|
||||
public function ogImageUrl(?string $pageImage = null): ?string
|
||||
{
|
||||
$filename = trim(explode(',', $pageImage ?: (string) $this->get('og_image', ''))[0]);
|
||||
|
||||
return $filename === '' ? null : url(Stoic::image($filename, 'large'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the networks that have a URL set, in display order.
|
||||
*
|
||||
* @return list<array{icon: string, label: string, url: string}>
|
||||
*/
|
||||
public function socialLinks(): array
|
||||
{
|
||||
$links = [];
|
||||
|
||||
foreach (self::SOCIALS as $icon => $label) {
|
||||
if ($url = $this->get("{$icon}_url")) {
|
||||
$links[] = ['icon' => $icon, 'label' => $label, 'url' => $url];
|
||||
}
|
||||
}
|
||||
|
||||
return $links;
|
||||
}
|
||||
|
||||
private function data(): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return $this->data[$locale] ??= $this->load();
|
||||
}
|
||||
|
||||
private function load(): array
|
||||
{
|
||||
$default = Stoic::defaultLocale();
|
||||
|
||||
if ($default === null) {
|
||||
return $this->parse(resource_path('stoic/content/settings.md'));
|
||||
}
|
||||
|
||||
return array_merge(
|
||||
$this->parse(resource_path("stoic/content/{$default}/settings.md")),
|
||||
Stoic::localeOverrides('settings.md'),
|
||||
);
|
||||
}
|
||||
|
||||
private function parse(string $path): array
|
||||
{
|
||||
return is_file($path) ? YamlFrontMatter::parse(file_get_contents($path))->matter() : [];
|
||||
}
|
||||
}
|
||||
@@ -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,11 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\CheckoutModuleServiceProvider;
|
||||
use App\Providers\PanelServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
PanelServiceProvider::class,
|
||||
CheckoutModuleServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Modules\Core\Catalog\Recommendations\RandomRule;
|
||||
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Product recommendation rules
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
|
||||
| the first rule that returns at least one product wins. The order here IS
|
||||
| the fallback chain: SameCategoryRule first, then RandomRule as a
|
||||
| last-resort so a product page is never left with zero recommendations
|
||||
| (as long as the store has more than one product). A consuming app can
|
||||
| reorder, add, or remove rules freely — nothing about the chain shape is
|
||||
| hardcoded in the service itself.
|
||||
|
|
||||
*/
|
||||
'recommendation_rules' => [
|
||||
SameCategoryRule::class,
|
||||
RandomRule::class,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Per-site settings for the cart + checkout module (see
|
||||
* Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
|
||||
* artisan vendor:publish --tag=core-config.
|
||||
*/
|
||||
return [
|
||||
|
||||
/*
|
||||
* Name of the storefront's login route. The checkout's login tab and the
|
||||
* confirmation page link to it with `?redirect=<checkout path>`, so the
|
||||
* login page must send the shopper back there afterwards. null: no login
|
||||
* offered in checkout at all.
|
||||
*/
|
||||
'login_route' => 'login',
|
||||
|
||||
/*
|
||||
* Name of the storefront's product-listing route — where confirmation()
|
||||
* redirects a visit with no placed order to look at (session expired,
|
||||
* direct navigation, a bookmark). route($this, $locale) must resolve.
|
||||
*/
|
||||
'products_route' => 'products',
|
||||
|
||||
/*
|
||||
* ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden
|
||||
* field, forced server-side — no country picker shown at all). null (the
|
||||
* default) gives the full country/region picker, for a multi-country
|
||||
* store. 3dealer is Greece-only for now.
|
||||
*/
|
||||
'store_country_iso3' => 'GRC',
|
||||
|
||||
/*
|
||||
* The `purpose` tag CartController expects a product custom field's
|
||||
* `file` answer to already carry (see Modules\Core\File\Models\File) —
|
||||
* matches whatever purpose string the host's own upload endpoint
|
||||
* (extending Modules\Core\File\Http\Controllers\UploadFileController)
|
||||
* tags its stored files with. This module never reaches into that
|
||||
* host controller directly; this config value is the one shared
|
||||
* source of truth between the two.
|
||||
*/
|
||||
'custom_field_upload_purpose' => 'custom-field-upload',
|
||||
|
||||
];
|
||||
@@ -16,4 +16,126 @@
|
||||
|
||||
'auto_create_customer_for_user' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Privacy / GDPR data-subject requests
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
|
||||
| that should be consulted for right-of-access/right-of-erasure requests. A
|
||||
| module never needs to be known to core in advance — it just adds its own
|
||||
| provider class here, the same way config('lunar.search.indexers') maps a
|
||||
| model to its indexer. See docs/privacy.md.
|
||||
|
|
||||
| 'grace_period_days' is how long an erasure request stays cancellable
|
||||
| (account deactivated, not yet erased) before it's actually processed by
|
||||
| the privacy:process-erasure-requests scheduled command.
|
||||
|
|
||||
*/
|
||||
|
||||
'privacy' => [
|
||||
'providers' => [
|
||||
// ActivityLogDataProvider MUST run before AddressDataProvider —
|
||||
// it resolves which activity_log rows belong to this customer
|
||||
// (including ones keyed by an Address id) before
|
||||
// AddressDataProvider hard-deletes those Address rows. See that
|
||||
// provider's own class docblock.
|
||||
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
|
||||
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
|
||||
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
|
||||
\Modules\Core\Order\Privacy\OrderDataProvider::class,
|
||||
\Modules\Core\Cart\Privacy\CartDataProvider::class,
|
||||
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
|
||||
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
|
||||
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
|
||||
],
|
||||
|
||||
'grace_period_days' => 30,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cart Abandonment Threshold
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| How long a cart (that hasn't converted to a placed order) can go without
|
||||
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
|
||||
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
|
||||
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
||||
|
|
||||
*/
|
||||
|
||||
'cart' => [
|
||||
'abandoned_after' => '1 hour',
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------------------
|
||||
| Unrecoverable Cap
|
||||
|----------------------------------------------------------------------
|
||||
|
|
||||
| Beyond this age, a stale cart stops being treated as an active
|
||||
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
|
||||
| CartLifecycleService) — too old to be a realistic recovery target
|
||||
| (pricing/stock/tax likely stale by then). This is about the
|
||||
| abandoned-cart pipeline only, not data retention — no rows are
|
||||
| deleted or pruned based on this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'unrecoverable_after' => '90 days',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Order Return Window
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| How many days after a carrier order is delivered (Order::fulfillment_status
|
||||
| becomes 'return_window_open') before Modules\Core\Order\Commands\
|
||||
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
|
||||
| Store-pickup orders have no return-window step and are unaffected by
|
||||
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
||||
|
|
||||
*/
|
||||
|
||||
'order' => [
|
||||
'return_window_days' => 14,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Storefront OTP Login
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Modules\Core\Auth\Services\UserOtpService's passwordless login.
|
||||
| max_attempts caps how many wrong codes a shopper can guess against ONE
|
||||
| generated code before it's invalidated outright. generation_limit/
|
||||
| generation_decay_minutes cap how often a NEW code can be requested for
|
||||
| the same email — independent of max_attempts, since generating a fresh
|
||||
| code also resets the guess count, so an attempt cap alone doesn't stop
|
||||
| an attacker from just requesting a new code every few tries. This same
|
||||
| limit is also what stands between a malicious/careless caller and
|
||||
| mail-bombing one inbox.
|
||||
|
|
||||
*/
|
||||
|
||||
'auth' => [
|
||||
'otp' => [
|
||||
'max_attempts' => 5,
|
||||
'generation_limit' => 3,
|
||||
'generation_decay_minutes' => 10,
|
||||
],
|
||||
|
||||
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
|
||||
// shape/reasoning as auth.otp above, independent limits since this
|
||||
// is a separate flow (changing an existing account's login email,
|
||||
// not logging in).
|
||||
'email_change' => [
|
||||
'max_attempts' => 5,
|
||||
'generation_limit' => 3,
|
||||
'generation_decay_minutes' => 10,
|
||||
'expiry_minutes' => 10,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -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,8 +59,22 @@
|
||||
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,
|
||||
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -35,6 +35,15 @@
|
||||
],
|
||||
],
|
||||
|
||||
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
|
||||
// local dev use hCaptcha's test keys, the real ones reject localhost:
|
||||
// sitekey 10000000-ffff-ffff-ffff-000000000001,
|
||||
// secret 0x0000000000000000000000000000000000000000.
|
||||
'hcaptcha' => [
|
||||
'sitekey' => env('HCAPTCHA_SITEKEY'),
|
||||
'secret' => env('HCAPTCHA_SECRET'),
|
||||
],
|
||||
|
||||
'stoic' => [
|
||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||
'host' => env('STOIC_HOST'),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ACS Courier credentials
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
|
||||
| HTTP header (gates the REST gateway itself) and four account fields
|
||||
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
|
||||
| request body. Both are supplied by ACS when your account is set up.
|
||||
|
|
||||
| Set these via environment variables — never commit real values.
|
||||
|
|
||||
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
|
||||
| every ACSAlias call).
|
||||
| ACS_API_KEY The AcsApiKey header value.
|
||||
| ACS_COMPANY_ID Company_ID body field.
|
||||
| ACS_COMPANY_PASSWORD Company_Password body field.
|
||||
| ACS_USER_ID User_ID body field.
|
||||
| ACS_USER_PASSWORD User_Password body field.
|
||||
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
|
||||
| calculation and voucher creation.
|
||||
| ACS_SENDER_* Static sender details reused on every voucher.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
|
||||
|
||||
'api_key' => env('ACS_API_KEY'),
|
||||
|
||||
'company_id' => env('ACS_COMPANY_ID'),
|
||||
'company_password' => env('ACS_COMPANY_PASSWORD'),
|
||||
'user_id' => env('ACS_USER_ID'),
|
||||
'user_password' => env('ACS_USER_PASSWORD'),
|
||||
|
||||
'billing_code' => env('ACS_BILLING_CODE'),
|
||||
|
||||
'sender' => [
|
||||
'name' => env('ACS_SENDER_NAME'),
|
||||
'address' => env('ACS_SENDER_ADDRESS'),
|
||||
'zip_code' => env('ACS_SENDER_ZIP'),
|
||||
'phone' => env('ACS_SENDER_PHONE'),
|
||||
],
|
||||
|
||||
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Box Now credentials
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
|
||||
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
|
||||
| ~1hr expiry), then attach it as an Authorization header on every call.
|
||||
| Unlike ACS, there is no separate per-request credential body — the
|
||||
| token alone authorizes all calls once obtained.
|
||||
|
|
||||
| Set these via environment variables — never commit real values.
|
||||
|
|
||||
| Box Now has two environments (see their Partner API manual, section 2):
|
||||
| Stage/Sandbox for testing, Production once live. Each has its own
|
||||
| client_id/client_secret pair and its own base_url/location_api_url —
|
||||
| there is no shared "switch an env var" flag, since stage credentials
|
||||
| don't work against the production host or vice versa.
|
||||
|
|
||||
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
|
||||
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
|
||||
| lookups (Box Now recommends this over the main
|
||||
| base URL for those two calls specifically).
|
||||
| BOXNOW_CLIENT_ID OAuth2 client id.
|
||||
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
|
||||
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
|
||||
| credentials. NOT used for REST API authentication
|
||||
| (BoxNowClient authenticates with client_id/
|
||||
| client_secret alone) — this is only consumed by
|
||||
| the client-side Destination Map widget config
|
||||
| (_bn_map_widget_config.partnerId), confirmed
|
||||
| against Box Now's own WooCommerce plugin source.
|
||||
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
|
||||
| the pickup origin on every delivery request.
|
||||
| BOXNOW_SENDER_* Static sender contact details reused on every
|
||||
| delivery request.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
|
||||
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
|
||||
|
||||
'client_id' => env('BOXNOW_CLIENT_ID'),
|
||||
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
|
||||
'partner_id' => env('BOXNOW_PARTNER_ID'),
|
||||
|
||||
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
|
||||
|
||||
'sender' => [
|
||||
'name' => env('BOXNOW_SENDER_NAME'),
|
||||
'email' => env('BOXNOW_SENDER_EMAIL'),
|
||||
'phone' => env('BOXNOW_SENDER_PHONE'),
|
||||
],
|
||||
|
||||
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
|
||||
|
||||
];
|
||||
@@ -1,211 +0,0 @@
|
||||
<?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.box_now_locker_label' => [
|
||||
'Choose a Box Now locker',
|
||||
'Επίλεξε Box Now locker',
|
||||
],
|
||||
'page.box_now_locker_loading' => [
|
||||
'Loading lockers…',
|
||||
'Φόρτωση lockers…',
|
||||
],
|
||||
'page.box_now_locker_required' => [
|
||||
'Choose a Box Now locker to continue.',
|
||||
'Επίλεξε ένα Box Now locker για να συνεχίσεις.',
|
||||
],
|
||||
'page.box_now_locker_select' => [
|
||||
'Select this locker',
|
||||
'Επιλογή αυτού του locker',
|
||||
],
|
||||
'page.box_now_locker_selected' => [
|
||||
'Selected',
|
||||
'Επιλέχθηκε',
|
||||
],
|
||||
'page.box_now_locker_search' => [
|
||||
'Search by area or address…',
|
||||
'Αναζήτηση με περιοχή ή διεύθυνση…',
|
||||
],
|
||||
'page.box_now_locker_no_results' => [
|
||||
'No lockers match your search.',
|
||||
'Δεν βρέθηκαν lockers για αυτή την αναζήτηση.',
|
||||
],
|
||||
'page.select_shipping_method' => ['Continue', 'Συνέχεια'],
|
||||
'page.shipping_option_invalid' => [
|
||||
'That shipping option is no longer available.',
|
||||
'Αυτός ο τρόπος αποστολής δεν είναι πλέον διαθέσιμος.',
|
||||
],
|
||||
'page.continue_to_payment' => ['Continue to payment', 'Συνέχεια στην πληρωμή'],
|
||||
'page.order_summary_heading' => ['Order summary', 'Σύνοψη παραγγελίας'],
|
||||
|
||||
// ── Payment step ────────────────────────────────────────────
|
||||
'page.payment_heading' => ['Payment', 'Πληρωμή'],
|
||||
'page.payment_method_none' => [
|
||||
'No payment methods are available right now.',
|
||||
'Δεν υπάρχουν διαθέσιμοι τρόποι πληρωμής αυτή τη στιγμή.',
|
||||
],
|
||||
'page.terms_accept' => [
|
||||
"I accept the <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', 'Συνέχεια αγορών'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,7 @@ services:
|
||||
scheduler:
|
||||
volumes:
|
||||
- ../boboko-core:/var/www/boboko-core
|
||||
|
||||
vite:
|
||||
volumes:
|
||||
- ../boboko-core:/boboko-core
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
|
||||
queue:
|
||||
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
|
||||
entrypoint: ["/entrypoint-worker.sh"]
|
||||
command: php artisan queue:work --tries=3 --max-jobs=500 --memory=256
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
@@ -68,6 +69,7 @@ services:
|
||||
|
||||
scheduler:
|
||||
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
|
||||
entrypoint: ["/entrypoint-worker.sh"]
|
||||
command: php artisan schedule:work
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Mirrors entrypoint.sh's Composer block: node_modules is a named/anonymous
|
||||
# volume, not the bind-mounted host directory, so this container needs its
|
||||
# own live install on every boot rather than relying on whatever was baked in
|
||||
# at image-build time.
|
||||
echo "[entrypoint-vite] Installing npm dependencies..."
|
||||
npm install
|
||||
|
||||
# Re-resolve @boboko/core specifically on every boot, whether it's a local
|
||||
# path-repo checkout (file:../boboko-core, needs docker-compose.core-dev.yml's
|
||||
# ../boboko-core:/boboko-core mount) or a tagged VCS install
|
||||
# (git+https://...#vX.Y.Z) — the same manual toggle composer.json's
|
||||
# repositories/_repositories block uses for the PHP side of this same
|
||||
# package. A plain `npm install` above only installs what package-lock.json
|
||||
# already pins; it won't notice a new commit on the path-repo checkout or a
|
||||
# re-pushed tag. `npm update` re-resolves that one package against whatever
|
||||
# package.json currently says and rewrites package-lock.json to pin it —
|
||||
# exactly like `composer update boboko/* --with-all-dependencies` rewrites
|
||||
# composer.lock, which is committed so prod picks up the resolved version via
|
||||
# `npm install` alone (no `npm update` in prod's build).
|
||||
echo "[entrypoint-vite] Re-resolving @boboko/core..."
|
||||
npm update @boboko/core
|
||||
|
||||
exec "$@"
|
||||
@@ -79,6 +79,16 @@ echo "[entrypoint] Touched migrated file"
|
||||
echo "[entrypoint] Trying Lunar install"
|
||||
php artisan lunar:install --quiet || true
|
||||
|
||||
# Same idempotent per-key upsert as lunar:install's own seeding above, just kept
|
||||
# as separate seeder classes rather than folded into InstallLunarCommand — these
|
||||
# are Spatie translation-loader `validation`/`checkout` groups, not Lunar store
|
||||
# data, and each is safe to re-run on every boot.
|
||||
echo "[entrypoint] Seeding validation translations..."
|
||||
php artisan db:seed --class="Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder" --force --quiet || true
|
||||
|
||||
echo "[entrypoint] Seeding checkout translations..."
|
||||
php artisan db:seed --class="Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder" --force --quiet || true
|
||||
|
||||
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
|
||||
# every boot rather than flushing and rebuilding the whole index each time. Runs
|
||||
# in production too — a deploy that changed an indexer's field list needs this
|
||||
|
||||
@@ -9,6 +9,14 @@ content_path: /content
|
||||
media_path: /media
|
||||
thumbs_path: /var/www/html/storage/app/public/stoic/
|
||||
repository_path: /app
|
||||
# First locale is Stoic's default: it holds every field, the others only the
|
||||
# translatable ones (content/{locale}/...). Codes match app.available_locales.
|
||||
i18n:
|
||||
locales:
|
||||
- code: el
|
||||
label: Ελληνικά
|
||||
- code: en
|
||||
label: English
|
||||
presets:
|
||||
- code: large
|
||||
mode: resize
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"axios": "^1.15.2",
|
||||
@@ -18,11 +19,21 @@
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@boboko/core": {
|
||||
"version": "0.26.5",
|
||||
"resolved": "git+https://code.radical-elements.com/boboko/core.git#5f0dbbb734f10f0957eb53554743bd71d6db22e1",
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"leaflet": "^1.9.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -34,7 +45,6 @@
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -45,7 +55,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -121,7 +130,6 @@
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -140,7 +148,6 @@
|
||||
"version": "0.138.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
@@ -153,7 +160,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -170,7 +176,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -187,7 +192,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -204,7 +208,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -221,7 +224,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -238,7 +240,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -255,7 +256,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -272,7 +272,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -289,7 +288,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -306,7 +304,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -323,7 +320,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -340,7 +336,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -357,7 +352,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -376,7 +370,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -393,7 +386,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -407,7 +399,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
@@ -686,7 +677,6 @@
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -894,7 +884,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -994,7 +983,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -1048,7 +1036,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1210,7 +1197,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
@@ -1253,7 +1240,6 @@
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
@@ -1286,7 +1272,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1307,7 +1292,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1328,7 +1312,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1349,7 +1332,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1370,7 +1352,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1391,7 +1372,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1412,7 +1392,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1433,7 +1412,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1454,7 +1432,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1475,7 +1452,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1496,7 +1472,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1560,7 +1535,6 @@
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1579,14 +1553,12 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -1599,7 +1571,6 @@
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1647,7 +1618,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.138.0",
|
||||
@@ -1704,7 +1674,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -1779,7 +1748,6 @@
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -1806,14 +1774,13 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
|
||||
@@ -14,9 +14,14 @@
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"axios": "^1.15.2",
|
||||
"leaflet": "^1.9.4"
|
||||
},
|
||||
"_comment_boboko_core": "Mirrors composer.json's repositories/_repositories toggle: swap @boboko/core above with the line in _dependencies below (and back) by hand to switch between the local path-repo checkout and a tagged VCS install. The #semver:0.x range mirrors composer.json's own \"boboko/core\": \"0.*\" constraint exactly — note ^0.23.0 would NOT be equivalent here, since npm's caret locks the minor version too below 1.0.0 (>=0.23.0 <0.24.0). 0.x matches any 0.y.z tag, same as Composer's 0.*. npm reads the repo's git tags, filters to valid semver ones, and resolves the best match, so `npm update @boboko/core` alone picks up a newly pushed tag within range, no manual edit here per release. See docker/entrypoint-vite.sh and CONTRIBUTE.md.",
|
||||
"_dependencies": {
|
||||
"@boboko/core": "file:../boboko-core"
|
||||
}
|
||||
}
|
||||
|
||||
|
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 |
@@ -331,7 +331,7 @@ @layer components {
|
||||
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
|
||||
}
|
||||
|
||||
.btn-primary:hover::after {
|
||||
.btn-primary:not(:disabled):hover::after {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,935 +0,0 @@
|
||||
/*
|
||||
* 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 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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-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 tabs */
|
||||
|
||||
.bbk-checkout-tabs { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
.bbk-checkout-tab {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
margin-right: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: var(--bbk-color-bg);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--bbk-color-muted);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.bbk-checkout-tab[aria-selected="true"] {
|
||||
background: var(--bbk-color-text);
|
||||
border-color: var(--bbk-color-text);
|
||||
color: var(--bbk-color-bg);
|
||||
}
|
||||
|
||||
.bbk-checkout-tab:focus-visible {
|
||||
outline: 2px solid var(--bbk-color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
.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); }
|
||||
|
||||
/* Dummy Box Now locker picker — see shipping-options.blade.php. */
|
||||
.bbk-checkout-box-now-locker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-map {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-search {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--bbk-color-accent);
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-chosen {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--bbk-color-accent);
|
||||
}
|
||||
|
||||
/* Custom SVG pin (see bbk-box-now-locker-controller.js pinIcon()) — no
|
||||
default Leaflet drop-shadow image, so a CSS shadow stands in for it. */
|
||||
.bbk-box-now-pin svg {
|
||||
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
|
||||
}
|
||||
|
||||
/* Leaflet's own popup chrome, restyled to match the checkout's cards
|
||||
instead of the library's square-cornered default. */
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 0.875rem;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-image {
|
||||
width: calc(100% + 1.75rem);
|
||||
margin: -0.875rem -0.875rem 0.125rem;
|
||||
height: 110px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-name::before {
|
||||
content: '';
|
||||
flex: 0 0 auto;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #00c389;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-address {
|
||||
margin: 0;
|
||||
padding-left: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-note {
|
||||
margin: 0 0 0 0.875rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: color-mix(in srgb, #00c389 8%, transparent);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-select {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: #00c389;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-select:hover { background: #00a876; transform: translateY(-1px); }
|
||||
.bbk-box-now-popup-select:active { transform: translateY(0); }
|
||||
|
||||
.bbk-box-now-popup-select--selected,
|
||||
.bbk-box-now-popup-select--selected:hover {
|
||||
background: var(--bbk-color-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
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-body {
|
||||
margin: 2rem 0;
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
.bbk-confirmation-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
|
||||
|
||||
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
|
||||
|
||||
.bbk-confirmation-addresses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bbk-confirmation-address-heading {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bbk-address-lines {
|
||||
font-style: normal;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-confirmation-continue {
|
||||
max-width: 280px;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -10,13 +10,15 @@ window.Turbo.session.drive = false;
|
||||
|
||||
import { Application } from "@hotwired/stimulus";
|
||||
import { registerControllers } from "./stimulus/index";
|
||||
import { registerCheckout } from "./checkout";
|
||||
import { registerCheckout, registerWishlist } from "@boboko/core";
|
||||
|
||||
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.
|
||||
// Portable cart + checkout + wishlist modules, installed as a real npm
|
||||
// dependency (see package.json). Each owns its own Stimulus controllers;
|
||||
// these are the only wiring lines they need here.
|
||||
registerCheckout(application);
|
||||
registerWishlist(application);
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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 {
|
||||
async add(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const form = this.element
|
||||
const submit = form.querySelector('[type="submit"]')
|
||||
|
||||
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',
|
||||
},
|
||||
body: new FormData(form),
|
||||
})
|
||||
|
||||
if (!response.ok) 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { csrfToken } from './csrf'
|
||||
|
||||
// Box Now's own brand green, used for the pin instead of Leaflet's default
|
||||
// blue teardrop — a small SVG data URI rather than another bundled asset.
|
||||
const PIN_COLOR = '#00c389'
|
||||
const PIN_COLOR_SELECTED = '#0a7a52'
|
||||
|
||||
function pinIcon(color) {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="46" viewBox="0 0 34 46">
|
||||
<path
|
||||
d="M17 0C7.6 0 0 7.6 0 17c0 12.75 17 29 17 29s17-16.25 17-29C34 7.6 26.4 0 17 0Z"
|
||||
fill="${color}"
|
||||
stroke="#ffffff"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="17" cy="17" r="7" fill="#ffffff" />
|
||||
</svg>
|
||||
`
|
||||
|
||||
return L.divIcon({
|
||||
className: 'bbk-box-now-pin',
|
||||
html: svg,
|
||||
iconSize: [34, 46],
|
||||
iconAnchor: [17, 46],
|
||||
popupAnchor: [0, -40],
|
||||
})
|
||||
}
|
||||
|
||||
const ICON = pinIcon(PIN_COLOR)
|
||||
const ICON_SELECTED = pinIcon(PIN_COLOR_SELECTED)
|
||||
|
||||
// A self-hosted Leaflet map standing in for Box Now's own Destination Map
|
||||
// JS widget — that widget only talks to Box Now's Production API (see
|
||||
// their Partner API manual §4.1), so it can't be used while developing
|
||||
// against Stage credentials. Same underlying /destinations data, rendered
|
||||
// with OpenStreetMap tiles instead of Box Now's map.
|
||||
//
|
||||
// Visibility is toggled by bbk-checkout-form (see its own
|
||||
// toggleBoxNowLocker()) whenever the "box-now" shipping option becomes
|
||||
// selected/deselected — this controller only owns loading the locker list
|
||||
// once visible, rendering pins, and autosaving the chosen one.
|
||||
export default class extends Controller {
|
||||
static targets = ['map', 'search', 'status', 'chosen']
|
||||
|
||||
static values = {
|
||||
lockersUrl: String,
|
||||
selectUrl: String,
|
||||
loading: String,
|
||||
selectLabel: String,
|
||||
selectedLabel: String,
|
||||
noResults: String,
|
||||
}
|
||||
|
||||
// Athens — a reasonable default center before any locker is loaded.
|
||||
static DEFAULT_CENTER = [37.9838, 23.7275]
|
||||
|
||||
connect() {
|
||||
this.map = null
|
||||
this.markers = new Map()
|
||||
this.selectedId = null
|
||||
this.loaded = false
|
||||
|
||||
if (!this.element.hidden) this.show()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.map?.remove()
|
||||
this.map = null
|
||||
}
|
||||
|
||||
// Called by bbk-checkout-form right after it un-hides this element.
|
||||
show() {
|
||||
this.element.hidden = false
|
||||
|
||||
// Leaflet measures its container's size on init — doing that while
|
||||
// the element (or an ancestor) is still `hidden` produces a
|
||||
// collapsed/blank map, so this is deferred to the same tick `hidden`
|
||||
// is cleared, then Leaflet is nudged once more via invalidateSize().
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.map) this.initMap()
|
||||
this.map.invalidateSize()
|
||||
|
||||
if (!this.loaded) this.loadLockers()
|
||||
})
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.element.hidden = true
|
||||
}
|
||||
|
||||
initMap() {
|
||||
this.map = L.map(this.mapTarget).setView(this.constructor.DEFAULT_CENTER, 10)
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19,
|
||||
}).addTo(this.map)
|
||||
|
||||
// Delegated: popup content is re-inserted by Leaflet on every open,
|
||||
// so a listener bound once on the map's container beats binding (and
|
||||
// losing) one on the button each time a popup renders.
|
||||
this.map.getContainer().addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-locker-id]')
|
||||
if (button) this.select(button.dataset.lockerId)
|
||||
})
|
||||
}
|
||||
|
||||
async loadLockers() {
|
||||
this.setStatus(this.loadingValue)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.lockersUrlValue, {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const { lockers } = await response.json()
|
||||
this.loaded = true
|
||||
this.lockers = new Map(lockers.map((locker) => [String(locker.id), locker]))
|
||||
this.renderMarkers(lockers)
|
||||
this.setStatus('')
|
||||
} catch {
|
||||
this.setStatus('')
|
||||
}
|
||||
}
|
||||
|
||||
renderMarkers(lockers) {
|
||||
this.markers.forEach((marker) => marker.remove())
|
||||
this.markers = new Map(lockers.map((locker) => {
|
||||
const marker = L.marker([locker.lat, locker.lng], { icon: ICON })
|
||||
.addTo(this.map)
|
||||
.bindPopup(this.popupHtml(locker), { maxWidth: 260 })
|
||||
|
||||
return [String(locker.id), marker]
|
||||
}))
|
||||
|
||||
if (this.markers.size) {
|
||||
this.map.fitBounds(L.featureGroup([...this.markers.values()]).getBounds().pad(0.2))
|
||||
}
|
||||
}
|
||||
|
||||
popupHtml(locker) {
|
||||
const isSelected = String(locker.id) === this.selectedId
|
||||
|
||||
return `
|
||||
<div class="bbk-box-now-popup">
|
||||
${locker.image ? `<img class="bbk-box-now-popup-image" src="${locker.image}" alt="">` : ''}
|
||||
<p class="bbk-box-now-popup-name">${locker.name}</p>
|
||||
<p class="bbk-box-now-popup-address">
|
||||
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')}
|
||||
${locker.postalCode ? ` ${locker.postalCode}` : ''}
|
||||
</p>
|
||||
${locker.note ? `<p class="bbk-box-now-popup-note">${locker.note}</p>` : ''}
|
||||
<button
|
||||
type="button"
|
||||
class="bbk-box-now-popup-select${isSelected ? ' bbk-box-now-popup-select--selected' : ''}"
|
||||
data-locker-id="${locker.id}"
|
||||
${isSelected ? 'disabled' : ''}
|
||||
>
|
||||
${isSelected ? this.selectedLabelValue : this.selectLabelValue}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
async select(lockerId) {
|
||||
const locker = this.lockers?.get(String(lockerId))
|
||||
if (!locker) return
|
||||
|
||||
const previousId = this.selectedId
|
||||
this.selectedId = String(lockerId)
|
||||
|
||||
this.restyleMarker(previousId, ICON)
|
||||
this.restyleMarker(this.selectedId, ICON_SELECTED)
|
||||
this.markers.get(this.selectedId)?.setPopupContent(this.popupHtml(locker))
|
||||
|
||||
this.chosenTarget.hidden = false
|
||||
this.chosenTarget.textContent = locker.addressLine1
|
||||
? `${locker.name} — ${locker.addressLine1}`
|
||||
: locker.name
|
||||
|
||||
const body = new FormData()
|
||||
body.append('locker_id', locker.id)
|
||||
body.append('locker_name', locker.name ?? '')
|
||||
body.append('locker_address', locker.addressLine1 ?? '')
|
||||
|
||||
try {
|
||||
await fetch(this.selectUrlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
})
|
||||
} catch {
|
||||
// Best-effort autosave, same as the rest of checkout — a failed
|
||||
// save here surfaces later at place-order time via the normal
|
||||
// shipment-creation error path, not as an inline field error.
|
||||
}
|
||||
}
|
||||
|
||||
restyleMarker(lockerId, icon) {
|
||||
if (!lockerId) return
|
||||
this.markers.get(lockerId)?.setIcon(icon)
|
||||
}
|
||||
|
||||
setStatus(text) {
|
||||
if (!this.hasStatusTarget) return
|
||||
|
||||
this.statusTarget.textContent = text
|
||||
this.statusTarget.hidden = !text
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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']
|
||||
|
||||
connect() {
|
||||
this.onChanged = this.onChanged.bind(this)
|
||||
this.onKeydown = this.onKeydown.bind(this)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) 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.send(form)
|
||||
}
|
||||
|
||||
async send(form) {
|
||||
this.bodyTarget.setAttribute('aria-busy', 'true')
|
||||
|
||||
try {
|
||||
const response = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: new FormData(form),
|
||||
})
|
||||
|
||||
if (response.ok) this.replaceBody(await response.text())
|
||||
} finally {
|
||||
this.bodyTarget.removeAttribute('aria-busy')
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
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 = [
|
||||
'guestTab', 'loginTab', 'guestPanel', 'loginPanel',
|
||||
'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()
|
||||
}
|
||||
|
||||
// ── Contact tabs ────────────────────────────────────────────────────
|
||||
|
||||
showGuest() {
|
||||
this.guestPanelTarget.hidden = false
|
||||
this.loginPanelTarget.hidden = true
|
||||
this.guestTabTarget.setAttribute('aria-selected', 'true')
|
||||
this.loginTabTarget.setAttribute('aria-selected', 'false')
|
||||
}
|
||||
|
||||
showLogin() {
|
||||
this.guestPanelTarget.hidden = true
|
||||
this.loginPanelTarget.hidden = false
|
||||
this.guestTabTarget.setAttribute('aria-selected', 'false')
|
||||
this.loginTabTarget.setAttribute('aria-selected', 'true')
|
||||
}
|
||||
|
||||
// ── Same as billing ────────────────────────────────────────────────
|
||||
|
||||
toggleSameAsBilling() {
|
||||
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'
|
||||
|| el.closest('[data-bbk-checkout-form-target="guestPanel"]')
|
||||
if (!belongsToForm) return
|
||||
|
||||
// No status during the wait — it only shows once the request is in flight,
|
||||
// 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) {
|
||||
this.toggleBoxNowLocker(event.target.value)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// The dummy Box Now locker <select> (see shipping-options.blade.php)
|
||||
// lives inside the #bbk-shipping-options fragment this controller
|
||||
// re-renders wholesale on every shipping-option change — so its own
|
||||
// Stimulus controller reconnects fresh each time and has no memory of
|
||||
// which option was previously selected. This is the one place that
|
||||
// knows the newly-chosen option's identifier, so it also owns
|
||||
// showing/hiding the picker.
|
||||
toggleBoxNowLocker(identifier) {
|
||||
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
|
||||
if (!picker) return
|
||||
|
||||
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
|
||||
|
||||
if (identifier === 'box-now') {
|
||||
controller?.show()
|
||||
} else {
|
||||
controller?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
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) })
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// 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') || ''
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
||||
import BbkBoxNowLockerController from './bbk-box-now-locker-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-box-now-locker', BbkBoxNowLockerController)
|
||||
application.register('bbk-cart', BbkCartController)
|
||||
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
||||
application.register('bbk-payment', BbkPaymentController)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// One product custom field of type `file` (see x-product-custom-fields).
|
||||
// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController)
|
||||
// as soon as it's picked, then writes the returned File row's id (boboko-core's
|
||||
// Modules\Core\File\Models\File) into the hidden input the add-to-cart form
|
||||
// actually submits — the checkout module never receives the file itself.
|
||||
//
|
||||
// While uploading, the file input is marked invalid via setCustomValidity(),
|
||||
// so the browser's own form validation blocks add-to-cart until the id is in
|
||||
// place. A failed upload clears the input, so `required` blocks it too.
|
||||
export default class extends Controller {
|
||||
static targets = ['file', 'reference', 'preview', 'error']
|
||||
static values = {
|
||||
url: String,
|
||||
label: String,
|
||||
uploadingMessage: String,
|
||||
failedMessage: String,
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.abortController?.abort()
|
||||
this.revokePreview()
|
||||
}
|
||||
|
||||
async upload() {
|
||||
this.reset()
|
||||
|
||||
const file = this.fileTarget.files[0]
|
||||
if (!file) return
|
||||
|
||||
const abortController = new AbortController()
|
||||
this.abortController = abortController
|
||||
|
||||
this.fileTarget.setCustomValidity(this.uploadingMessageValue)
|
||||
this.fileTarget.setAttribute('aria-busy', 'true')
|
||||
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
body.append('label', this.labelValue)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.urlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok || !data?.file_id) {
|
||||
this.fail(data?.error)
|
||||
return
|
||||
}
|
||||
|
||||
this.referenceTarget.value = data.file_id
|
||||
this.showPreview(file)
|
||||
} catch (error) {
|
||||
// A newer pick superseded this upload — reset() already handled it.
|
||||
if (error.name === 'AbortError') return
|
||||
this.fail()
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) this.markIdle()
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.abortController?.abort()
|
||||
this.referenceTarget.value = ''
|
||||
this.errorTarget.hidden = true
|
||||
this.markIdle()
|
||||
this.revokePreview()
|
||||
}
|
||||
|
||||
fail(message) {
|
||||
this.fileTarget.value = ''
|
||||
this.errorTarget.textContent = message || this.failedMessageValue
|
||||
this.errorTarget.hidden = false
|
||||
}
|
||||
|
||||
markIdle() {
|
||||
this.fileTarget.setCustomValidity('')
|
||||
this.fileTarget.removeAttribute('aria-busy')
|
||||
}
|
||||
|
||||
showPreview(file) {
|
||||
this.previewUrl = URL.createObjectURL(file)
|
||||
this.previewTarget.src = this.previewUrl
|
||||
this.previewTarget.hidden = false
|
||||
}
|
||||
|
||||
// Formats the browser can't render (HEIC outside Safari) — the file
|
||||
// input's own filename is enough there.
|
||||
hidePreview() {
|
||||
this.previewTarget.hidden = true
|
||||
}
|
||||
|
||||
revokePreview() {
|
||||
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
|
||||
this.previewUrl = null
|
||||
this.previewTarget.removeAttribute('src')
|
||||
this.previewTarget.hidden = true
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import AutoSubmitController from './auto-submit-controller'
|
||||
import BackToTopController from './back-to-top-controller'
|
||||
import CartCountController from './cart-count-controller'
|
||||
import CarouselController from './carousel-controller'
|
||||
import CustomFieldUploadController from './custom-field-upload-controller'
|
||||
import DropdownController from './dropdown-controller'
|
||||
import FrameScrollController from './frame-scroll-controller'
|
||||
import NavSearchController from './nav-search-controller'
|
||||
@@ -16,6 +17,7 @@ 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'
|
||||
|
||||
export function registerControllers(application) {
|
||||
@@ -24,6 +26,7 @@ export function registerControllers(application) {
|
||||
application.register('back-to-top', BackToTopController)
|
||||
application.register('cart-count', CartCountController)
|
||||
application.register('carousel', CarouselController)
|
||||
application.register('custom-field-upload', CustomFieldUploadController)
|
||||
application.register('dropdown', DropdownController)
|
||||
application.register('frame-scroll', FrameScrollController)
|
||||
application.register('nav-search', NavSearchController)
|
||||
@@ -32,5 +35,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -2,25 +2,138 @@ import { Controller } from '@hotwired/stimulus'
|
||||
import { formatPrice } from '../utils/format-price'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['price', 'image', 'swatch', 'colorName']
|
||||
static values = { variants: Array, selected: Number }
|
||||
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError', 'submit']
|
||||
static values = {
|
||||
variants: Array,
|
||||
selected: Number,
|
||||
stockCheckUrl: String,
|
||||
// Two pre-rendered translated templates (see product/show.blade.php)
|
||||
// rather than one — this controller doesn't reimplement Laravel's
|
||||
// pluralization rules, it just picks whichever of these two the
|
||||
// count actually needs and fills in the number.
|
||||
stockErrorOne: String,
|
||||
stockErrorMany: String,
|
||||
}
|
||||
|
||||
connect() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlId = parseInt(params.get('variant'))
|
||||
const defaultId = this.variantsValue[0]?.id
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlId = parseInt(params.get('variant'))
|
||||
const urlVariant = this.variantsValue.find(v => v.id === urlId)
|
||||
const initial = urlVariant ?? this.variantsValue[0]
|
||||
|
||||
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
|
||||
? urlId
|
||||
: defaultId
|
||||
// A product can have several independent options (e.g. size + style
|
||||
// + person-count) — this tracks the currently-picked value id per
|
||||
// option handle, and selectVariant() below resolves the full
|
||||
// combination back to one exact variant on every change.
|
||||
this.selections = { ...initial?.options }
|
||||
this.selectedValue = initial?.id
|
||||
|
||||
// Capture phase, on this controller's own root element (an ancestor
|
||||
// of the checkout module's add-to-cart <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 = !this.selectedVariant?.inStock
|
||||
}
|
||||
|
||||
form.dataset.bbkStockChecked = 'true'
|
||||
form.requestSubmit()
|
||||
}
|
||||
|
||||
showStockError(available) {
|
||||
if (!this.hasStockErrorTarget) return
|
||||
|
||||
this.stockErrorTarget.textContent = available === 1
|
||||
? this.stockErrorOneValue
|
||||
: this.stockErrorManyValue.replace(':count', String(available))
|
||||
this.stockErrorTarget.hidden = false
|
||||
}
|
||||
|
||||
clearStockError() {
|
||||
if (!this.hasStockErrorTarget) return
|
||||
this.stockErrorTarget.hidden = true
|
||||
}
|
||||
|
||||
get selectedVariant() {
|
||||
return this.variantsValue.find(v => v.id === this.selectedValue)
|
||||
}
|
||||
|
||||
selectVariant(event) {
|
||||
const id = parseInt(event.currentTarget.dataset.variantId)
|
||||
this.selectedValue = id
|
||||
const option = event.currentTarget.dataset.option
|
||||
const valueId = parseInt(event.currentTarget.dataset.valueId)
|
||||
this.selections = { ...this.selections, [option]: valueId }
|
||||
|
||||
const match = this.variantsValue.find(variant =>
|
||||
Object.keys(this.selections).every(key => variant.options?.[key] === this.selections[key])
|
||||
)
|
||||
|
||||
// No variant exists for this combination (e.g. an option value that
|
||||
// isn't offered together with another currently-selected value) —
|
||||
// leave the previous selection in place rather than pointing the
|
||||
// add-to-cart form at nothing.
|
||||
if (!match) return
|
||||
|
||||
this.selectedValue = match.id
|
||||
|
||||
const url = new URL(window.location)
|
||||
url.searchParams.set('variant', id)
|
||||
url.searchParams.set('variant', match.id)
|
||||
window.history.pushState({}, '', url)
|
||||
}
|
||||
|
||||
@@ -30,6 +143,8 @@ export default class extends Controller {
|
||||
const variant = this.variantsValue.find(v => v.id === id)
|
||||
if (!variant) return
|
||||
|
||||
this.clearStockError()
|
||||
|
||||
if (this.hasPriceTarget && variant.price !== null) {
|
||||
this.priceTarget.textContent = formatPrice(variant.price)
|
||||
}
|
||||
@@ -45,13 +160,20 @@ export default class extends Controller {
|
||||
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
|
||||
if (purchasableInput) purchasableInput.value = id
|
||||
|
||||
// Out-of-stock variant (as of page load) can't be added at all.
|
||||
if (this.hasSubmitTarget) this.submitTarget.disabled = !variant.inStock
|
||||
|
||||
this.swatchTargets.forEach(swatch => {
|
||||
const isSelected = parseInt(swatch.dataset.variantId) === id
|
||||
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
|
||||
swatch.classList.toggle('is-selected', isSelected)
|
||||
swatch.setAttribute('aria-pressed', String(isSelected))
|
||||
|
||||
if (isSelected && this.hasColorNameTarget) {
|
||||
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
|
||||
if (isSelected) {
|
||||
// Each color-option group has its own colorName echo (see
|
||||
// x-ui.color-swatch) — matched by option handle so a swatch
|
||||
// in one group never overwrites another group's label.
|
||||
const colorName = this.colorNameTargets.find(target => target.dataset.option === swatch.dataset.option)
|
||||
if (colorName) colorName.textContent = swatch.getAttribute('aria-label')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,13 @@
|
||||
---
|
||||
name: "Επικοινωνία"
|
||||
slug: "contact"
|
||||
|
||||
meta_title: "Επικοινωνία"
|
||||
meta_description: "Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό."
|
||||
og_image: ""
|
||||
|
||||
heading_first: "Επικοινώνησε"
|
||||
heading_second: "μαζί μας"
|
||||
heading_highlight: "άμεσα"
|
||||
intro: "Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό."
|
||||
---
|
||||
@@ -18,12 +18,12 @@ ## 1. Υπεύθυνος επεξεργασίας
|
||||
|
||||
Υπεύθυνος επεξεργασίας των προσωπικών δεδομένων που συλλέγονται μέσω του ηλεκτρονικού καταστήματος είναι:
|
||||
|
||||
**3DEALER**
|
||||
**{store_legal_name}**
|
||||
Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα
|
||||
ΑΦΜ: @TODO
|
||||
ΓΕΜΗ: @TODO
|
||||
Email: [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||
Τηλέφωνο: 6976 443140
|
||||
ΑΦΜ: {store_tax_identifier}
|
||||
ΓΕΜΗ: {store_registration_number}
|
||||
Email: [{store_contact_email}](mailto:{store_contact_email})
|
||||
Τηλέφωνο: {store_phone}
|
||||
|
||||
Η 3DEALER καθορίζει τους σκοπούς και τα μέσα επεξεργασίας των προσωπικών δεδομένων των πελατών και χρηστών του ηλεκτρονικού καταστήματος.
|
||||
|
||||
@@ -81,7 +81,7 @@ ## 6. Newsletter και εμπορική επικοινωνία
|
||||
|
||||
Η εγγραφή στο newsletter πραγματοποιείται με τη συγκατάθεσή σας.
|
||||
|
||||
Μπορείτε να ανακαλέσετε τη συγκατάθεσή σας οποιαδήποτε στιγμή, χρησιμοποιώντας τον σύνδεσμο απεγγραφής που περιλαμβάνεται στα emails ή επικοινωνώντας μαζί μας στο [print@3dealer.gr](mailto:print@3dealer.gr).
|
||||
Μπορείτε να ανακαλέσετε τη συγκατάθεσή σας οποιαδήποτε στιγμή, χρησιμοποιώντας τον σύνδεσμο απεγγραφής που περιλαμβάνεται στα emails ή επικοινωνώντας μαζί μας στο [{store_contact_email}](mailto:{store_contact_email}).
|
||||
|
||||
Η ανάκληση της συγκατάθεσης δεν επηρεάζει τη νομιμότητα της επεξεργασίας που πραγματοποιήθηκε πριν από αυτήν.
|
||||
|
||||
@@ -186,8 +186,8 @@ ## 16. Τα δικαιώματά σας
|
||||
|
||||
Για την άσκηση των δικαιωμάτων σας μπορείτε να επικοινωνήσετε μαζί μας:
|
||||
|
||||
**Email:** [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||
**Τηλέφωνο:** 6976 443140
|
||||
**Email:** [{store_contact_email}](mailto:{store_contact_email})
|
||||
**Τηλέφωνο:** {store_phone}
|
||||
|
||||
Θα απαντήσουμε στο αίτημά σας χωρίς αδικαιολόγητη καθυστέρηση και, κατά κανόνα, εντός ενός (1) μήνα από την παραλαβή του.
|
||||
|
||||
@@ -45,7 +45,7 @@ ## 3. Χρόνος παράδοσης
|
||||
|
||||
Η 3DEALER δεν ευθύνεται για καθυστερήσεις που οφείλονται αποκλειστικά στην εταιρεία μεταφοράς, σε ακραία καιρικά φαινόμενα, απεργίες, προβλήματα στο δίκτυο μεταφορών ή άλλες περιστάσεις που βρίσκονται εκτός του εύλογου ελέγχου της.
|
||||
|
||||
Σε περίπτωση καθυστέρησης, μπορείτε να επικοινωνήσετε μαζί μας στο [print@3dealer.gr](mailto:print@3dealer.gr).
|
||||
Σε περίπτωση καθυστέρησης, μπορείτε να επικοινωνήσετε μαζί μας στο [{store_contact_email}](mailto:{store_contact_email}).
|
||||
|
||||
## 4. Παραλαβή παραγγελίας
|
||||
|
||||
@@ -55,7 +55,7 @@ ## 4. Παραλαβή παραγγελίας
|
||||
|
||||
## 5. Προϊόν που παραδόθηκε κατεστραμμένο
|
||||
|
||||
Σε περίπτωση που το προϊόν παραδοθεί εμφανώς κατεστραμμένο, παρακαλούμε να επικοινωνήσετε μαζί μας το συντομότερο δυνατό στο [print@3dealer.gr](mailto:print@3dealer.gr) και να μας αποστείλετε φωτογραφίες του προϊόντος και της συσκευασίας.
|
||||
Σε περίπτωση που το προϊόν παραδοθεί εμφανώς κατεστραμμένο, παρακαλούμε να επικοινωνήσετε μαζί μας το συντομότερο δυνατό στο [{store_contact_email}](mailto:{store_contact_email}) και να μας αποστείλετε φωτογραφίες του προϊόντος και της συσκευασίας.
|
||||
|
||||
Η 3DEALER θα εξετάσει το περιστατικό και, εφόσον διαπιστωθεί ζημιά που προέκυψε κατά τη μεταφορά ή άλλη έλλειψη συμμόρφωσης, θα προχωρήσει στην κατάλληλη λύση σύμφωνα με την ισχύουσα νομοθεσία.
|
||||
|
||||
@@ -63,7 +63,7 @@ ## 6. Δικαίωμα υπαναχώρησης
|
||||
|
||||
Για τις αγορές που εμπίπτουν στο πεδίο εφαρμογής του δικαιώματος υπαναχώρησης, ο καταναλωτής μπορεί να υπαναχωρήσει από τη σύμβαση εντός δεκατεσσάρων (14) ημερολογιακών ημερών από την ημέρα παραλαβής του προϊόντος.
|
||||
|
||||
Για την άσκηση του δικαιώματος υπαναχώρησης, ο πελάτης πρέπει να ενημερώσει την 3DEALER με σαφή δήλωση στο [print@3dealer.gr](mailto:print@3dealer.gr).
|
||||
Για την άσκηση του δικαιώματος υπαναχώρησης, ο πελάτης πρέπει να ενημερώσει την 3DEALER με σαφή δήλωση στο [{store_contact_email}](mailto:{store_contact_email}).
|
||||
|
||||
Το προϊόν πρέπει να επιστραφεί χωρίς αδικαιολόγητη καθυστέρηση και, σε κάθε περίπτωση, εντός δεκατεσσάρων (14) ημερών από την ημέρα κατά την οποία ο πελάτης ενημέρωσε την 3DEALER για την απόφασή του να υπαναχωρήσει.
|
||||
|
||||
@@ -101,4 +101,4 @@ ## 10. Ελαττωματικά ή μη συμμορφούμενα προϊόν
|
||||
|
||||
Η ύπαρξη μικρών αισθητικών χαρακτηριστικών που είναι φυσιολογικά για την τρισδιάστατη εκτύπωση, όπως γραμμές στρώσεων ή μικρές διαφοροποιήσεις, δεν αποτελεί από μόνη της ελάττωμα.
|
||||
|
||||
Για οποιοδήποτε πρόβλημα με προϊόν, επικοινωνήστε μαζί μας στο [print@3dealer.gr](mailto:print@3dealer.gr), αναφέροντας τον αριθμό παραγγελίας και, όπου είναι δυνατόν, επισυνάπτοντας φωτογραφίες.
|
||||
Για οποιοδήποτε πρόβλημα με προϊόν, επικοινωνήστε μαζί μας στο [{store_contact_email}](mailto:{store_contact_email}), αναφέροντας τον αριθμό παραγγελίας και, όπου είναι δυνατόν, επισυνάπτοντας φωτογραφίες.
|
||||
@@ -1,25 +1,23 @@
|
||||
---
|
||||
name: "Όροι & Προϋποθέσεις"
|
||||
name: "Όροι & Προϋποθέσεις Terms"
|
||||
slug: "terms-and-conditions"
|
||||
|
||||
meta_title: ""
|
||||
meta_description: ""
|
||||
og_image: ""
|
||||
noindex: true
|
||||
|
||||
---
|
||||
|
||||
## 1. Γενικά
|
||||
|
||||
Το ηλεκτρονικό κατάστημα 3DEALER (εφεξής «το Κατάστημα» ή «3DEALER») λειτουργεί από την επιχείρηση 3DEALER, με έδρα στην Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα.
|
||||
Το ηλεκτρονικό κατάστημα 3DEALER (εφεξής «το Κατάστημα» ή «3DEALER») λειτουργεί από την επιχείρηση {store_legal_name}, με έδρα στην Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα.
|
||||
|
||||
**ΑΦΜ:** @TODO
|
||||
**ΓΕΜΗ:** @TODO
|
||||
**ΑΦΜ:** {store_tax_identifier}\
|
||||
**ΓΕΜΗ:** {store_registration_number}
|
||||
|
||||
Για οποιαδήποτε πληροφορία ή επικοινωνία σχετικά με παραγγελίες, προϊόντα ή τη λειτουργία του Καταστήματος, μπορείτε να επικοινωνείτε μαζί μας:
|
||||
|
||||
**Email:** [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||
**Τηλέφωνο:** 6976 443140
|
||||
**Email:** [{store_contact_email}](mailto:{store_contact_email})\
|
||||
**Τηλέφωνο:** {store_phone}
|
||||
|
||||
Η χρήση του Καταστήματος και η πραγματοποίηση αγορών μέσω αυτού προϋποθέτουν την αποδοχή των παρόντων Όρων & Προϋποθέσεων.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
meta_description: ""
|
||||
og_image: ""
|
||||
facebook_url: "https://www.facebook.com/3Dealer.gr/"
|
||||
instagram_url: "https://www.instagram.com/3dealer.gr/"
|
||||
tiktok_url: "https://www.tiktok.com/@3dealer.gr"
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: "Contact"
|
||||
meta_title: "Contact"
|
||||
meta_description: "Get in touch with any question or idea you have and we'll get back to you as soon as we can."
|
||||
heading_first: "Contact"
|
||||
heading_second: "us"
|
||||
heading_highlight: "today"
|
||||
intro: "Got a question, an idea or something to tell us? Send us your message and we'll get back to you as soon as we can."
|
||||
---
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: "Cookie Policy"
|
||||
meta_title: ""
|
||||
meta_description: ""
|
||||
---
|
||||
|
||||
This Cookie Policy explains how 3DEALER uses cookies and similar technologies when you visit the online store.
|
||||
|
||||
## 1. What cookies are
|
||||
|
||||
Cookies are small text files stored on your device when you visit a website. They are used to make the website work properly, store preferences, collect statistics and, where permitted, show or measure advertising.
|
||||
|
||||
Besides cookies, we may also use similar technologies, such as pixels and tags.
|
||||
|
||||
## 2. Which cookies we use
|
||||
|
||||
3DEALER uses different categories of cookies, depending on their purpose.
|
||||
|
||||
### Essential cookies
|
||||
|
||||
Essential cookies are those required for the online store to work correctly and securely.
|
||||
|
||||
They can be used, among other things, for:
|
||||
|
||||
* running the shopping cart,
|
||||
* keeping your session,
|
||||
* signing in to your account,
|
||||
* completing checkout,
|
||||
* keeping the website secure,
|
||||
* storing your cookie choices.
|
||||
|
||||
These cookies cannot be turned off through the consent tool, because without them some basic functions of the online store would not be available.
|
||||
|
||||
### Functionality cookies
|
||||
|
||||
Functionality cookies are used to store choices and preferences that improve your experience of the online store.
|
||||
|
||||
**@TODO: List the specific functionality cookies used by Boboko/3DEALER.**
|
||||
|
||||
### Analytics cookies
|
||||
|
||||
Analytics cookies are used to understand how visitors use the online store.
|
||||
|
||||
3DEALER uses services such as **Google Analytics** and may use **Google Tag Manager** to manage the related tags.
|
||||
|
||||
The information collected may include details such as:
|
||||
|
||||
* which pages you visit,
|
||||
* how long you stay on the website,
|
||||
* which page you came from,
|
||||
* the device and browser you use,
|
||||
* general information about how you interact with the website.
|
||||
|
||||
These cookies are only activated in line with your consent choices, where required by applicable law.
|
||||
|
||||
### Advertising and marketing cookies
|
||||
|
||||
3DEALER uses advertising measurement and marketing technologies, such as:
|
||||
|
||||
* **Meta Pixel**
|
||||
* **TikTok Pixel**
|
||||
|
||||
These technologies can be used to measure how effective advertising is, to understand interactions with ads and, where supported and permitted, to show more relevant ads.
|
||||
|
||||
The related cookies and pixels are only activated in line with your consent choices, where required.
|
||||
|
||||
**@TODO: Confirm the exact Meta/TikTok cookies and events used in production.**
|
||||
|
||||
## 3. Cookie table
|
||||
|
||||
The table below should be updated based on the cookies actually used in the production environment.
|
||||
|
||||
| Cookie / Technology | Provider | Category | Purpose | Duration |
|
||||
| ------------------- | ---------------- | --------- | ------------------------------------------- | -------- |
|
||||
| @TODO | Boboko / 3DEALER | Essential | Store operation | @TODO |
|
||||
| @TODO | Boboko / 3DEALER | Essential | Cart / checkout | @TODO |
|
||||
| @TODO | Google | Analytics | Usage statistics | @TODO |
|
||||
| @TODO | Meta | Marketing | Advertising measurement | @TODO |
|
||||
| @TODO | TikTok | Marketing | Advertising measurement | @TODO |
|
||||
| @TODO | hCaptcha | Security | Protection against automated/malicious use | @TODO |
|
||||
|
||||
**@TODO: The table must be completed after checking the production site, ideally with a cookie scan.**
|
||||
|
||||
## 4. Consent to the use of cookies
|
||||
|
||||
On your first visit to the online store, a cookie consent tool is shown, where you can choose which categories of non-essential cookies you allow.
|
||||
|
||||
You can:
|
||||
|
||||
* accept all cookies,
|
||||
* reject non-essential cookies,
|
||||
* choose specific categories of cookies.
|
||||
|
||||
Essential cookies stay active, as they are needed for the online store to work.
|
||||
|
||||
Your choice is saved and applied on your future visits.
|
||||
|
||||
## 5. Withdrawing or changing your consent
|
||||
|
||||
You can change or withdraw your consent to the use of non-essential cookies at any time.
|
||||
|
||||
**@TODO: Add how Boboko's cookie preference centre is reopened, e.g. through a "Cookie settings" link in the footer.**
|
||||
|
||||
Withdrawing consent does not affect the lawfulness of processing carried out before the withdrawal.
|
||||
|
||||
## 6. Third-party cookies
|
||||
|
||||
Some of the services we use are provided by third parties, such as Google, Meta, TikTok and hCaptcha.
|
||||
|
||||
These third-party providers may process information collected through cookies or similar technologies in accordance with their own policies and terms.
|
||||
|
||||
Using third-party services may also involve transferring data outside the European Economic Area. Where required, the safeguards provided by law for such transfers are applied.
|
||||
|
||||
## 7. Cookies and personal data
|
||||
|
||||
Some cookies or similar technologies may lead to the processing of information that counts as personal data.
|
||||
|
||||
For more information on how 3DEALER collects and processes personal data, see the **Privacy Policy**.
|
||||
|
||||
## 8. Browser settings
|
||||
|
||||
You can also limit or delete cookies through your browser settings.
|
||||
|
||||
However, turning off some cookies may affect how the online store works or prevent some services from working properly.
|
||||
|
||||
## 9. Changes to this Cookie Policy
|
||||
|
||||
This Cookie Policy may be changed when the technologies we use, the services of the online store or the relevant legislation change.
|
||||
|
||||
The latest version will always be available on the 3DEALER website.
|
||||
|
||||
**Last updated:** @TODO
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: "Home"
|
||||
meta_title: "3dealer: Whatever you imagine, printed in 3D"
|
||||
meta_description: "Gaming figures, skulls, key holders and the craziest things you can dream up. Designed and 3D printed, one by one."
|
||||
trivia_body: "Gaming figures, skulls, key holders, dark humor and the craziest things you can dream up, all designed and printed right here in Greece."
|
||||
classics_title: "Legendary & unbeatable"
|
||||
---
|
||||
@@ -0,0 +1,196 @@
|
||||
---
|
||||
name: "Privacy Policy"
|
||||
meta_title: ""
|
||||
meta_description: ""
|
||||
---
|
||||
|
||||
3DEALER ("3DEALER", "we", "us") respects the privacy of its visitors and customers and protects their personal data in accordance with applicable law, including the General Data Protection Regulation (EU) 2016/679 ("GDPR") and the related Greek legislation.
|
||||
|
||||
This Privacy Policy explains what personal data we collect, what we use it for, who we may share it with and what rights you have.
|
||||
|
||||
## 1. Data controller
|
||||
|
||||
The controller of the personal data collected through the online store is:
|
||||
|
||||
**{store_legal_name}**
|
||||
Parou 31, Kypseli, 11255 Athens, Greece
|
||||
Tax ID (ΑΦΜ): {store_tax_identifier}
|
||||
Company registration number (ΓΕΜΗ): {store_registration_number}
|
||||
Email: [{store_contact_email}](mailto:{store_contact_email})
|
||||
Phone: {store_phone}
|
||||
|
||||
3DEALER determines the purposes and means of processing the personal data of the online store's customers and users.
|
||||
|
||||
## 2. What data we collect
|
||||
|
||||
Depending on how you use the online store, we may collect:
|
||||
|
||||
* your full name,
|
||||
* your email address,
|
||||
* your phone number,
|
||||
* your shipping and billing address,
|
||||
* order and product details,
|
||||
* payment-related details,
|
||||
* contact details and the content of messages you send us,
|
||||
* user account details,
|
||||
* reviews you choose to publish,
|
||||
* technical data about your use of the website,
|
||||
* data collected through cookies and similar technologies, as described in the Cookie Policy.
|
||||
|
||||
We do not store your full credit or debit card details on 3DEALER's servers. Electronic payments are made through external payment providers.
|
||||
|
||||
## 3. Creating an account
|
||||
|
||||
You can create an account in the online store to manage your orders and details.
|
||||
|
||||
To create and run your account, we may process details such as your name, email and login details.
|
||||
|
||||
This processing is necessary to provide the account service.
|
||||
|
||||
The online store also supports guest checkout. With guest checkout, no user account is created. The details you provide are used only to the extent necessary to process and complete the order and for the related legal obligations.
|
||||
|
||||
## 4. Orders and purchases
|
||||
|
||||
When you place an order, we process the details needed to:
|
||||
|
||||
* record and fulfil the order,
|
||||
* process the payment,
|
||||
* ship and deliver the products,
|
||||
* contact you about the order,
|
||||
* issue the necessary invoices and receipts,
|
||||
* meet our tax and accounting obligations,
|
||||
* handle any returns, cancellations or complaints.
|
||||
|
||||
The legal basis for this processing is, as the case may be, the performance of the contract, compliance with a legal obligation and 3DEALER's legitimate interest in the proper operation and protection of its business.
|
||||
|
||||
## 5. Contacting us
|
||||
|
||||
If you contact us by email, phone or through the contact form, we process the details you give us in order to respond to your request.
|
||||
|
||||
The legal basis is, depending on the case, taking steps at your request before entering into a contract, the performance of a contract, or our legitimate interest in serving our users and customers.
|
||||
|
||||
## 6. Newsletter and marketing communication
|
||||
|
||||
If you choose to subscribe to the 3DEALER newsletter, we use your email address to send you updates, offers and other marketing content.
|
||||
|
||||
Subscribing to the newsletter is based on your consent.
|
||||
|
||||
You can withdraw your consent at any time by using the unsubscribe link included in the emails or by contacting us at [{store_contact_email}](mailto:{store_contact_email}).
|
||||
|
||||
Withdrawing consent does not affect the lawfulness of processing carried out before the withdrawal.
|
||||
|
||||
## 7. Product reviews
|
||||
|
||||
If you choose to submit a product review, we may publish your name or another identifier you chose to display with the review.
|
||||
|
||||
Please do not include your own or other people's personal data in a public review unless it is necessary.
|
||||
|
||||
## 8. Payment providers
|
||||
|
||||
To complete payments we may use third-party providers such as Stripe and PayPal, as well as banks for payments by bank transfer or IRIS.
|
||||
|
||||
The data required to complete a payment may be passed to the relevant payment provider.
|
||||
|
||||
**@TODO: Confirm the exact payment providers/banks that will be used in production and add them to the final version.**
|
||||
|
||||
These providers process data in accordance with their own privacy policies and applicable terms.
|
||||
|
||||
## 9. Shipping and delivery companies
|
||||
|
||||
To ship your orders we use shipping and delivery services, including ELTA Courier and BOX NOW.
|
||||
|
||||
To deliver an order, details such as your full name, address, phone number and other details needed for delivery may be passed to the relevant provider.
|
||||
|
||||
## 10. Accounting and tax services
|
||||
|
||||
Order details and the necessary customer details may be passed to the Epsilon system that 3DEALER uses to issue and manage invoices and receipts and to meet its tax and accounting obligations.
|
||||
|
||||
## 11. Security and abuse protection
|
||||
|
||||
We use technical and organizational measures to protect personal data against loss, unauthorized access, alteration or unlawful processing.
|
||||
|
||||
To protect the website against malicious or automated use, we may use the hCaptcha service. Using this service may involve processing technical data in accordance with the relevant provider's privacy policy.
|
||||
|
||||
## 12. Analytics and advertising services
|
||||
|
||||
3DEALER uses traffic analytics and advertising measurement services, such as:
|
||||
|
||||
* Google Analytics
|
||||
* Google Tag Manager
|
||||
* Meta Pixel
|
||||
* TikTok Pixel
|
||||
|
||||
These technologies may use cookies or similar technologies to collect information about how the website is used and how effective advertising is.
|
||||
|
||||
Non-essential technologies are only activated in line with the consent choices you make through the cookie management tool.
|
||||
|
||||
More information is available in the Cookie Policy.
|
||||
|
||||
**@TODO: Confirm the exact tools, accounts and services that will be active in production.**
|
||||
|
||||
## 13. Who may have access to your data
|
||||
|
||||
3DEALER does not sell or rent your personal data.
|
||||
|
||||
To the extent necessary to provide the relevant services, the following may have access to personal data:
|
||||
|
||||
* payment providers,
|
||||
* courier companies,
|
||||
* hosting and technical support providers,
|
||||
* email/newsletter service providers,
|
||||
* analytics and advertising providers,
|
||||
* accounting and tax services,
|
||||
* public authorities and bodies, where required by law.
|
||||
|
||||
Third-party providers that process data on behalf of 3DEALER are used in accordance with the requirements of applicable data protection law.
|
||||
|
||||
## 14. Transfers outside the European Economic Area
|
||||
|
||||
Some of the providers we use may process data outside the European Economic Area.
|
||||
|
||||
In these cases, 3DEALER takes appropriate measures to ensure the transfer complies with the GDPR and that personal data receives the required level of protection.
|
||||
|
||||
**@TODO: Check the exact processing countries and transfer mechanisms for Stripe, PayPal, Google, Meta, TikTok, hCaptcha and the newsletter provider.**
|
||||
|
||||
## 15. Retention period
|
||||
|
||||
We keep personal data only for as long as necessary for the purpose it was collected for.
|
||||
|
||||
Data relating to orders and transactions is kept for as long as required by tax, accounting and commercial law.
|
||||
|
||||
Account data is kept for as long as the account remains active, unless there is a legal reason to keep it longer.
|
||||
|
||||
Data used for the newsletter is kept until you withdraw your consent or unsubscribe, unless there is another legal reason to keep it.
|
||||
|
||||
Contact data is kept for as long as necessary to handle your request and, where required, to protect our legitimate interests.
|
||||
|
||||
## 16. Your rights
|
||||
|
||||
Under the GDPR, and subject to the conditions set out in the law, you have the right to:
|
||||
|
||||
* access your personal data,
|
||||
* correct inaccurate or incomplete data,
|
||||
* have your data erased,
|
||||
* restrict processing,
|
||||
* data portability,
|
||||
* object to certain types of processing,
|
||||
* withdraw your consent, where processing is based on consent.
|
||||
|
||||
Exercising a right does not mean it can be applied in every case. For example, we may be required to keep certain data because of a tax or other legal obligation.
|
||||
|
||||
To exercise your rights, you can contact us:
|
||||
|
||||
**Email:** [{store_contact_email}](mailto:{store_contact_email})
|
||||
**Phone:** {store_phone}
|
||||
|
||||
We will respond to your request without undue delay and, as a rule, within one (1) month of receiving it.
|
||||
|
||||
## 17. Right to lodge a complaint
|
||||
|
||||
If you believe the processing of your personal data breaches applicable law, you have the right to lodge a complaint with the Hellenic Data Protection Authority.
|
||||
|
||||
## 18. Changes to this Privacy Policy
|
||||
|
||||
This Privacy Policy may be changed when necessary, for example due to changes in legislation, in the services we use or in the way the online store operates.
|
||||
|
||||
The latest version will always be available on our website.
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: "Shipping & Returns"
|
||||
meta_title: ""
|
||||
meta_description: ""
|
||||
---
|
||||
|
||||
## 1. Order preparation
|
||||
|
||||
All 3DEALER products are printed and prepared to order.
|
||||
|
||||
The usual preparation time for an order is **2 to 7 business days**.
|
||||
|
||||
Preparation time does not include the courier's transit time.
|
||||
|
||||
During periods of high demand there may be delays. In the event of a significant delay, 3DEALER will inform the customer.
|
||||
|
||||
## 2. Shipping
|
||||
|
||||
Orders are shipped via:
|
||||
|
||||
* ELTA Courier
|
||||
* BOX NOW
|
||||
|
||||
We ship to the regions supported by the online store:
|
||||
|
||||
* Greece
|
||||
* Cyprus
|
||||
|
||||
The available shipping options and costs are shown at checkout.
|
||||
|
||||
**Shipping costs:** @TODO — shipping rates are set dynamically in the Store's settings.
|
||||
|
||||
Free shipping is not offered.
|
||||
|
||||
Cash on delivery is not supported.
|
||||
|
||||
## 3. Delivery time
|
||||
|
||||
Delivery time depends on the courier and the destination.
|
||||
|
||||
3DEALER is not responsible for delays caused solely by the carrier, extreme weather, strikes, problems in the transport network or other circumstances beyond its reasonable control.
|
||||
|
||||
In case of a delay, you can contact us at [{store_contact_email}](mailto:{store_contact_email}).
|
||||
|
||||
## 4. Receiving your order
|
||||
|
||||
The customer is responsible for the accuracy of the delivery details entered when placing the order.
|
||||
|
||||
If the address is incorrect or incomplete, delivery may be delayed or a new shipment may be required. Any additional costs arising from incorrect delivery details may be charged to the customer.
|
||||
|
||||
## 5. Product delivered damaged
|
||||
|
||||
If the product arrives visibly damaged, please contact us as soon as possible at [{store_contact_email}](mailto:{store_contact_email}) and send us photos of the product and the packaging.
|
||||
|
||||
3DEALER will look into the case and, if damage that occurred in transit or another non-conformity is found, will provide an appropriate remedy in accordance with applicable law.
|
||||
|
||||
## 6. Right of withdrawal
|
||||
|
||||
For purchases that fall within the scope of the right of withdrawal, the consumer may withdraw from the contract within fourteen (14) calendar days of the day the product was received.
|
||||
|
||||
To exercise the right of withdrawal, the customer must inform 3DEALER with a clear statement at [{store_contact_email}](mailto:{store_contact_email}).
|
||||
|
||||
The product must be returned without undue delay and, in any case, within fourteen (14) days of the day the customer informed 3DEALER of their decision to withdraw.
|
||||
|
||||
The customer bears the direct cost of returning the product, unless otherwise agreed or 3DEALER did not inform them of this beforehand.
|
||||
|
||||
The customer is responsible for any reduction in the product's value resulting from handling beyond what is necessary to establish its nature, characteristics and functioning.
|
||||
|
||||
## 7. Exceptions to the right of withdrawal
|
||||
|
||||
The right of withdrawal does not apply in the cases excluded by applicable law.
|
||||
|
||||
In particular, it may not apply to products made to the customer's specifications or clearly personalized.
|
||||
|
||||
**@TODO: Confirm which 3DEALER products are considered personalized/custom and whether any products are not subject to the right of withdrawal.**
|
||||
|
||||
The fact that a product is produced after the order is placed does not in itself exclude it from the right of withdrawal.
|
||||
|
||||
## 8. Refunds
|
||||
|
||||
In the case of a valid withdrawal, 3DEALER refunds the money received from the customer for the purchase, including, where required by law, the standard delivery costs.
|
||||
|
||||
The refund is made using the same payment method as the original transaction, unless otherwise agreed.
|
||||
|
||||
3DEALER may withhold the refund until it has received the returned product or until the customer has provided proof of having sent it, whichever comes first.
|
||||
|
||||
## 9. Product exchanges
|
||||
|
||||
**@TODO: Confirm whether 3DEALER offers product/size exchanges or whether exchanges are handled only through a return and a new order.**
|
||||
|
||||
Where exchanges are offered, the procedure and any shipping costs will be communicated to the customer before the exchange is completed.
|
||||
|
||||
## 10. Defective or non-conforming products
|
||||
|
||||
If a product is defective or does not conform to the contract, the customer has the rights provided by applicable legislation on the sale of goods and consumer protection.
|
||||
|
||||
Minor cosmetic characteristics that are normal for 3D printing, such as layer lines or small variations, do not in themselves constitute a defect.
|
||||
|
||||
For any problem with a product, contact us at [{store_contact_email}](mailto:{store_contact_email}), stating your order number and, where possible, attaching photos.
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: "Terms & Conditions"
|
||||
meta_title: ""
|
||||
meta_description: ""
|
||||
---
|
||||
|
||||
## 1. General
|
||||
|
||||
The online store 3DEALER (hereinafter "the Store" or "3DEALER") is operated by the business {store_legal_name}, registered at Parou 31, Kypseli, 11255 Athens, Greece.
|
||||
|
||||
**Tax ID (ΑΦΜ):** {store_tax_identifier}\
|
||||
**Company registration number (ΓΕΜΗ):** {store_registration_number}
|
||||
|
||||
For any information or questions about orders, products or the operation of the Store, you can contact us:
|
||||
|
||||
**Email:** [{store_contact_email}](mailto:{store_contact_email})\
|
||||
**Phone:** {store_phone}
|
||||
|
||||
Using the Store and making purchases through it requires acceptance of these Terms & Conditions.
|
||||
|
||||
These terms govern purchases made through the online store and are supplemented by the Privacy Policy and the Shipping & Returns Policy.
|
||||
|
||||
## 2. Products
|
||||
|
||||
The products offered through the Store are described and pictured as accurately as possible.
|
||||
|
||||
3DEALER products are made by 3D printing. Due to the nature of the production process, they may show layer lines, seams, small variations or other minor cosmetic imperfections. These characteristics are a normal result of the 3D printing process and are not in themselves considered a product defect.
|
||||
|
||||
All products are printed and prepared to order. The usual preparation time is 2 to 7 business days.
|
||||
|
||||
Unless otherwise stated in the description of a specific product, products are intended for decorative use or for use within reasonable and realistic limits. Use with excessive force, at very high temperatures or in a way other than intended may damage the product.
|
||||
|
||||
## 3. Prices
|
||||
|
||||
All product prices are shown in euros (€) and include the applicable VAT.
|
||||
|
||||
Shipping costs are calculated at checkout and shown before the purchase is confirmed.
|
||||
|
||||
**Shipping costs:** @TODO — the exact cost is calculated dynamically based on the Store's shipping settings.
|
||||
|
||||
## 4. Orders
|
||||
|
||||
Customers can make a purchase either by creating an account in the Store or by using guest checkout, without creating an account.
|
||||
|
||||
Completing an order requires the details necessary for processing, payment and delivery.
|
||||
|
||||
Before finalizing the order, the customer can review the order details and the total cost, including any shipping charges.
|
||||
|
||||
Once the order is completed, a confirmation is sent to the email address provided by the customer.
|
||||
|
||||
**@TODO: Confirm whether the order is considered final once payment is completed or once the order confirmation is sent.**
|
||||
|
||||
## 5. Payment methods
|
||||
|
||||
The Store supports the following payment methods:
|
||||
|
||||
* Credit or debit card
|
||||
* PayPal
|
||||
* Stripe
|
||||
* IRIS
|
||||
* Bank transfer
|
||||
|
||||
Electronic payments may use third-party payment services. Card details or other sensitive payment details are not stored by 3DEALER, but are processed by the relevant payment provider in accordance with its own terms and privacy policy.
|
||||
|
||||
## 6. Order cancellations
|
||||
|
||||
**@TODO: Confirm the cancellation policy.**
|
||||
|
||||
As a general rule, customers who wish to cancel an order can contact 3DEALER as soon as possible after placing it.
|
||||
|
||||
If preparation of the order has not yet started, 3DEALER can cancel it and refund the amount paid.
|
||||
|
||||
If production has already started, the terms of the Shipping & Returns Policy apply, together with the statutory right of withdrawal where applicable.
|
||||
|
||||
## 7. Right of withdrawal and returns
|
||||
|
||||
Where provided for by applicable law, consumers have the right to withdraw from a distance contract within fourteen (14) calendar days of receiving the product, without having to give a reason.
|
||||
|
||||
Exceptions to the right of withdrawal apply in the cases provided for by applicable law, including for products made to the consumer's specifications or clearly personalized.
|
||||
|
||||
The returns procedure and terms are described in detail in the Shipping & Returns Policy.
|
||||
|
||||
## 8. Legal guarantee and defective products
|
||||
|
||||
3DEALER is responsible for the conformity of products with the contract and for the consumer's statutory rights in the event of non-conformity or a defect, in accordance with applicable law.
|
||||
|
||||
Normal characteristics of 3D printing, such as layer lines or small cosmetic variations that are expected from this production process, do not in themselves constitute non-conformity.
|
||||
|
||||
## 9. Availability
|
||||
|
||||
3DEALER makes every effort to keep product availability information accurate.
|
||||
|
||||
In exceptional cases where a product cannot be produced or shipped, 3DEALER will contact the customer and explain the available options.
|
||||
|
||||
## 10. Intellectual property
|
||||
|
||||
The content of the Store, including but not limited to designs, photographs, texts, graphics, logos and other material, is the property of 3DEALER or is lawfully used by it, and is protected by applicable intellectual and industrial property law.
|
||||
|
||||
Copying, reproducing, modifying, distributing or commercially exploiting the content without prior written permission is not allowed.
|
||||
|
||||
## 11. Personal data
|
||||
|
||||
Customers' personal data is processed in accordance with the 3DEALER Privacy Policy.
|
||||
|
||||
## 12. Changes to these Terms
|
||||
|
||||
3DEALER reserves the right to change these Terms & Conditions when necessary, in particular due to changes in legislation, services or the way the Store operates.
|
||||
|
||||
The terms that apply to each order are those that were available at the time that order was placed.
|
||||
|
||||
## 13. Governing law
|
||||
|
||||
These Terms & Conditions are governed by Greek law.
|
||||
|
||||
Any dispute relating to the use of the Store or the purchase of products is subject to the applicable Greek and European consumer protection legislation.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
meta_description: "Your local 3Dealer of 3D printed, high-quality products. Geek culture, decoration, fun and skulls, and custom orders."
|
||||
---
|
||||
@@ -9,6 +9,14 @@ content_path: /content
|
||||
media_path: /media
|
||||
thumbs_path: /storage/app/public/stoic/
|
||||
repository_path: /repository
|
||||
# First locale is Stoic's default: it holds every field, the others only the
|
||||
# translatable ones (content/{locale}/...). Codes match app.available_locales.
|
||||
i18n:
|
||||
locales:
|
||||
- code: el
|
||||
label: Ελληνικά
|
||||
- code: en
|
||||
label: English
|
||||
presets:
|
||||
- code: large
|
||||
mode: resize
|
||||
|
||||
@@ -2,6 +2,7 @@ schemas:
|
||||
- name: pages
|
||||
label: Pages
|
||||
type: files
|
||||
translatable: true
|
||||
files:
|
||||
- name: home
|
||||
label: Home Page
|
||||
@@ -10,15 +11,18 @@ schemas:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
@@ -27,10 +31,50 @@ schemas:
|
||||
ui: image
|
||||
- name: trivia_body
|
||||
label: "Trivia Body (intro paragraph under the hero)"
|
||||
ui: markdown
|
||||
ui: text
|
||||
translatable: true
|
||||
- name: classics_title
|
||||
label: "Classics Section Title (favorites carousel heading)"
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: contact
|
||||
label: Contact Page
|
||||
file: pages/contact.md
|
||||
fields:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
- name: heading_first
|
||||
label: "Heading, first line (animated)"
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: heading_second
|
||||
label: "Heading, second line"
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: heading_highlight
|
||||
label: "Heading, last word(s) of the second line (animated)"
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: intro
|
||||
label: "Intro (paragraph above the form)"
|
||||
ui: text
|
||||
translatable: true
|
||||
- name: terms-and-conditions
|
||||
label: "Terms & Conditions Page"
|
||||
file: pages/terms-and-conditions.md
|
||||
@@ -38,15 +82,18 @@ schemas:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
@@ -56,6 +103,7 @@ schemas:
|
||||
- name: body
|
||||
label: Body
|
||||
ui: markdown
|
||||
translatable: true
|
||||
- name: shipping-returns
|
||||
label: "Shipping & Returns Page"
|
||||
file: pages/shipping-returns.md
|
||||
@@ -63,15 +111,18 @@ schemas:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
@@ -81,6 +132,7 @@ schemas:
|
||||
- name: body
|
||||
label: Body
|
||||
ui: markdown
|
||||
translatable: true
|
||||
- name: privacy-policy
|
||||
label: "Privacy Policy Page"
|
||||
file: pages/privacy-policy.md
|
||||
@@ -88,15 +140,18 @@ schemas:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
@@ -106,6 +161,7 @@ schemas:
|
||||
- name: body
|
||||
label: Body
|
||||
ui: markdown
|
||||
translatable: true
|
||||
- name: cookies-policy
|
||||
label: "Cookies Policy Page"
|
||||
file: pages/cookies-policy.md
|
||||
@@ -113,15 +169,18 @@ schemas:
|
||||
- name: name
|
||||
label: Name
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: slug
|
||||
label: Slug
|
||||
ui: slug
|
||||
- name: meta_title
|
||||
label: Meta Title
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: meta_description
|
||||
label: Meta Description
|
||||
ui: string
|
||||
translatable: true
|
||||
- name: og_image
|
||||
label: Og Image
|
||||
ui: image
|
||||
@@ -131,3 +190,26 @@ schemas:
|
||||
- name: body
|
||||
label: Body
|
||||
ui: markdown
|
||||
translatable: true
|
||||
- name: settings
|
||||
label: Settings
|
||||
type: singleton
|
||||
file: settings.md
|
||||
translatable: true
|
||||
fields:
|
||||
- name: meta_description
|
||||
label: "Default Meta Description (used when a page has none)"
|
||||
ui: text
|
||||
translatable: true
|
||||
- name: og_image
|
||||
label: "Default Og Image (used when a page has none, ideally 1200×630)"
|
||||
ui: image
|
||||
- name: facebook_url
|
||||
label: Facebook URL
|
||||
ui: string
|
||||
- name: instagram_url
|
||||
label: Instagram URL
|
||||
ui: string
|
||||
- name: tiktok_url
|
||||
label: TikTok URL
|
||||
ui: string
|
||||
|
||||
@@ -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,70 @@
|
||||
@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)
|
||||
@php
|
||||
$orderReference = \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order);
|
||||
@endphp
|
||||
<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>
|
||||
#{{ $orderReference }}
|
||||
</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' => $orderReference]) }}"
|
||||
>
|
||||
{{ __('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,162 @@
|
||||
@extends('layouts.account')
|
||||
|
||||
@php
|
||||
$orderReference = \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order);
|
||||
@endphp
|
||||
|
||||
@section('title', __('storefront.orders.order_title', ['number' => $orderReference]))
|
||||
|
||||
@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' => $orderReference]) }}
|
||||
</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,79 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', __('storefront.auth.login'))
|
||||
|
||||
@push('seo')
|
||||
<meta name="robots" content="noindex, follow">
|
||||
@endpush
|
||||
|
||||
{{-- Only on pages with a captcha, not in the global layout. --}}
|
||||
@push('scripts')
|
||||
<script src="https://js.hcaptcha.com/1/api.js?hl={{ app()->getLocale() }}" async defer></script>
|
||||
@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>
|
||||
|
||||
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha. The widget adds
|
||||
the `h-captcha-response` field itself. Fixed height reserves the
|
||||
iframe's space so the button doesn't jump when it loads. --}}
|
||||
<x-ui.field for="login-captcha" :error="$errors->first('h-captcha-response')">
|
||||
<div
|
||||
id="login-captcha"
|
||||
class="h-captcha min-h-[78px]"
|
||||
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
|
||||
@if ($errors->has('h-captcha-response')) aria-describedby="login-captcha-error" @endif
|
||||
></div>
|
||||
</x-ui.field>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{{--
|
||||
<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 }}
|
||||
</form>
|
||||
@@ -1,15 +0,0 @@
|
||||
{{--
|
||||
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>
|
||||
@@ -1,29 +0,0 @@
|
||||
{{--
|
||||
<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>
|
||||
@@ -1,52 +0,0 @@
|
||||
{{--
|
||||
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>
|
||||