generated from boboko/starter
Compare commits
27
Commits
fa172860db
...
cart-temp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
78c8165c04 | ||
|
|
c7bd1efdaa |
+21
-1
@@ -31,7 +31,7 @@ DB_DATABASE=boboko
|
|||||||
DB_USERNAME=root
|
DB_USERNAME=root
|
||||||
DB_PASSWORD=dev
|
DB_PASSWORD=dev
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=redis
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
@@ -61,6 +61,9 @@ MAIL_FROM_ADDRESS="hello@example.com"
|
|||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
CONTACT_EMAIL=
|
CONTACT_EMAIL=
|
||||||
|
|
||||||
|
HCAPTCHA_SITEKEY=
|
||||||
|
HCAPTCHA_SECRET=
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
@@ -85,6 +88,9 @@ SCOUT_QUEUE=true
|
|||||||
MEILISEARCH_KEY=dev-meilisearch-key-change-me
|
MEILISEARCH_KEY=dev-meilisearch-key-change-me
|
||||||
MEILISEARCH_HOST=http://meilisearch:7700
|
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
|
# Host-side ports — change these if another project on this machine already uses the defaults
|
||||||
APP_PORT=8090
|
APP_PORT=8090
|
||||||
STOIC_PORT=2727
|
STOIC_PORT=2727
|
||||||
@@ -93,3 +99,17 @@ POSTGRES_HOST_PORT=5436
|
|||||||
MAILPIT_PORT=8025
|
MAILPIT_PORT=8025
|
||||||
VITE_PORT=5173
|
VITE_PORT=5173
|
||||||
MEILISEARCH_PORT=7700
|
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
|
||||||
|
|||||||
+13
-4
@@ -10,11 +10,19 @@ RUN composer install \
|
|||||||
--optimize-autoloader \
|
--optimize-autoloader \
|
||||||
--ignore-platform-reqs
|
--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
|
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
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
|
||||||
RUN npm ci --ignore-scripts
|
RUN chmod +x /entrypoint-vite.sh
|
||||||
|
ENTRYPOINT ["/entrypoint-vite.sh"]
|
||||||
|
|
||||||
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS node-build
|
FROM node:22-alpine AS node-build
|
||||||
@@ -94,7 +102,8 @@ RUN php artisan package:discover --ansi \
|
|||||||
&& php artisan filament:assets --ansi
|
&& php artisan filament:assets --ansi
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
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
|
EXPOSE 9000
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
+2
-8
@@ -19,7 +19,6 @@
|
|||||||
pull
|
pull
|
||||||
docker_build
|
docker_build
|
||||||
docker_up
|
docker_up
|
||||||
migrate
|
|
||||||
artisan_optimize
|
artisan_optimize
|
||||||
restart_workers
|
restart_workers
|
||||||
stoic_generate_thumbs
|
stoic_generate_thumbs
|
||||||
@@ -45,12 +44,6 @@
|
|||||||
{{ $compose }} up -d --remove-orphans
|
{{ $compose }} up -d --remove-orphans
|
||||||
@endtask
|
@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'])
|
@task('artisan_optimize', ['on' => 'web'])
|
||||||
echo ">>> Optimizing..."
|
echo ">>> Optimizing..."
|
||||||
cd {{ $path }}
|
cd {{ $path }}
|
||||||
@@ -61,9 +54,10 @@
|
|||||||
@endtask
|
@endtask
|
||||||
|
|
||||||
@task('restart_workers', ['on' => 'web'])
|
@task('restart_workers', ['on' => 'web'])
|
||||||
echo ">>> Restarting queue workers..."
|
echo ">>> Restarting queue workers and scheduler..."
|
||||||
cd {{ $path }}
|
cd {{ $path }}
|
||||||
{{ $compose }} restart queue
|
{{ $compose }} restart queue
|
||||||
|
{{ $compose }} restart scheduler
|
||||||
@endtask
|
@endtask
|
||||||
|
|
||||||
@task('password_protect', ['on' => 'web'])
|
@task('password_protect', ['on' => 'web'])
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ final class ProductCard
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $product one item from ProductService's localized array shape
|
* @param array<string, mixed> $product one item from ProductService's localized array shape
|
||||||
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool}
|
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool, soldOut: bool}
|
||||||
*/
|
*/
|
||||||
public static function fromIndexed(array $product): array
|
public static function fromIndexed(array $product): array
|
||||||
{
|
{
|
||||||
@@ -38,6 +38,10 @@ public static function fromIndexed(array $product): array
|
|||||||
// can't be quick-added from a card — the card links to the
|
// can't be quick-added from a card — the card links to the
|
||||||
// product page instead, even when every field is optional.
|
// product page instead, even when every field is optional.
|
||||||
'hasCustomFields' => ! empty($product['custom_fields']),
|
'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),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,16 +75,13 @@ public function update(string $locale, Request $request): RedirectResponse
|
|||||||
|
|
||||||
$invoice = $request->boolean('invoice');
|
$invoice = $request->boolean('invoice');
|
||||||
|
|
||||||
$customer = $this->account->updateProfile($user, [
|
$this->account->updateProfile($user, [
|
||||||
'first_name' => $data['first_name'] ?? null,
|
'first_name' => $data['first_name'] ?? null,
|
||||||
'last_name' => $data['last_name'] ?? null,
|
'last_name' => $data['last_name'] ?? null,
|
||||||
'company_name' => $invoice ? $data['company_name'] : null,
|
'company_name' => $invoice ? $data['company_name'] : null,
|
||||||
|
'tax_identifier' => $invoice ? $data['tax_identifier'] : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Written directly: core's updateProfile() allowlists `vat_no`, but
|
|
||||||
// Lunar's column is `tax_identifier`, so it can't go through there yet.
|
|
||||||
$customer->update(['tax_identifier' => $invoice ? $data['tax_identifier'] : null]);
|
|
||||||
|
|
||||||
if (filled($data['line_one'] ?? null)) {
|
if (filled($data['line_one'] ?? null)) {
|
||||||
$addressData = [
|
$addressData = [
|
||||||
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
|
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
|
||||||
@@ -103,28 +100,20 @@ public function update(string $locale, Request $request): RedirectResponse
|
|||||||
: $this->account->createAddress($user, $addressData);
|
: $this->account->createAddress($user, $addressData);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->updateRecoveryConsent($customer, $request->boolean('recovery_consent'));
|
$this->updateRecoveryConsent($user, $request->boolean('recovery_consent'));
|
||||||
|
|
||||||
return redirect()->route('account')->with('status', __('storefront.account.saved'));
|
return redirect()->route('account')->with('status', __('storefront.account.saved'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Email me a reminder if I don't finish my order", as a standing choice.
|
* "Email me a reminder if I don't finish my order", as a standing
|
||||||
* Stored on the customer in the same meta shape the checkout writes (see
|
* choice — stored on the customer via boboko-core's
|
||||||
* CheckoutController::rememberRecoveryConsent()), and applied to the
|
* CustomerAccountService::setRecoveryConsent(), and applied to the
|
||||||
* current cart too, so opting out stops reminders for it right away.
|
* current cart too, so opting out stops reminders for it right away.
|
||||||
*/
|
*/
|
||||||
private function updateRecoveryConsent($customer, bool $consent): void
|
private function updateRecoveryConsent($user, bool $consent): void
|
||||||
{
|
{
|
||||||
if ((bool) data_get($customer, 'meta.recovery_consent') !== $consent) {
|
$this->account->setRecoveryConsent($user, $consent);
|
||||||
$customer->meta = [
|
|
||||||
...($customer->meta?->toArray() ?? []),
|
|
||||||
'recovery_consent' => $consent,
|
|
||||||
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
|
|
||||||
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
|
|
||||||
];
|
|
||||||
$customer->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only an existing cart; never create one just to record this.
|
// Only an existing cart; never create one just to record this.
|
||||||
$cart = app(CartService::class)->current();
|
$cart = app(CartService::class)->current();
|
||||||
|
|||||||
@@ -3,43 +3,36 @@
|
|||||||
namespace App\Http\Controllers\Account;
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Mail\EmailChangeCodeMail;
|
|
||||||
use App\Mail\EmailChangedNoticeMail;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Mail;
|
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Modules\Core\Customer\Services\GuestOrderClaimer;
|
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: new email → 6-digit code sent to that address →
|
* Changing the login email — a thin wrapper over boboko-core's
|
||||||
* switched. The email is the login, so it only changes once the shopper has
|
* Customer\Services\CustomerEmailChangeService, which owns the actual
|
||||||
* proved they can receive mail there; a typo can never lock them out.
|
* request/confirm mechanics, throttling, pending-change storage, and
|
||||||
*
|
* mailables. This controller's own job is just the storefront's session-
|
||||||
* Once switched, the OLD address gets a notice (EmailChangedNoticeMail).
|
* 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
|
||||||
* Core has no email-change flow, so the pending change lives in the session
|
* translating the service's exceptions into the flash-message flow the
|
||||||
* (the new address, a hash of the code, expiry and wrong-guess count). Limits
|
* views expect.
|
||||||
* mirror core's login OTP: 3 codes per 10 minutes, 5 guesses per code.
|
|
||||||
*/
|
*/
|
||||||
class EmailController extends Controller
|
class EmailController extends Controller
|
||||||
{
|
{
|
||||||
private const SESSION_KEY = 'email_change';
|
private const SESSION_KEY = 'pending_email_change';
|
||||||
private const EXPIRY_MINUTES = 10;
|
|
||||||
private const MAX_ATTEMPTS = 5;
|
|
||||||
private const SEND_LIMIT = 3;
|
|
||||||
private const SEND_DECAY_SECONDS = 600;
|
|
||||||
|
|
||||||
public function edit(string $locale, Request $request): View
|
public function edit(string $locale, Request $request): View
|
||||||
{
|
{
|
||||||
return view('account.email', ['user' => $request->user()]);
|
return view('account.email', ['user' => $request->user()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send(string $locale, Request $request): RedirectResponse
|
public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
@@ -51,119 +44,80 @@ public function send(string $locale, Request $request): RedirectResponse
|
|||||||
'email',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
Rule::notIn([$user->email]),
|
Rule::notIn([$user->email]),
|
||||||
Rule::unique($user->getTable(), 'email')->ignore($user->id),
|
|
||||||
],
|
],
|
||||||
], [
|
], [
|
||||||
'email.not_in' => __('storefront.account.email_same'),
|
'email.not_in' => __('storefront.account.email_same'),
|
||||||
'email.unique' => __('storefront.account.email_taken'),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (! $this->sendCode($request, $validated['email'])) {
|
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')]);
|
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$request->session()->put(self::SESSION_KEY, $validated['email']);
|
||||||
|
|
||||||
return redirect()->route('account.email.code');
|
return redirect()->route('account.email.code');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function code(string $locale, Request $request): View|RedirectResponse
|
public function code(string $locale, Request $request): View|RedirectResponse
|
||||||
{
|
{
|
||||||
$pending = $request->session()->get(self::SESSION_KEY);
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
if (! $pending) {
|
if (! $pendingEmail) {
|
||||||
return redirect()->route('account.email.edit');
|
return redirect()->route('account.email.edit');
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('account.email-code', ['email' => $pending['email']]);
|
return view('account.email-code', ['email' => $pendingEmail]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resend(string $locale, Request $request): RedirectResponse
|
public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
{
|
{
|
||||||
$pending = $request->session()->get(self::SESSION_KEY);
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
if (! $pending) {
|
if (! $pendingEmail) {
|
||||||
return redirect()->route('account.email.edit');
|
return redirect()->route('account.email.edit');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->sendCode($request, $pending['email'])) {
|
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()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return back()->with('status', __('storefront.auth.code_resent'));
|
return back()->with('status', __('storefront.auth.code_resent'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function verify(string $locale, Request $request, GuestOrderClaimer $claimer): RedirectResponse
|
public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
{
|
{
|
||||||
$pending = $request->session()->get(self::SESSION_KEY);
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
if (! $pending) {
|
if (! $pendingEmail) {
|
||||||
return redirect()->route('account.email.edit');
|
return redirect()->route('account.email.edit');
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
||||||
|
|
||||||
$valid = $pending['code_hash'] !== null
|
try {
|
||||||
&& now()->timestamp < $pending['expires_at']
|
$emailChange->confirm($request->user(), $validated['code']);
|
||||||
&& Hash::check($validated['code'], $pending['code_hash']);
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
|
||||||
if (! $valid) {
|
|
||||||
// Too many wrong guesses burns the code; only "resend" helps then.
|
|
||||||
$pending['attempts']++;
|
|
||||||
|
|
||||||
if ($pending['attempts'] >= self::MAX_ATTEMPTS) {
|
|
||||||
$pending['code_hash'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->session()->put(self::SESSION_KEY, $pending);
|
|
||||||
|
|
||||||
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
// Someone may have signed up with this address since the code was sent.
|
|
||||||
if ($user->newQuery()->where('email', $pending['email'])->whereKeyNot($user->id)->exists()) {
|
|
||||||
$request->session()->forget(self::SESSION_KEY);
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
return redirect()->route('account.email.edit')
|
return redirect()->route('account.email.edit')
|
||||||
->withErrors(['email' => __('storefront.account.email_taken')]);
|
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (InvalidEmailChangeCodeException) {
|
||||||
|
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldEmail = $user->email;
|
|
||||||
|
|
||||||
$user->forceFill(['email' => $pending['email'], 'email_verified_at' => now()])->save();
|
|
||||||
|
|
||||||
// Lets the owner notice if someone else changed it from a hijacked session.
|
|
||||||
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($pending['email']));
|
|
||||||
|
|
||||||
// The code just proved they own the new address too.
|
|
||||||
$claimer->claim($user);
|
|
||||||
|
|
||||||
$request->session()->forget(self::SESSION_KEY);
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
|
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sendCode(Request $request, string $email): bool
|
|
||||||
{
|
|
||||||
$limiterKey = 'email-change:'.$request->user()->id;
|
|
||||||
|
|
||||||
if (RateLimiter::tooManyAttempts($limiterKey, self::SEND_LIMIT)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
RateLimiter::hit($limiterKey, self::SEND_DECAY_SECONDS);
|
|
||||||
|
|
||||||
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
|
||||||
|
|
||||||
$request->session()->put(self::SESSION_KEY, [
|
|
||||||
'email' => $email,
|
|
||||||
'code_hash' => Hash::make($code),
|
|
||||||
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
|
|
||||||
'attempts' => 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mail::to($email)->send(new EmailChangeCodeMail($code));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Rules\HCaptcha;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -43,8 +44,13 @@ public function create(string $locale, Request $request): View
|
|||||||
|
|
||||||
public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
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([
|
$validated = $request->validate([
|
||||||
'email' => ['required', 'email', 'max:255'],
|
'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']));
|
$email = Str::lower(trim($validated['email']));
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Checkout;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Controllers\CustomFieldUploadController;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
use Lunar\Exceptions\Carts\CartException;
|
|
||||||
use Lunar\Models\CartLine;
|
|
||||||
use Lunar\Models\ProductVariant;
|
|
||||||
use Illuminate\Support\Facades\App;
|
|
||||||
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
|
||||||
use Modules\Core\Cart\Services\CartService;
|
|
||||||
use Modules\Core\File\Models\File;
|
|
||||||
use Modules\Core\File\Services\FileService;
|
|
||||||
use Modules\Core\Localization\Services\LanguageCache;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Thin storefront cart endpoints for the checkout module. Every action mutates
|
|
||||||
* the session cart via boboko-core's CartService and returns the same
|
|
||||||
* server-rendered `cart-body` partial — the drawer's Stimulus controller swaps
|
|
||||||
* that fragment in place (no JSON, no client-side templating). $cart / $lines
|
|
||||||
* for the partial come from the view composer in CheckoutModuleServiceProvider.
|
|
||||||
*/
|
|
||||||
class CartController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly CartService $cart,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CartException here is Lunar's own add_to_cart validation pipeline
|
|
||||||
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
|
|
||||||
* "not enough stock at this quantity" for a tracked (purchasable =
|
|
||||||
* in_stock) variant. Its own message is an untranslated, hardcoded
|
|
||||||
* English string not meant for storefront display, so this returns our
|
|
||||||
* own translated one instead rather than passing it through — a
|
|
||||||
* storefront.* key rather than checkout.*, since this is a catalog/stock
|
|
||||||
* concern the storefront owns, not something specific to the portable
|
|
||||||
* checkout module.
|
|
||||||
*/
|
|
||||||
public function add(string $locale, Request $request): View|JsonResponse
|
|
||||||
{
|
|
||||||
$data = $request->validate([
|
|
||||||
'purchasable_id' => ['required', 'integer'],
|
|
||||||
'quantity' => ['nullable', 'integer', 'min:1'],
|
|
||||||
'custom_fields' => ['nullable', 'array'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []);
|
|
||||||
} catch (ValidationException $e) {
|
|
||||||
return response()->json(['error' => collect($e->errors())->flatten()->first()], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->cart->addLine($variant, $data['quantity'] ?? 1, $meta);
|
|
||||||
} catch (CartException) {
|
|
||||||
return $this->stockError($variant);
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('checkout::partials.cart-body');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The shopper's answers to the product's custom fields (boboko-core's
|
|
||||||
* Product::$custom_fields — {key, type: text|textarea|file, label,
|
|
||||||
* required}), as cart line meta. Lunar copies CartLine.meta onto the
|
|
||||||
* OrderLine at order creation, so this is also what the order keeps.
|
|
||||||
*
|
|
||||||
* Only keys the product actually defines are kept, nested under
|
|
||||||
* `custom_fields` — line meta also carries behavior flags (core's
|
|
||||||
* `saved_for_later` zeroes the line's price), so shopper input must never
|
|
||||||
* be merged into it directly. Label and type are snapshotted alongside
|
|
||||||
* each value so the cart/order still reads correctly if the product's
|
|
||||||
* fields are edited later.
|
|
||||||
*
|
|
||||||
* A `file` answer is the id of a File row the host's own upload endpoint
|
|
||||||
* (CustomFieldUploadController) already created via boboko-core's
|
|
||||||
* FileService — never the file's bytes, disk, or path, all of which
|
|
||||||
* FileService alone is the source of truth for. A shopper can't point
|
|
||||||
* this at someone else's file: the id must resolve to a File that is
|
|
||||||
* BOTH unowned (isFileAnswerValid()) and tagged with
|
|
||||||
* CustomFieldUploadController::PURPOSE, so an id belonging to an
|
|
||||||
* already-ordered file (owned, and/or a different purpose entirely) is
|
|
||||||
* rejected. Attaching the File to the real CartLine it belongs to
|
|
||||||
* happens afterward, in boboko-core's own Modules\Core\File\Listeners\
|
|
||||||
* AttachCustomFieldFileToCartLine (listening for Cart\Events\
|
|
||||||
* CartLineAdded) — not here, since this method only builds the meta
|
|
||||||
* $this->cart->addLine() is about to receive, before any CartLine
|
|
||||||
* actually exists to own anything.
|
|
||||||
*
|
|
||||||
* Two adds with identical answers merge into one line (Lunar matches
|
|
||||||
* existing lines on meta); different answers stay separate lines.
|
|
||||||
*/
|
|
||||||
private function customFieldsMeta(ProductVariant $variant, array $input): array
|
|
||||||
{
|
|
||||||
$fields = collect($variant->product?->custom_fields ?? [])
|
|
||||||
->keyBy('key')
|
|
||||||
->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]);
|
|
||||||
|
|
||||||
if ($fields->isEmpty()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = Validator::make(
|
|
||||||
$input,
|
|
||||||
$fields->map(fn (array $field) => [
|
|
||||||
($field['required'] ?? false) ? 'required' : 'nullable',
|
|
||||||
...match ($field['type']) {
|
|
||||||
'textarea' => ['string', 'max:2000'],
|
|
||||||
'file' => [function (string $attribute, mixed $value, Closure $fail) {
|
|
||||||
if (! $this->isFileAnswerValid($value)) {
|
|
||||||
$fail('validation.uploaded')->translate();
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
default => ['string', 'max:255'],
|
|
||||||
},
|
|
||||||
])->all(),
|
|
||||||
[],
|
|
||||||
$fields->map(fn (array $field) => $field['label'])->all(),
|
|
||||||
)->validate();
|
|
||||||
|
|
||||||
$answers = $fields
|
|
||||||
->filter(fn (array $field) => filled($validated[$field['key']] ?? null))
|
|
||||||
->map(fn (array $field) => [
|
|
||||||
'key' => $field['key'],
|
|
||||||
'label' => $field['label'],
|
|
||||||
'type' => $field['type'],
|
|
||||||
...($field['type'] === 'file'
|
|
||||||
? ['file_id' => (int) $validated[$field['key']]]
|
|
||||||
: ['value' => $validated[$field['key']]]),
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return $answers === [] ? [] : ['custom_fields' => $answers];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Product::$custom_fields stores `label` as {locale: string} (see
|
|
||||||
* boboko-core's Catalog\Filament\Pages\ManageProductCustomFields) — this
|
|
||||||
* resolves it to the single current-locale string cart/order line meta
|
|
||||||
* actually needs, the same filled()-over-?? fallback boboko-core's
|
|
||||||
* ProductDocumentLocalizer uses for every other translated field (an
|
|
||||||
* empty string for the current locale still falls through to the
|
|
||||||
* store's default language, rather than showing blank). A product
|
|
||||||
* saved before labels became translatable still has a plain string
|
|
||||||
* here, returned as-is.
|
|
||||||
*/
|
|
||||||
private function resolveLabel(mixed $label): string
|
|
||||||
{
|
|
||||||
if (! is_array($label)) {
|
|
||||||
return (string) $label;
|
|
||||||
}
|
|
||||||
|
|
||||||
$locale = App::getLocale();
|
|
||||||
$fallbackLocale = app(LanguageCache::class)->defaultLocale();
|
|
||||||
|
|
||||||
return filled($label[$locale] ?? null)
|
|
||||||
? $label[$locale]
|
|
||||||
: ($label[$fallbackLocale] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isFileAnswerValid(mixed $fileId): bool
|
|
||||||
{
|
|
||||||
$file = File::find($fileId);
|
|
||||||
|
|
||||||
return $file !== null
|
|
||||||
&& $file->purpose === CustomFieldUploadController::PURPOSE
|
|
||||||
&& $file->owner_id === null
|
|
||||||
&& app(FileService::class)->exists($file);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
|
|
||||||
{
|
|
||||||
$quantity = (int) $request->validate([
|
|
||||||
'quantity' => ['required', 'integer', 'min:0'],
|
|
||||||
])['quantity'];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$quantity === 0
|
|
||||||
? $this->cart->removeLine($line)
|
|
||||||
: $this->cart->updateLine($line, $quantity);
|
|
||||||
} catch (CartException) {
|
|
||||||
$variant = CartLine::find($line)?->purchasable;
|
|
||||||
|
|
||||||
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('checkout::partials.cart-body');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
|
|
||||||
* checked against (stock, for a tracked in_stock variant) — telling the
|
|
||||||
* shopper how many are actually left beats a generic "not enough stock"
|
|
||||||
* they'd otherwise have to guess around by trial and error.
|
|
||||||
*/
|
|
||||||
private function stockError(?ProductVariant $variant): JsonResponse
|
|
||||||
{
|
|
||||||
$available = $variant?->getTotalInventory() ?? 0;
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function remove(string $locale, int $line): View
|
|
||||||
{
|
|
||||||
$this->cart->removeLine($line);
|
|
||||||
|
|
||||||
return view('checkout::partials.cart-body');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bad code is a normal, expected outcome here (typo, expired code), not
|
|
||||||
* an error state for the request — it re-renders the same cart-body
|
|
||||||
* partial with $couponError set, rather than a 4xx/redirect, so the fetch
|
|
||||||
* + swap in bbk-cart-controller stays the one code path for every cart
|
|
||||||
* mutation.
|
|
||||||
*/
|
|
||||||
public function applyCoupon(string $locale, Request $request): View
|
|
||||||
{
|
|
||||||
$code = $request->validate([
|
|
||||||
'code' => ['required', 'string'],
|
|
||||||
])['code'];
|
|
||||||
|
|
||||||
$couponError = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->cart->applyCoupon($code);
|
|
||||||
} catch (InvalidCouponException) {
|
|
||||||
$couponError = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('checkout::partials.cart-body', ['couponError' => $couponError]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function removeCoupon(string $locale): View
|
|
||||||
{
|
|
||||||
$this->cart->removeCoupon();
|
|
||||||
|
|
||||||
return view('checkout::partials.cart-body');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,699 +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\Auth;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
use Lunar\Exceptions\Carts\CartException;
|
|
||||||
use Lunar\Exceptions\FingerprintMismatchException;
|
|
||||||
use Lunar\Facades\CartSession;
|
|
||||||
use Lunar\Models\Cart;
|
|
||||||
use Lunar\Models\Country;
|
|
||||||
use Lunar\Models\Order;
|
|
||||||
use Lunar\Models\State;
|
|
||||||
use Modules\Core\Cart\Services\CartService;
|
|
||||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
|
||||||
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
|
||||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
|
||||||
use Modules\Core\Checkout\Services\CheckoutService;
|
|
||||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
|
||||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
|
||||||
use Modules\Core\Payment\Models\PaymentMethod;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The checkout page — one page, sections (contact / billing / shipping /
|
|
||||||
* shipping method), reviewed against boboko-core's CheckoutService. Stops
|
|
||||||
* short of payment for this slice (see project memory).
|
|
||||||
*
|
|
||||||
* The address form and the shipping-method radios **autosave** — no submit
|
|
||||||
* buttons. `saveAddress()` / `selectShippingOption()` are called by
|
|
||||||
* bbk-checkout-form (debounced fetch) and return a JSON envelope of
|
|
||||||
* server-rendered fragments (shipping options + order summary) plus any
|
|
||||||
* field errors, rather than redirecting. Address validation is deliberately
|
|
||||||
* lenient — nothing is rejected mid-typing; required-field enforcement is the
|
|
||||||
* job of the (not-yet-built) "Continue to payment" gate.
|
|
||||||
*
|
|
||||||
* Guests type their email; the login tab links to the storefront's own
|
|
||||||
* `login` route and back. Logged in: the email is the account's (forced in
|
|
||||||
* saveAddress()), the first visit prefills addresses from the account
|
|
||||||
* (prefillFromAccount()), and Lunar's Login listener has already attached the
|
|
||||||
* cart, so the placed order lands in the account's history.
|
|
||||||
*
|
|
||||||
* Single-country store: STORE_COUNTRY_ISO3 fixes the country to Greece (hidden
|
|
||||||
* field, forced server-side). Set it to null for the full country picker (the
|
|
||||||
* portable path for a multi-country boboko store).
|
|
||||||
*/
|
|
||||||
class CheckoutController extends Controller
|
|
||||||
{
|
|
||||||
private const STORE_COUNTRY_ISO3 = 'GRC';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly CartService $cart,
|
|
||||||
private readonly CheckoutService $checkout,
|
|
||||||
private readonly CustomerAccountService $account,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function show(string $locale): View
|
|
||||||
{
|
|
||||||
$cart = $this->cart->current();
|
|
||||||
$lines = $cart ? $this->cart->activeLines($cart) : collect();
|
|
||||||
$storeCountry = $this->storeCountry();
|
|
||||||
|
|
||||||
$shippingOptions = collect();
|
|
||||||
|
|
||||||
// Captured before prefillFromAccount(), which may recreate the address
|
|
||||||
// row (dropping its shipping_option) — same reason as in saveAddress().
|
|
||||||
$previousOption = $cart?->shippingAddress?->shipping_option;
|
|
||||||
|
|
||||||
if ($cart && Auth::check()) {
|
|
||||||
$cart = $this->prefillFromAccount($cart);
|
|
||||||
|
|
||||||
// Nothing chosen on this cart yet: carry over the account's standing
|
|
||||||
// opt-in (an explicit earlier choice, recorded with its own
|
|
||||||
// timestamp/policy version). Never opts anyone in by default.
|
|
||||||
if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? [])
|
|
||||||
&& data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) {
|
|
||||||
$cart = $this->checkout->setRecoveryConsent(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($cart?->shippingAddress) {
|
|
||||||
$shippingOptions = $this->syncShipping($cart, $previousOption);
|
|
||||||
|
|
||||||
// Cart's CachesProperties::refresh() explicitly nulls total/
|
|
||||||
// subTotal/shippingTotal/etc. back to their defaults — every
|
|
||||||
// Lunar call site pairs it with recalculate() for exactly that
|
|
||||||
// reason. Bare refresh() here was leaving $cart->total null on
|
|
||||||
// reload, which fed a 0 amount straight into the Stripe Element.
|
|
||||||
$cart->refresh()->recalculate();
|
|
||||||
}
|
|
||||||
|
|
||||||
$paymentMethods = $this->checkout->getPaymentMethods();
|
|
||||||
|
|
||||||
// Nothing checked yet (fresh cart), or the shopper's earlier pick is
|
|
||||||
// no longer offered (method disabled/removed since) — auto-select
|
|
||||||
// the first one, same as a manual click would, so the payment
|
|
||||||
// section (and the Stripe Element mounting under it) isn't sitting
|
|
||||||
// inert behind an unchecked radio. A still-valid previous choice is
|
|
||||||
// left alone.
|
|
||||||
$firstMethod = $paymentMethods->first();
|
|
||||||
|
|
||||||
if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) {
|
|
||||||
$cart = $this->checkout->selectPaymentMethod($firstMethod->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
return view('checkout::page', [
|
|
||||||
'cart' => $cart,
|
|
||||||
'lines' => $lines,
|
|
||||||
'billingAddress' => $cart?->billingAddress,
|
|
||||||
'shippingAddress' => $cart?->shippingAddress,
|
|
||||||
'shippingOptions' => $shippingOptions,
|
|
||||||
'paymentMethods' => $paymentMethods,
|
|
||||||
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
|
|
||||||
'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false),
|
|
||||||
'storeCountry' => $storeCountry,
|
|
||||||
'countries' => $storeCountry
|
|
||||||
? collect()
|
|
||||||
: Country::orderBy('name')->get(['id', 'name']),
|
|
||||||
'regions' => $storeCountry
|
|
||||||
? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name'])
|
|
||||||
: collect(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function saveAddress(string $locale, Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$storeCountry = $this->storeCountry();
|
|
||||||
$sameAsBilling = $request->boolean('same_as_billing');
|
|
||||||
|
|
||||||
// Only the fields shipping rates resolve against — if none of these
|
|
||||||
// changed (shopper edited their name, phone, email, …) there's no point
|
|
||||||
// re-quoting shipping or re-rendering the summary.
|
|
||||||
$addressBefore = $this->cart->current()?->shippingAddress;
|
|
||||||
$rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']);
|
|
||||||
|
|
||||||
// setShippingAddress() below always deletes + recreates this row (see
|
|
||||||
// syncShipping()'s docblock) — capture what was selected NOW, before
|
|
||||||
// it's gone, so it can be carried forward onto the fresh row.
|
|
||||||
$previousOption = $addressBefore?->shipping_option;
|
|
||||||
|
|
||||||
$stateRule = $storeCountry
|
|
||||||
? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)]
|
|
||||||
: ['nullable', 'string', 'max:255'];
|
|
||||||
$countryRule = $storeCountry
|
|
||||||
? ['nullable']
|
|
||||||
: ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id'];
|
|
||||||
|
|
||||||
// Lenient — only format checks. Anything that fails is simply left out
|
|
||||||
// of what gets persisted, and reported back for inline display.
|
|
||||||
$validator = Validator::make($request->all(), [
|
|
||||||
'contact_email' => ['nullable', 'email'],
|
|
||||||
|
|
||||||
'billing_first_name' => ['nullable', 'string', 'max:255'],
|
|
||||||
'billing_last_name' => ['nullable', 'string', 'max:255'],
|
|
||||||
'billing_company_name' => ['nullable', 'string', 'max:255'],
|
|
||||||
'billing_tax_identifier' => ['nullable', 'string', 'max:255'],
|
|
||||||
'billing_line_one' => ['nullable', 'string', 'max:255'],
|
|
||||||
'billing_city' => ['nullable', 'string', 'max:255'],
|
|
||||||
'billing_state' => $stateRule,
|
|
||||||
'billing_postcode' => ['nullable', 'string', 'max:20'],
|
|
||||||
'billing_country_id' => $countryRule,
|
|
||||||
'billing_contact_phone' => ['nullable', 'string', 'max:50'],
|
|
||||||
|
|
||||||
'shipping_first_name' => ['nullable', 'string', 'max:255'],
|
|
||||||
'shipping_last_name' => ['nullable', 'string', 'max:255'],
|
|
||||||
'shipping_line_one' => ['nullable', 'string', 'max:255'],
|
|
||||||
'shipping_city' => ['nullable', 'string', 'max:255'],
|
|
||||||
'shipping_state' => $stateRule,
|
|
||||||
'shipping_postcode' => ['nullable', 'string', 'max:20'],
|
|
||||||
'shipping_country_id' => $countryRule,
|
|
||||||
'shipping_contact_phone' => ['nullable', 'string', 'max:50'],
|
|
||||||
'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$errors = $validator->errors()->toArray();
|
|
||||||
$data = $validator->valid();
|
|
||||||
|
|
||||||
// Logged in: the order email is always the account's. It isn't a field
|
|
||||||
// on the page then, and a submitted value isn't trusted.
|
|
||||||
if ($user = Auth::user()) {
|
|
||||||
$data['contact_email'] = $user->email;
|
|
||||||
}
|
|
||||||
|
|
||||||
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
|
|
||||||
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
|
|
||||||
|
|
||||||
// Company/ΑΦΜ only count when "I want an invoice" is ticked; the fields
|
|
||||||
// stay in the DOM (just hidden) when it isn't, so ignore what they send.
|
|
||||||
$wantsInvoice = $request->boolean('wants_invoice');
|
|
||||||
|
|
||||||
$billing = [
|
|
||||||
'first_name' => $data['billing_first_name'] ?? null,
|
|
||||||
'last_name' => $data['billing_last_name'] ?? null,
|
|
||||||
'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null,
|
|
||||||
'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null,
|
|
||||||
'line_one' => $data['billing_line_one'] ?? null,
|
|
||||||
'city' => $data['billing_city'] ?? null,
|
|
||||||
'state' => $data['billing_state'] ?? null,
|
|
||||||
'postcode' => $data['billing_postcode'] ?? null,
|
|
||||||
'country_id' => $billingCountryId,
|
|
||||||
'contact_email' => $data['contact_email'] ?? null,
|
|
||||||
'contact_phone' => $data['billing_contact_phone'] ?? null,
|
|
||||||
];
|
|
||||||
|
|
||||||
$shipping = $sameAsBilling
|
|
||||||
? [
|
|
||||||
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
|
||||||
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
'first_name' => $data['shipping_first_name'] ?? null,
|
|
||||||
'last_name' => $data['shipping_last_name'] ?? null,
|
|
||||||
'line_one' => $data['shipping_line_one'] ?? null,
|
|
||||||
'city' => $data['shipping_city'] ?? null,
|
|
||||||
'state' => $data['shipping_state'] ?? null,
|
|
||||||
'postcode' => $data['shipping_postcode'] ?? null,
|
|
||||||
'country_id' => $shippingCountryId,
|
|
||||||
'contact_email' => $data['contact_email'] ?? null,
|
|
||||||
'contact_phone' => $data['shipping_contact_phone'] ?? null,
|
|
||||||
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
|
||||||
];
|
|
||||||
|
|
||||||
$this->checkout->setBillingAddress($billing);
|
|
||||||
$cart = $this->checkout->setShippingAddress($shipping);
|
|
||||||
|
|
||||||
$cart->meta = [
|
|
||||||
...($cart->meta?->toArray() ?? []),
|
|
||||||
'ship_to_billing' => $sameAsBilling,
|
|
||||||
'wants_invoice' => $wantsInvoice,
|
|
||||||
];
|
|
||||||
$cart->save();
|
|
||||||
|
|
||||||
// Abandoned-cart-recovery opt-in — boboko-core owns the record (bool +
|
|
||||||
// timestamp + policy version on Cart::meta, RecoveryConsentSet event).
|
|
||||||
// Deliberately its own scope, not merged with any future newsletter opt-in.
|
|
||||||
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
|
|
||||||
|
|
||||||
if (Auth::check()) {
|
|
||||||
$this->rememberRecoveryConsent($request->boolean('recovery_consent'));
|
|
||||||
}
|
|
||||||
|
|
||||||
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
|
|
||||||
$rateChanged = $rateKeyAfter != $rateKeyBefore;
|
|
||||||
|
|
||||||
// setShippingAddress() above always deletes and recreates the
|
|
||||||
// CartAddress row (Lunar's AddAddress action), which drops whatever
|
|
||||||
// shipping_option was previously selected — regardless of whether the
|
|
||||||
// rate-determining fields actually changed. So this always has to run
|
|
||||||
// to restore/re-validate it, even on a save that only touched e.g. the
|
|
||||||
// phone number. Only the fragment RE-RENDER is skippable when nothing
|
|
||||||
// rate-relevant moved — the re-select itself is not optional.
|
|
||||||
$options = $this->syncShipping($cart, $previousOption);
|
|
||||||
|
|
||||||
if (! $rateChanged) {
|
|
||||||
return $this->fragments($cart, null, $errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->fragments($cart, $options, $errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function selectShippingOption(string $locale, Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$identifier = (string) $request->input('shipping_option');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->checkout->selectShippingOption($identifier);
|
|
||||||
} catch (InvalidShippingOptionException) {
|
|
||||||
// Re-render with whatever is currently valid; no hard error surfaced.
|
|
||||||
}
|
|
||||||
|
|
||||||
$cart = $this->cart->current();
|
|
||||||
$options = $cart?->shippingAddress
|
|
||||||
? $this->checkout->getShippingOptions()
|
|
||||||
: collect();
|
|
||||||
|
|
||||||
return $this->fragments($cart, $options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Autosave-select a payment method (radio change). Persists it via
|
|
||||||
* CheckoutService (which also records it on Cart::meta and re-snapshots
|
|
||||||
* the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary.
|
|
||||||
*/
|
|
||||||
public function selectPaymentMethod(string $locale, Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$type = (string) $request->input('payment_type');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->checkout->selectPaymentMethod($type);
|
|
||||||
} catch (UnknownPaymentTypeException) {
|
|
||||||
// Radio value out of sync with what's offered — ignore, the summary
|
|
||||||
// just won't reflect a method fee. place-order re-checks properly.
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'summaryHtml' => view('checkout::partials.cart-body')->render(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The real submit — the hard gate. Re-selects the payment method (fresh
|
|
||||||
* fingerprint), then hands off to CheckoutService::initiatePayment(), which
|
|
||||||
* creates the draft order, records terms acceptance, and charges the driver.
|
|
||||||
* Returns JSON the bbk-payment controller routes on:
|
|
||||||
* { redirect } — placed, go to confirmation
|
|
||||||
* { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls
|
|
||||||
* { status: 'failed', message } — declined
|
|
||||||
* { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection
|
|
||||||
*/
|
|
||||||
public function placeOrder(string $locale, Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
if (! $request->boolean('terms_accepted')) {
|
|
||||||
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->checkout->selectPaymentMethod((string) $request->input('payment_type'));
|
|
||||||
} catch (UnknownPaymentTypeException) {
|
|
||||||
return response()->json(['error' => __('checkout.page.choose_payment_method')], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cart = $this->cart->current();
|
|
||||||
|
|
||||||
// Captured now, before initiatePayment() can place the order — Lunar's
|
|
||||||
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
|
|
||||||
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
|
|
||||||
// (i.e. has an order with placed_at set), which happens synchronously
|
|
||||||
// for an immediately-captured payment. Any later $this->cart->current()
|
|
||||||
// call in this same flow (here, or in a subsequent orderStatus() poll
|
|
||||||
// once the 3-D Secure webhook sets placed_at) would then resolve to
|
|
||||||
// that fresh, order-less cart instead of the one that was just placed.
|
|
||||||
// Storing the real cart id ourselves, under our own session key,
|
|
||||||
// sidesteps CartSession entirely for the rest of the placement flow.
|
|
||||||
session(['checkout.cart_id' => $cart?->id]);
|
|
||||||
|
|
||||||
// Lunar's own ValidateCartForOrderCreation (order_create validator)
|
|
||||||
// never checks for this — an empty cart with a valid billing address
|
|
||||||
// sails straight through it and would place a real, zero-line order.
|
|
||||||
// The disabled "place order" button is only the client-side half of
|
|
||||||
// this fix; this is the half that actually matters.
|
|
||||||
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
|
|
||||||
return response()->json([
|
|
||||||
'status' => 'invalid',
|
|
||||||
'message' => __('checkout.page.cart_empty'),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lenient autosave never requires these; this is the gate.
|
|
||||||
if (data_get($cart, 'meta.wants_invoice')
|
|
||||||
&& (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) {
|
|
||||||
return response()->json([
|
|
||||||
'status' => 'invalid',
|
|
||||||
'message' => __('checkout.page.invoice_required'),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same check Lunar's own ValidateCartForOrderCreation runs inside
|
|
||||||
// initiatePayment() (a product unpublished/deleted after it was
|
|
||||||
// added to the cart) — checked here first so the shopper is told
|
|
||||||
// which product is the problem, rather than falling into the
|
|
||||||
// catch-all "complete your billing/shipping details" message below,
|
|
||||||
// which is what actually happened and is generic to every
|
|
||||||
// CartException reason, misleading when the real cause is a line,
|
|
||||||
// not an address.
|
|
||||||
$unavailableLines = $this->cart->activeLines($cart)->filter(
|
|
||||||
fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($unavailableLines->isNotEmpty()) {
|
|
||||||
$names = $unavailableLines
|
|
||||||
->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier())
|
|
||||||
->filter()
|
|
||||||
->implode(', ');
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'status' => 'invalid',
|
|
||||||
'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]),
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The one incomplete-cart case worth a specific message + pointing the
|
|
||||||
// shopper at the right section: a region resolving 2+ methods needs an
|
|
||||||
// explicit pick (no auto-select), easy to miss since nothing else on
|
|
||||||
// the page demands it. Everything else CartException catches below.
|
|
||||||
if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) {
|
|
||||||
return response()->json([
|
|
||||||
'status' => 'invalid',
|
|
||||||
'message' => __('checkout.page.shipping_method_required'),
|
|
||||||
'field' => 'shipping_option',
|
|
||||||
], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? '');
|
|
||||||
|
|
||||||
$data = $request->filled('payment_method')
|
|
||||||
? ['payment_method' => (string) $request->input('payment_method')]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$result = $this->checkout->initiatePayment(
|
|
||||||
$fingerprint,
|
|
||||||
termsAccepted: true,
|
|
||||||
policyVersion: (string) config('legal.terms_version'),
|
|
||||||
data: $data,
|
|
||||||
);
|
|
||||||
} catch (FingerprintMismatchException) {
|
|
||||||
return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409);
|
|
||||||
} catch (CartException $e) {
|
|
||||||
return response()->json([
|
|
||||||
'status' => 'invalid',
|
|
||||||
'message' => __('checkout.page.payment_incomplete_details'),
|
|
||||||
'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(),
|
|
||||||
], 422);
|
|
||||||
} catch (TermsNotAcceptedException) {
|
|
||||||
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pending with no continuation (cash-on-delivery, or any other
|
|
||||||
// deferred/offline method) means CheckoutService::initiatePayment()
|
|
||||||
// already created the placed order — money just hasn't changed
|
|
||||||
// hands yet. Only a Pending WITH a continuation (Stripe's client
|
|
||||||
// secret) means the shopper still has something to do before the
|
|
||||||
// order exists as far as the storefront is concerned.
|
|
||||||
return match (true) {
|
|
||||||
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
|
|
||||||
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
|
|
||||||
$result->status === PaymentResultStatus::Pending => response()->json([
|
|
||||||
'status' => 'pending',
|
|
||||||
'clientSecret' => $result->continuation?->value,
|
|
||||||
]),
|
|
||||||
default => response()->json([
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
|
|
||||||
'retriable' => $result->retriable,
|
|
||||||
], 422),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Poll target for the 3-D Secure path: has the webhook placed the order yet?
|
|
||||||
* boboko-core's StripeWebhookController -> handleCallback -> PaymentCaptured
|
|
||||||
* -> ApplyResolvedPaymentStatus sets placed_at.
|
|
||||||
*/
|
|
||||||
public function orderStatus(string $locale): JsonResponse
|
|
||||||
{
|
|
||||||
$order = $this->placedOrder();
|
|
||||||
|
|
||||||
if (! $order) {
|
|
||||||
return response()->json(['placed' => false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
session(['checkout.order_id' => $order->id]);
|
|
||||||
CartSession::forget();
|
|
||||||
|
|
||||||
return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function confirmation(string $locale): View|RedirectResponse
|
|
||||||
{
|
|
||||||
$orderId = session('checkout.order_id');
|
|
||||||
|
|
||||||
$order = $orderId
|
|
||||||
? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (! $order) {
|
|
||||||
return redirect()->route('products', $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Looked up by type rather than a stored relation — the method may since
|
|
||||||
// have been disabled/deleted, but the order still needs to show what was
|
|
||||||
// actually used at the time.
|
|
||||||
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
|
||||||
->first()
|
|
||||||
?->translate('name');
|
|
||||||
|
|
||||||
return view('checkout::confirmation', [
|
|
||||||
'order' => $order,
|
|
||||||
'paymentMethodName' => $paymentMethodName,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function orderPlacedResponse(string $locale): JsonResponse
|
|
||||||
{
|
|
||||||
if ($order = $this->placedOrder()) {
|
|
||||||
session(['checkout.order_id' => $order->id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
CartSession::forget();
|
|
||||||
|
|
||||||
return response()->json(['redirect' => route('checkout.confirmation', $locale)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function placedOrder(): ?Order
|
|
||||||
{
|
|
||||||
$cartId = session('checkout.cart_id');
|
|
||||||
|
|
||||||
if ($cartId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Order::where('cart_id', $cartId)
|
|
||||||
->whereNotNull('placed_at')
|
|
||||||
->latest('placed_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-resolve shipping options for the cart's current address and keep the
|
|
||||||
* selection sane: auto-select when exactly one resolves, or carry a
|
|
||||||
* previous pick forward when it's still among the resolved options.
|
|
||||||
*
|
|
||||||
* $previousOption must be captured by the CALLER before setShippingAddress()
|
|
||||||
* runs — Lunar's AddAddress action always deletes and recreates the
|
|
||||||
* CartAddress row on every save (see saveAddress()), so by the time this
|
|
||||||
* runs, $address->shipping_option is unconditionally null regardless of
|
|
||||||
* what was selected a moment ago. There is nothing meaningful left to read
|
|
||||||
* off $address itself; $previousOption is the only source of truth for
|
|
||||||
* "what was chosen before this save wiped the row." show() passes the
|
|
||||||
* address's own (not-just-wiped) current value, since nothing recreated
|
|
||||||
* anything in that path.
|
|
||||||
*
|
|
||||||
* Always (re-)applies the resolved target via selectShippingOption() rather
|
|
||||||
* than comparing against the (always-blank, post-recreation) current value
|
|
||||||
* — the fresh row needs the write regardless of whether the decision
|
|
||||||
* "which option" actually changed.
|
|
||||||
*
|
|
||||||
* @return Collection<int, \Lunar\DataTypes\ShippingOption>
|
|
||||||
*/
|
|
||||||
private function syncShipping(Cart $cart, ?string $previousOption): Collection
|
|
||||||
{
|
|
||||||
if (! $cart->shippingAddress) {
|
|
||||||
return collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
$options = $this->checkout->getShippingOptions();
|
|
||||||
|
|
||||||
$target = match (true) {
|
|
||||||
$options->count() === 1 => $options->first()->identifier,
|
|
||||||
$previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($target !== null) {
|
|
||||||
try {
|
|
||||||
$this->checkout->selectShippingOption($target);
|
|
||||||
} catch (InvalidShippingOptionException) {
|
|
||||||
// $target came from $options itself — shouldn't happen, stay defensive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* $options === null means "nothing money-relevant changed" — acknowledge the
|
|
||||||
* save (and any field errors) without re-rendering the shipping options or
|
|
||||||
* the order summary, so a plain name/phone edit is a cheap round-trip.
|
|
||||||
*/
|
|
||||||
private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'errors' => collect($errors)
|
|
||||||
->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages)
|
|
||||||
->all(),
|
|
||||||
'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [
|
|
||||||
'shippingAddress' => $cart?->shippingAddress,
|
|
||||||
'shippingOptions' => $options,
|
|
||||||
])->render(),
|
|
||||||
// Composer (CheckoutModuleServiceProvider) fills $cart / $lines.
|
|
||||||
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logged-in shopper: fills any BLANK cart address field from the account
|
|
||||||
* (name, saved default address, phone, email), on every checkout load, so
|
|
||||||
* an account filled in after checkout started still shows up. Never
|
|
||||||
* overwrites anything already in the cart.
|
|
||||||
*
|
|
||||||
* Company/ΑΦΜ (and ticking "I want an invoice") only on the first pass
|
|
||||||
* (meta.account_prefilled): someone who then clears them or unticks the
|
|
||||||
* box for this order shouldn't get them back on the next reload.
|
|
||||||
*
|
|
||||||
* Writes only when something actually changes, so a normal reload costs
|
|
||||||
* nothing extra.
|
|
||||||
*/
|
|
||||||
private function prefillFromAccount(Cart $cart): Cart
|
|
||||||
{
|
|
||||||
$user = Auth::user();
|
|
||||||
$customer = $this->account->customer($user);
|
|
||||||
$addresses = collect($this->account->addresses($user));
|
|
||||||
$saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
|
||||||
$firstPass = ! data_get($cart, 'meta.account_prefilled');
|
|
||||||
|
|
||||||
$fromAccount = array_filter([
|
|
||||||
'first_name' => $customer?->first_name ?: $saved?->first_name,
|
|
||||||
'last_name' => $customer?->last_name ?: $saved?->last_name,
|
|
||||||
'line_one' => $saved?->line_one,
|
|
||||||
'city' => $saved?->city,
|
|
||||||
'state' => $saved?->state,
|
|
||||||
'postcode' => $saved?->postcode,
|
|
||||||
'country_id' => $this->storeCountry()?->id ?? $saved?->country_id,
|
|
||||||
'contact_email' => $user->email,
|
|
||||||
'contact_phone' => $saved?->contact_phone,
|
|
||||||
], 'filled');
|
|
||||||
|
|
||||||
$invoice = $firstPass
|
|
||||||
? array_filter([
|
|
||||||
'company_name' => $customer?->company_name,
|
|
||||||
'tax_identifier' => $customer?->tax_identifier,
|
|
||||||
], 'filled')
|
|
||||||
: [];
|
|
||||||
|
|
||||||
$fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city',
|
|
||||||
'state', 'postcode', 'country_id', 'contact_email', 'contact_phone'];
|
|
||||||
|
|
||||||
$fillBlanks = function (?array $current, array $values) {
|
|
||||||
$current ??= [];
|
|
||||||
|
|
||||||
foreach ($values as $key => $value) {
|
|
||||||
if (blank($current[$key] ?? null)) {
|
|
||||||
$current[$key] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $current;
|
|
||||||
};
|
|
||||||
|
|
||||||
$billingBefore = $cart->billingAddress?->only($fields);
|
|
||||||
$billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]);
|
|
||||||
|
|
||||||
// Shipping has no company/ΑΦΜ (same shape saveAddress() writes).
|
|
||||||
$shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true);
|
|
||||||
$shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions'];
|
|
||||||
|
|
||||||
$shippingBefore = $cart->shippingAddress?->only($shippingFields);
|
|
||||||
$shipping = $shipToBilling
|
|
||||||
? [
|
|
||||||
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
|
||||||
'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null,
|
|
||||||
]
|
|
||||||
: $fillBlanks($shippingBefore, $fromAccount);
|
|
||||||
|
|
||||||
if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) {
|
|
||||||
$this->checkout->setBillingAddress($billing);
|
|
||||||
$cart = $this->checkout->setShippingAddress($shipping);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($firstPass) {
|
|
||||||
$cart->meta = [
|
|
||||||
...($cart->meta?->toArray() ?? []),
|
|
||||||
'account_prefilled' => true,
|
|
||||||
'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [],
|
|
||||||
];
|
|
||||||
$cart->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cart;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The shopper's latest reminder choice, kept on their customer record
|
|
||||||
* (meta, same shape CheckoutService::setRecoveryConsent() writes on the
|
|
||||||
* cart) so their next checkout starts from it. The storefront's account
|
|
||||||
* page reads/writes the same keys. Candidate for a boboko-core method.
|
|
||||||
*/
|
|
||||||
private function rememberRecoveryConsent(bool $consent): void
|
|
||||||
{
|
|
||||||
$customer = $this->account->customer(Auth::user());
|
|
||||||
|
|
||||||
if (! $customer || (bool) data_get($customer, 'meta.recovery_consent') === $consent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$customer->meta = [
|
|
||||||
...($customer->meta?->toArray() ?? []),
|
|
||||||
'recovery_consent' => $consent,
|
|
||||||
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
|
|
||||||
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
|
|
||||||
];
|
|
||||||
$customer->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function storeCountry(): ?Country
|
|
||||||
{
|
|
||||||
if (self::STORE_COUNTRY_ISO3 === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Store\Services\StoreDetailsService;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +28,12 @@ class ContactController extends Controller
|
|||||||
|
|
||||||
public function index(string $locale): View
|
public function index(string $locale): View
|
||||||
{
|
{
|
||||||
return view('contact');
|
// Address and phone come from Store Details; the email is still
|
||||||
|
// CONTACT_EMAIL until Store Details has an email field.
|
||||||
|
return view('contact', [
|
||||||
|
'store' => app(StoreDetailsService::class)->current(),
|
||||||
|
'contactEmail' => config('services.contact.email'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send(string $locale, ContactRequest $request): RedirectResponse
|
public function send(string $locale, ContactRequest $request): RedirectResponse
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public function __construct(private readonly ProductService $products) {}
|
|||||||
|
|
||||||
public function index(string $locale)
|
public function index(string $locale)
|
||||||
{
|
{
|
||||||
$page = StoicPage::firstWhere('slug', 'home');
|
$page = StoicPage::localized('home');
|
||||||
|
|
||||||
if (! $page) {
|
if (! $page) {
|
||||||
abort(404);
|
abort(404);
|
||||||
|
|||||||
@@ -3,9 +3,26 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\StoicPage;
|
use App\Models\StoicPage;
|
||||||
|
use Modules\Core\Store\Services\StoreDetailsService;
|
||||||
|
|
||||||
class LegalPageController extends Controller
|
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_tax_identifier}' => 'tax_identifier',
|
||||||
|
'{store_registration_number}' => 'registration_number',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly StoreDetailsService $storeDetails,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function terms(string $locale)
|
public function terms(string $locale)
|
||||||
{
|
{
|
||||||
return $this->show('terms-and-conditions');
|
return $this->show('terms-and-conditions');
|
||||||
@@ -28,12 +45,30 @@ public function cookies(string $locale)
|
|||||||
|
|
||||||
private function show(string $slug)
|
private function show(string $slug)
|
||||||
{
|
{
|
||||||
$page = StoicPage::firstWhere('slug', $slug);
|
$page = StoicPage::localized($slug);
|
||||||
|
|
||||||
if (! $page) {
|
if (! $page) {
|
||||||
abort(404);
|
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,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ private function buildOptionPicker(int $productId): array
|
|||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'price' => $variant->prices->first()?->price?->decimal(),
|
'price' => $variant->prices->first()?->price?->decimal(),
|
||||||
'image' => $variant->images->first()?->getUrl(),
|
'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
|
// handle => selected value id, for matching a combination of
|
||||||
// selections back to this variant — see selectVariant() in
|
// selections back to this variant — see selectVariant() in
|
||||||
// product-form-controller.js.
|
// product-form-controller.js.
|
||||||
|
|||||||
@@ -3,19 +3,21 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Catalog\ProductCard;
|
use App\Catalog\ProductCard;
|
||||||
use App\Services\Wishlist;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Lunar\Models\Product;
|
|
||||||
use Modules\Core\Catalog\Services\ProductService;
|
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
|
class WishlistController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly Wishlist $wishlist,
|
private readonly WishlistService $wishlist,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,24 +41,6 @@ public function guest(string $locale, ProductService $products): View|RedirectRe
|
|||||||
return view('wishlist.guest', ['products' => $this->products($products)]);
|
return view('wishlist.guest', ['products' => $this->products($products)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds or removes a product, for guests and logged-in shoppers alike. The
|
|
||||||
* heart button's Stimulus controller asks for JSON; without JS the form
|
|
||||||
* posts normally and comes back to the same page.
|
|
||||||
*/
|
|
||||||
public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse
|
|
||||||
{
|
|
||||||
abort_unless(Product::whereKey($productId)->exists(), 404);
|
|
||||||
|
|
||||||
$active = $this->wishlist->toggle($productId);
|
|
||||||
|
|
||||||
if ($request->expectsJson()) {
|
|
||||||
return response()->json(['active' => $active]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return back();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Product cards for the current wishlist, newest first. Products no longer
|
* Product cards for the current wishlist, newest first. Products no longer
|
||||||
* in the search index (deleted, unpublished) are simply skipped.
|
* in the search index (deleted, unpublished) are simply skipped.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Requests;
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Rules\HCaptcha;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class ContactRequest extends FormRequest
|
class ContactRequest extends FormRequest
|
||||||
@@ -17,6 +18,14 @@ public function rules(): array
|
|||||||
'name' => ['required', 'string', 'max:100'],
|
'name' => ['required', 'string', 'max:100'],
|
||||||
'email' => ['required', 'email', 'max:255'],
|
'email' => ['required', 'email', 'max:255'],
|
||||||
'message' => ['required', 'string', 'max:5000'],
|
'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'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use App\Services\Wishlist;
|
|
||||||
use Modules\Core\Auth\Events\UserAuthenticated;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picked up by Laravel's listener discovery (app/Listeners), no manual
|
|
||||||
* registration. Runs inside the login request, so it can read the guest
|
|
||||||
* wishlist cookie and queue its removal.
|
|
||||||
*/
|
|
||||||
class MergeGuestWishlistOnLogin
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly Wishlist $wishlist,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(UserAuthenticated $event): void
|
|
||||||
{
|
|
||||||
$this->wishlist->mergeGuestInto($event->user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The code that confirms a new login email — see Account\EmailController.
|
|
||||||
*/
|
|
||||||
class EmailChangeCodeMail extends Mailable
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $code,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function envelope(): Envelope
|
|
||||||
{
|
|
||||||
return new Envelope(subject: 'Επιβεβαίωσε το νέο σου email');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function content(): Content
|
|
||||||
{
|
|
||||||
return new Content(view: 'emails.email-change-code');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Mail;
|
|
||||||
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sent to the OLD address once the login email has changed (see
|
|
||||||
* Account\EmailController), so the owner notices a takeover. The new address
|
|
||||||
* is shown masked, e.g. "n•••@example.com".
|
|
||||||
*/
|
|
||||||
class EmailChangedNoticeMail extends Mailable
|
|
||||||
{
|
|
||||||
public readonly string $maskedEmail;
|
|
||||||
|
|
||||||
public function __construct(string $newEmail)
|
|
||||||
{
|
|
||||||
[$local, $domain] = explode('@', $newEmail, 2);
|
|
||||||
|
|
||||||
$this->maskedEmail = mb_substr($local, 0, 1).'•••@'.$domain;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function envelope(): Envelope
|
|
||||||
{
|
|
||||||
return new Envelope(subject: 'Το email του λογαριασμού σου άλλαξε');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function content(): Content
|
|
||||||
{
|
|
||||||
return new Content(view: 'emails.email-changed-notice');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,16 +2,32 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\Stoic;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
|
use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
|
||||||
use JacobJoergensen\LaravelPaper\Attributes\Driver;
|
use JacobJoergensen\LaravelPaper\Attributes\Driver;
|
||||||
use JacobJoergensen\LaravelPaper\Attributes\Timestamps;
|
use JacobJoergensen\LaravelPaper\Attributes\Timestamps;
|
||||||
use JacobJoergensen\LaravelPaper\Paper;
|
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")]
|
#[Driver("markdown")]
|
||||||
#[ContentPath("resources/stoic/content/pages")]
|
#[ContentPath("resources/stoic/content/el/pages")]
|
||||||
#[Timestamps]
|
#[Timestamps]
|
||||||
class StoicPage extends Model
|
class StoicPage extends Model
|
||||||
{
|
{
|
||||||
use Paper;
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One product on a logged-in user's wishlist. Guests' wishlists live in a
|
|
||||||
* cookie instead — see App\Services\Wishlist.
|
|
||||||
*/
|
|
||||||
class WishlistItem extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = ['user_id', 'product_id'];
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Staff;
|
use App\Models\Staff;
|
||||||
|
use App\Services\StoicSettings;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
@@ -19,9 +20,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
// One instance per request: it caches the guest cookie's ids, so a
|
$this->app->scoped(StoicSettings::class);
|
||||||
// toggle and a later has() in the same request agree.
|
|
||||||
$this->app->scoped(\App\Services\Wishlist::class);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
@@ -39,6 +38,13 @@ public function boot(): void
|
|||||||
)->items());
|
)->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)),
|
||||||
|
);
|
||||||
|
|
||||||
if ($this->app->environment('production')) {
|
if ($this->app->environment('production')) {
|
||||||
URL::forceScheme('https');
|
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\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Spatie\YamlFrontMatter\YamlFrontMatter;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class Stoic
|
class Stoic
|
||||||
@@ -29,6 +30,42 @@ public static function presets(): array
|
|||||||
return static::$presets;
|
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.
|
// Returns the public URL for a stoic image at a given preset.
|
||||||
public static function image(string $filename, string $preset): string
|
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() : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\WishlistItem;
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Cookie;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current shopper's wishlist, product ids only.
|
|
||||||
*
|
|
||||||
* Logged in: rows in wishlist_items. Guest: a 1-year cookie holding the ids
|
|
||||||
* (encrypted like every cookie, by the web group's EncryptCookies), so nothing
|
|
||||||
* is written to the database for anonymous visitors. On login the cookie is
|
|
||||||
* merged into the account and cleared (MergeGuestWishlistOnLogin).
|
|
||||||
*/
|
|
||||||
class Wishlist
|
|
||||||
{
|
|
||||||
public const COOKIE = 'wishlist';
|
|
||||||
|
|
||||||
private const COOKIE_MINUTES = 60 * 24 * 365;
|
|
||||||
|
|
||||||
// Keeps the cookie well under the 4KB browser limit.
|
|
||||||
private const GUEST_MAX = 100;
|
|
||||||
|
|
||||||
/** @var array<int>|null ids for this request, including a toggle just made */
|
|
||||||
private ?array $guestIds = null;
|
|
||||||
|
|
||||||
/** @return array<int> newest first */
|
|
||||||
public function ids(): array
|
|
||||||
{
|
|
||||||
if ($user = Auth::user()) {
|
|
||||||
return WishlistItem::where('user_id', $user->id)
|
|
||||||
->latest('id')
|
|
||||||
->pluck('product_id')
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->guestIds();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function has(int $productId): bool
|
|
||||||
{
|
|
||||||
return in_array($productId, $this->ids(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return bool whether the product is on the wishlist afterwards
|
|
||||||
*/
|
|
||||||
public function toggle(int $productId): bool
|
|
||||||
{
|
|
||||||
if ($user = Auth::user()) {
|
|
||||||
$deleted = WishlistItem::where('user_id', $user->id)->where('product_id', $productId)->delete();
|
|
||||||
|
|
||||||
if ($deleted) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
WishlistItem::create(['user_id' => $user->id, 'product_id' => $productId]);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ids = $this->guestIds();
|
|
||||||
|
|
||||||
if (in_array($productId, $ids, true)) {
|
|
||||||
$this->storeGuestIds(array_values(array_diff($ids, [$productId])));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->storeGuestIds(array_slice([$productId, ...$ids], 0, self::GUEST_MAX));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function remove(int $productId): void
|
|
||||||
{
|
|
||||||
if ($this->has($productId)) {
|
|
||||||
$this->toggle($productId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves the guest cookie's products onto $user's wishlist and clears it.
|
|
||||||
*/
|
|
||||||
public function mergeGuestInto(Authenticatable $user): void
|
|
||||||
{
|
|
||||||
$ids = $this->guestIds();
|
|
||||||
|
|
||||||
if ($ids === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oldest first, so the newest cookie item also ends up newest here.
|
|
||||||
foreach (array_reverse($ids) as $productId) {
|
|
||||||
WishlistItem::firstOrCreate(['user_id' => $user->id, 'product_id' => $productId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->guestIds = [];
|
|
||||||
Cookie::queue(Cookie::forget(self::COOKIE));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int> */
|
|
||||||
private function guestIds(): array
|
|
||||||
{
|
|
||||||
if ($this->guestIds !== null) {
|
|
||||||
return $this->guestIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode((string) request()->cookie(self::COOKIE), true);
|
|
||||||
|
|
||||||
return $this->guestIds = is_array($decoded)
|
|
||||||
? array_values(array_unique(array_filter(array_map('intval', $decoded))))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<int> $ids */
|
|
||||||
private function storeGuestIds(array $ids): void
|
|
||||||
{
|
|
||||||
$this->guestIds = $ids;
|
|
||||||
|
|
||||||
Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
use App\Providers\CheckoutModuleServiceProvider;
|
|
||||||
use App\Providers\PanelServiceProvider;
|
use App\Providers\PanelServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
PanelServiceProvider::class,
|
PanelServiceProvider::class,
|
||||||
CheckoutModuleServiceProvider::class,
|
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+262
-102
@@ -515,11 +515,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"version": "0.20.0",
|
"version": "0.26.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://code.radical-elements.com/boboko/core.git",
|
"url": "https://code.radical-elements.com/boboko/core.git",
|
||||||
"reference": "c7035d678275a6c7aae6eb2a2ee1b12569da1a3d"
|
"reference": "5f0dbbb734f10f0957eb53554743bd71d6db22e1"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
@@ -552,14 +552,18 @@
|
|||||||
"Modules\\Core\\Providers\\AuthServiceProvider",
|
"Modules\\Core\\Providers\\AuthServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CheckoutServiceProvider",
|
"Modules\\Core\\Providers\\CheckoutServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\CheckoutModuleServiceProvider",
|
||||||
"Modules\\Core\\Providers\\PaymentServiceProvider",
|
"Modules\\Core\\Providers\\PaymentServiceProvider",
|
||||||
"Modules\\Core\\Providers\\LocalizationServiceProvider",
|
"Modules\\Core\\Providers\\LocalizationServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CartServiceProvider",
|
"Modules\\Core\\Providers\\CartServiceProvider",
|
||||||
"Modules\\Core\\Providers\\ReviewServiceProvider",
|
"Modules\\Core\\Providers\\ReviewServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\FileServiceProvider",
|
||||||
"Modules\\Core\\Providers\\ShippingServiceProvider",
|
"Modules\\Core\\Providers\\ShippingServiceProvider",
|
||||||
"Modules\\Core\\Providers\\OrderServiceProvider",
|
"Modules\\Core\\Providers\\OrderServiceProvider",
|
||||||
"Modules\\Core\\Providers\\PrivacyServiceProvider"
|
"Modules\\Core\\Providers\\PrivacyServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\WishlistServiceProvider",
|
||||||
|
"Modules\\Core\\Providers\\StoreServiceProvider"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -569,7 +573,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"time": "2026-09-23T06:47:28+00:00"
|
"time": "2026-09-28T14:36:39+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -933,6 +937,151 @@
|
|||||||
],
|
],
|
||||||
"time": "2023-12-20T15:40:13+00:00"
|
"time": "2023-12-20T15:40:13+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/class-map-generator",
|
||||||
|
"version": "1.8.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/class-map-generator.git",
|
||||||
|
"reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/class-map-generator/zipball/c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4",
|
||||||
|
"reference": "c0efbeb2f3fd7e23c7f180a59765c0b48a31fbc4",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer/pcre": "^2.1 || ^3.1",
|
||||||
|
"php": "^7.2 || ^8.0",
|
||||||
|
"symfony/finder": "^4.4 || ^5.3 || ^6 || ^7 || ^8"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.12 || ^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^1 || ^2",
|
||||||
|
"phpstan/phpstan-phpunit": "^1 || ^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^1.1 || ^2",
|
||||||
|
"phpunit/phpunit": "^8",
|
||||||
|
"symfony/filesystem": "^5.4 || ^6 || ^7 || ^8"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "1.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\ClassMapGenerator\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "https://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Utilities to scan PHP code and generate class maps.",
|
||||||
|
"keywords": [
|
||||||
|
"classmap"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/composer/class-map-generator/issues",
|
||||||
|
"source": "https://github.com/composer/class-map-generator/tree/1.8.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-09-25T15:24:19+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/pcre",
|
||||||
|
"version": "3.4.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/pcre.git",
|
||||||
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
|
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"phpstan/phpstan": "<2.2.2"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^2",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2",
|
||||||
|
"phpunit/phpunit": "^9"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"phpstan": {
|
||||||
|
"includes": [
|
||||||
|
"extension.neon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Pcre\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||||
|
"keywords": [
|
||||||
|
"PCRE",
|
||||||
|
"preg",
|
||||||
|
"regex",
|
||||||
|
"regular expression"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/composer/pcre/issues",
|
||||||
|
"source": "https://github.com/composer/pcre/tree/3.4.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-06-07T11:47:49+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "composer/semver",
|
"name": "composer/semver",
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
@@ -1691,16 +1840,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/actions",
|
"name": "filament/actions",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/actions.git",
|
"url": "https://github.com/filamentphp/actions.git",
|
||||||
"reference": "1eb39ac07f06302262cf6f5d77927bff1ed626fb"
|
"reference": "ff79ce562cd774665531202c9c78549dc4fa922c"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/actions/zipball/1eb39ac07f06302262cf6f5d77927bff1ed626fb",
|
"url": "https://api.github.com/repos/filamentphp/actions/zipball/ff79ce562cd774665531202c9c78549dc4fa922c",
|
||||||
"reference": "1eb39ac07f06302262cf6f5d77927bff1ed626fb",
|
"reference": "ff79ce562cd774665531202c9c78549dc4fa922c",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1736,20 +1885,20 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2026-09-15T16:56:16+00:00"
|
"time": "2026-09-26T19:38:45+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/filament",
|
"name": "filament/filament",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/panels.git",
|
"url": "https://github.com/filamentphp/panels.git",
|
||||||
"reference": "1215d7d79b5609f66c1e432933610eefa0ab3ad6"
|
"reference": "e0f5272ee6f75f64dc831a8ac5ed05e31fa55436"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/panels/zipball/1215d7d79b5609f66c1e432933610eefa0ab3ad6",
|
"url": "https://api.github.com/repos/filamentphp/panels/zipball/e0f5272ee6f75f64dc831a8ac5ed05e31fa55436",
|
||||||
"reference": "1215d7d79b5609f66c1e432933610eefa0ab3ad6",
|
"reference": "e0f5272ee6f75f64dc831a8ac5ed05e31fa55436",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1794,20 +1943,20 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2026-09-20T19:15:07+00:00"
|
"time": "2026-09-26T19:39:41+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/forms",
|
"name": "filament/forms",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/forms.git",
|
"url": "https://github.com/filamentphp/forms.git",
|
||||||
"reference": "10d7ba430544ae706a406713105ba37b18f52008"
|
"reference": "10c084a854bf6b9962717b8c21e12c579a8e9127"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/forms/zipball/10d7ba430544ae706a406713105ba37b18f52008",
|
"url": "https://api.github.com/repos/filamentphp/forms/zipball/10c084a854bf6b9962717b8c21e12c579a8e9127",
|
||||||
"reference": "10d7ba430544ae706a406713105ba37b18f52008",
|
"reference": "10c084a854bf6b9962717b8c21e12c579a8e9127",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -1818,6 +1967,9 @@
|
|||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"ueberdosis/tiptap-php": "^2.0"
|
"ueberdosis/tiptap-php": "^2.0"
|
||||||
},
|
},
|
||||||
|
"suggest": {
|
||||||
|
"fakerphp/faker": "Required to generate fake rich content and Markdown."
|
||||||
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
@@ -1844,11 +1996,11 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2026-09-20T18:45:30+00:00"
|
"time": "2026-09-26T19:39:08+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/infolists",
|
"name": "filament/infolists",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/infolists.git",
|
"url": "https://github.com/filamentphp/infolists.git",
|
||||||
@@ -1893,7 +2045,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/notifications",
|
"name": "filament/notifications",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/notifications.git",
|
"url": "https://github.com/filamentphp/notifications.git",
|
||||||
@@ -1940,7 +2092,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/query-builder",
|
"name": "filament/query-builder",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/query-builder.git",
|
"url": "https://github.com/filamentphp/query-builder.git",
|
||||||
@@ -1986,16 +2138,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/schemas",
|
"name": "filament/schemas",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/schemas.git",
|
"url": "https://github.com/filamentphp/schemas.git",
|
||||||
"reference": "dc4eeb57671b104b9e60f6d3f435f803c705e6df"
|
"reference": "fdb14a2cab2ffd2f7c6d8eef4593a4a4ed2c25ba"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/schemas/zipball/dc4eeb57671b104b9e60f6d3f435f803c705e6df",
|
"url": "https://api.github.com/repos/filamentphp/schemas/zipball/fdb14a2cab2ffd2f7c6d8eef4593a4a4ed2c25ba",
|
||||||
"reference": "dc4eeb57671b104b9e60f6d3f435f803c705e6df",
|
"reference": "fdb14a2cab2ffd2f7c6d8eef4593a4a4ed2c25ba",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2027,11 +2179,11 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2026-09-20T18:49:21+00:00"
|
"time": "2026-09-26T19:39:19+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/spatie-laravel-media-library-plugin",
|
"name": "filament/spatie-laravel-media-library-plugin",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git",
|
"url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git",
|
||||||
@@ -2068,27 +2220,28 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/support",
|
"name": "filament/support",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/support.git",
|
"url": "https://github.com/filamentphp/support.git",
|
||||||
"reference": "6bd7bdb5f5ff3b1a70cac6dabe28f5d996a36bcc"
|
"reference": "8468fa804bb8eed56ae7a86b2648822ec26e0476"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/support/zipball/6bd7bdb5f5ff3b1a70cac6dabe28f5d996a36bcc",
|
"url": "https://api.github.com/repos/filamentphp/support/zipball/8468fa804bb8eed56ae7a86b2648822ec26e0476",
|
||||||
"reference": "6bd7bdb5f5ff3b1a70cac6dabe28f5d996a36bcc",
|
"reference": "8468fa804bb8eed56ae7a86b2648822ec26e0476",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"blade-ui-kit/blade-heroicons": "^2.5",
|
"blade-ui-kit/blade-heroicons": "^2.5",
|
||||||
"composer-runtime-api": "^2.1",
|
"composer-runtime-api": "^2.1",
|
||||||
|
"composer/class-map-generator": "^1.6",
|
||||||
"danharrin/livewire-rate-limiting": "^2.0",
|
"danharrin/livewire-rate-limiting": "^2.0",
|
||||||
"ext-intl": "*",
|
"ext-intl": "*",
|
||||||
"illuminate/contracts": "^11.28|^12.0|^13.0",
|
"illuminate/contracts": "^11.28|^12.0|^13.0",
|
||||||
"kirschbaum-development/eloquent-power-joins": "^4.0",
|
"kirschbaum-development/eloquent-power-joins": "^4.0",
|
||||||
"league/uri-components": "^7.0",
|
"league/uri-components": "^7.0",
|
||||||
"livewire/livewire": "^3.7",
|
"livewire/livewire": "^3.8.6",
|
||||||
"nette/php-generator": "^4.0",
|
"nette/php-generator": "^4.0",
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"ryangjchandler/blade-capture-directive": "^1.0",
|
"ryangjchandler/blade-capture-directive": "^1.0",
|
||||||
@@ -2123,20 +2276,20 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2026-09-20T19:14:52+00:00"
|
"time": "2026-09-26T19:38:44+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/tables",
|
"name": "filament/tables",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/tables.git",
|
"url": "https://github.com/filamentphp/tables.git",
|
||||||
"reference": "8a0c6634d752e0b71ed0871f92d533c0d03d7601"
|
"reference": "420ca362362a0a5059706b7c850b01706d27f6b6"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/filamentphp/tables/zipball/8a0c6634d752e0b71ed0871f92d533c0d03d7601",
|
"url": "https://api.github.com/repos/filamentphp/tables/zipball/420ca362362a0a5059706b7c850b01706d27f6b6",
|
||||||
"reference": "8a0c6634d752e0b71ed0871f92d533c0d03d7601",
|
"reference": "420ca362362a0a5059706b7c850b01706d27f6b6",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2169,11 +2322,11 @@
|
|||||||
"issues": "https://github.com/filamentphp/filament/issues",
|
"issues": "https://github.com/filamentphp/filament/issues",
|
||||||
"source": "https://github.com/filamentphp/filament"
|
"source": "https://github.com/filamentphp/filament"
|
||||||
},
|
},
|
||||||
"time": "2026-09-20T18:53:09+00:00"
|
"time": "2026-09-26T19:39:27+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/widgets",
|
"name": "filament/widgets",
|
||||||
"version": "v4.13.4",
|
"version": "v4.14.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/filamentphp/widgets.git",
|
"url": "https://github.com/filamentphp/widgets.git",
|
||||||
@@ -2288,16 +2441,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "google/protobuf",
|
"name": "google/protobuf",
|
||||||
"version": "v5.36.1",
|
"version": "v5.36.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/protocolbuffers/protobuf-php.git",
|
"url": "https://github.com/protocolbuffers/protobuf-php.git",
|
||||||
"reference": "d64d16befba8632967f604b9644c0bb8f64cfbc3"
|
"reference": "020bc8e831611869ac387ddd38fbbadd78d15195"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/d64d16befba8632967f604b9644c0bb8f64cfbc3",
|
"url": "https://api.github.com/repos/protocolbuffers/protobuf-php/zipball/020bc8e831611869ac387ddd38fbbadd78d15195",
|
||||||
"reference": "d64d16befba8632967f604b9644c0bb8f64cfbc3",
|
"reference": "020bc8e831611869ac387ddd38fbbadd78d15195",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2326,9 +2479,9 @@
|
|||||||
"proto"
|
"proto"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.1"
|
"source": "https://github.com/protocolbuffers/protobuf-php/tree/v5.36.2"
|
||||||
},
|
},
|
||||||
"time": "2026-08-31T22:07:31+00:00"
|
"time": "2026-09-17T15:56:42+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "graham-campbell/result-type",
|
"name": "graham-campbell/result-type",
|
||||||
@@ -2869,16 +3022,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "jacobjoergensen/laravel-paper",
|
"name": "jacobjoergensen/laravel-paper",
|
||||||
"version": "1.16.0",
|
"version": "1.18.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/JacobJoergensen/laravel-paper.git",
|
"url": "https://github.com/JacobJoergensen/laravel-paper.git",
|
||||||
"reference": "3bcdc1d929ace91e138b268d2394830d559e3695"
|
"reference": "fd68cb14448245185953f673c0e1504c6c4f71cf"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/JacobJoergensen/laravel-paper/zipball/3bcdc1d929ace91e138b268d2394830d559e3695",
|
"url": "https://api.github.com/repos/JacobJoergensen/laravel-paper/zipball/fd68cb14448245185953f673c0e1504c6c4f71cf",
|
||||||
"reference": "3bcdc1d929ace91e138b268d2394830d559e3695",
|
"reference": "fd68cb14448245185953f673c0e1504c6c4f71cf",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2886,7 +3039,8 @@
|
|||||||
"illuminate/filesystem": "^12.0|^13.0",
|
"illuminate/filesystem": "^12.0|^13.0",
|
||||||
"illuminate/support": "^12.0|^13.0",
|
"illuminate/support": "^12.0|^13.0",
|
||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
"spatie/yaml-front-matter": "^2.0"
|
"spatie/yaml-front-matter": "^2.0",
|
||||||
|
"symfony/yaml": "^7.0|^8.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"laravel/pint": "^1.29",
|
"laravel/pint": "^1.29",
|
||||||
@@ -2920,6 +3074,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "A flat-file Eloquent driver for modern Laravel.",
|
"description": "A flat-file Eloquent driver for modern Laravel.",
|
||||||
|
"homepage": "https://laravel-paper.com",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"cms",
|
"cms",
|
||||||
"content",
|
"content",
|
||||||
@@ -2931,22 +3086,22 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/JacobJoergensen/laravel-paper/issues",
|
"issues": "https://github.com/JacobJoergensen/laravel-paper/issues",
|
||||||
"source": "https://github.com/JacobJoergensen/laravel-paper/tree/1.16.0"
|
"source": "https://github.com/JacobJoergensen/laravel-paper/tree/1.18.0"
|
||||||
},
|
},
|
||||||
"time": "2026-08-25T18:56:34+00:00"
|
"time": "2026-09-25T00:16:02+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "kirschbaum-development/eloquent-power-joins",
|
"name": "kirschbaum-development/eloquent-power-joins",
|
||||||
"version": "4.3.3",
|
"version": "4.3.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/kirschbaum-development/eloquent-power-joins.git",
|
"url": "https://github.com/kirschbaum-development/eloquent-power-joins.git",
|
||||||
"reference": "c609dbbe4ad2051b667e937f1ab554067519d64b"
|
"reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/c609dbbe4ad2051b667e937f1ab554067519d64b",
|
"url": "https://api.github.com/repos/kirschbaum-development/eloquent-power-joins/zipball/a0e6c0420c381861e1830693c4b74bdac7f7fb5f",
|
||||||
"reference": "c609dbbe4ad2051b667e937f1ab554067519d64b",
|
"reference": "a0e6c0420c381861e1830693c4b74bdac7f7fb5f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -2994,9 +3149,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/kirschbaum-development/eloquent-power-joins/issues",
|
"issues": "https://github.com/kirschbaum-development/eloquent-power-joins/issues",
|
||||||
"source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.3"
|
"source": "https://github.com/kirschbaum-development/eloquent-power-joins/tree/4.3.4"
|
||||||
},
|
},
|
||||||
"time": "2026-07-23T11:41:37+00:00"
|
"time": "2026-09-25T11:10:42+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
@@ -5150,16 +5305,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nesbot/carbon",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.14.0",
|
"version": "3.14.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/CarbonPHP/carbon.git",
|
"url": "https://github.com/CarbonPHP/carbon.git",
|
||||||
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2"
|
"reference": "34e9109535165bf1d7ff17a610492372ae73a896"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/0023eaa2c9110e47446dd512a263c69c40cd41f2",
|
"url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/34e9109535165bf1d7ff17a610492372ae73a896",
|
||||||
"reference": "0023eaa2c9110e47446dd512a263c69c40cd41f2",
|
"reference": "34e9109535165bf1d7ff17a610492372ae73a896",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -5251,7 +5406,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-09-12T20:45:19+00:00"
|
"time": "2026-09-27T12:07:26+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nette/php-generator",
|
"name": "nette/php-generator",
|
||||||
@@ -6031,16 +6186,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "open-telemetry/opentelemetry-auto-laravel",
|
"name": "open-telemetry/opentelemetry-auto-laravel",
|
||||||
"version": "1.8.0",
|
"version": "1.9.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/opentelemetry-php/contrib-auto-laravel.git",
|
"url": "https://github.com/opentelemetry-php/contrib-auto-laravel.git",
|
||||||
"reference": "f0a750eeb0a69012d81c84010a4c7a7b61926e00"
|
"reference": "fd81cb8e18af4b5405fb67c84622833886fa51da"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/opentelemetry-php/contrib-auto-laravel/zipball/f0a750eeb0a69012d81c84010a4c7a7b61926e00",
|
"url": "https://api.github.com/repos/opentelemetry-php/contrib-auto-laravel/zipball/fd81cb8e18af4b5405fb67c84622833886fa51da",
|
||||||
"reference": "f0a750eeb0a69012d81c84010a4c7a7b61926e00",
|
"reference": "fd81cb8e18af4b5405fb67c84622833886fa51da",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -6057,7 +6212,7 @@
|
|||||||
"nunomaduro/collision": "*",
|
"nunomaduro/collision": "*",
|
||||||
"open-telemetry/sdk": "^1.8",
|
"open-telemetry/sdk": "^1.8",
|
||||||
"orchestra/testbench": ">=7.41.3",
|
"orchestra/testbench": ">=7.41.3",
|
||||||
"phan/phan": "^5.0",
|
"phan/phan": "^6.0",
|
||||||
"php-http/mock-client": "*",
|
"php-http/mock-client": "*",
|
||||||
"phpstan/phpstan": "^1.1",
|
"phpstan/phpstan": "^1.1",
|
||||||
"phpstan/phpstan-phpunit": "^1.0",
|
"phpstan/phpstan-phpunit": "^1.0",
|
||||||
@@ -6094,9 +6249,9 @@
|
|||||||
"tracing"
|
"tracing"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/opentelemetry-php/contrib-auto-laravel/tree/1.8.0"
|
"source": "https://github.com/opentelemetry-php/contrib-auto-laravel/tree/1.9.1"
|
||||||
},
|
},
|
||||||
"time": "2026-07-09T20:34:43+00:00"
|
"time": "2026-09-17T05:26:08+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "open-telemetry/sdk",
|
"name": "open-telemetry/sdk",
|
||||||
@@ -6200,16 +6355,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "open-telemetry/sem-conv",
|
"name": "open-telemetry/sem-conv",
|
||||||
"version": "1.38.0",
|
"version": "1.44.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/opentelemetry-php/sem-conv.git",
|
"url": "https://github.com/opentelemetry-php/sem-conv.git",
|
||||||
"reference": "e613bc640a407def4991b8a936a9b27edd9a3240"
|
"reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/e613bc640a407def4991b8a936a9b27edd9a3240",
|
"url": "https://api.github.com/repos/opentelemetry-php/sem-conv/zipball/c94bc7ebacef039cda4f4c9270807dcc58b4e3af",
|
||||||
"reference": "e613bc640a407def4991b8a936a9b27edd9a3240",
|
"reference": "c94bc7ebacef039cda4f4c9270807dcc58b4e3af",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -6253,7 +6408,7 @@
|
|||||||
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
|
"issues": "https://github.com/open-telemetry/opentelemetry-php/issues",
|
||||||
"source": "https://github.com/open-telemetry/opentelemetry-php"
|
"source": "https://github.com/open-telemetry/opentelemetry-php"
|
||||||
},
|
},
|
||||||
"time": "2026-01-21T04:14:03+00:00"
|
"time": "2026-09-09T22:06:17+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "openspout/openspout",
|
"name": "openspout/openspout",
|
||||||
@@ -6802,20 +6957,25 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpdocumentor/reflection-common",
|
"name": "phpdocumentor/reflection-common",
|
||||||
"version": "2.2.0",
|
"version": "2.2.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/phpDocumentor/ReflectionCommon.git",
|
"url": "https://github.com/phpDocumentor/ReflectionCommon.git",
|
||||||
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b"
|
"reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b",
|
"url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/ee15a5a2022c8383906b225055e9496a6a5e6e3b",
|
||||||
"reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b",
|
"reference": "ee15a5a2022c8383906b225055e9496a6a5e6e3b",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^7.2 || ^8.0"
|
"php": "^7.4 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.8",
|
||||||
|
"phpunit/phpunit": "^9.5",
|
||||||
|
"vimeo/psalm": "^4.25"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
@@ -6839,7 +6999,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Common reflection classes used by phpdocumentor to reflect the code structure",
|
"description": "Common reflection classes used by phpdocumentor to reflect the code structure",
|
||||||
"homepage": "http://www.phpdoc.org",
|
"homepage": "https://www.phpdoc.org",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"FQSEN",
|
"FQSEN",
|
||||||
"phpDocumentor",
|
"phpDocumentor",
|
||||||
@@ -6849,9 +7009,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
|
"issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
|
||||||
"source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x"
|
"source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.2.1"
|
||||||
},
|
},
|
||||||
"time": "2020-06-27T09:03:43+00:00"
|
"time": "2025-11-10T21:21:55+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpdocumentor/reflection-docblock",
|
"name": "phpdocumentor/reflection-docblock",
|
||||||
@@ -7053,16 +7213,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpdoc-parser",
|
"name": "phpstan/phpdoc-parser",
|
||||||
"version": "2.3.5",
|
"version": "2.3.6",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
||||||
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9"
|
"reference": "1427af4647235b4a73b13940b1f1258d3584f36e"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
|
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1427af4647235b4a73b13940b1f1258d3584f36e",
|
||||||
"reference": "148cefffaf0233e4c08cc13db8a195a56dd6dfe9",
|
"reference": "1427af4647235b4a73b13940b1f1258d3584f36e",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -7094,9 +7254,9 @@
|
|||||||
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
|
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
|
||||||
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.5"
|
"source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.6"
|
||||||
},
|
},
|
||||||
"time": "2026-08-31T16:05:28+00:00"
|
"time": "2026-09-27T20:07:45+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "pragmarx/google2fa",
|
"name": "pragmarx/google2fa",
|
||||||
@@ -8847,16 +9007,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "spatie/laravel-package-tools",
|
"name": "spatie/laravel-package-tools",
|
||||||
"version": "1.93.2",
|
"version": "1.93.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/spatie/laravel-package-tools.git",
|
"url": "https://github.com/spatie/laravel-package-tools.git",
|
||||||
"reference": "e927d5b5b05e9fecae098e559e51918aa608ad02"
|
"reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/e927d5b5b05e9fecae098e559e51918aa608ad02",
|
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/7aeef26bd8d4909bec744dbf47aee7d1395588c1",
|
||||||
"reference": "e927d5b5b05e9fecae098e559e51918aa608ad02",
|
"reference": "7aeef26bd8d4909bec744dbf47aee7d1395588c1",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -8896,7 +9056,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
||||||
"source": "https://github.com/spatie/laravel-package-tools/tree/1.93.2"
|
"source": "https://github.com/spatie/laravel-package-tools/tree/1.93.3"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -8904,7 +9064,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-08-26T09:13:25+00:00"
|
"time": "2026-09-28T07:16:50+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "spatie/laravel-permission",
|
"name": "spatie/laravel-permission",
|
||||||
@@ -12600,16 +12760,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "technikermathe/blade-lucide-icons",
|
"name": "technikermathe/blade-lucide-icons",
|
||||||
"version": "v3.181.0",
|
"version": "v3.182.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/PascaleBeier/blade-lucide-icons.git",
|
"url": "https://github.com/PascaleBeier/blade-lucide-icons.git",
|
||||||
"reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98"
|
"reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98",
|
"url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/11253aa7d9aa7430da61d3534c62dc69a95b703b",
|
||||||
"reference": "64ccac4ecfe1b833e9a5cf1ebe7216acbe3cac98",
|
"reference": "11253aa7d9aa7430da61d3534c62dc69a95b703b",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -12659,9 +12819,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues",
|
"issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues",
|
||||||
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.181.0"
|
"source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.182.0"
|
||||||
},
|
},
|
||||||
"time": "2026-09-18T02:09:04+00:00"
|
"time": "2026-09-25T02:29:00+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
+30
-4
@@ -2,17 +2,43 @@
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Per-site settings for the cart + checkout module (see
|
* Per-site settings for the cart + checkout module (see
|
||||||
* App\Providers\CheckoutModuleServiceProvider). Becomes the package's
|
* Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
|
||||||
* publishable config when the module moves to boboko-core.
|
* artisan vendor:publish --tag=core-config.
|
||||||
*/
|
*/
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Name of the storefront's login route. The checkout's login tab and the
|
* 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
|
* confirmation page link to it with `?redirect=<checkout path>`, so the
|
||||||
* login page must send the shopper back there afterwards (3dealer's
|
* login page must send the shopper back there afterwards. null: no login
|
||||||
* Auth\LoginController does). null: no login offered in checkout at all.
|
* offered in checkout at all.
|
||||||
*/
|
*/
|
||||||
'login_route' => 'login',
|
'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',
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+122
@@ -16,4 +16,126 @@
|
|||||||
|
|
||||||
'auto_create_customer_for_user' => true,
|
'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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
// ones that already worked, like collection_ids), not just the
|
// ones that already worked, like collection_ids), not just the
|
||||||
// new order_count one. Caught in practice, reverted.
|
// new order_count one. Caught in practice, reverted.
|
||||||
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
|
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
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -40,6 +40,15 @@
|
|||||||
'email' => env('CONTACT_EMAIL'),
|
'email' => env('CONTACT_EMAIL'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
|
||||||
|
// local dev use hCaptcha's test keys, the real ones reject localhost:
|
||||||
|
// sitekey 10000000-ffff-ffff-ffff-000000000001,
|
||||||
|
// secret 0x0000000000000000000000000000000000000000.
|
||||||
|
'hcaptcha' => [
|
||||||
|
'sitekey' => env('HCAPTCHA_SITEKEY'),
|
||||||
|
'secret' => env('HCAPTCHA_SECRET'),
|
||||||
|
],
|
||||||
|
|
||||||
'stoic' => [
|
'stoic' => [
|
||||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||||
'host' => env('STOIC_HOST'),
|
'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,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('wishlist_items', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
|
||||||
$table->foreignId('product_id')->constrained('lunar_products')->cascadeOnDelete();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->unique(['user_id', 'product_id']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('wishlist_items');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,183 +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.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', 'Συνέχεια αγορών'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Database\Seeders;
|
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
use Modules\Core\Localization\Services\TranslationService;
|
|
||||||
use Spatie\TranslationLoader\LanguageLine;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default `validation` translation lines — Laravel's own error-message group
|
|
||||||
* (`validation.required`, `validation.email`, `validation.attributes.*`, …),
|
|
||||||
* resolved by every `Validator::make()`/`$request->validate()` call in the app
|
|
||||||
* (see CheckoutController::saveAddress(), CartController, ProductController),
|
|
||||||
* not just the checkout module.
|
|
||||||
*
|
|
||||||
* Spatie's DB loader (spatie/laravel-translation-loader, wired in boboko-core's
|
|
||||||
* LocalizationServiceProvider) merges this group over Laravel's file-based
|
|
||||||
* validation.php, which the project doesn't ship a `lang/` copy of — so without
|
|
||||||
* this, Greek requests fall back to Laravel's untranslated English defaults.
|
|
||||||
* Only the rule keys and field attributes actually in use are seeded; add more
|
|
||||||
* as new rules/fields show up.
|
|
||||||
*
|
|
||||||
* Additive and idempotent: a key that already exists is left untouched, so
|
|
||||||
* anything edited in the Filament Language Lines UI wins on a re-run. Runs
|
|
||||||
* explicitly — `php artisan db:seed --class=ValidationTranslationsSeeder` — it
|
|
||||||
* is not wired into DatabaseSeeder.
|
|
||||||
*
|
|
||||||
* Greek copy uses the project's informal register (εσύ/σου).
|
|
||||||
*/
|
|
||||||
class ValidationTranslationsSeeder extends Seeder
|
|
||||||
{
|
|
||||||
public function run(): void
|
|
||||||
{
|
|
||||||
$translations = app(TranslationService::class);
|
|
||||||
|
|
||||||
foreach ($this->lines() as $key => [$en, $el]) {
|
|
||||||
$exists = LanguageLine::query()
|
|
||||||
->where('group', 'validation')
|
|
||||||
->where('key', $key)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($exists) {
|
|
||||||
$this->command?->warn("validation.{$key} already exists — skipped");
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$translations->create('validation', $key, ['en' => $en, 'el' => $el]);
|
|
||||||
$this->command?->info("validation.{$key} added");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* key => [English, Greek].
|
|
||||||
*
|
|
||||||
* @return array<string, array{0: string, 1: string}>
|
|
||||||
*/
|
|
||||||
private function lines(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
// ── Rule messages ─────────────────────────────────────────────
|
|
||||||
'required' => ['The :attribute field is required.', 'Το πεδίο :attribute είναι υποχρεωτικό.'],
|
|
||||||
'email' => ['The :attribute field must be a valid email address.', 'Το πεδίο :attribute πρέπει να είναι έγκυρη διεύθυνση email.'],
|
|
||||||
'string' => ['The :attribute field must be a string.', 'Το πεδίο :attribute πρέπει να είναι κείμενο.'],
|
|
||||||
'integer' => ['The :attribute field must be an integer.', 'Το πεδίο :attribute πρέπει να είναι ακέραιος αριθμός.'],
|
|
||||||
'boolean' => ['The :attribute field must be true or false.', 'Το πεδίο :attribute πρέπει να είναι true ή false.'],
|
|
||||||
'min.numeric' => ['The :attribute field must be at least :min.', 'Το πεδίο :attribute πρέπει να είναι τουλάχιστον :min.'],
|
|
||||||
'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'],
|
|
||||||
'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'],
|
|
||||||
'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'],
|
|
||||||
// Product custom-field photo uploads (CustomFieldUploadController, CartController).
|
|
||||||
'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'],
|
|
||||||
'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'],
|
|
||||||
'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'],
|
|
||||||
'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :attribute απέτυχε.'],
|
|
||||||
|
|
||||||
// ── Field names (checkout: billing/shipping address) ──────────
|
|
||||||
'attributes.contact_email' => ['email', 'email'],
|
|
||||||
'attributes.billing_first_name' => ['first name', 'όνομα'],
|
|
||||||
'attributes.billing_last_name' => ['last name', 'επώνυμο'],
|
|
||||||
'attributes.billing_company_name' => ['company name', 'επωνυμία εταιρείας'],
|
|
||||||
'attributes.billing_tax_identifier' => ['tax ID', 'ΑΦΜ'],
|
|
||||||
'attributes.billing_line_one' => ['address', 'διεύθυνση'],
|
|
||||||
'attributes.billing_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
|
|
||||||
'attributes.billing_city' => ['city', 'πόλη'],
|
|
||||||
'attributes.billing_state' => ['region', 'νομό / περιοχή'],
|
|
||||||
'attributes.billing_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
|
|
||||||
'attributes.billing_country_id' => ['country', 'χώρα'],
|
|
||||||
'attributes.billing_contact_phone' => ['phone', 'τηλέφωνο'],
|
|
||||||
'attributes.shipping_first_name' => ['first name', 'όνομα'],
|
|
||||||
'attributes.shipping_last_name' => ['last name', 'επώνυμο'],
|
|
||||||
'attributes.shipping_company_name' => ['company name', 'επωνυμία εταιρείας'],
|
|
||||||
'attributes.shipping_line_one' => ['address', 'διεύθυνση'],
|
|
||||||
'attributes.shipping_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
|
|
||||||
'attributes.shipping_city' => ['city', 'πόλη'],
|
|
||||||
'attributes.shipping_state' => ['region', 'νομό / περιοχή'],
|
|
||||||
'attributes.shipping_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
|
|
||||||
'attributes.shipping_country_id' => ['country', 'χώρα'],
|
|
||||||
'attributes.shipping_contact_phone' => ['phone', 'τηλέφωνο'],
|
|
||||||
'attributes.shipping_delivery_instructions' => ['delivery notes', 'σχόλια για την παράδοση'],
|
|
||||||
|
|
||||||
// ── Field names (cart) ─────────────────────────────────────────
|
|
||||||
'attributes.purchasable_id' => ['product', 'προϊόν'],
|
|
||||||
'attributes.quantity' => ['quantity', 'ποσότητα'],
|
|
||||||
'attributes.code' => ['coupon code', 'κωδικό κουπονιού'],
|
|
||||||
|
|
||||||
// ── Field names (product reviews / stock check) ────────────────
|
|
||||||
'attributes.variant' => ['variant', 'παραλλαγή'],
|
|
||||||
'attributes.rating' => ['rating', 'βαθμολογία'],
|
|
||||||
'attributes.content' => ['review text', 'κείμενο κριτικής'],
|
|
||||||
'attributes.name' => ['name', 'όνομα'],
|
|
||||||
'attributes.email' => ['email', 'email'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,3 +10,7 @@ services:
|
|||||||
scheduler:
|
scheduler:
|
||||||
volumes:
|
volumes:
|
||||||
- ../boboko-core:/var/www/boboko-core
|
- ../boboko-core:/var/www/boboko-core
|
||||||
|
|
||||||
|
vite:
|
||||||
|
volumes:
|
||||||
|
- ../boboko-core:/boboko-core
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ services:
|
|||||||
|
|
||||||
queue:
|
queue:
|
||||||
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
|
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
|
||||||
|
entrypoint: ["/entrypoint-worker.sh"]
|
||||||
command: php artisan queue:work --tries=3 --max-jobs=500 --memory=256
|
command: php artisan queue:work --tries=3 --max-jobs=500 --memory=256
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
@@ -68,6 +69,7 @@ services:
|
|||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
|
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
|
||||||
|
entrypoint: ["/entrypoint-worker.sh"]
|
||||||
command: php artisan schedule:work
|
command: php artisan schedule:work
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
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"
|
echo "[entrypoint] Trying Lunar install"
|
||||||
php artisan lunar:install --quiet || true
|
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
|
# 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
|
# 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
|
# in production too — a deploy that changed an indexer's field list needs this
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ content_path: /content
|
|||||||
media_path: /media
|
media_path: /media
|
||||||
thumbs_path: /var/www/html/storage/app/public/stoic/
|
thumbs_path: /var/www/html/storage/app/public/stoic/
|
||||||
repository_path: /app
|
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:
|
presets:
|
||||||
- code: large
|
- code: large
|
||||||
mode: resize
|
mode: resize
|
||||||
|
|||||||
Generated
+22
-62
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "boboko-starter",
|
"name": "app",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"@phosphor-icons/web": "^2.1.2",
|
|
||||||
"axios": "^1.15.2",
|
"axios": "^1.15.2",
|
||||||
"flatpickr": "^4.6.13"
|
"leaflet": "^1.9.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -19,11 +19,21 @@
|
|||||||
"vite": "^8.0.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -35,7 +45,6 @@
|
|||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -46,7 +55,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -122,7 +130,6 @@
|
|||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -141,18 +148,11 @@
|
|||||||
"version": "0.138.0",
|
"version": "0.138.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@phosphor-icons/web": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||||
@@ -160,7 +160,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -177,7 +176,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -194,7 +192,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -211,7 +208,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -228,7 +224,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -245,7 +240,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -262,7 +256,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -279,7 +272,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -296,7 +288,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -313,7 +304,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -330,7 +320,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -347,7 +336,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -364,7 +352,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -383,7 +370,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -400,7 +386,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -414,7 +399,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
@@ -693,7 +677,6 @@
|
|||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -901,7 +884,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -1001,7 +983,6 @@
|
|||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -1015,12 +996,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatpickr": {
|
|
||||||
"version": "4.6.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
|
||||||
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
@@ -1061,7 +1036,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -1223,7 +1197,7 @@
|
|||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -1256,11 +1230,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
@@ -1293,7 +1272,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1314,7 +1292,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1335,7 +1312,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1356,7 +1332,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1377,7 +1352,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1398,7 +1372,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1419,7 +1392,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1440,7 +1412,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1461,7 +1432,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1482,7 +1452,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1503,7 +1472,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1567,7 +1535,6 @@
|
|||||||
"version": "3.3.15",
|
"version": "3.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1586,14 +1553,12 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -1606,7 +1571,6 @@
|
|||||||
"version": "8.5.16",
|
"version": "8.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1654,7 +1618,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.138.0",
|
"@oxc-project/types": "=0.138.0",
|
||||||
@@ -1711,7 +1674,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -1786,7 +1748,6 @@
|
|||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -1813,14 +1774,13 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.3",
|
"version": "8.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
|
|||||||
+7
-1
@@ -14,8 +14,14 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"axios": "^1.15.2"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ @layer components {
|
|||||||
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
|
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);
|
transform: translate(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,856 +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 {
|
|
||||||
display: block;
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.bbk-cart-item-title:hover { text-decoration: underline; }
|
|
||||||
|
|
||||||
.bbk-cart-item-variant {
|
|
||||||
margin: 0 0 0.25rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
|
|
||||||
.bbk-line-fields {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-line-field dt {
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-line-field dd {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-line;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-line-field-file {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-line-field-file img {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-item-unit {
|
|
||||||
margin: 0 0 0.625rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-item-aside {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-item-total { margin: 0; font-weight: 600; }
|
|
||||||
|
|
||||||
.bbk-cart-item-remove {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-qty {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-qty-btn {
|
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
background: var(--bbk-color-bg-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); }
|
|
||||||
|
|
||||||
.bbk-cart-qty-input {
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
border: 0;
|
|
||||||
border-left: 1px solid var(--bbk-color-border);
|
|
||||||
border-right: 1px solid var(--bbk-color-border);
|
|
||||||
text-align: center;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--bbk-color-bg);
|
|
||||||
appearance: textfield;
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-qty-input::-webkit-outer-spin-button,
|
|
||||||
.bbk-cart-qty-input::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-summary {
|
|
||||||
padding-top: 1.25rem;
|
|
||||||
border-top: 1px solid var(--bbk-color-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.625rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-summary-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
|
|
||||||
|
|
||||||
.bbk-cart-summary-pending {
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-summary-row--total {
|
|
||||||
margin-top: 0.375rem;
|
|
||||||
padding-top: 0.875rem;
|
|
||||||
border-top: 1px solid var(--bbk-color-border);
|
|
||||||
font-size: 1.0625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-coupon-form {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-coupon-input {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--bbk-color-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-coupon-submit {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
background: var(--bbk-color-bg-muted);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); }
|
|
||||||
|
|
||||||
.bbk-cart-coupon-applied {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.625rem 0.875rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
background: var(--bbk-color-bg-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-coupon-code {
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-coupon-remove {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
background: none;
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); }
|
|
||||||
|
|
||||||
.bbk-cart-coupon-error {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-error {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.75rem 1.5rem 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-add-to-cart-error {
|
|
||||||
margin: 0.375rem 0 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-checkout {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1.25rem;
|
|
||||||
border: 1px solid var(--bbk-color-accent);
|
|
||||||
border-radius: var(--bbk-radius);
|
|
||||||
background: var(--bbk-color-accent);
|
|
||||||
color: var(--bbk-color-accent-text);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-checkout:hover { opacity: 0.85; }
|
|
||||||
|
|
||||||
.bbk-cart-checkout:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-cart-empty {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
padding: 2.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ───────────────────────────────────────────────────────────────────
|
|
||||||
Checkout page — two columns: fields on the left, order summary (the
|
|
||||||
same cart-body partial the drawer uses) on the right.
|
|
||||||
─────────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.bbk-checkout-page {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2.5rem 1.5rem 5rem;
|
|
||||||
font-family: var(--bbk-font);
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--bbk-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-heading {
|
|
||||||
margin: 0 0 2rem;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 380px;
|
|
||||||
gap: 3rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.bbk-checkout { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-section {
|
|
||||||
padding-bottom: 2rem;
|
|
||||||
border-bottom: 1px solid var(--bbk-color-border);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-section-heading {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-note {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Contact: "logged in as" line, or the guest login prompt */
|
|
||||||
|
|
||||||
.bbk-checkout-logged-in,
|
|
||||||
.bbk-checkout-login-prompt { margin: 0; }
|
|
||||||
|
|
||||||
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
|
|
||||||
|
|
||||||
/* Fields */
|
|
||||||
|
|
||||||
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
|
|
||||||
|
|
||||||
.bbk-field-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.bbk-field-row { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-field-label {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-field-input {
|
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--bbk-color-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-field-input:focus-visible {
|
|
||||||
outline: 2px solid var(--bbk-color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-field-input:disabled {
|
|
||||||
background: var(--bbk-color-bg-muted);
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
|
|
||||||
|
|
||||||
.bbk-field-error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A fixed, non-editable field value (e.g. the store's single country). */
|
|
||||||
.bbk-field-static {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.625rem 0.75rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
background: var(--bbk-color-bg-muted);
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.bbk-field-input { resize: vertical; }
|
|
||||||
|
|
||||||
.bbk-checkbox {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For a full-sentence label that can wrap — align the box to the first line. */
|
|
||||||
/* "I want an invoice": company/ΑΦΜ only while ticked */
|
|
||||||
|
|
||||||
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
|
|
||||||
|
|
||||||
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
|
|
||||||
|
|
||||||
.bbk-checkbox--stacked {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Shipping method */
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option-detail {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option-name { font-weight: 600; }
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option-description {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-shipping-option-price { font-weight: 600; }
|
|
||||||
|
|
||||||
/* The single auto-selected option — a fixed line, not a choosable radio. */
|
|
||||||
.bbk-checkout-shipping-confirmed {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border: 1px solid var(--bbk-color-accent);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Autosave status line under the address form. */
|
|
||||||
.bbk-checkout-status {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
|
||||||
|
|
||||||
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
|
||||||
|
|
||||||
.bbk-checkout-continue {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1.25rem;
|
|
||||||
border: 1px solid var(--bbk-color-accent);
|
|
||||||
border-radius: var(--bbk-radius);
|
|
||||||
background: var(--bbk-color-accent);
|
|
||||||
color: var(--bbk-color-accent-text);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
box-sizing: border-box;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-continue:hover { opacity: 0.85; }
|
|
||||||
|
|
||||||
.bbk-checkout-continue:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Order summary column */
|
|
||||||
|
|
||||||
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
|
|
||||||
|
|
||||||
.bbk-checkout-summary {
|
|
||||||
padding: 1.5rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius);
|
|
||||||
background: var(--bbk-color-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-summary-heading {
|
|
||||||
margin: 0 0 1.25rem;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
|
|
||||||
nowhere further to send you from here. */
|
|
||||||
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
|
|
||||||
|
|
||||||
/* ── Payment ───────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.bbk-checkout-payment-options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-payment-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border: 1px solid var(--bbk-color-border);
|
|
||||||
border-radius: var(--bbk-radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
|
||||||
|
|
||||||
.bbk-checkout-payment-option-name { font-weight: 600; }
|
|
||||||
|
|
||||||
.bbk-payment-element { margin: 0.25rem 0; }
|
|
||||||
|
|
||||||
.bbk-checkout-withdrawal {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-checkout-withdrawal a { color: inherit; }
|
|
||||||
|
|
||||||
.bbk-checkout-error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--bbk-color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Processing overlay — fixed, covers the page while a payment confirms. */
|
|
||||||
.bbk-checkout-processing {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 1100;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
|
|
||||||
text-align: center;
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-spinner {
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
border: 3px solid var(--bbk-color-border);
|
|
||||||
border-top-color: var(--bbk-color-accent);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: bbk-spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bbk-spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Confirmation page ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.bbk-confirmation {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 3rem 1.5rem 5rem;
|
|
||||||
font-family: var(--bbk-font);
|
|
||||||
color: var(--bbk-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-heading {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
|
|
||||||
|
|
||||||
.bbk-confirmation-meta {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-meta-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
|
|
||||||
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
|
|
||||||
|
|
||||||
.bbk-confirmation-body {
|
|
||||||
margin: 2rem 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-lines {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-line {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 72px 1fr auto;
|
|
||||||
align-items: start;
|
|
||||||
gap: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-line-detail { min-width: 0; }
|
|
||||||
|
|
||||||
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
|
|
||||||
|
|
||||||
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
|
|
||||||
|
|
||||||
.bbk-confirmation-addresses {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-confirmation-address-heading {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bbk-address-lines {
|
|
||||||
font-style: normal;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--bbk-color-muted);
|
|
||||||
}
|
|
||||||
+5
-3
@@ -10,13 +10,15 @@ window.Turbo.session.drive = false;
|
|||||||
|
|
||||||
import { Application } from "@hotwired/stimulus";
|
import { Application } from "@hotwired/stimulus";
|
||||||
import { registerControllers } from "./stimulus/index";
|
import { registerControllers } from "./stimulus/index";
|
||||||
import { registerCheckout } from "./checkout";
|
import { registerCheckout, registerWishlist } from "@boboko/core";
|
||||||
|
|
||||||
const application = Application.start();
|
const application = Application.start();
|
||||||
application.debug = false;
|
application.debug = false;
|
||||||
|
|
||||||
registerControllers(application);
|
registerControllers(application);
|
||||||
|
|
||||||
// Portable cart + checkout module (destined for boboko-core). Owns its own
|
// Portable cart + checkout + wishlist modules, installed as a real npm
|
||||||
// bbk-* Stimulus controllers; this is the only wiring line it needs here.
|
// dependency (see package.json). Each owns its own Stimulus controllers;
|
||||||
|
// these are the only wiring lines they need here.
|
||||||
registerCheckout(application);
|
registerCheckout(application);
|
||||||
|
registerWishlist(application);
|
||||||
|
|||||||
@@ -1,57 +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 {
|
|
||||||
static targets = ['error']
|
|
||||||
|
|
||||||
async add(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
const form = this.element
|
|
||||||
const submit = form.querySelector('[type="submit"]')
|
|
||||||
|
|
||||||
this.clearError()
|
|
||||||
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
|
|
||||||
if (submit) submit.disabled = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.action, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new FormData(form),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
this.showError(data?.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
|
|
||||||
detail: { html: await response.text() },
|
|
||||||
}))
|
|
||||||
} finally {
|
|
||||||
form.removeAttribute('data-bbk-add-to-cart-state')
|
|
||||||
if (submit) submit.disabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
if (!this.hasErrorTarget || !message) return
|
|
||||||
this.errorTarget.textContent = message
|
|
||||||
this.errorTarget.hidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
clearError() {
|
|
||||||
if (!this.hasErrorTarget) return
|
|
||||||
this.errorTarget.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,161 +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', 'error']
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.onChanged = this.onChanged.bind(this)
|
|
||||||
this.onKeydown = this.onKeydown.bind(this)
|
|
||||||
this.updateTimers = new Map() // line id -> pending debounce timer
|
|
||||||
|
|
||||||
window.addEventListener('bbk-cart:changed', this.onChanged)
|
|
||||||
window.addEventListener('bbk-cart:open', this.open.bind(this))
|
|
||||||
document.addEventListener('keydown', this.onKeydown)
|
|
||||||
|
|
||||||
// Prime the host with the count rendered server-side on page load.
|
|
||||||
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
window.removeEventListener('bbk-cart:changed', this.onChanged)
|
|
||||||
document.removeEventListener('keydown', this.onKeydown)
|
|
||||||
this.updateTimers.forEach((timer) => clearTimeout(timer))
|
|
||||||
}
|
|
||||||
|
|
||||||
onChanged(event) {
|
|
||||||
if (event.detail?.html) this.replaceBody(event.detail.html)
|
|
||||||
this.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
onKeydown(event) {
|
|
||||||
if (event.key === 'Escape' && !this.element.hidden) this.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
open() {
|
|
||||||
if (!this.element.hidden) return
|
|
||||||
this.element.hidden = false
|
|
||||||
// Next frame, so the panel transitions from its off-canvas start.
|
|
||||||
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.element.removeAttribute('data-bbk-cart-state')
|
|
||||||
|
|
||||||
const panel = this.panelTarget
|
|
||||||
const done = () => {
|
|
||||||
this.element.hidden = true
|
|
||||||
panel.removeEventListener('transitionend', done)
|
|
||||||
}
|
|
||||||
panel.addEventListener('transitionend', done)
|
|
||||||
}
|
|
||||||
|
|
||||||
// change on a line quantity input, or submit of a line's remove form
|
|
||||||
submit(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
const form = event.target.closest('form')
|
|
||||||
if (!form) return
|
|
||||||
|
|
||||||
// A remove is a deliberate, one-shot action — only the quantity form
|
|
||||||
// (typing, or the +/- stepper below) benefits from debouncing.
|
|
||||||
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
// +/- stepper buttons inside a line
|
|
||||||
step(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
const form = event.target.closest('form')
|
|
||||||
const input = form.querySelector('input[type="number"]')
|
|
||||||
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
|
|
||||||
input.value = String(next)
|
|
||||||
this.scheduleSend(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repeated clicks (or spinner nudges) update the input instantly but only
|
|
||||||
// send once they settle for 300ms — sending on every single click was
|
|
||||||
// firing overlapping requests that raced each other and made the drawer
|
|
||||||
// visibly flicker/lag under quick clicking.
|
|
||||||
scheduleSend(form) {
|
|
||||||
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
|
|
||||||
if (!lineId) return this.send(form)
|
|
||||||
|
|
||||||
clearTimeout(this.updateTimers.get(lineId))
|
|
||||||
this.updateTimers.set(lineId, setTimeout(() => {
|
|
||||||
this.updateTimers.delete(lineId)
|
|
||||||
this.send(form)
|
|
||||||
}, 300))
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(form) {
|
|
||||||
this.bodyTarget.setAttribute('aria-busy', 'true')
|
|
||||||
this.clearError()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.action, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new FormData(form),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
this.replaceBody(await response.text())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
this.showError(data?.error)
|
|
||||||
|
|
||||||
// The rejected quantity (typed, or from a +/- click) is left
|
|
||||||
// sitting in the input with nothing to correct it — the update
|
|
||||||
// never reached the cart, so the input must be put back to what
|
|
||||||
// the cart actually still holds, not just left showing whatever
|
|
||||||
// was rejected.
|
|
||||||
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
|
|
||||||
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
|
|
||||||
} finally {
|
|
||||||
this.bodyTarget.removeAttribute('aria-busy')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
if (!this.hasErrorTarget || !message) return
|
|
||||||
this.errorTarget.textContent = message
|
|
||||||
this.errorTarget.hidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
clearError() {
|
|
||||||
if (!this.hasErrorTarget) return
|
|
||||||
this.errorTarget.hidden = true
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceBody(html) {
|
|
||||||
this.bodyTarget.innerHTML = html
|
|
||||||
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
|
|
||||||
}
|
|
||||||
|
|
||||||
emitUpdated(node) {
|
|
||||||
if (!node) return
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
|
|
||||||
detail: {
|
|
||||||
count: parseInt(node.dataset.bbkCartCount || '0', 10),
|
|
||||||
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,204 +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 = [
|
|
||||||
'sameAsBilling', 'shippingFields',
|
|
||||||
'form', 'shippingOptions', 'status',
|
|
||||||
]
|
|
||||||
|
|
||||||
static values = {
|
|
||||||
saveUrl: String,
|
|
||||||
selectShippingUrl: String,
|
|
||||||
statusSaving: String,
|
|
||||||
statusSaved: String,
|
|
||||||
statusError: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.saveTimer = null
|
|
||||||
this.saveController = null
|
|
||||||
this.statusTimer = null
|
|
||||||
this.shippingPromise = null
|
|
||||||
|
|
||||||
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
clearTimeout(this.saveTimer)
|
|
||||||
clearTimeout(this.statusTimer)
|
|
||||||
this.saveController?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Same as billing ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
toggleSameAsBilling() {
|
|
||||||
this.applySameAsBilling()
|
|
||||||
}
|
|
||||||
|
|
||||||
applySameAsBilling() {
|
|
||||||
const on = this.sameAsBillingTarget.checked
|
|
||||||
|
|
||||||
// Checked: shipping *is* billing — copy every value across, then hide +
|
|
||||||
// disable so the browser doesn't submit them; the server reuses billing.
|
|
||||||
// Unchecked: reveal them pre-filled from billing wherever still empty.
|
|
||||||
this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => {
|
|
||||||
const shippingField = this.element.querySelector(
|
|
||||||
`[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`,
|
|
||||||
)
|
|
||||||
if (shippingField && (on || !shippingField.value)) {
|
|
||||||
shippingField.value = billingField.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.shippingFieldsTarget.hidden = on
|
|
||||||
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
|
|
||||||
field.disabled = on
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Autosave ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
scheduleSave(event) {
|
|
||||||
// The shipping-method and payment radios live inside this controller's
|
|
||||||
// element too, and this action is bound on .bbk-checkout-main to also
|
|
||||||
// catch the contact email/consent that sit outside the <form>. Only
|
|
||||||
// react to fields that actually belong to the address form.
|
|
||||||
const el = event.target
|
|
||||||
const belongsToForm = el.form?.id === 'bbk-address-form'
|
|
||||||
if (!belongsToForm) return
|
|
||||||
|
|
||||||
// No status during the wait — it only shows once the request is in flight,
|
|
||||||
// so the indicator isn't flickering "saving" on every keystroke.
|
|
||||||
clearTimeout(this.saveTimer)
|
|
||||||
this.saveTimer = setTimeout(() => this.save(), 700)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called by bbk-payment right before place-order — a debounced save (and
|
|
||||||
// the shipping-option auto-select that happens as part of it) might still
|
|
||||||
// be pending when the shopper clicks "place order"; this guarantees the
|
|
||||||
// server has processed the current form state first.
|
|
||||||
async flush() {
|
|
||||||
clearTimeout(this.saveTimer)
|
|
||||||
await this.save()
|
|
||||||
// A shipping-method radio click fires its own (undebounced) request —
|
|
||||||
// still async, still racy against an immediate "place order" click.
|
|
||||||
if (this.shippingPromise) await this.shippingPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
async save() {
|
|
||||||
this.saveController?.abort()
|
|
||||||
this.saveController = new AbortController()
|
|
||||||
this.setStatus('saving')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.saveUrlValue, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new FormData(this.formTarget),
|
|
||||||
signal: this.saveController.signal,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) return this.setStatus('error')
|
|
||||||
|
|
||||||
this.applyResult(await response.json())
|
|
||||||
this.setStatus('saved')
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name !== 'AbortError') this.setStatus('error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectShipping(event) {
|
|
||||||
// Tracked so flush() can await it — nothing else stops "place order"
|
|
||||||
// (a separate, unrelated click) from racing ahead of this request.
|
|
||||||
this.shippingPromise = this.doSelectShipping(event.target.value)
|
|
||||||
await this.shippingPromise
|
|
||||||
}
|
|
||||||
|
|
||||||
async doSelectShipping(value) {
|
|
||||||
this.saveController?.abort()
|
|
||||||
this.setStatus('saving')
|
|
||||||
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('shipping_option', value)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.selectShippingUrlValue, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) return this.setStatus('error')
|
|
||||||
|
|
||||||
this.applyResult(await response.json())
|
|
||||||
this.setStatus('saved')
|
|
||||||
} catch {
|
|
||||||
this.setStatus('error')
|
|
||||||
} finally {
|
|
||||||
this.shippingPromise = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyResult(data) {
|
|
||||||
this.applyErrors(data.errors || {})
|
|
||||||
|
|
||||||
if (data.shippingOptionsHtml != null) {
|
|
||||||
this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.summaryHtml != null) {
|
|
||||||
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
|
|
||||||
detail: { html: data.summaryHtml },
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyErrors(errors) {
|
|
||||||
this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => {
|
|
||||||
const message = errors[el.dataset.bbkFieldError]
|
|
||||||
el.textContent = message || ''
|
|
||||||
el.hidden = !message
|
|
||||||
|
|
||||||
const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`)
|
|
||||||
field?.classList.toggle('bbk-field-input--error', Boolean(message))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(state) {
|
|
||||||
if (!this.hasStatusTarget) return
|
|
||||||
|
|
||||||
const text = {
|
|
||||||
saving: this.statusSavingValue,
|
|
||||||
saved: this.statusSavedValue,
|
|
||||||
error: this.statusErrorValue,
|
|
||||||
}[state]
|
|
||||||
|
|
||||||
this.statusTarget.textContent = text
|
|
||||||
this.statusTarget.hidden = false
|
|
||||||
this.statusTarget.dataset.state = state
|
|
||||||
|
|
||||||
clearTimeout(this.statusTimer)
|
|
||||||
if (state === 'saved') {
|
|
||||||
this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,289 +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) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Removing the last line while sitting on the checkout page (via
|
|
||||||
// the order summary's own remove form) must not leave "place
|
|
||||||
// order" clickable with nothing left to charge for — this fires
|
|
||||||
// from both the drawer and the checkout page's own summary
|
|
||||||
// instance, whichever the shopper actually used.
|
|
||||||
const count = event.detail?.count
|
|
||||||
if (typeof count === 'number' && this.hasSubmitTarget) {
|
|
||||||
this.submitTarget.disabled = count === 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
|
|
||||||
|
|
||||||
if (this.selectedIsStripe()) this.mountStripe()
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
|
|
||||||
this.unmountStripe()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Method selection ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async selectMethod(event) {
|
|
||||||
const isStripe = event.target.dataset.paymentDriver === 'stripe'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.selectUrlValue, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({ payment_type: event.target.value }),
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.summaryHtml != null) {
|
|
||||||
window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// summary just won't refresh — non-fatal
|
|
||||||
}
|
|
||||||
|
|
||||||
isStripe ? this.mountStripe() : this.unmountStripe()
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedRadio() {
|
|
||||||
return this.element.querySelector('input[name="payment_type"]:checked')
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedIsStripe() {
|
|
||||||
return this.selectedRadio()?.dataset.paymentDriver === 'stripe'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stripe Payment Element ────────────────────────────────────────
|
|
||||||
|
|
||||||
async loadStripe() {
|
|
||||||
if (window.Stripe) return window.Stripe
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const existing = document.querySelector(`script[src="${STRIPE_JS}"]`)
|
|
||||||
if (existing) {
|
|
||||||
existing.addEventListener('load', resolve)
|
|
||||||
existing.addEventListener('error', reject)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = STRIPE_JS
|
|
||||||
script.onload = resolve
|
|
||||||
script.onerror = reject
|
|
||||||
document.head.appendChild(script)
|
|
||||||
})
|
|
||||||
|
|
||||||
return window.Stripe
|
|
||||||
}
|
|
||||||
|
|
||||||
async mountStripe() {
|
|
||||||
if (this.paymentElement || !this.stripeKeyValue) return
|
|
||||||
|
|
||||||
const Stripe = await this.loadStripe()
|
|
||||||
this.stripe = this.stripe || Stripe(this.stripeKeyValue)
|
|
||||||
|
|
||||||
this.elements = this.stripe.elements({
|
|
||||||
mode: 'payment',
|
|
||||||
amount: Math.max(this.amountValue, 1),
|
|
||||||
currency: this.currencyValue,
|
|
||||||
paymentMethodCreation: 'manual',
|
|
||||||
// Card only — matches the server confirming with
|
|
||||||
// automatic_payment_methods.allow_redirects = 'never' (no
|
|
||||||
// return_url in our flow: 3-D Secure resolves in-page via
|
|
||||||
// handleNextAction, never a full-page redirect).
|
|
||||||
paymentMethodTypes: ['card'],
|
|
||||||
})
|
|
||||||
this.paymentElement = this.elements.create('payment')
|
|
||||||
this.paymentElement.mount(this.elementTarget)
|
|
||||||
this.elementTarget.hidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
unmountStripe() {
|
|
||||||
this.paymentElement?.unmount()
|
|
||||||
this.paymentElement = null
|
|
||||||
this.elements = null
|
|
||||||
|
|
||||||
if (this.hasElementTarget) {
|
|
||||||
this.elementTarget.innerHTML = ''
|
|
||||||
this.elementTarget.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Place order ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Sibling controller on the same element (.bbk-checkout-main) — used to
|
|
||||||
// flush a pending debounced address autosave before placing the order.
|
|
||||||
get checkoutForm() {
|
|
||||||
return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form')
|
|
||||||
}
|
|
||||||
|
|
||||||
async placeOrder() {
|
|
||||||
this.clearError()
|
|
||||||
this.submitTarget.disabled = true
|
|
||||||
|
|
||||||
// A debounced address save (and the shipping-option auto-select that
|
|
||||||
// happens as part of it) might still be pending — make sure the
|
|
||||||
// server has the latest state before we ask it to place the order.
|
|
||||||
await this.checkoutForm?.flush()
|
|
||||||
|
|
||||||
if (!this.termsTarget.checked) {
|
|
||||||
this.submitTarget.disabled = false
|
|
||||||
this.showError(this.termsRequiredValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const radio = this.selectedRadio()
|
|
||||||
if (!radio) {
|
|
||||||
this.submitTarget.disabled = false
|
|
||||||
this.showError(this.chooseMethodValue)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let paymentMethodId = null
|
|
||||||
if (radio.dataset.paymentDriver === 'stripe') {
|
|
||||||
const { error: submitError } = await this.elements.submit()
|
|
||||||
if (submitError) return this.fail(submitError.message)
|
|
||||||
|
|
||||||
const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
|
|
||||||
if (pmError) return this.fail(pmError.message)
|
|
||||||
paymentMethodId = paymentMethod.id
|
|
||||||
}
|
|
||||||
|
|
||||||
let data
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.placeOrderUrlValue, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
payment_type: radio.value,
|
|
||||||
payment_method: paymentMethodId ?? '',
|
|
||||||
terms_accepted: '1',
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
data = await response.json()
|
|
||||||
} catch {
|
|
||||||
return this.fail(this.genericErrorValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.redirect) {
|
|
||||||
window.location.assign(data.redirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.status === 'pending' && data.clientSecret) {
|
|
||||||
await this.resolvePending(data.clientSecret)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Points at the section that actually needs attention, rather than
|
|
||||||
// leaving a generic error and making the shopper hunt for it — e.g. a
|
|
||||||
// region with 2+ shipping methods needs an explicit pick, easy to miss.
|
|
||||||
if (data.field === 'shipping_option') {
|
|
||||||
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.fail(data.message || data.error || this.genericErrorValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
async resolvePending(clientSecret) {
|
|
||||||
this.processingTarget.hidden = false
|
|
||||||
|
|
||||||
const { error } = await this.stripe.handleNextAction({ clientSecret })
|
|
||||||
if (error) {
|
|
||||||
this.processingTarget.hidden = true
|
|
||||||
return this.fail(error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3-D Secure cleared client-side — the webhook places the order. Poll.
|
|
||||||
const startedAt = Date.now()
|
|
||||||
const tick = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } })
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.placed && data.redirect) {
|
|
||||||
window.location.assign(data.redirect)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// keep polling
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Date.now() - startedAt > POLL_TIMEOUT) {
|
|
||||||
this.processingTextTarget.textContent = this.processingSlowValue
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTimeout(tick, POLL_INTERVAL)
|
|
||||||
}
|
|
||||||
tick()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fail(message, { scroll = true } = {}) {
|
|
||||||
this.showError(message, { scroll })
|
|
||||||
this.submitTarget.disabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message, { scroll = true } = {}) {
|
|
||||||
this.errorTarget.textContent = message
|
|
||||||
this.errorTarget.hidden = false
|
|
||||||
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
clearError() {
|
|
||||||
this.errorTarget.textContent = ''
|
|
||||||
this.errorTarget.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,19 +0,0 @@
|
|||||||
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
|
||||||
import BbkCartController from './bbk-cart-controller'
|
|
||||||
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
|
||||||
import BbkPaymentController from './bbk-payment-controller'
|
|
||||||
|
|
||||||
// Registers the checkout module's Stimulus controllers onto the host app's
|
|
||||||
// Stimulus application. Call once from the host's JS entry point:
|
|
||||||
//
|
|
||||||
// import { registerCheckout } from './checkout'
|
|
||||||
// registerCheckout(application)
|
|
||||||
//
|
|
||||||
// When this module moves to boboko-core this file ships with it unchanged;
|
|
||||||
// only that one import line in the host entry point differs per project.
|
|
||||||
export function registerCheckout(application) {
|
|
||||||
application.register('bbk-add-to-cart', BbkAddToCartController)
|
|
||||||
application.register('bbk-cart', BbkCartController)
|
|
||||||
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
|
||||||
application.register('bbk-payment', BbkPaymentController)
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ import RangeSliderController from './range-slider-controller'
|
|||||||
import StarRatingController from './star-rating-controller'
|
import StarRatingController from './star-rating-controller'
|
||||||
import TabLinkController from './tab-link-controller'
|
import TabLinkController from './tab-link-controller'
|
||||||
import TabsController from './tabs-controller'
|
import TabsController from './tabs-controller'
|
||||||
import WishlistController from './wishlist-controller'
|
|
||||||
|
|
||||||
export function registerControllers(application) {
|
export function registerControllers(application) {
|
||||||
application.register('appear', AppearController)
|
application.register('appear', AppearController)
|
||||||
@@ -38,5 +37,4 @@ export function registerControllers(application) {
|
|||||||
application.register('star-rating', StarRatingController)
|
application.register('star-rating', StarRatingController)
|
||||||
application.register('tab-link', TabLinkController)
|
application.register('tab-link', TabLinkController)
|
||||||
application.register('tabs', TabsController)
|
application.register('tabs', TabsController)
|
||||||
application.register('wishlist', WishlistController)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Controller } from '@hotwired/stimulus'
|
|||||||
import { formatPrice } from '../utils/format-price'
|
import { formatPrice } from '../utils/format-price'
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError']
|
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError', 'submit']
|
||||||
static values = {
|
static values = {
|
||||||
variants: Array,
|
variants: Array,
|
||||||
selected: Number,
|
selected: Number,
|
||||||
@@ -90,7 +90,7 @@ export default class extends Controller {
|
|||||||
// own server-side check have the final word rather than
|
// own server-side check have the final word rather than
|
||||||
// silently blocking the shopper here.
|
// silently blocking the shopper here.
|
||||||
} finally {
|
} finally {
|
||||||
if (submit) submit.disabled = false
|
if (submit) submit.disabled = !this.selectedVariant?.inStock
|
||||||
}
|
}
|
||||||
|
|
||||||
form.dataset.bbkStockChecked = 'true'
|
form.dataset.bbkStockChecked = 'true'
|
||||||
@@ -111,6 +111,10 @@ export default class extends Controller {
|
|||||||
this.stockErrorTarget.hidden = true
|
this.stockErrorTarget.hidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get selectedVariant() {
|
||||||
|
return this.variantsValue.find(v => v.id === this.selectedValue)
|
||||||
|
}
|
||||||
|
|
||||||
selectVariant(event) {
|
selectVariant(event) {
|
||||||
const option = event.currentTarget.dataset.option
|
const option = event.currentTarget.dataset.option
|
||||||
const valueId = parseInt(event.currentTarget.dataset.valueId)
|
const valueId = parseInt(event.currentTarget.dataset.valueId)
|
||||||
@@ -156,6 +160,9 @@ export default class extends Controller {
|
|||||||
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
|
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
|
||||||
if (purchasableInput) purchasableInput.value = id
|
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 => {
|
this.swatchTargets.forEach(swatch => {
|
||||||
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
|
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
|
||||||
swatch.classList.toggle('is-selected', isSelected)
|
swatch.classList.toggle('is-selected', isSelected)
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus'
|
|
||||||
|
|
||||||
// Heart toggle (x-wishlist-button). Posts the form with fetch and reflects the
|
|
||||||
// server's answer on aria-pressed, which the CSS uses to swap the outline and
|
|
||||||
// filled heart. If the request fails, falls back to a normal form submit.
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['button', 'status']
|
|
||||||
|
|
||||||
static values = {
|
|
||||||
addLabel: String,
|
|
||||||
removeLabel: String,
|
|
||||||
addedMessage: String,
|
|
||||||
removedMessage: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggle(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
if (this.busy) return
|
|
||||||
this.busy = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.element.action, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
|
||||||
body: new FormData(this.element),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Wishlist toggle failed: ${response.status}`)
|
|
||||||
|
|
||||||
const { active } = await response.json()
|
|
||||||
|
|
||||||
this.buttonTarget.setAttribute('aria-pressed', active ? 'true' : 'false')
|
|
||||||
this.buttonTarget.setAttribute('aria-label', active ? this.removeLabelValue : this.addLabelValue)
|
|
||||||
this.statusTarget.textContent = active ? this.addedMessageValue : this.removedMessageValue
|
|
||||||
} catch {
|
|
||||||
this.element.submit()
|
|
||||||
} finally {
|
|
||||||
this.busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -20,8 +20,8 @@ ## 1. Υπεύθυνος επεξεργασίας
|
|||||||
|
|
||||||
**3DEALER**
|
**3DEALER**
|
||||||
Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα
|
Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα
|
||||||
ΑΦΜ: @TODO
|
ΑΦΜ: {store_tax_identifier}
|
||||||
ΓΕΜΗ: @TODO
|
ΓΕΜΗ: {store_registration_number}
|
||||||
Email: [print@3dealer.gr](mailto:print@3dealer.gr)
|
Email: [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||||
Τηλέφωνο: 6976 443140
|
Τηλέφωνο: 6976 443140
|
||||||
|
|
||||||
+4
-6
@@ -1,20 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: "Όροι & Προϋποθέσεις"
|
name: "Όροι & Προϋποθέσεις Terms"
|
||||||
slug: "terms-and-conditions"
|
slug: "terms-and-conditions"
|
||||||
|
|
||||||
meta_title: ""
|
meta_title: ""
|
||||||
meta_description: ""
|
meta_description: ""
|
||||||
og_image: ""
|
og_image: ""
|
||||||
noindex: true
|
noindex: true
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Γενικά
|
## 1. Γενικά
|
||||||
|
|
||||||
Το ηλεκτρονικό κατάστημα 3DEALER (εφεξής «το Κατάστημα» ή «3DEALER») λειτουργεί από την επιχείρηση 3DEALER, με έδρα στην Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα.
|
Το ηλεκτρονικό κατάστημα 3DEALER (εφεξής «το Κατάστημα» ή «3DEALER») λειτουργεί από την επιχείρηση 3DEALER, με έδρα στην Πάρου 31, Κυψέλη, ΤΚ 11255, Αθήνα. test english
|
||||||
|
|
||||||
**ΑΦΜ:** @TODO
|
**ΑΦΜ:** {store_tax_identifier}
|
||||||
**ΓΕΜΗ:** @TODO
|
**ΓΕΜΗ:** {store_registration_number}
|
||||||
|
|
||||||
Για οποιαδήποτε πληροφορία ή επικοινωνία σχετικά με παραγγελίες, προϊόντα ή τη λειτουργία του Καταστήματος, μπορείτε να επικοινωνείτε μαζί μας:
|
Για οποιαδήποτε πληροφορία ή επικοινωνία σχετικά με παραγγελίες, προϊόντα ή τη λειτουργία του Καταστήματος, μπορείτε να επικοινωνείτε μαζί μας:
|
||||||
|
|
||||||
@@ -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,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:
|
||||||
|
|
||||||
|
**3DEALER**
|
||||||
|
Parou 31, Kypseli, 11255 Athens, Greece
|
||||||
|
Tax ID (ΑΦΜ): {store_tax_identifier}
|
||||||
|
Company registration number (ΓΕΜΗ): {store_registration_number}
|
||||||
|
Email: [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||||
|
Phone: +30 6976 443140
|
||||||
|
|
||||||
|
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 [print@3dealer.gr](mailto:print@3dealer.gr).
|
||||||
|
|
||||||
|
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:** [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||||
|
**Phone:** +30 6976 443140
|
||||||
|
|
||||||
|
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 [print@3dealer.gr](mailto:print@3dealer.gr).
|
||||||
|
|
||||||
|
## 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 [print@3dealer.gr](mailto:print@3dealer.gr) 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 [print@3dealer.gr](mailto:print@3dealer.gr).
|
||||||
|
|
||||||
|
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 [print@3dealer.gr](mailto:print@3dealer.gr), 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 3DEALER, 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:** [print@3dealer.gr](mailto:print@3dealer.gr)
|
||||||
|
**Phone:** +30 6976 443140
|
||||||
|
|
||||||
|
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
|
media_path: /media
|
||||||
thumbs_path: /storage/app/public/stoic/
|
thumbs_path: /storage/app/public/stoic/
|
||||||
repository_path: /repository
|
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:
|
presets:
|
||||||
- code: large
|
- code: large
|
||||||
mode: resize
|
mode: resize
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ schemas:
|
|||||||
- name: pages
|
- name: pages
|
||||||
label: Pages
|
label: Pages
|
||||||
type: files
|
type: files
|
||||||
|
translatable: true
|
||||||
files:
|
files:
|
||||||
- name: home
|
- name: home
|
||||||
label: Home Page
|
label: Home Page
|
||||||
@@ -10,15 +11,18 @@ schemas:
|
|||||||
- name: name
|
- name: name
|
||||||
label: Name
|
label: Name
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: slug
|
- name: slug
|
||||||
label: Slug
|
label: Slug
|
||||||
ui: slug
|
ui: slug
|
||||||
- name: meta_title
|
- name: meta_title
|
||||||
label: Meta Title
|
label: Meta Title
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: meta_description
|
- name: meta_description
|
||||||
label: Meta Description
|
label: Meta Description
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: og_image
|
- name: og_image
|
||||||
label: Og Image
|
label: Og Image
|
||||||
ui: image
|
ui: image
|
||||||
@@ -27,10 +31,12 @@ schemas:
|
|||||||
ui: image
|
ui: image
|
||||||
- name: trivia_body
|
- name: trivia_body
|
||||||
label: "Trivia Body (intro paragraph under the hero)"
|
label: "Trivia Body (intro paragraph under the hero)"
|
||||||
ui: markdown
|
ui: text
|
||||||
|
translatable: true
|
||||||
- name: classics_title
|
- name: classics_title
|
||||||
label: "Classics Section Title (favorites carousel heading)"
|
label: "Classics Section Title (favorites carousel heading)"
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: terms-and-conditions
|
- name: terms-and-conditions
|
||||||
label: "Terms & Conditions Page"
|
label: "Terms & Conditions Page"
|
||||||
file: pages/terms-and-conditions.md
|
file: pages/terms-and-conditions.md
|
||||||
@@ -38,15 +44,18 @@ schemas:
|
|||||||
- name: name
|
- name: name
|
||||||
label: Name
|
label: Name
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: slug
|
- name: slug
|
||||||
label: Slug
|
label: Slug
|
||||||
ui: slug
|
ui: slug
|
||||||
- name: meta_title
|
- name: meta_title
|
||||||
label: Meta Title
|
label: Meta Title
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: meta_description
|
- name: meta_description
|
||||||
label: Meta Description
|
label: Meta Description
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: og_image
|
- name: og_image
|
||||||
label: Og Image
|
label: Og Image
|
||||||
ui: image
|
ui: image
|
||||||
@@ -56,6 +65,7 @@ schemas:
|
|||||||
- name: body
|
- name: body
|
||||||
label: Body
|
label: Body
|
||||||
ui: markdown
|
ui: markdown
|
||||||
|
translatable: true
|
||||||
- name: shipping-returns
|
- name: shipping-returns
|
||||||
label: "Shipping & Returns Page"
|
label: "Shipping & Returns Page"
|
||||||
file: pages/shipping-returns.md
|
file: pages/shipping-returns.md
|
||||||
@@ -63,15 +73,18 @@ schemas:
|
|||||||
- name: name
|
- name: name
|
||||||
label: Name
|
label: Name
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: slug
|
- name: slug
|
||||||
label: Slug
|
label: Slug
|
||||||
ui: slug
|
ui: slug
|
||||||
- name: meta_title
|
- name: meta_title
|
||||||
label: Meta Title
|
label: Meta Title
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: meta_description
|
- name: meta_description
|
||||||
label: Meta Description
|
label: Meta Description
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: og_image
|
- name: og_image
|
||||||
label: Og Image
|
label: Og Image
|
||||||
ui: image
|
ui: image
|
||||||
@@ -81,6 +94,7 @@ schemas:
|
|||||||
- name: body
|
- name: body
|
||||||
label: Body
|
label: Body
|
||||||
ui: markdown
|
ui: markdown
|
||||||
|
translatable: true
|
||||||
- name: privacy-policy
|
- name: privacy-policy
|
||||||
label: "Privacy Policy Page"
|
label: "Privacy Policy Page"
|
||||||
file: pages/privacy-policy.md
|
file: pages/privacy-policy.md
|
||||||
@@ -88,15 +102,18 @@ schemas:
|
|||||||
- name: name
|
- name: name
|
||||||
label: Name
|
label: Name
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: slug
|
- name: slug
|
||||||
label: Slug
|
label: Slug
|
||||||
ui: slug
|
ui: slug
|
||||||
- name: meta_title
|
- name: meta_title
|
||||||
label: Meta Title
|
label: Meta Title
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: meta_description
|
- name: meta_description
|
||||||
label: Meta Description
|
label: Meta Description
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: og_image
|
- name: og_image
|
||||||
label: Og Image
|
label: Og Image
|
||||||
ui: image
|
ui: image
|
||||||
@@ -106,6 +123,7 @@ schemas:
|
|||||||
- name: body
|
- name: body
|
||||||
label: Body
|
label: Body
|
||||||
ui: markdown
|
ui: markdown
|
||||||
|
translatable: true
|
||||||
- name: cookies-policy
|
- name: cookies-policy
|
||||||
label: "Cookies Policy Page"
|
label: "Cookies Policy Page"
|
||||||
file: pages/cookies-policy.md
|
file: pages/cookies-policy.md
|
||||||
@@ -113,15 +131,18 @@ schemas:
|
|||||||
- name: name
|
- name: name
|
||||||
label: Name
|
label: Name
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: slug
|
- name: slug
|
||||||
label: Slug
|
label: Slug
|
||||||
ui: slug
|
ui: slug
|
||||||
- name: meta_title
|
- name: meta_title
|
||||||
label: Meta Title
|
label: Meta Title
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: meta_description
|
- name: meta_description
|
||||||
label: Meta Description
|
label: Meta Description
|
||||||
ui: string
|
ui: string
|
||||||
|
translatable: true
|
||||||
- name: og_image
|
- name: og_image
|
||||||
label: Og Image
|
label: Og Image
|
||||||
ui: image
|
ui: image
|
||||||
@@ -131,3 +152,26 @@ schemas:
|
|||||||
- name: body
|
- name: body
|
||||||
label: Body
|
label: Body
|
||||||
ui: markdown
|
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
|
||||||
|
|||||||
@@ -29,6 +29,9 @@
|
|||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
@foreach ($orders as $order)
|
@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">
|
<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>
|
||||||
<span class="sr-only">{{ __('storefront.orders.date') }}:</span>
|
<span class="sr-only">{{ __('storefront.orders.date') }}:</span>
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-right font-bold sm:text-left">
|
<span class="text-right font-bold sm:text-left">
|
||||||
<span class="sr-only">{{ __('storefront.orders.number') }}:</span>
|
<span class="sr-only">{{ __('storefront.orders.number') }}:</span>
|
||||||
#{{ $order->reference }}
|
#{{ $orderReference }}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<span class="sr-only">{{ __('storefront.orders.status') }}:</span>
|
<span class="sr-only">{{ __('storefront.orders.status') }}:</span>
|
||||||
@@ -49,7 +52,7 @@
|
|||||||
<a
|
<a
|
||||||
href="{{ route('account.orders.show', ['orderId' => $order->id]) }}"
|
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"
|
class="col-span-2 mt-2 inline-flex w-24 items-center gap-2 font-bold underline hover:no-underline sm:col-span-1 sm:mt-0"
|
||||||
aria-label="{{ __('storefront.orders.view_order', ['number' => $order->reference]) }}"
|
aria-label="{{ __('storefront.orders.view_order', ['number' => $orderReference]) }}"
|
||||||
>
|
>
|
||||||
{{ __('storefront.orders.view') }}
|
{{ __('storefront.orders.view') }}
|
||||||
<x-ui.icon name="arrow-right" :size="16" />
|
<x-ui.icon name="arrow-right" :size="16" />
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
@extends('layouts.account')
|
@extends('layouts.account')
|
||||||
|
|
||||||
@section('title', __('storefront.orders.order_title', ['number' => $order->reference]))
|
@php
|
||||||
|
$orderReference = \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@section('title', __('storefront.orders.order_title', ['number' => $orderReference]))
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$productLines = $order->lines->where('type', '!=', 'shipping');
|
$productLines = $order->lines->where('type', '!=', 'shipping');
|
||||||
@@ -18,7 +22,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
<h1 class="font-display text-h3 font-black tracking-tight sm:text-h2">
|
||||||
{{ __('storefront.orders.order_title', ['number' => $order->reference]) }}
|
{{ __('storefront.orders.order_title', ['number' => $orderReference]) }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
<meta name="robots" content="noindex, follow">
|
<meta name="robots" content="noindex, follow">
|
||||||
@endpush
|
@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('content')
|
||||||
|
|
||||||
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
|
<section class="mx-auto max-w-2xl px-4 py-20 sm:px-8 sm:py-32">
|
||||||
@@ -52,6 +57,18 @@
|
|||||||
]) !!}
|
]) !!}
|
||||||
</p>
|
</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>
|
<div>
|
||||||
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
|
<x-ui.button type="submit">{{ __('storefront.auth.send_code') }}</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +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 }}
|
|
||||||
|
|
||||||
<p class="bbk-add-to-cart-error" data-bbk-add-to-cart-target="error" hidden role="alert"></p>
|
|
||||||
</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>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
{{--
|
|
||||||
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
|
|
||||||
<x-checkout::select name="shipping_state" label="Region" :options="$regions" value-field="name" translation-group="states" required />
|
|
||||||
|
|
||||||
`options` is an iterable of models/objects; `label` is always read from
|
|
||||||
`->name`, the submitted value from `->{$valueField}` (default `id`, but e.g.
|
|
||||||
`name` for Lunar states — table-rate-shipping resolves those with
|
|
||||||
State::whereName(), so the address must carry the exact name string).
|
|
||||||
|
|
||||||
`translationGroup` (optional, e.g. "countries"/"states") looks the raw
|
|
||||||
`->name` up in boboko-core's `core::{group}.{name}` lang file (see
|
|
||||||
boboko-core's lang/el/countries.php, lang/el/states.php) for the
|
|
||||||
DISPLAYED label only — the submitted `value` is always the untranslated
|
|
||||||
`->{$valueField}`, since table-rate-shipping/Lunar's Country lookups key
|
|
||||||
off the original English name. Falls back to the raw name when no
|
|
||||||
translation exists for the current locale (e.g. English, or a country
|
|
||||||
outside the covered set).
|
|
||||||
--}}
|
|
||||||
@props([
|
|
||||||
'name',
|
|
||||||
'label',
|
|
||||||
'options' => [],
|
|
||||||
'value' => null,
|
|
||||||
'placeholder' => null,
|
|
||||||
'required' => false,
|
|
||||||
'valueField' => 'id',
|
|
||||||
'translationGroup' => null,
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$optionLabel = function ($option) use ($translationGroup) {
|
|
||||||
if (! $translationGroup) {
|
|
||||||
return $option->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = "core::{$translationGroup}.{$option->name}";
|
|
||||||
|
|
||||||
return \Illuminate\Support\Facades\Lang::has($key) ? __($key) : $option->name;
|
|
||||||
};
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@php($selected = old($name, $value))
|
|
||||||
|
|
||||||
<div class="bbk-field">
|
|
||||||
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
|
|
||||||
<select
|
|
||||||
name="{{ $name }}"
|
|
||||||
id="bbk-{{ $name }}"
|
|
||||||
@if ($required) required @endif
|
|
||||||
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
|
|
||||||
>
|
|
||||||
@if ($placeholder)
|
|
||||||
<option value="" @selected(! $selected)>{{ $placeholder }}</option>
|
|
||||||
@endif
|
|
||||||
@foreach ($options as $option)
|
|
||||||
<option value="{{ $option->{$valueField} }}" @selected((string) $selected === (string) $option->{$valueField})>
|
|
||||||
{{ $optionLabel($option) }}
|
|
||||||
</option>
|
|
||||||
@endforeach
|
|
||||||
</select>
|
|
||||||
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
@props([
|
|
||||||
'name',
|
|
||||||
'label',
|
|
||||||
'value' => null,
|
|
||||||
'required' => false,
|
|
||||||
])
|
|
||||||
|
|
||||||
<div class="bbk-field">
|
|
||||||
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
|
|
||||||
<textarea
|
|
||||||
name="{{ $name }}"
|
|
||||||
id="bbk-{{ $name }}"
|
|
||||||
rows="3"
|
|
||||||
@if ($required) required @endif
|
|
||||||
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
|
|
||||||
>{{ old($name, $value) }}</textarea>
|
|
||||||
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
{{--
|
|
||||||
Order confirmation. Reached only via a session flash of the placed order id
|
|
||||||
(CheckoutController::confirmation) — not deep-linkable. $order is a
|
|
||||||
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
|
|
||||||
--}}
|
|
||||||
@extends('layouts.app')
|
|
||||||
|
|
||||||
@section('title', __('checkout.page.confirmation_title') . ' — ' . config('app.name'))
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<div class="bbk-confirmation">
|
|
||||||
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
|
|
||||||
|
|
||||||
<dl class="bbk-confirmation-meta">
|
|
||||||
<div class="bbk-confirmation-meta-row">
|
|
||||||
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
|
|
||||||
<dd>{{ $order->reference }}</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($order->billingAddress?->contact_email)
|
|
||||||
<div class="bbk-confirmation-meta-row">
|
|
||||||
<dt>{{ __('checkout.page.email_label') }}</dt>
|
|
||||||
<dd>{{ $order->billingAddress->contact_email }}</dd>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($paymentMethodName)
|
|
||||||
<div class="bbk-confirmation-meta-row">
|
|
||||||
<dt>{{ __('checkout.page.payment_heading') }}</dt>
|
|
||||||
<dd>{{ $paymentMethodName }}</dd>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($shippingLine = $order->lines->firstWhere('type', 'shipping'))
|
|
||||||
<div class="bbk-confirmation-meta-row">
|
|
||||||
<dt>{{ __('checkout.page.shipping_method_heading') }}</dt>
|
|
||||||
<dd>{{ $shippingLine->description }}</dd>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
|
|
||||||
|
|
||||||
{{-- Guests: logging in with the order's email attaches it to an account
|
|
||||||
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
|
|
||||||
so it shows in their history. --}}
|
|
||||||
@guest
|
|
||||||
@if ($loginRoute = config('checkout.login_route'))
|
|
||||||
<p class="bbk-checkout-note">
|
|
||||||
{{ __('checkout.page.confirmation_login_hint') }}
|
|
||||||
<a href="{{ route($loginRoute) }}">{{ __('checkout.page.login_link') }}</a>
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
@endguest
|
|
||||||
|
|
||||||
<div class="bbk-confirmation-body">
|
|
||||||
<div class="bbk-confirmation-lines">
|
|
||||||
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
|
|
||||||
<div class="bbk-confirmation-line">
|
|
||||||
<div class="bbk-cart-item-media">
|
|
||||||
@if ($thumb = $line->purchasable?->getThumbnailImage())
|
|
||||||
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bbk-confirmation-line-detail">
|
|
||||||
<span class="bbk-confirmation-line-name">
|
|
||||||
{{ $line->description }}
|
|
||||||
<span class="bbk-confirmation-line-qty">× {{ $line->quantity }}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@if ($line->option)
|
|
||||||
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary">
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.subtotal') }}</span>
|
|
||||||
<span>{{ $order->sub_total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($order->discount_total?->value > 0)
|
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
|
||||||
<span>{{ __('checkout.cart.discount') }}</span>
|
|
||||||
<span>−{{ $order->discount_total->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.shipping') }}</span>
|
|
||||||
<span>{{ $order->shipping_total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($order->tax_total?->value > 0)
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.tax') }}</span>
|
|
||||||
<span>{{ $order->tax_total->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
|
||||||
<span>{{ __('checkout.cart.total') }}</span>
|
|
||||||
<span>{{ $order->total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bbk-confirmation-addresses">
|
|
||||||
@if ($order->shippingAddress)
|
|
||||||
<div class="bbk-confirmation-address">
|
|
||||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
|
|
||||||
<x-checkout::address-lines :address="$order->shippingAddress" />
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($order->billingAddress)
|
|
||||||
<div class="bbk-confirmation-address">
|
|
||||||
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
|
|
||||||
<x-checkout::address-lines :address="$order->billingAddress" />
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{{--
|
|
||||||
Slide-in cart drawer. Rendered once, globally, from the app layout
|
|
||||||
(@include('checkout::drawer')). Structure only — all styling lives in
|
|
||||||
resources/css/checkout.css under @layer bbk-checkout; the host restyles the
|
|
||||||
.bbk-* classes from its own stylesheet. No host components, no Tailwind.
|
|
||||||
--}}
|
|
||||||
<div class="bbk-cart" data-controller="bbk-cart" hidden>
|
|
||||||
<div class="bbk-cart-backdrop" data-action="click->bbk-cart#close"></div>
|
|
||||||
|
|
||||||
<aside
|
|
||||||
class="bbk-cart-panel"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="bbk-cart-heading"
|
|
||||||
data-bbk-cart-target="panel"
|
|
||||||
>
|
|
||||||
<header class="bbk-cart-panel-header">
|
|
||||||
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bbk-cart-dismiss"
|
|
||||||
data-action="bbk-cart#close"
|
|
||||||
aria-label="{{ __('checkout.cart.close') }}"
|
|
||||||
>×</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@include('checkout::partials.cart-error')
|
|
||||||
|
|
||||||
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
|
|
||||||
@include('checkout::partials.cart-body')
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
{{--
|
|
||||||
The checkout page. Two columns: left is contact + billing + shipping +
|
|
||||||
shipping method, right is the order summary (the same cart-body partial the
|
|
||||||
drawer uses, minus its own "Checkout" CTA — see .bbk-checkout-summary in
|
|
||||||
checkout.css). Stops short of payment for this slice — see
|
|
||||||
CheckoutController's class docblock.
|
|
||||||
|
|
||||||
$cart, $lines, $billingAddress, $shippingAddress, $shippingOptions,
|
|
||||||
$countries come from CheckoutController::show().
|
|
||||||
--}}
|
|
||||||
@extends('layouts.app')
|
|
||||||
|
|
||||||
@section('title', __('checkout.page.title') . ' — ' . config('app.name'))
|
|
||||||
|
|
||||||
@section('content')
|
|
||||||
<div class="bbk-checkout-page">
|
|
||||||
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
|
|
||||||
|
|
||||||
<div class="bbk-checkout">
|
|
||||||
<div
|
|
||||||
class="bbk-checkout-main"
|
|
||||||
data-controller="bbk-checkout-form bbk-payment"
|
|
||||||
data-action="input->bbk-checkout-form#scheduleSave"
|
|
||||||
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
|
|
||||||
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
|
|
||||||
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
|
|
||||||
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
|
|
||||||
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
|
|
||||||
data-bbk-payment-select-url-value="{{ route('checkout.payment-method.select', app()->getLocale()) }}"
|
|
||||||
data-bbk-payment-place-order-url-value="{{ route('checkout.place-order', app()->getLocale()) }}"
|
|
||||||
data-bbk-payment-order-status-url-value="{{ route('checkout.order-status', app()->getLocale()) }}"
|
|
||||||
data-bbk-payment-stripe-key-value="{{ config('services.stripe.public_key') }}"
|
|
||||||
data-bbk-payment-amount-value="{{ $cart?->total?->value ?? 0 }}"
|
|
||||||
data-bbk-payment-currency-value="{{ strtolower($cart?->total?->currency?->code ?? 'eur') }}"
|
|
||||||
data-bbk-payment-terms-required-value="{{ __('checkout.page.terms_required') }}"
|
|
||||||
data-bbk-payment-choose-method-value="{{ __('checkout.page.choose_payment_method') }}"
|
|
||||||
data-bbk-payment-generic-error-value="{{ __('checkout.page.payment_failed') }}"
|
|
||||||
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
|
|
||||||
>
|
|
||||||
|
|
||||||
{{-- Contact. Logged in: the order email is the account's (forced
|
|
||||||
server-side in saveAddress()), so there's no field. Guests type
|
|
||||||
their email, plus a login link when config('checkout.login_route')
|
|
||||||
is set; the storefront's login page sends them back here and Lunar
|
|
||||||
merges the guest cart into the account. --}}
|
|
||||||
@php($loginRoute = config('checkout.login_route'))
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
@auth
|
|
||||||
<p class="bbk-checkout-logged-in">
|
|
||||||
{{ __('checkout.page.logged_in_as') }} <strong>{{ auth()->user()->email }}</strong>
|
|
||||||
</p>
|
|
||||||
@else
|
|
||||||
@if ($loginRoute)
|
|
||||||
<p class="bbk-checkout-login-prompt">
|
|
||||||
{{ __('checkout.page.login_prompt') }}
|
|
||||||
<a href="{{ route($loginRoute, ['redirect' => route('checkout.show', app()->getLocale(), false)]) }}">{{ __('checkout.page.login_link') }}</a>
|
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-checkout::field
|
|
||||||
name="contact_email"
|
|
||||||
label="{{ __('checkout.page.email_label') }}"
|
|
||||||
type="email"
|
|
||||||
:value="$shippingAddress?->contact_email ?? $billingAddress?->contact_email"
|
|
||||||
required
|
|
||||||
form="bbk-address-form"
|
|
||||||
/>
|
|
||||||
@endauth
|
|
||||||
|
|
||||||
{{-- Abandoned-cart-recovery opt-in. Optional, unticked, never
|
|
||||||
required — direct marketing under ePrivacy (GR L. 3471/2006
|
|
||||||
art. 11), so it needs an explicit opt-in and checkout can't be
|
|
||||||
gated on it. Narrow scope by design (boboko-core's
|
|
||||||
setRecoveryConsent) — a general newsletter opt-in, if wanted,
|
|
||||||
is a separate checkbox. --}}
|
|
||||||
<label class="bbk-checkbox bbk-checkbox--stacked">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="recovery_consent"
|
|
||||||
value="1"
|
|
||||||
form="bbk-address-form"
|
|
||||||
{{ old('recovery_consent', data_get($cart, 'meta.recovery_consent')) ? 'checked' : '' }}
|
|
||||||
>
|
|
||||||
{{ __('checkout.page.recovery_consent') }}
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{-- Autosaves — no submit button. Any `change` inside .bbk-checkout-main
|
|
||||||
(this form, plus the contact email/consent which sit outside it but
|
|
||||||
link via form="bbk-address-form") is debounced and POSTed as the whole
|
|
||||||
form; the shipping-method radios are excluded in scheduleSave(). --}}
|
|
||||||
<form
|
|
||||||
id="bbk-address-form"
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.address.save', app()->getLocale()) }}"
|
|
||||||
data-bbk-checkout-form-target="form"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
|
|
||||||
{{-- Billing --}}
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.billing_heading') }}</h2>
|
|
||||||
|
|
||||||
<div class="bbk-field-row">
|
|
||||||
<x-checkout::field name="billing_first_name" label="{{ __('checkout.page.first_name') }}" :value="$billingAddress?->first_name" required />
|
|
||||||
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS
|
|
||||||
(:has on the checkbox), saved/cleared by saveAddress(), and
|
|
||||||
required at place-order. --}}
|
|
||||||
<div class="bbk-invoice">
|
|
||||||
<label class="bbk-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="wants_invoice"
|
|
||||||
value="1"
|
|
||||||
aria-controls="bbk-invoice-fields"
|
|
||||||
@checked($wantsInvoice)
|
|
||||||
>
|
|
||||||
{{ __('checkout.page.wants_invoice') }}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="bbk-field-row bbk-invoice-fields" id="bbk-invoice-fields">
|
|
||||||
<x-checkout::field name="billing_company_name" label="{{ __('checkout.page.company_name') }}" :value="$billingAddress?->company_name" />
|
|
||||||
<x-checkout::field name="billing_tax_identifier" label="{{ __('checkout.page.tax_identifier') }}" :value="$billingAddress?->tax_identifier" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
|
|
||||||
|
|
||||||
<div class="bbk-field-row">
|
|
||||||
<x-checkout::field name="billing_city" label="{{ __('checkout.page.city') }}" :value="$billingAddress?->city" required />
|
|
||||||
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::region-country
|
|
||||||
prefix="billing"
|
|
||||||
:store-country="$storeCountry"
|
|
||||||
:regions="$regions"
|
|
||||||
:countries="$countries"
|
|
||||||
:address="$billingAddress"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{-- Shipping --}}
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_heading') }}</h2>
|
|
||||||
|
|
||||||
<label class="bbk-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="same_as_billing"
|
|
||||||
value="1"
|
|
||||||
data-bbk-checkout-form-target="sameAsBilling"
|
|
||||||
data-action="bbk-checkout-form#toggleSameAsBilling"
|
|
||||||
@checked($shipToBilling)
|
|
||||||
>
|
|
||||||
{{ __('checkout.page.same_as_billing') }}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="bbk-checkout-shipping-fields" data-bbk-checkout-form-target="shippingFields">
|
|
||||||
<div class="bbk-field-row">
|
|
||||||
<x-checkout::field name="shipping_first_name" label="{{ __('checkout.page.first_name') }}" :value="$shippingAddress?->first_name" required />
|
|
||||||
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
|
|
||||||
|
|
||||||
<div class="bbk-field-row">
|
|
||||||
<x-checkout::field name="shipping_city" label="{{ __('checkout.page.city') }}" :value="$shippingAddress?->city" required />
|
|
||||||
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::region-country
|
|
||||||
prefix="shipping"
|
|
||||||
:store-country="$storeCountry"
|
|
||||||
:regions="$regions"
|
|
||||||
:countries="$countries"
|
|
||||||
:address="$shippingAddress"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-checkout::textarea
|
|
||||||
name="shipping_delivery_instructions"
|
|
||||||
label="{{ __('checkout.page.delivery_instructions') }}"
|
|
||||||
:value="$shippingAddress?->delivery_instructions"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p
|
|
||||||
class="bbk-checkout-status"
|
|
||||||
data-bbk-checkout-form-target="status"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
hidden
|
|
||||||
></p>
|
|
||||||
|
|
||||||
{{-- Shipping method — resolves from the saved shipping address;
|
|
||||||
re-rendered as a fragment by bbk-checkout-form after each
|
|
||||||
autosave / option change. --}}
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
|
|
||||||
|
|
||||||
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
|
|
||||||
@include('checkout::partials.shipping-options', [
|
|
||||||
'shippingAddress' => $shippingAddress,
|
|
||||||
'shippingOptions' => $shippingOptions,
|
|
||||||
])
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{-- Payment --}}
|
|
||||||
<section class="bbk-checkout-section">
|
|
||||||
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
|
|
||||||
|
|
||||||
<div id="bbk-payment-methods">
|
|
||||||
@include('checkout::partials.payment-methods', [
|
|
||||||
'paymentMethods' => $paymentMethods,
|
|
||||||
'cart' => $cart,
|
|
||||||
])
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Stripe Payment Element mounts here when a Stripe method is picked. --}}
|
|
||||||
<div class="bbk-payment-element" data-bbk-payment-target="element" hidden></div>
|
|
||||||
|
|
||||||
<label class="bbk-checkbox bbk-checkbox--stacked">
|
|
||||||
<input type="checkbox" data-bbk-payment-target="terms">
|
|
||||||
{!! __('checkout.page.terms_accept', [
|
|
||||||
'terms' => route('legal.terms', app()->getLocale()),
|
|
||||||
'privacy' => route('legal.privacy', app()->getLocale()),
|
|
||||||
]) !!}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<p class="bbk-checkout-withdrawal">
|
|
||||||
{!! __('checkout.page.withdrawal_notice', [
|
|
||||||
'link' => route('legal.shipping-returns', app()->getLocale()),
|
|
||||||
]) !!}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="bbk-checkout-error" data-bbk-payment-target="error" role="alert" hidden></p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bbk-checkout-continue"
|
|
||||||
data-bbk-payment-target="submit"
|
|
||||||
data-action="bbk-payment#placeOrder"
|
|
||||||
@disabled($lines->isEmpty())
|
|
||||||
>
|
|
||||||
{{ __('checkout.page.place_order') }}
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{-- Fixed overlay while a payment is confirming (3-D Secure / webhook
|
|
||||||
poll). Inside .bbk-checkout-main so bbk-payment can target it. --}}
|
|
||||||
<div class="bbk-checkout-processing" data-bbk-payment-target="processing" hidden>
|
|
||||||
<span class="bbk-spinner" aria-hidden="true"></span>
|
|
||||||
<p data-bbk-payment-target="processingText">{{ __('checkout.page.payment_processing') }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="bbk-checkout-aside">
|
|
||||||
<div class="bbk-checkout-summary" data-controller="bbk-cart">
|
|
||||||
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
|
|
||||||
@include('checkout::partials.cart-error')
|
|
||||||
<div data-bbk-cart-target="body" aria-live="polite">
|
|
||||||
@include('checkout::partials.cart-body')
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
{{--
|
|
||||||
Server-rendered cart contents. Rendered inline on first page load inside
|
|
||||||
checkout/drawer.blade.php, and re-fetched + swapped into the drawer by
|
|
||||||
bbk-cart-controller after every mutation. $cart / $lines come from the view
|
|
||||||
composer in CheckoutModuleServiceProvider.
|
|
||||||
|
|
||||||
The data-bbk-cart-* attributes on the root are the module's read API for the
|
|
||||||
host (e.g. the header bag-icon count) — bbk-cart-controller reads them after
|
|
||||||
each swap and re-emits them on the `bbk-cart:updated` window event.
|
|
||||||
--}}
|
|
||||||
@php($count = $lines->sum('quantity'))
|
|
||||||
|
|
||||||
{{-- @dump($lines) --}}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bbk-cart-content"
|
|
||||||
data-bbk-cart-count="{{ $count }}"
|
|
||||||
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
|
|
||||||
>
|
|
||||||
@if ($lines->isEmpty())
|
|
||||||
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
|
|
||||||
@else
|
|
||||||
<ul class="bbk-cart-items">
|
|
||||||
@each('checkout::partials.cart-line', $lines, 'line')
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary">
|
|
||||||
<div class="bbk-cart-coupon">
|
|
||||||
@if ($cart?->coupon_code)
|
|
||||||
<div class="bbk-cart-coupon-applied">
|
|
||||||
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
|
|
||||||
data-action="submit->bbk-cart#submit"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button type="submit" class="bbk-cart-coupon-remove">
|
|
||||||
{{ __('checkout.cart.coupon_remove') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<form
|
|
||||||
class="bbk-cart-coupon-form"
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
|
|
||||||
data-action="submit->bbk-cart#submit"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
<label class="bbk-visually-hidden" for="bbk-coupon-code">
|
|
||||||
{{ __('checkout.cart.coupon_label') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="code"
|
|
||||||
id="bbk-coupon-code"
|
|
||||||
class="bbk-cart-coupon-input"
|
|
||||||
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<button type="submit" class="bbk-cart-coupon-submit">
|
|
||||||
{{ __('checkout.cart.coupon_apply') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@if ($couponError ?? false)
|
|
||||||
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.subtotal') }}</span>
|
|
||||||
<span>{{ $cart?->subTotal?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($cart?->discountTotal?->value > 0)
|
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
|
|
||||||
<span>{{ __('checkout.cart.discount') }}</span>
|
|
||||||
<span>−{{ $cart->discountTotal->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Shipping + tax appear once the shopper has a shipping address
|
|
||||||
(i.e. they're on the checkout page). In the drawer, where no
|
|
||||||
address is set yet, only subtotal + total show. --}}
|
|
||||||
@if ($cart?->shippingAddress)
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.shipping') }}</span>
|
|
||||||
@if ($cart->shippingAddress->shipping_option)
|
|
||||||
<span>{{ $cart->shippingTotal?->formatted() }}</span>
|
|
||||||
@else
|
|
||||||
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($cart?->taxTotal?->value > 0)
|
|
||||||
<div class="bbk-cart-summary-row">
|
|
||||||
<span>{{ __('checkout.cart.tax') }}</span>
|
|
||||||
<span>{{ $cart->taxTotal->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Always shown — equals subtotal with nothing else applied,
|
|
||||||
diverges as discount / shipping / tax come in. --}}
|
|
||||||
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
|
|
||||||
<span>{{ __('checkout.cart.total') }}</span>
|
|
||||||
<span>{{ $cart?->total?->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
|
|
||||||
{{ __('checkout.cart.checkout') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{{--
|
|
||||||
Shared error slot for any host wrapping cart-body in a bbk-cart controller
|
|
||||||
instance (the drawer, and the checkout page's own order summary) —
|
|
||||||
bbk-cart-controller.js#showError() writes into whichever one is present.
|
|
||||||
Without this element in a given host, a rejected quantity update (e.g.
|
|
||||||
over stock) still gets rejected server-side, but the shopper never sees
|
|
||||||
why.
|
|
||||||
--}}
|
|
||||||
<p class="bbk-cart-error" data-bbk-cart-target="error" hidden role="alert"></p>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
{{--
|
|
||||||
One cart line. $line is a Lunar\Models\CartLine (iteration var set by
|
|
||||||
@each in cart-body). The two forms post through bbk-cart-controller
|
|
||||||
(fetch + method spoofing) and the response re-renders cart-body.
|
|
||||||
--}}
|
|
||||||
@php
|
|
||||||
$variant = $line->purchasable;
|
|
||||||
$product = $variant?->product;
|
|
||||||
$name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—';
|
|
||||||
// The variant's own image (falls back to the product's thumbnail
|
|
||||||
// internally — see ProductVariant::getThumbnail()) — the specific option
|
|
||||||
// the shopper picked, not just the product in general.
|
|
||||||
$thumb = $variant?->getThumbnailImage() ?: null;
|
|
||||||
$variantLabel = $variant?->getOption();
|
|
||||||
// Not routed through checkout::'s own locale-explicit convention — this
|
|
||||||
// is a storefront route, so it follows the storefront's own (implicit
|
|
||||||
// locale) call shape, same as App\Catalog\ProductCard. Carries the
|
|
||||||
// variant id along so the product page can restore the same option the
|
|
||||||
// shopper actually has in their cart, not just default to the first one
|
|
||||||
// (see product-form-controller.js reading ?variant= on connect()).
|
|
||||||
$productUrl = $product ? route('product.show', ['id' => $product->id, 'variant' => $variant?->id]) : null;
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
|
|
||||||
<div class="bbk-cart-item-media">
|
|
||||||
@if ($thumb)
|
|
||||||
@if ($productUrl)
|
|
||||||
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
|
|
||||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
|
||||||
</a>
|
|
||||||
@else
|
|
||||||
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bbk-cart-item-detail">
|
|
||||||
@if ($productUrl)
|
|
||||||
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
|
|
||||||
@else
|
|
||||||
<p class="bbk-cart-item-title">{{ $name }}</p>
|
|
||||||
@endif
|
|
||||||
@if ($variantLabel)
|
|
||||||
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
|
|
||||||
@endif
|
|
||||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
|
||||||
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
|
|
||||||
|
|
||||||
<form
|
|
||||||
class="bbk-cart-qty"
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
@method('PATCH')
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bbk-cart-qty-btn"
|
|
||||||
data-action="bbk-cart#step"
|
|
||||||
data-bbk-cart-dir-param="-1"
|
|
||||||
aria-label="{{ __('checkout.cart.decrease') }}"
|
|
||||||
>−</button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="quantity"
|
|
||||||
value="{{ $line->quantity }}"
|
|
||||||
min="0"
|
|
||||||
inputmode="numeric"
|
|
||||||
class="bbk-cart-qty-input"
|
|
||||||
data-action="change->bbk-cart#submit"
|
|
||||||
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
|
|
||||||
aria-label="{{ __('checkout.cart.quantity') }}"
|
|
||||||
>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bbk-cart-qty-btn"
|
|
||||||
data-action="bbk-cart#step"
|
|
||||||
data-bbk-cart-dir-param="1"
|
|
||||||
aria-label="{{ __('checkout.cart.increase') }}"
|
|
||||||
>+</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bbk-cart-item-aside">
|
|
||||||
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
|
|
||||||
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
|
|
||||||
data-action="submit->bbk-cart#submit"
|
|
||||||
>
|
|
||||||
@csrf
|
|
||||||
@method('DELETE')
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="bbk-cart-item-remove"
|
|
||||||
aria-label="{{ __('checkout.cart.remove') }}"
|
|
||||||
>×</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
{{--
|
|
||||||
@include('checkout::partials.line-custom-fields', ['line' => $line])
|
|
||||||
|
|
||||||
A cart or order line's custom-field answers (meta.custom_fields, written by
|
|
||||||
CartController::customFieldsMeta()). A file answer only carries a File id
|
|
||||||
(Modules\Core\File\Models\File is the source of truth for name/mime/disk/
|
|
||||||
path — never duplicated into meta), resolved here and linked through
|
|
||||||
boboko-core's own signed download route (files.download), minted fresh on
|
|
||||||
every render, with a thumbnail when the browser can display the format
|
|
||||||
(HEIC can't be shown outside Safari, so it gets the name only).
|
|
||||||
--}}
|
|
||||||
@php
|
|
||||||
$fields = $line->meta['custom_fields'] ?? [];
|
|
||||||
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if (! empty($fields))
|
|
||||||
<dl class="bbk-line-fields">
|
|
||||||
@foreach ($fields as $field)
|
|
||||||
<div class="bbk-line-field">
|
|
||||||
<dt>{{ $field['label'] }}</dt>
|
|
||||||
<dd>
|
|
||||||
@if ($field['type'] === 'file')
|
|
||||||
@php
|
|
||||||
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
|
|
||||||
@endphp
|
|
||||||
@if ($file)
|
|
||||||
@php
|
|
||||||
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
|
|
||||||
'files.download',
|
|
||||||
now()->addHours(2),
|
|
||||||
['file' => $file->id],
|
|
||||||
);
|
|
||||||
@endphp
|
|
||||||
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
|
|
||||||
@if (in_array($file->mime, $previewable, true))
|
|
||||||
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
|
|
||||||
@endif
|
|
||||||
<span>{{ $file->original_name }}</span>
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
@else
|
|
||||||
{{ $field['value'] }}
|
|
||||||
@endif
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</dl>
|
|
||||||
@endif
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{{--
|
|
||||||
Payment method radios. $paymentMethods is Collection<Modules\Core\Payment\
|
|
||||||
Models\PaymentMethod> from CheckoutService::getPaymentMethods() (already
|
|
||||||
filtered to enabled + driver-resolves + isConfigured()). Selecting one
|
|
||||||
autosaves via bbk-payment#selectMethod; `data-payment-driver` tells the
|
|
||||||
controller whether to mount the Stripe Element.
|
|
||||||
|
|
||||||
$paymentMethods, $cart come from the page / controller.
|
|
||||||
--}}
|
|
||||||
@php($selected = $cart?->meta['payment_method'] ?? null)
|
|
||||||
|
|
||||||
@if ($paymentMethods->isEmpty())
|
|
||||||
<p class="bbk-checkout-note">{{ __('checkout.page.payment_method_none') }}</p>
|
|
||||||
@else
|
|
||||||
<div class="bbk-checkout-payment-options">
|
|
||||||
@foreach ($paymentMethods as $method)
|
|
||||||
<label class="bbk-checkout-payment-option">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="payment_type"
|
|
||||||
value="{{ $method->type }}"
|
|
||||||
data-payment-driver="{{ $method->driver }}"
|
|
||||||
@checked($selected === $method->type)
|
|
||||||
data-action="change->bbk-payment#selectMethod"
|
|
||||||
>
|
|
||||||
<span class="bbk-checkout-payment-option-name">{{ $method->translate('name') }}</span>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{{--
|
|
||||||
Shipping methods for the checkout page. Rendered inline by page.blade.php on
|
|
||||||
load, and re-rendered as a fragment by CheckoutController after every
|
|
||||||
address save / option change (bbk-checkout-form swaps it in). Radios
|
|
||||||
autosave via bbk-checkout-form#selectShipping — no submit button. A single
|
|
||||||
resolved option is auto-selected server-side and shown as a fixed line.
|
|
||||||
|
|
||||||
$shippingAddress, $shippingOptions come from the controller / page scope.
|
|
||||||
--}}
|
|
||||||
@php($selected = $shippingAddress?->shipping_option)
|
|
||||||
|
|
||||||
{{-- Rate resolution needs country (always Greece here) + postcode; until a
|
|
||||||
postcode is saved there's nothing to quote against yet. --}}
|
|
||||||
@if (! $shippingAddress?->postcode)
|
|
||||||
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
|
|
||||||
@elseif ($shippingOptions->isEmpty())
|
|
||||||
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
|
|
||||||
@elseif ($shippingOptions->count() === 1)
|
|
||||||
@php($only = $shippingOptions->first())
|
|
||||||
<div class="bbk-checkout-shipping-confirmed">
|
|
||||||
<span class="bbk-checkout-shipping-option-detail">
|
|
||||||
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
|
|
||||||
@if ($only->description)
|
|
||||||
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
|
|
||||||
@endif
|
|
||||||
</span>
|
|
||||||
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="bbk-checkout-shipping-options">
|
|
||||||
@foreach ($shippingOptions as $option)
|
|
||||||
<label class="bbk-checkout-shipping-option">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="shipping_option"
|
|
||||||
value="{{ $option->identifier }}"
|
|
||||||
@checked($selected === $option->identifier)
|
|
||||||
data-action="change->bbk-checkout-form#selectShipping"
|
|
||||||
>
|
|
||||||
<span class="bbk-checkout-shipping-option-detail">
|
|
||||||
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
|
|
||||||
@if ($option->description)
|
|
||||||
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
|
|
||||||
@endif
|
|
||||||
</span>
|
|
||||||
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
|
|
||||||
</label>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{{--
|
||||||
|
Store address and phone from Lunar's Store Details (Modules\Core\Store),
|
||||||
|
email from CONTACT_EMAIL until Store Details has an email field too.
|
||||||
|
Each line is left out when its value is empty.
|
||||||
|
--}}
|
||||||
|
@props([
|
||||||
|
'store' => null,
|
||||||
|
'email' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$address = $store?->translate('address');
|
||||||
|
$phone = $store?->phone;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if (filled($address) || filled($phone) || filled($email))
|
||||||
|
<address {{ $attributes->merge(['class' => 'not-italic']) }}>
|
||||||
|
<dl class="flex flex-col gap-10">
|
||||||
|
@if (filled($address))
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<dt class="font-display text-sm font-semibold uppercase">{{ __('storefront.contact.address') }}</dt>
|
||||||
|
<dd class="font-display text-h4 font-semibold leading-tight">{{ $address }}</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($phone))
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<dt class="font-display text-sm font-semibold uppercase">{{ __('storefront.contact.phone') }}</dt>
|
||||||
|
<dd class="font-display text-h4 font-semibold leading-tight">
|
||||||
|
<a href="tel:{{ preg_replace('/[^\d+]/', '', $phone) }}" class="underline-slide [--slide-h:1px] pb-0">{{ $phone }}</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (filled($email))
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<dt class="font-display text-sm font-semibold uppercase">Email</dt>
|
||||||
|
<dd class="font-display text-h4 font-semibold leading-tight break-all">
|
||||||
|
<a href="mailto:{{ $email }}" class="underline-slide [--slide-h:1px] pb-0">{{ $email }}</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</dl>
|
||||||
|
</address>
|
||||||
|
@endif
|
||||||
@@ -26,29 +26,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Social media --}}
|
{{-- Social media — URLs from Stoic (settings.md); empty ones are hidden. --}}
|
||||||
<nav aria-label="Social media">
|
@if ($stoicSettings->socialLinks())
|
||||||
<ul class="flex items-center gap-4">
|
<nav aria-label="Social media">
|
||||||
<li>
|
<ul class="flex items-center gap-4">
|
||||||
<a href="#" aria-label="Facebook" class="hover:text-brand">
|
@foreach ($stoicSettings->socialLinks() as $social)
|
||||||
<x-ui.icon name="facebook" :size="22" color="text-neutral-300" />
|
<li>
|
||||||
{{-- <span class="leading-none text-[22px] font-extrabold italic group-hover:underline">FB.</span> --}}
|
<a href="{{ $social['url'] }}" aria-label="{{ $social['label'] }}" class="hover:text-brand">
|
||||||
</a>
|
<x-ui.icon :name="$social['icon']" :size="22" />
|
||||||
</li>
|
</a>
|
||||||
<li>
|
</li>
|
||||||
<a href="#" aria-label="Instagram" class="hover:text-brand">
|
@endforeach
|
||||||
<x-ui.icon name="instagram" :size="22" />
|
</ul>
|
||||||
{{-- <span class="leading-none text-[22px] font-extrabold italic group-hover:underline">IG.</span> --}}
|
</nav>
|
||||||
</a>
|
@endif
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" aria-label="TikTok" class="hover:text-brand">
|
|
||||||
<x-ui.icon name="tiktok" :size="22" />
|
|
||||||
{{-- <span class="leading-none text-[22px] font-extrabold italic group-hover:underline">TT.</span> --}}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{{-- Copyright --}}
|
{{-- Copyright --}}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
<section {{ $attributes }}>
|
<section {{ $attributes }}>
|
||||||
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 border border-black">
|
<div class="grid grid-cols-1 lg:grid-cols-2">
|
||||||
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16">
|
<div class="flex flex-col justify-center gap-8 px-8 py-16 lg:px-16 items-center">
|
||||||
<h2
|
<h2
|
||||||
class="text-h2 leading-tight"
|
class="text-h1 leading-[1.3] max-w-xl text-center"
|
||||||
aria-label="Γράψου στο newsletter μας"
|
aria-label="Γράψου στο newsletter μας"
|
||||||
data-controller="appear"
|
data-controller="appear"
|
||||||
data-appear-visible-class="is-visible"
|
data-appear-visible-class="is-visible"
|
||||||
@@ -17,13 +17,13 @@ class="text-h2 leading-tight"
|
|||||||
μας
|
μας
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="max-w-md text-neutral-600">
|
<p class="max-w-xl text-neutral-600 text-center">
|
||||||
Γίνε μέλος της λίστας μας και κέρδισε <span class="font-bold">5% έκπτωση</span> στην πρώτη σου παραγγελία, μαζί με αποκλειστικές προσφορές και νέα.
|
Γίνε μέλος της λίστας μας και κέρδισε <span class="font-bold">5% έκπτωση</span> στην πρώτη σου παραγγελία, μαζί με αποκλειστικές προσφορές και νέα.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="POST" action="#" class="flex flex-col gap-6 max-w-md">
|
<form method="POST" action="#" class="flex flex-col gap-6 max-w-xl items-center">
|
||||||
@csrf
|
@csrf
|
||||||
<div class="relative">
|
<div class="relative max-w-xs w-full">
|
||||||
<x-ui.input
|
<x-ui.input
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -44,7 +44,7 @@ class="absolute right-0 bottom-2 cursor-pointer"
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-ui.checkbox name="gdpr_consent" :required="true" id="newsletter_split_gdpr_consent" class="after:mix-blend-multiply">
|
<x-ui.checkbox name="gdpr_consent" :required="true" id="newsletter_split_gdpr_consent" class="after:mix-blend-multiply">
|
||||||
<span class="text-sm text-left">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
<span class="text-sm">Επιθυμώ διακαώς 🔥 να εγγραφώ στη λίστα αποστολής ενημερωτικού υλικού</span>
|
||||||
</x-ui.checkbox>
|
</x-ui.checkbox>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
inside the add-to-cart <form> so their values travel with it as
|
inside the add-to-cart <form> so their values travel with it as
|
||||||
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
|
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
|
||||||
|
|
||||||
|
The label is styled like the product option labels (option-buttons /
|
||||||
|
color-swatch); the optional help_text shows under it. Help text is
|
||||||
|
product-page only; the cart/checkout line meta only snapshots the label.
|
||||||
|
|
||||||
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
|
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
|
||||||
and only the resulting File row's id (boboko-core's Modules\Core\File\
|
and only the resulting File row's id (boboko-core's Modules\Core\File\
|
||||||
Models\File) is submitted with the form — the file input itself has no
|
Models\File) is submitted with the form — the file input itself has no
|
||||||
@@ -22,18 +26,25 @@
|
|||||||
$id = 'custom-field-'.$field['key'];
|
$id = 'custom-field-'.$field['key'];
|
||||||
$name = 'custom_fields['.$field['key'].']';
|
$name = 'custom_fields['.$field['key'].']';
|
||||||
$required = (bool) ($field['required'] ?? false);
|
$required = (bool) ($field['required'] ?? false);
|
||||||
|
$hint = filled($field['help_text'] ?? null) ? $field['help_text'] : null;
|
||||||
|
// mb-1 on top of the field's gap-2 matches the option labels' mb-3;
|
||||||
|
// with a hint, the label + hint group carries that spacing instead.
|
||||||
|
$labelClass = 'font-bold text-sm uppercase tracking-wide'.($hint ? '' : ' mb-1');
|
||||||
|
$hintId = $hint ? $id.'-hint' : null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@switch($field['type'])
|
@switch($field['type'])
|
||||||
@case('textarea')
|
@case('textarea')
|
||||||
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
<x-ui.field :label="$field['label']" :label-class="$labelClass" :hint="$hint" :for="$id" :required="$required">
|
||||||
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" />
|
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" :aria-describedby="$hintId" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
@break
|
@break
|
||||||
|
|
||||||
@case('file')
|
@case('file')
|
||||||
<x-ui.field
|
<x-ui.field
|
||||||
:label="$field['label']"
|
:label="$field['label']"
|
||||||
|
:label-class="$labelClass"
|
||||||
|
:hint="$hint"
|
||||||
:for="$id"
|
:for="$id"
|
||||||
:required="$required"
|
:required="$required"
|
||||||
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
|
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
|
||||||
@@ -49,7 +60,7 @@
|
|||||||
:id="$id"
|
:id="$id"
|
||||||
:accept="CustomFieldUploadController::accept()"
|
:accept="CustomFieldUploadController::accept()"
|
||||||
:required="$required"
|
:required="$required"
|
||||||
aria-describedby="{{ $id }}-description {{ $id }}-error"
|
aria-describedby="{{ $hintId ? $hintId.' ' : '' }}{{ $id }}-description {{ $id }}-error"
|
||||||
data-custom-field-upload-target="file"
|
data-custom-field-upload-target="file"
|
||||||
data-action="change->custom-field-upload#upload"
|
data-action="change->custom-field-upload#upload"
|
||||||
/>
|
/>
|
||||||
@@ -69,8 +80,8 @@ class="w-24 h-24 object-cover border border-black"
|
|||||||
@break
|
@break
|
||||||
|
|
||||||
@default
|
@default
|
||||||
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
<x-ui.field :label="$field['label']" :label-class="$labelClass" :hint="$hint" :for="$id" :required="$required">
|
||||||
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" />
|
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" :aria-describedby="$hintId" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
@endswitch
|
@endswitch
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
:href="$product['href'] ?? '#'"
|
:href="$product['href'] ?? '#'"
|
||||||
:variant-id="$product['variantId'] ?? null"
|
:variant-id="$product['variantId'] ?? null"
|
||||||
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
:has-custom-fields="$product['hasCustomFields'] ?? false"
|
||||||
|
:sold-out="$product['soldOut'] ?? false"
|
||||||
/>
|
/>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
// "absolute" via the class prop — Tailwind's generated stylesheet always
|
// "absolute" via the class prop — Tailwind's generated stylesheet always
|
||||||
// orders the "relative" utility after "absolute", so on a class clash
|
// orders the "relative" utility after "absolute", so on a class clash
|
||||||
// "relative" silently wins and the button never actually gets positioned.
|
// "relative" silently wins and the button never actually gets positioned.
|
||||||
$class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline '.$variantClasses.' '.$sizeClasses);
|
$class = trim($position.' isolate inline-flex items-center justify-center border border-black text-black cursor-pointer no-underline disabled:cursor-not-allowed disabled:opacity-50 '.$variantClasses.' '.$sizeClasses);
|
||||||
|
|
||||||
$attrs = $href
|
$attrs = $href
|
||||||
? $attributes->merge(['href' => $href, 'class' => $class])
|
? $attributes->merge(['href' => $href, 'class' => $class])
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
@props([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
'labelDescription' => null,
|
'labelDescription' => null,
|
||||||
|
'labelClass' => null,
|
||||||
|
'hint' => null,
|
||||||
'for' => null,
|
'for' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'error' => null,
|
'error' => null,
|
||||||
@@ -10,9 +12,23 @@
|
|||||||
<div {{ $attributes->merge(['class' => 'flex flex-col gap-2']) }}>
|
<div {{ $attributes->merge(['class' => 'flex flex-col gap-2']) }}>
|
||||||
|
|
||||||
@if ($label)
|
@if ($label)
|
||||||
|
{{-- With a hint, label + hint are grouped so the hint sits tight
|
||||||
|
under the label, with the usual gap before the input. The input
|
||||||
|
should reference {for}-hint in its aria-describedby. --}}
|
||||||
|
@if ($hint) <div class="flex flex-col gap-1"> @endif
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@if ($for) for="{{ $for }}" @endif
|
@if ($for) for="{{ $for }}" @endif
|
||||||
|
@if ($labelClass) class="{{ $labelClass }}" @endif
|
||||||
>{{ $label }}@if ($required)<span class="ml-1" aria-hidden="true">*</span>@endif @if ($labelDescription) <span class="text-sm text-neutral-500">({{ $labelDescription }})</span> @endif</label>
|
>{{ $label }}@if ($required)<span class="ml-1" aria-hidden="true">*</span>@endif @if ($labelDescription) <span class="text-sm text-neutral-500">({{ $labelDescription }})</span> @endif</label>
|
||||||
|
|
||||||
|
@if ($hint)
|
||||||
|
<p
|
||||||
|
@if ($for) id="{{ $for }}-hint" @endif
|
||||||
|
class="text-sm text-neutral-500"
|
||||||
|
>{{ $hint }}</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
'href' => '#',
|
'href' => '#',
|
||||||
'variantId' => null,
|
'variantId' => null,
|
||||||
'hasCustomFields' => false,
|
'hasCustomFields' => false,
|
||||||
|
// Shows the "sold" star and drops the quick add-to-cart/personalize button.
|
||||||
|
'soldOut' => false,
|
||||||
// Heart toggle at the top right of the image; needs productId.
|
// Heart toggle at the top right of the image; needs productId.
|
||||||
'productId' => null,
|
'productId' => null,
|
||||||
'wishlist' => false,
|
'wishlist' => false,
|
||||||
@@ -36,7 +38,9 @@ class="w-full h-auto block"
|
|||||||
<x-wishlist-button :product-id="$productId" class="absolute top-4 right-4 z-10" />
|
<x-wishlist-button :product-id="$productId" class="absolute top-4 right-4 z-10" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($hasCustomFields)
|
@if ($soldOut)
|
||||||
|
<x-ui.sold-badge class="absolute top-2 right-2 {{ $wishlist && $productId ? 'mt-12' : '' }}" />
|
||||||
|
@elseif ($hasCustomFields)
|
||||||
{{-- Custom fields have to be filled in on the product page. --}}
|
{{-- Custom fields have to be filled in on the product page. --}}
|
||||||
<x-ui.button
|
<x-ui.button
|
||||||
:href="$href"
|
:href="$href"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{{-- Black "sold" star for out-of-stock products. Position it from the caller
|
||||||
|
(e.g. class="absolute top-2 right-2"). --}}
|
||||||
|
<div {{ $attributes->class('z-10 flex h-20 w-22 items-center justify-center text-black') }}>
|
||||||
|
<svg viewBox="0 0 87 80" class="absolute inset-0 size-full" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M56.942,0l2.607,18.642,19.143-3.363L69.468,31.842,87,40,69.468,48.158l9.224,16.563L59.549,61.358,56.942,80,43.5,66.4,30.058,80,27.451,61.358,8.308,64.721l9.224-16.563L0,40l17.532-8.158L8.308,15.279l19.143,3.363L30.058,0,43.5,13.6Z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="relative text-base italic font-black text-white uppercase">{{ __('storefront.product.sold') }}</span>
|
||||||
|
</div>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
@props(['productId'])
|
@props(['productId'])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$active = app(\App\Services\Wishlist::class)->has((int) $productId);
|
$active = app(\Modules\Core\Wishlist\Services\WishlistService::class)->has((int) $productId);
|
||||||
$addLabel = __('storefront.wishlist.add');
|
$addLabel = __('storefront.wishlist.add');
|
||||||
$removeLabel = __('storefront.wishlist.remove');
|
$removeLabel = __('storefront.wishlist.remove');
|
||||||
@endphp
|
@endphp
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
@section('title', __('storefront.nav.contact'))
|
@section('title', __('storefront.nav.contact'))
|
||||||
@section('description', 'Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό.')
|
@section('description', 'Επικοινώνησε μαζί μας για οποιαδήποτε απορία ή ιδέα έχεις και θα σου απαντήσουμε το συντομότερο δυνατό.')
|
||||||
|
|
||||||
|
{{-- 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('content')
|
||||||
|
|
||||||
<section class="grid grid-cols-1 lg:grid-cols-2 lg:divide-x lg:divide-black lg:min-h-[calc(100vh-102px)]">
|
<section class="grid grid-cols-1 lg:grid-cols-2 lg:divide-x lg:divide-black lg:min-h-[calc(100vh-102px)]">
|
||||||
@@ -13,14 +18,14 @@
|
|||||||
<path d="m88.4 0 4 28.9 29.7-5.2-14.3 25.7L135 62l-27.2 12.6 14.3 25.7-29.7-5.2-4 28.9-20.9-21.1L46.6 124l-4-28.9-29.7 5.2 14.3-25.7L0 62l27.2-12.6-14.3-25.7 29.7 5.2 4-28.9 20.9 21.1L88.4 0z"/>
|
<path d="m88.4 0 4 28.9 29.7-5.2-14.3 25.7L135 62l-27.2 12.6 14.3 25.7-29.7-5.2-4 28.9-20.9-21.1L46.6 124l-4-28.9-29.7 5.2 14.3-25.7L0 62l27.2-12.6-14.3-25.7 29.7 5.2 4-28.9 20.9 21.1L88.4 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<h1 class="font-display text-h2 font-semibold leading-tight lg:text-h2"
|
<h1 class="font-display text-h1 font-semibold leading-tight"
|
||||||
data-controller="appear"
|
data-controller="appear"
|
||||||
data-appear-visible-class="is-visible">
|
data-appear-visible-class="is-visible">
|
||||||
<span class="text-dance">Επικοινώνησε</span><br>
|
<span class="text-dance">Επικοινώνησε</span><br>
|
||||||
μαζί μας <span class="text-dance [--dance-delay:200ms]">άμεσα</span>
|
μαζί μας <span class="text-dance [--dance-delay:200ms]">άμεσα</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p class="max-w-md ">
|
<p class="max-w-xl ">
|
||||||
Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό.
|
Έχεις απορία, ιδέα ή θες να μας πεις κάτι; Γράψε μας το μήνυμά σου και θα σου απαντήσουμε το συντομότερο δυνατό.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -30,25 +35,35 @@
|
|||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2">
|
||||||
<x-ui.field label="Το όνομά σου" for="contact-name" :error="$errors->first('name')">
|
<x-ui.field label="Το όνομά σου *" for="contact-name" :error="$errors->first('name')">
|
||||||
<x-ui.input id="contact-name" name="name" autocomplete="name" maxlength="100" :value="old('name')" :required="true"
|
<x-ui.input id="contact-name" name="name" autocomplete="name" maxlength="100" :value="old('name')" :required="true"
|
||||||
:aria-invalid="$errors->has('name') ? 'true' : null"
|
:aria-invalid="$errors->has('name') ? 'true' : null"
|
||||||
:aria-describedby="$errors->has('name') ? 'contact-name-error' : null" />
|
:aria-describedby="$errors->has('name') ? 'contact-name-error' : null" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
<x-ui.field label="Το email σου" for="contact-email" :error="$errors->first('email')">
|
<x-ui.field label="Το email σου *" for="contact-email" :error="$errors->first('email')">
|
||||||
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" maxlength="255" :value="old('email')" :required="true"
|
<x-ui.input id="contact-email" name="email" type="email" autocomplete="email" maxlength="255" :value="old('email')" :required="true"
|
||||||
:aria-invalid="$errors->has('email') ? 'true' : null"
|
:aria-invalid="$errors->has('email') ? 'true' : null"
|
||||||
:aria-describedby="$errors->has('email') ? 'contact-email-error' : null" />
|
:aria-describedby="$errors->has('email') ? 'contact-email-error' : null" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-ui.field label="Το μήνυμά σου" for="contact-message" :error="$errors->first('message')">
|
<x-ui.field label="Το μήνυμά σου *" for="contact-message" :error="$errors->first('message')">
|
||||||
<x-ui.textarea id="contact-message" name="message" rows="5" maxlength="5000" :required="true"
|
<x-ui.textarea id="contact-message" name="message" rows="5" maxlength="5000" :required="true"
|
||||||
:aria-invalid="$errors->has('message') ? 'true' : null"
|
:aria-invalid="$errors->has('message') ? 'true' : null"
|
||||||
:aria-describedby="$errors->has('message') ? 'contact-message-error' : null">{{ old('message') }}</x-ui.textarea>
|
:aria-describedby="$errors->has('message') ? 'contact-message-error' : null">{{ old('message') }}</x-ui.textarea>
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
|
|
||||||
|
{{-- hCaptcha checkbox, verified by App\Rules\HCaptcha (see auth/login). --}}
|
||||||
|
<x-ui.field for="contact-captcha" :error="$errors->first('h-captcha-response')">
|
||||||
|
<div
|
||||||
|
id="contact-captcha"
|
||||||
|
class="h-captcha min-h-[78px]"
|
||||||
|
data-sitekey="{{ config('services.hcaptcha.sitekey') }}"
|
||||||
|
@if ($errors->has('h-captcha-response')) aria-describedby="contact-captcha-error" @endif
|
||||||
|
></div>
|
||||||
|
</x-ui.field>
|
||||||
|
|
||||||
@if (session('contact_error'))
|
@if (session('contact_error'))
|
||||||
<p role="alert" class="text-sm text-red-600">{{ session('contact_error') }}</p>
|
<p role="alert" class="text-sm text-red-600">{{ session('contact_error') }}</p>
|
||||||
@endif
|
@endif
|
||||||
@@ -60,7 +75,9 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-h-[320px] bg-neutral-400 lg:min-h-full" aria-hidden="true"></div>
|
<div class="flex flex-col justify-center border-t border-black px-8 py-16 lg:border-t-0 lg:px-16">
|
||||||
|
<x-contact-details :store="$store" :email="$contactEmail" />
|
||||||
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user