Author SHA1 Message Date
elvira ac888f91e3 claude suggestions 2026-09-29 17:30:42 +03:00
elvira 47f250d27f Merge branch 'cart-temp' 2026-09-29 16:12:20 +03:00
arvanitakis 40c6b27ccb Bumping core to 0.27.2 2026-09-29 14:54:52 +03:00
arvanitakis db01151ca3 Bumping Core to 0.27.1 2026-09-29 14:35:42 +03:00
arvanitakis 9fcea8fb09 Feat: Adding php soap and favicon for boboko 2026-09-29 14:34:01 +03:00
arvanitakis 38e71927bc Bumping core version to 0.27.0 2026-09-29 01:15:00 +03:00
arvanitakis 70787ace44 Feat: Updating entrypoint and missing lunar translations! 2026-09-29 00:03:37 +03:00
arvanitakis 25021bdafb Fix: Correcting vector file name 2026-09-28 21:39:03 +03:00
arvanitakis 03b46e7bfb Fix: Removing publish from entrypoint 2026-09-28 20:55:22 +03:00
arvanitakis ec9ac92a95 Fix: un-gitignoring public assets 2026-09-28 20:53:46 +03:00
arvanitakis 8025bf2692 Feat: Adding vendor publish for nginx to be able to grab them 2026-09-28 20:38:46 +03:00
arvanitakis c48d3b1756 Fix: Publishing assets on prod 2026-09-28 20:10:41 +03:00
arvanitakis d053f57b10 Feat: Adding laravel envoy 2026-09-28 19:43:55 +03:00
elvira b522d60954 multilingual content, change in how we show header categories and homepage hero products 2026-09-28 19:42:35 +03:00
arvanitakis b38e190714 Feat: Removing OTEL collector files and adding victoria logs 2026-09-28 19:42:26 +03:00
arvanitakis d81adb4ddc Fix: Adding missing vector data 2026-09-28 19:35:03 +03:00
arvanitakis 094763fd86 Feat: Add vector image to boboko 2026-09-28 19:34:07 +03:00
arvanitakis a1a98c75c5 Adding cache store as redis on .env.example 2026-09-28 19:15:33 +03:00
arvanitakis fb5ec85c9f Feat: Adding vite build to dockerfile 2026-09-28 19:13:20 +03:00
arvanitakis e8054b2292 Feat: Adding Private Storage to dockerignore 2026-09-28 18:34:29 +03:00
elvira 60ae6cf7a6 Merge branch 'master' into cart-temp 2026-09-28 18:23:55 +03:00
elvira 296d6001cd lock files 2026-09-28 18:23:33 +03:00
elvira aa051525a1 stoic multilingual 2026-09-28 18:17:33 +03:00
arvanitakis b288513bb4 Feat: Adding stripe and boxnow variables to .env.example 2026-09-28 18:16:36 +03:00
arvanitakis 74930351f1 Commiting composer.lock and package-lock.json 2026-09-28 18:12:37 +03:00
arvanitakis 65d4603793 Merge branch 'cart-temp' 2026-09-28 18:09:29 +03:00
arvanitakis 023c11019c Fix: Aligning docker compose and Dockerfile with entrypoints 2026-09-28 18:04:23 +03:00
elvira 88d8ae0954 stoic settings and site meta and legal 2026-09-28 17:32:22 +03:00
elvira ca362f81c8 setup for stoic settings, changes in legal pages to use core's store details and contact page to show contact info next to form 2026-09-28 17:09:08 +03:00
arvanitakis 4634e14924 Feat: Moving seeders to core, updating entrypoint to auto seed the validation and checkout translations 2026-09-28 13:43:44 +03:00
arvanitakis 69486f65ee Feat: Adding Order reference to views 2026-09-28 13:31:29 +03:00
arvanitakis 060b6c647e Merge branch 'box-now-test-' into cart-temp 2026-09-28 10:11:26 +03:00
arvanitakis 36806cac5b Chore: Moving Wishlist from 3dealer to Core 2026-09-28 10:10:50 +03:00
arvanitakis b1162761de Fix: correct package.json for boboko/core npm integration 2026-09-28 08:54:09 +03:00
arvanitakis 8b362c9436 Feat: Correctly extracting boboko/core's js 2026-09-28 08:46:14 +03:00
arvanitakis cd45d8f578 Feat: Removing Controllers and Movong to core 2026-09-27 20:49:07 +03:00
elvira 8e186fc231 minor improvement in showing custom fields in product page and boboko-core sourced locally 2026-09-25 22:39:14 +03:00
arvanitakis a51ce78538 Merge branch 'cart-temp' into box-now-test- 2026-09-25 21:49:30 +03:00
elvira ab7e560a3d add to cart button disabled when 0 stock and newsletter form theming 2026-09-25 20:23:22 +03:00
arvanitakis 081893ef57 Feat: Removing Cart and Checkout from core 2026-09-25 20:22:38 +03:00
elvira e2d7bbb043 order emails theming, sold out product card, contact page hcaptcha 2026-09-25 17:44:42 +03:00
elvira 3347febb3c login hcaptcha 2026-09-25 16:19:56 +03:00
arvanitakis d43b615297 Feat: Moving Email Updates to Core 2026-09-25 15:37:34 +03:00
arvanitakis 7f6c1e6307 Chore: Moving Recovery Consent to Core 2026-09-25 14:13:15 +03:00
arvanitakis 5bbf0aabcd Fix: Updating Account Controller to Handle tax_identifier correctly 2026-09-25 14:03:37 +03:00
arvanitakis fa172860db Chore: Claiming Guest Orders Moved to Core 2026-09-25 13:58:13 +03:00
arvanitakis a51e9456d6 Feat: Moving File Handling To Core 2026-09-25 13:47:58 +03:00
arvanitakis 78c8165c04 Feat: Box now tests 2026-09-17 19:55:39 +03:00
arvanitakis c7bd1efdaa Feature: Adding Meilisearch to 3dealer 2026-08-05 23:12:22 +03:00
195 changed files with 3318 additions and 5707 deletions
+1
View File
@@ -5,6 +5,7 @@ vendor
public/build
public/hot
storage/app/public/*
storage/app/private/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
+23 -9
View File
@@ -31,7 +31,7 @@ DB_DATABASE=boboko
DB_USERNAME=root
DB_PASSWORD=dev
SESSION_DRIVER=database
SESSION_DRIVER=redis
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
@@ -41,7 +41,7 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
CACHE_STORE=redis
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
@@ -58,8 +58,11 @@ MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
# Fallback only: the sender name comes from Store Details' "Mail from name" when set.
MAIL_FROM_NAME="${APP_NAME}"
CONTACT_EMAIL=
HCAPTCHA_SITEKEY=
HCAPTCHA_SECRET=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
@@ -69,12 +72,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
OTEL_SERVICE_NAME=boboko-starter
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_PROPAGATORS=baggage,tracecontext
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_EXPORTER_OTLP_ENDPOINT=
# Stoic is the headless CMS, proxied at /stoic/ behind the app in prod (see docker/nginx/prod.conf)
STOIC_SECRET=dev-secret-change-me
STOIC_SSO_SECRET=dev-sso-secret-change-me
@@ -85,6 +82,9 @@ SCOUT_QUEUE=true
MEILISEARCH_KEY=dev-meilisearch-key-change-me
MEILISEARCH_HOST=http://meilisearch:7700
# Lunar sets all prices inclusive of tax with these values
LUNAR_STORE_INCLUSIVE_OF_TAX=true
# Host-side ports — change these if another project on this machine already uses the defaults
APP_PORT=8090
STOIC_PORT=2727
@@ -93,3 +93,17 @@ POSTGRES_HOST_PORT=5436
MAILPIT_PORT=8025
VITE_PORT=5173
MEILISEARCH_PORT=7700
STRIPE_SECRET=secret
STRIPE_PUBLIC_KEY=public_key
STRIPE_WEBHOOK_SECRET=webhook_secret
BOXNOW_BASE_URL=https://api-stage.boxnow.gr/api/v1
BOXNOW_LOCATION_API_URL=https://locationapi-stage.boxnow.gr/api/v1
BOXNOW_CLIENT_ID=box-client-id
BOXNOW_CLIENT_SECRET=box-client-secret
BOXNOW_PARTNER_ID=box-partner-id
BOXNOW_ORIGIN_LOCATION_ID=box-location-id
BOXNOW_SENDER_NAME=test
BOXNOW_SENDER_EMAIL=test@test.com
BOXNOW_SENDER_PHONE=+306912345678
-4
View File
@@ -17,11 +17,7 @@
/public/build
/public/hot
/public/sitemap.xml
/public/logos/core
/public/storage
/public/css/filament
/public/js/filament
/public/fonts/filament
/storage/*.key
/storage/framework/migrated
/storage/pail
+17 -14
View File
@@ -10,14 +10,25 @@ RUN composer install \
--optimize-autoloader \
--ignore-platform-reqs
# ── Stage 2a: Vite dev (node_modules only, no build) ─────────────────────────
# ── Stage 2a: Vite dev (node_modules only, no build) ──────────────────────────
FROM node:22-alpine AS vite-dev
# git: needed for npm to install @boboko/core's git+https tagged-VCS form (see
# package.json's _dependencies and docker/entrypoint-vite.sh). No build-time
# `npm install` here — package.json's boboko/core path-repo form (../boboko-core)
# isn't visible in the build context, only once bind-mounted at container start,
# and entrypoint-vite.sh already runs npm install on every boot, so this would be
# redundant even if it could work.
RUN apk add --no-cache git
WORKDIR /app
COPY package*.json ./
RUN npm ci --ignore-scripts
COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
RUN chmod +x /entrypoint-vite.sh
ENTRYPOINT ["/entrypoint-vite.sh"]
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
FROM node:22-alpine AS node-build
# git: same reason as vite-dev above — npm needs it to resolve @boboko/core's
# git+https tagged-VCS form.
RUN apk add --no-cache git
WORKDIR /app
COPY package*.json ./
RUN npm ci --ignore-scripts
@@ -53,6 +64,7 @@ RUN apt-get update \
php8.5-xml \
php8.5-curl \
php8.5-redis \
php8.5-soap \
postgresql-client-18 \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /run/php \
@@ -83,18 +95,9 @@ COPY . .
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
# Package discovery and asset publishing are fully determined by composer.lock + code,
# so they belong in the image, not in every container's entrypoint. Only asset-type
# tags are force-republished here — config tags (core-config, lunar) are a deliberate
# one-time `vendor:publish` step you run and commit by hand, since re-running them would
# silently overwrite any local edits to config/core.php or config/lunar/*.php.
RUN php artisan package:discover --ansi \
&& php artisan vendor:publish --tag=core-assets --force --ansi \
&& php artisan vendor:publish --tag=public --force --ansi \
&& php artisan filament:assets --ansi
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
EXPOSE 9000
ENTRYPOINT ["/entrypoint.sh"]
+2 -8
View File
@@ -19,7 +19,6 @@
pull
docker_build
docker_up
migrate
artisan_optimize
restart_workers
stoic_generate_thumbs
@@ -45,12 +44,6 @@
{{ $compose }} up -d --remove-orphans
@endtask
@task('migrate', ['on' => 'web'])
echo ">>> Running migrations..."
cd {{ $path }}
{{ $compose }} exec -T app php artisan migrate --force < /dev/null
@endtask
@task('artisan_optimize', ['on' => 'web'])
echo ">>> Optimizing..."
cd {{ $path }}
@@ -61,9 +54,10 @@
@endtask
@task('restart_workers', ['on' => 'web'])
echo ">>> Restarting queue workers..."
echo ">>> Restarting queue workers and scheduler..."
cd {{ $path }}
{{ $compose }} restart queue
{{ $compose }} restart scheduler
@endtask
@task('password_protect', ['on' => 'web'])
+5 -1
View File
@@ -19,7 +19,7 @@ final class ProductCard
{
/**
* @param array<string, mixed> $product one item from ProductService's localized array shape
* @return array{name: ?string, price: ?string, image: ?string, href: string, 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
{
@@ -38,6 +38,10 @@ public static function fromIndexed(array $product): array
// can't be quick-added from a card — the card links to the
// product page instead, even when every field is optional.
'hasCustomFields' => ! empty($product['custom_fields']),
// Index-time stock (see boboko-core's ProductIndexer `in_stock`),
// so only as fresh as the last reindex. A document missing the
// field is treated as in stock rather than hiding its cart button.
'soldOut' => ! ($product['in_stock'] ?? true),
];
}
}
@@ -1,73 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Http\Controllers\CustomFieldUploadController;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Lunar\Models\CartLine;
use Lunar\Models\OrderLine;
/**
* Custom-field photos are uploaded the moment a shopper picks them (see
* CustomFieldUploadController), before add-to-cart — so a shopper who picks a
* photo and then leaves, or picks a different one, leaves a file nobody
* references. This deletes those: any upload older than the grace period that
* no cart line or order line's meta.custom_fields points to.
*
* The grace period covers a shopper still on the product page with a picked
* but not-yet-added photo. A file referenced by a cart line is kept for as
* long as that cart line exists; once the line (or its cart) is removed, the
* next run deletes the file. Order line references are kept indefinitely.
*/
class PruneCustomFieldUploads extends Command
{
protected $signature = 'custom-fields:prune-uploads {--hours=24 : Only delete unreferenced uploads older than this}';
protected $description = 'Delete custom-field photo uploads not referenced by any cart or order line';
public function handle(): int
{
$disk = Storage::disk(CustomFieldUploadController::DISK);
$referenced = $this->referencedPaths();
$cutoff = now()->subHours((int) $this->option('hours'))->getTimestamp();
$deleted = 0;
foreach ($disk->files(CustomFieldUploadController::DIRECTORY) as $path) {
if (isset($referenced[$path]) || $disk->lastModified($path) > $cutoff) {
continue;
}
$disk->delete($path);
$deleted++;
}
$this->info("Deleted {$deleted} unreferenced custom-field upload(s).");
return self::SUCCESS;
}
/**
* @return array<string, true>
*/
private function referencedPaths(): array
{
$paths = [];
foreach ([CartLine::class, OrderLine::class] as $model) {
$model::query()
->where('meta', 'like', '%custom_fields%')
->select('meta')
->cursor()
->each(function ($line) use (&$paths) {
foreach ($line->meta['custom_fields'] ?? [] as $field) {
if (! empty($field['path'])) {
$paths[$field['path']] = true;
}
}
});
}
return $paths;
}
}
@@ -75,16 +75,13 @@ public function update(string $locale, Request $request): RedirectResponse
$invoice = $request->boolean('invoice');
$customer = $this->account->updateProfile($user, [
$this->account->updateProfile($user, [
'first_name' => $data['first_name'] ?? null,
'last_name' => $data['last_name'] ?? null,
'company_name' => $invoice ? $data['company_name'] : null,
'tax_identifier' => $invoice ? $data['tax_identifier'] : null,
]);
// Written directly: core's updateProfile() allowlists `vat_no`, but
// Lunar's column is `tax_identifier`, so it can't go through there yet.
$customer->update(['tax_identifier' => $invoice ? $data['tax_identifier'] : null]);
if (filled($data['line_one'] ?? null)) {
$addressData = [
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
@@ -103,28 +100,20 @@ public function update(string $locale, Request $request): RedirectResponse
: $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'));
}
/**
* "Email me a reminder if I don't finish my order", as a standing choice.
* Stored on the customer in the same meta shape the checkout writes (see
* CheckoutController::rememberRecoveryConsent()), and applied to the
* "Email me a reminder if I don't finish my order", as a standing
* choice — stored on the customer via boboko-core's
* CustomerAccountService::setRecoveryConsent(), and applied to the
* current cart too, so opting out stops reminders for it right away.
*/
private function updateRecoveryConsent($customer, bool $consent): void
private function updateRecoveryConsent($user, bool $consent): void
{
if ((bool) data_get($customer, 'meta.recovery_consent') !== $consent) {
$customer->meta = [
...($customer->meta?->toArray() ?? []),
'recovery_consent' => $consent,
'recovery_consent_at' => $consent ? now()->toIso8601String() : null,
'recovery_consent_policy_version' => $consent ? config('legal.privacy_policy_version') : null,
];
$customer->save();
}
$this->account->setRecoveryConsent($user, $consent);
// Only an existing cart; never create one just to record this.
$cart = app(CartService::class)->current();
@@ -3,43 +3,36 @@
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Mail\EmailChangeCodeMail;
use App\Mail\EmailChangedNoticeMail;
use App\Services\GuestOrderClaimer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
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 →
* switched. The email is the login, so it only changes once the shopper has
* proved they can receive mail there; a typo can never lock them out.
*
* Once switched, the OLD address gets a notice (EmailChangedNoticeMail).
*
* Core has no email-change flow, so the pending change lives in the session
* (the new address, a hash of the code, expiry and wrong-guess count). Limits
* mirror core's login OTP: 3 codes per 10 minutes, 5 guesses per code.
* Changing the login email — a thin wrapper over boboko-core's
* Customer\Services\CustomerEmailChangeService, which owns the actual
* request/confirm mechanics, throttling, pending-change storage, and
* mailables. This controller's own job is just the storefront's session-
* scoped "which email did I just ask to switch to" UI state (so the
* .code/.resend pages know which address to show/resend to) and
* translating the service's exceptions into the flash-message flow the
* views expect.
*/
class EmailController extends Controller
{
private const SESSION_KEY = 'email_change';
private const EXPIRY_MINUTES = 10;
private const MAX_ATTEMPTS = 5;
private const SEND_LIMIT = 3;
private const SEND_DECAY_SECONDS = 600;
private const SESSION_KEY = 'pending_email_change';
public function edit(string $locale, Request $request): View
{
return view('account.email', ['user' => $request->user()]);
}
public function send(string $locale, Request $request): RedirectResponse
public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
{
$user = $request->user();
@@ -51,119 +44,80 @@ public function send(string $locale, Request $request): RedirectResponse
'email',
'max:255',
Rule::notIn([$user->email]),
Rule::unique($user->getTable(), 'email')->ignore($user->id),
],
], [
'email.not_in' => __('storefront.account.email_same'),
'email.unique' => __('storefront.account.email_taken'),
]);
if (! $this->sendCode($request, $validated['email'])) {
try {
$emailChange->request($user, $validated['email']);
} catch (EmailAlreadyTakenException) {
return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]);
} catch (OtpThrottledException) {
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
}
$request->session()->put(self::SESSION_KEY, $validated['email']);
return redirect()->route('account.email.code');
}
public function code(string $locale, Request $request): View|RedirectResponse
{
$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 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');
}
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()->with('status', __('storefront.auth.code_resent'));
}
public function verify(string $locale, Request $request, GuestOrderClaimer $orders): 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');
}
$validated = $request->validate(['code' => ['required', 'digits:6']]);
$valid = $pending['code_hash'] !== null
&& now()->timestamp < $pending['expires_at']
&& Hash::check($validated['code'], $pending['code_hash']);
if (! $valid) {
// Too many wrong guesses burns the code; only "resend" helps then.
$pending['attempts']++;
if ($pending['attempts'] >= self::MAX_ATTEMPTS) {
$pending['code_hash'] = null;
}
$request->session()->put(self::SESSION_KEY, $pending);
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
}
$user = $request->user();
// Someone may have signed up with this address since the code was sent.
if ($user->newQuery()->where('email', $pending['email'])->whereKeyNot($user->id)->exists()) {
try {
$emailChange->confirm($request->user(), $validated['code']);
} catch (EmailAlreadyTakenException) {
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account.email.edit')
->withErrors(['email' => __('storefront.account.email_taken')]);
} catch (InvalidEmailChangeCodeException) {
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
}
$oldEmail = $user->email;
$user->forceFill(['email' => $pending['email'], 'email_verified_at' => now()])->save();
// Lets the owner notice if someone else changed it from a hijacked session.
Mail::to($oldEmail)->send(new EmailChangedNoticeMail($pending['email']));
// The code just proved they own the new address too.
$orders->claim($user);
$request->session()->forget(self::SESSION_KEY);
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
}
private function sendCode(Request $request, string $email): bool
{
$limiterKey = 'email-change:'.$request->user()->id;
if (RateLimiter::tooManyAttempts($limiterKey, self::SEND_LIMIT)) {
return false;
}
RateLimiter::hit($limiterKey, self::SEND_DECAY_SECONDS);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$request->session()->put(self::SESSION_KEY, [
'email' => $email,
'code_hash' => Hash::make($code),
'expires_at' => now()->addMinutes(self::EXPIRY_MINUTES)->timestamp,
'attempts' => 0,
]);
Mail::to($email)->send(new EmailChangeCodeMail($code));
return true;
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Rules\HCaptcha;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -43,8 +44,13 @@ public function create(string $locale, Request $request): View
public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse
{
// Captcha only here: verify() and resend() need the email this step
// puts in the session, so they can't be reached without passing it.
$validated = $request->validate([
'email' => ['required', 'email', 'max:255'],
'h-captcha-response' => ['bail', 'required', new HCaptcha],
], [
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
]);
$email = Str::lower(trim($validated['email']));
@@ -1,233 +0,0 @@
<?php
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Closure;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Lunar\Exceptions\Carts\CartException;
use Lunar\Models\CartLine;
use Lunar\Models\ProductVariant;
use Modules\Core\Cart\Exceptions\InvalidCouponException;
use Modules\Core\Cart\Services\CartService;
/**
* Thin storefront cart endpoints for the checkout module. Every action mutates
* the session cart via boboko-core's CartService and returns the same
* server-rendered `cart-body` partial — the drawer's Stimulus controller swaps
* that fragment in place (no JSON, no client-side templating). $cart / $lines
* for the partial come from the view composer in CheckoutModuleServiceProvider.
*/
class CartController extends Controller
{
public function __construct(
private readonly CartService $cart,
) {}
/**
* CartException here is Lunar's own add_to_cart validation pipeline
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
* "not enough stock at this quantity" for a tracked (purchasable =
* in_stock) variant. Its own message is an untranslated, hardcoded
* English string not meant for storefront display, so this returns our
* own translated one instead rather than passing it through — a
* storefront.* key rather than checkout.*, since this is a catalog/stock
* concern the storefront owns, not something specific to the portable
* checkout module.
*/
public function add(string $locale, Request $request): View|JsonResponse
{
$data = $request->validate([
'purchasable_id' => ['required', 'integer'],
'quantity' => ['nullable', 'integer', 'min:1'],
'custom_fields' => ['nullable', 'array'],
]);
$variant = ProductVariant::findOrFail($data['purchasable_id']);
try {
$meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []);
} catch (ValidationException $e) {
return response()->json(['error' => collect($e->errors())->flatten()->first()], 422);
}
try {
$this->cart->addLine($variant, $data['quantity'] ?? 1, $meta);
} catch (CartException) {
return $this->stockError($variant);
}
return view('checkout::partials.cart-body');
}
/**
* The shopper's answers to the product's custom fields (boboko-core's
* Product::$custom_fields — {key, type: text|textarea|file, label,
* required}), as cart line meta. Lunar copies CartLine.meta onto the
* OrderLine at order creation, so this is also what the order keeps.
*
* Only keys the product actually defines are kept, nested under
* `custom_fields` — line meta also carries behavior flags (core's
* `saved_for_later` zeroes the line's price), so shopper input must never
* be merged into it directly. Label and type are snapshotted alongside
* each value so the cart/order still reads correctly if the product's
* fields are edited later.
*
* A `file` answer is the opaque reference returned by the host's upload
* endpoint: an encrypted JSON {disk, path, name, mime}. The module doesn't
* decide which files are acceptable (that's the host's upload endpoint) —
* it only checks the reference is genuine and the file still exists.
*
* Two adds with identical answers merge into one line (Lunar matches
* existing lines on meta); different answers stay separate lines.
*/
private function customFieldsMeta(ProductVariant $variant, array $input): array
{
$fields = collect($variant->product?->custom_fields ?? [])->keyBy('key');
if ($fields->isEmpty()) {
return [];
}
$validated = Validator::make(
$input,
$fields->map(fn (array $field) => [
($field['required'] ?? false) ? 'required' : 'nullable',
'string',
match ($field['type']) {
'textarea' => 'max:2000',
'file' => function (string $attribute, mixed $value, Closure $fail) {
if ($this->decodeUpload($value) === null) {
$fail('validation.uploaded')->translate();
}
},
default => 'max:255',
},
])->all(),
[],
$fields->map(fn (array $field) => $field['label'])->all(),
)->validate();
$answers = $fields
->filter(fn (array $field) => filled($validated[$field['key']] ?? null))
->map(fn (array $field) => [
'key' => $field['key'],
'label' => $field['label'],
'type' => $field['type'],
...($field['type'] === 'file'
? $this->fileAnswer($this->decodeUpload($validated[$field['key']]))
: ['value' => $validated[$field['key']]]),
])
->values()
->all();
return $answers === [] ? [] : ['custom_fields' => $answers];
}
/**
* @return array{disk: string, path: string, name: string, mime: ?string}|null
*/
private function decodeUpload(string $reference): ?array
{
try {
$upload = json_decode(Crypt::decryptString($reference), true);
} catch (DecryptException) {
return null;
}
if (! is_array($upload) || ! isset($upload['disk'], $upload['path'], $upload['name'])) {
return null;
}
return Storage::disk($upload['disk'])->exists($upload['path']) ? $upload : null;
}
private function fileAnswer(array $upload): array
{
return [
'value' => $upload['name'],
'disk' => $upload['disk'],
'path' => $upload['path'],
'mime' => $upload['mime'] ?? null,
];
}
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
{
$quantity = (int) $request->validate([
'quantity' => ['required', 'integer', 'min:0'],
])['quantity'];
try {
$quantity === 0
? $this->cart->removeLine($line)
: $this->cart->updateLine($line, $quantity);
} catch (CartException) {
$variant = CartLine::find($line)?->purchasable;
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
}
return view('checkout::partials.cart-body');
}
/**
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
* checked against (stock, for a tracked in_stock variant) — telling the
* shopper how many are actually left beats a generic "not enough stock"
* they'd otherwise have to guess around by trial and error.
*/
private function stockError(?ProductVariant $variant): JsonResponse
{
$available = $variant?->getTotalInventory() ?? 0;
return response()->json([
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
], 422);
}
public function remove(string $locale, int $line): View
{
$this->cart->removeLine($line);
return view('checkout::partials.cart-body');
}
/**
* A bad code is a normal, expected outcome here (typo, expired code), not
* an error state for the request — it re-renders the same cart-body
* partial with $couponError set, rather than a 4xx/redirect, so the fetch
* + swap in bbk-cart-controller stays the one code path for every cart
* mutation.
*/
public function applyCoupon(string $locale, Request $request): View
{
$code = $request->validate([
'code' => ['required', 'string'],
])['code'];
$couponError = false;
try {
$this->cart->applyCoupon($code);
} catch (InvalidCouponException) {
$couponError = true;
}
return view('checkout::partials.cart-body', ['couponError' => $couponError]);
}
public function removeCoupon(string $locale): View
{
$this->cart->removeCoupon();
return view('checkout::partials.cart-body');
}
}
@@ -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();
}
}
@@ -1,28 +0,0 @@
<?php
namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Serves a file answer to a product custom field (a cart/order line's
* meta.custom_fields, see CartController::customFieldsMeta()) from its private
* disk. Only reachable through a temporary signed URL — generated per render
* by checkout::partials.line-custom-fields — so disk and path in the query
* can't be tampered with, and a link stops working once it expires.
*/
class CustomFieldFileController extends Controller
{
public function __invoke(string $locale, Request $request): StreamedResponse
{
$disk = Storage::disk((string) $request->query('disk'));
$path = (string) $request->query('path');
abort_unless($disk->exists($path), 404);
return $disk->response($path, null, ['Cache-Control' => 'private, max-age=3600']);
}
}
+15 -4
View File
@@ -5,16 +5,18 @@
use App\Http\Requests\ContactRequest;
use App\Mail\ContactConfirmationMail;
use App\Mail\ContactMessageMail;
use App\Models\StoicPage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\View\View;
use Modules\Core\Store\Services\StoreDetailsService;
use Throwable;
/**
* Contact form. Nothing is stored: the message is emailed to the store
* (CONTACT_EMAIL) and the sender gets a generic confirmation. Both are sent
* (Store Details' contact email) and the sender gets a generic confirmation. Both are sent
* synchronously so a failed store email can be reported back on the form.
*
* Limited per IP in here rather than with throttle middleware, so the limit
@@ -27,7 +29,16 @@ class ContactController extends Controller
public function index(string $locale): View
{
return view('contact');
$page = StoicPage::localized('contact');
if (! $page) {
abort(404);
}
return view('contact', [
'page' => $page,
'store' => app(StoreDetailsService::class)->current(),
]);
}
public function send(string $locale, ContactRequest $request): RedirectResponse
@@ -41,11 +52,11 @@ public function send(string $locale, ContactRequest $request): RedirectResponse
RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
$data = $request->validated();
$to = config('services.contact.email');
$to = app(StoreDetailsService::class)->current()->contact_email;
try {
if (blank($to)) {
throw new \RuntimeException('CONTACT_EMAIL is not set.');
throw new \RuntimeException('Store Details has no contact email.');
}
Mail::to($to)->send(new ContactMessageMail(
@@ -2,33 +2,32 @@
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Validator;
use Modules\Core\File\Http\Controllers\UploadFileController;
/**
* Stores the shopper's photo for a product custom field of type `file` (see
* boboko-core's Product::$custom_fields) the moment it's picked on the product
* page — before add-to-cart, see custom-field-upload-controller.js.
*
* Which files are acceptable is a per-site decision (this site: photographs),
* so it lives here in the storefront, not in the checkout module. The module's
* add-to-cart endpoint only ever receives the opaque `reference` returned
* below — an encrypted {disk, path, name, mime} payload, so a shopper can
* neither forge a reference to some other private file nor read the path —
* and copies it onto the cart line's meta (see Checkout\CartController::
* customFieldsMeta()).
* Which files are acceptable (extensions, size) is a per-site decision — this
* site: photographs — so it lives here as this app's own policy, extending
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
* endpoint only ever receives the stored File row's own `id` — FileService is
* the single source of truth for disk/path/name/mime, never duplicated into
* cart/order line meta (see Checkout\CartController::customFieldsMeta()). A
* shopper can't point a cart line at someone else's file: CartController only
* accepts an id that is both unowned and tagged with this exact PURPOSE.
*
* Stored on the private `local` disk: these are customers' personal photos,
* never reachable by a public URL. Uploads nobody added to a cart are removed
* by the custom-fields:prune-uploads command (see PruneCustomFieldUploads).
* never reachable by a public URL except through FileService's own signed
* download route. Uploads nobody adds to a cart are removed by core's
* `boboko:file:prune-unowned custom-field-upload` command.
*/
class CustomFieldUploadController extends Controller
class CustomFieldUploadController extends UploadFileController
{
public const DISK = 'local';
public const DIRECTORY = 'custom-field-uploads';
public const PURPOSE = 'custom-field-upload';
public const MAX_KILOBYTES = 10240;
@@ -42,33 +41,22 @@ public static function accept(): string
return '.'.implode(',.', self::EXTENSIONS);
}
public function store(string $locale, Request $request): JsonResponse
protected function purpose(): string
{
// `label` is the admin-authored field label, only used as the
// :attribute in the validation message shown next to that field.
$validator = Validator::make(
$request->all(),
['file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES]],
[],
['file' => (string) $request->input('label', 'file')],
);
return self::PURPOSE;
}
if ($validator->fails()) {
return response()->json(['error' => $validator->errors()->first('file')], 422);
}
protected function validationRules(Request $request): array
{
return [
'file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES],
];
}
$file = $request->file('file');
$path = $file->store(self::DIRECTORY, self::DISK);
abort_if($path === false, 500);
return response()->json([
'reference' => Crypt::encryptString(json_encode([
'disk' => self::DISK,
'path' => $path,
'name' => $file->getClientOriginalName(),
'mime' => $file->getMimeType(),
])),
]);
// `label` is the admin-authored field label, only used as the
// :attribute in the validation message shown next to that field.
protected function validationAttributes(Request $request): array
{
return ['file' => (string) $request->input('label', 'file')];
}
}
+51 -6
View File
@@ -4,27 +4,72 @@
use App\Catalog\ProductCard;
use App\Models\StoicPage;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Services\CollectionService;
use Modules\Core\Catalog\Services\ProductService;
class HomeController extends Controller
{
public function __construct(private readonly ProductService $products) {}
/** Slug of the hero collection in the "Homepage" collection group. */
private const HERO_COLLECTION_SLUG = 'hero';
private const HERO_FALLBACK_COUNT = 5;
private const CLASSICS_COUNT = 8;
public function __construct(
private readonly ProductService $products,
private readonly CollectionService $collections,
) {}
public function index(string $locale)
{
$page = StoicPage::firstWhere('slug', 'home');
$page = StoicPage::localized('home');
if (! $page) {
abort(404);
}
$products = collect($this->products->random(13))
->map(fn (array $product) => ProductCard::fromIndexed($product));
// Random pool for the classics carousel, and for the hero too when the
// hero collection is missing or empty. Oversized so hero products can be
// excluded from it and still leave enough.
$random = collect($this->products->random(self::HERO_FALLBACK_COUNT + self::CLASSICS_COUNT));
$hero = collect($this->heroCollectionProducts());
if ($hero->isEmpty()) {
$hero = $random->take(self::HERO_FALLBACK_COUNT);
}
$heroIds = $hero->pluck('id')->all();
$classics = $random
->reject(fn (array $product) => in_array($product['id'], $heroIds))
->take(self::CLASSICS_COUNT);
return view('home', [
'page' => $page,
'heroProducts' => $products->take(5)->values(),
'classicsProducts' => $products->slice(5)->values(),
'heroProducts' => $hero->map(fn (array $product) => ProductCard::fromIndexed($product))->values(),
'classicsProducts' => $classics->map(fn (array $product) => ProductCard::fromIndexed($product))->values(),
]);
}
/**
* Products the store owner picked for the hero, via the Lunar collection
* with slug "hero". Not in the owner's drag order yet: the product index
* doesn't carry per-collection position (pending a boboko-core task).
*/
private function heroCollectionProducts(): array
{
$collection = $this->collections->getBySlug(self::HERO_COLLECTION_SLUG);
if ($collection === null) {
return [];
}
return $this->products
->list(new ProductFilters(collectionId: $collection['id']))
->products
->items();
}
}
+39 -2
View File
@@ -3,9 +3,28 @@
namespace App\Http\Controllers;
use App\Models\StoicPage;
use Modules\Core\Store\Services\StoreDetailsService;
class LegalPageController extends Controller
{
/**
* {placeholder} => StoreDetails column, usable in the Stoic legal page
* markdown.
*/
private const PLACEHOLDERS = [
'{store_name}' => 'name',
'{store_address}' => 'address',
'{store_phone}' => 'phone',
'{store_contact_email}' => 'contact_email',
'{store_legal_name}' => 'legal_name',
'{store_tax_identifier}' => 'tax_identifier',
'{store_registration_number}' => 'registration_number',
];
public function __construct(
private readonly StoreDetailsService $storeDetails,
) {}
public function terms(string $locale)
{
return $this->show('terms-and-conditions');
@@ -28,12 +47,30 @@ public function cookies(string $locale)
private function show(string $slug)
{
$page = StoicPage::firstWhere('slug', $slug);
$page = StoicPage::localized($slug);
if (! $page) {
abort(404);
}
return view('legal', ['page' => $page]);
return view('legal', [
'page' => $page,
'content' => $this->fillPlaceholders($page->content ?? ''),
]);
}
/**
* Swapped in before the markdown is rendered, so values are escaped: the
* rendered markdown is printed unescaped. An empty field renders as
* nothing rather than leaving the raw placeholder on the page.
*/
private function fillPlaceholders(string $content): string
{
$details = $this->storeDetails->current();
return strtr($content, array_map(
fn (string $column) => e((string) ($details->translate($column) ?? '')),
self::PLACEHOLDERS,
));
}
}
+4 -1
View File
@@ -45,7 +45,7 @@ public function show(string $locale, int $id)
$product = $this->mergeJustSubmittedReview($product);
$collection = $product['collections'][0] ?? null;
// dd($product);
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
return view('product.show', [
@@ -109,6 +109,9 @@ private function buildOptionPicker(int $productId): array
'id' => $variant->id,
'price' => $variant->prices->first()?->price?->decimal(),
'image' => $variant->images->first()?->getUrl(),
// Live from the DB (not the index's in_stock), with Lunar's own
// purchasability rule — drives the disabled add-to-cart button.
'inStock' => $variant->canBeFulfilledAtQuantity(1),
// handle => selected value id, for matching a combination of
// selections back to this variant — see selectVariant() in
// product-form-controller.js.
+7 -23
View File
@@ -3,19 +3,21 @@
namespace App\Http\Controllers;
use App\Catalog\ProductCard;
use App\Services\Wishlist;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Lunar\Models\Product;
use Modules\Core\Catalog\Services\ProductService;
use Modules\Core\Wishlist\Services\WishlistService;
/**
* Page rendering only — the toggle action itself lives in core
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
* `wishlist.toggle`), since it needs no app-specific presentation.
*/
class WishlistController extends Controller
{
public function __construct(
private readonly 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)]);
}
/**
* Adds or removes a product, for guests and logged-in shoppers alike. The
* heart button's Stimulus controller asks for JSON; without JS the form
* posts normally and comes back to the same page.
*/
public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse
{
abort_unless(Product::whereKey($productId)->exists(), 404);
$active = $this->wishlist->toggle($productId);
if ($request->expectsJson()) {
return response()->json(['active' => $active]);
}
return back();
}
/**
* Product cards for the current wishlist, newest first. Products no longer
* in the search index (deleted, unpublished) are simply skipped.
+9
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Rules\HCaptcha;
use Illuminate\Foundation\Http\FormRequest;
class ContactRequest extends FormRequest
@@ -17,6 +18,14 @@ public function rules(): array
'name' => ['required', 'string', 'max:100'],
'email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'max:5000'],
'h-captcha-response' => ['bail', 'required', new HCaptcha],
];
}
public function messages(): array
{
return [
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
];
}
}
-22
View File
@@ -1,22 +0,0 @@
<?php
namespace App\Listeners;
use App\Services\GuestOrderClaimer;
use Modules\Core\Auth\Events\UserAuthenticated;
/**
* Picked up by Laravel's listener discovery (app/Listeners), no manual
* registration. UserAuthenticated only fires after a valid login code.
*/
class ClaimGuestOrdersOnLogin
{
public function __construct(
private readonly GuestOrderClaimer $claimer,
) {}
public function handle(UserAuthenticated $event): void
{
$this->claimer->claim($event->user);
}
}
@@ -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 -1
View File
@@ -8,7 +8,7 @@
use Illuminate\Mail\Mailables\Envelope;
/**
* A contact-form message, sent to the store's CONTACT_EMAIL. Reply-To is the
* A contact-form message, sent to Store Details' contact email. Reply-To is the
* sender, so replying from the inbox goes straight to them.
*/
class ContactMessageMail extends Mailable
-27
View File
@@ -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');
}
}
-34
View File
@@ -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');
}
}
+17 -1
View File
@@ -2,16 +2,32 @@
namespace App\Models;
use App\Services\Stoic;
use Illuminate\Database\Eloquent\Model;
use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
use JacobJoergensen\LaravelPaper\Attributes\Driver;
use JacobJoergensen\LaravelPaper\Attributes\Timestamps;
use JacobJoergensen\LaravelPaper\Paper;
// Points at the default locale (el), which Stoic writes every field to. Other
// locales hold only the translatable fields — use localized() to get a page
// with those layered on top for the current locale.
#[Driver("markdown")]
#[ContentPath("resources/stoic/content/pages")]
#[ContentPath("resources/stoic/content/el/pages")]
#[Timestamps]
class StoicPage extends Model
{
use Paper;
/**
* The page in the current locale: the default-locale entry, with the
* current locale's non-empty fields (and body) swapped in. Untranslated
* fields fall back to the default locale.
*/
public static function localized(string $slug): ?static
{
$page = static::firstWhere('slug', $slug);
return $page?->forceFill(Stoic::localeOverrides("pages/{$slug}.md"));
}
}
+4
View File
@@ -35,6 +35,10 @@ class User extends Authenticatable implements LunarUserInterface
*/
protected $hidden = [
'remember_token',
'otp_code',
'otp_expires_at',
'otp_attempts',
'pending_email_code_hash',
];
/**
-14
View File
@@ -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'];
}
+47 -8
View File
@@ -4,41 +4,80 @@
use App\Models\Customer;
use App\Models\Staff;
use App\Services\StoicSettings;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance;
use Lunar\Facades\ModelManifest;
use Lunar\Facades\Telemetry;
use Lunar\Models\CollectionGroup;
use Modules\Core\Catalog\DTOs\CollectionFilters;
use Modules\Core\Catalog\Enums\CollectionSort;
use Modules\Core\Catalog\Services\CollectionService;
use Modules\Core\Store\Services\StoreDetailsService;
use Symfony\Component\Mime\Address;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// One instance per request: it caches the guest cookie's ids, so a
// toggle and a later has() in the same request agree.
$this->app->scoped(\App\Services\Wishlist::class);
$this->app->scoped(StoicSettings::class);
}
public function boot(): void
{
Telemetry::optOut();
// header.blade.php's category dropdown — root collections only, resolved
// per-request so the composer runs after `locale` middleware has already
// set App::getLocale(), which CollectionService's name resolution depends on.
// header.blade.php's category dropdown — root collections of the catalog
// group only, so merchandising groups (e.g. "Homepage") stay out of the
// nav. Resolved per-request so the composer runs after `locale` middleware
// has already set App::getLocale(), which CollectionService's name
// resolution depends on. The group is looked up by handle, not id, since
// ids can differ between environments.
View::composer('components.header', function (ViewInstance $view) {
$view->with('categories', app(CollectionService::class)->list(
filters: new CollectionFilters(rootOnly: true),
$groupId = CollectionGroup::where('handle', 'shopify')->value('id');
$view->with('categories', $groupId === null ? [] : app(CollectionService::class)->list(
filters: new CollectionFilters(groupId: $groupId, rootOnly: true),
perPage: 100,
sort: CollectionSort::Position,
)->items());
});
// Site-wide settings edited in Stoic (settings.md): default meta
// description, social links for the footer, emails and schema.org.
View::composer(
['layouts.app', 'components.footer', 'emails.layout', 'home'],
fn (ViewInstance $view) => $view->with('stoicSettings', app(StoicSettings::class)),
);
// INTERIM, belongs in boboko-core's Store module: the sender name on
// outgoing emails comes from Store Details' "Mail from name", with
// MAIL_FROM_NAME as the fallback when it's empty. Done per message
// rather than by setting config at boot, so a long-running queue
// worker picks up a changed name without a restart. Only the default
// from address is renamed; a mailable with its own from is left alone.
Event::listen(MessageSending::class, function (MessageSending $event) {
$name = app(StoreDetailsService::class)->current()->mail_from_name;
if (blank($name)) {
return;
}
$defaultAddress = config('mail.from.address');
$event->message->from(...array_map(
fn (Address $from) => $from->getAddress() === $defaultAddress
? new Address($from->getAddress(), $name)
: $from,
$event->message->getFrom(),
));
});
if ($this->app->environment('production')) {
URL::forceScheme('https');
}
@@ -1,53 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance;
use Modules\Core\Cart\Services\CartService;
/**
* The seam for the (currently in-repo) cart + checkout module.
*
* Everything the module needs to boot inside a host app lives here: its view /
* component namespaces, its routes, and the composer that feeds the
* always-present cart drawer. Strings (__('checkout.cart.*')) are NOT wired up
* here — same as storefront.* elsewhere in this app, they resolve through
* Lunar's DB-backed translation UI (spatie/laravel-translation-loader), added
* manually there rather than shipped as lang/ files. The module's own files
* (resources/views/checkout/**, resources/js/checkout/**, resources/css/checkout.css,
* routes/checkout.php, app/Http/Controllers/Checkout/**) contain nothing
* 3dealer-specific.
*
* When the module is extracted to boboko-core, this class is deleted and its
* body becomes the package's own ServiceProvider. The only other host wiring
* is the one registerCheckout() call in resources/js/app.js and the two lines
* in the layout (the checkout.css @vite entry and @include('checkout::drawer')).
*/
class CheckoutModuleServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadViewsFrom(resource_path('views/checkout'), 'checkout');
Blade::anonymousComponentNamespace('checkout::components', 'checkout');
Route::middleware('web')->group(base_path('routes/checkout.php'));
// The drawer is rendered on every page (from the layout) and its body
// partial is re-rendered on every cart mutation — both need the current
// cart without a controller in the loop.
View::composer(
['checkout::drawer', 'checkout::partials.cart-body'],
function (ViewInstance $view) {
$service = app(CartService::class);
$cart = $service->current();
$view->with('cart', $cart);
$view->with('lines', $cart ? $service->activeLines($cart) : collect());
},
);
}
}
+66
View File
@@ -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'));
}
}
}
-37
View File
@@ -1,37 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Contracts\Auth\Authenticatable;
use Lunar\Models\Order;
/**
* Attaches placed guest orders to an account when their billing email matches
* the account's email. Only ever called right after the shopper has proved
* they own that email (a login code, or the code confirming an email change),
* which is what makes matching on email safe.
*
* Orders already belonging to any customer or user are never touched.
*/
class GuestOrderClaimer
{
public function claim(Authenticatable $user): int
{
$customer = $user->latestCustomer();
if (! $customer || ! $user->email) {
return 0;
}
return Order::query()
->whereNotNull('placed_at')
->whereNull('customer_id')
->whereNull('user_id')
->whereHas('billingAddress', fn ($query) => $query
->whereRaw('lower(contact_email) = ?', [strtolower($user->email)]))
->update([
'customer_id' => $customer->id,
'user_id' => $user->id,
]);
}
}
+37
View File
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
use Spatie\YamlFrontMatter\YamlFrontMatter;
use Symfony\Component\Yaml\Yaml;
class Stoic
@@ -29,6 +30,42 @@ public static function presets(): array
return static::$presets;
}
// Stoic's default locale (first in i18n.locales), or null when i18n is off.
// Its content folder holds every field; other locales only translatable ones.
public static function defaultLocale(): ?string
{
static::loadConfig();
return static::$config["i18n"]["locales"][0]["code"] ?? null;
}
// The current locale's translated fields for a content file (path relative
// to a locale folder, e.g. "pages/home.md"), with the markdown body as
// `content`. Empty when the current locale is the default one (or i18n is
// off), the file doesn't exist, or a field is left blank — so layering this
// over the default-locale values falls back to them field by field.
public static function localeOverrides(string $file): array
{
$default = static::defaultLocale();
$locale = app()->getLocale();
if ($default === null || $locale === $default) {
return [];
}
$path = resource_path("stoic/content/{$locale}/{$file}");
if (!is_file($path)) {
return [];
}
$document = YamlFrontMatter::parse(file_get_contents($path));
return array_filter(
[...$document->matter(), "content" => trim($document->body())],
fn($value) => filled($value),
);
}
// Returns the public URL for a stoic image at a given preset.
public static function image(string $filename, string $preset): string
{
+96
View File
@@ -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() : [];
}
}
-126
View File
@@ -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);
}
}
+13
View File
@@ -23,6 +23,19 @@
// has set URL::defaults(['locale' => …]), so route() needs no locale arg.
$middleware->redirectGuestsTo(fn () => route('login'));
$middleware->redirectUsersTo(fn () => route('home'));
// In production, the host's reverse proxy talks to the nginx container
// (bound to 127.0.0.1 only), so without this every visitor has the
// proxy's IP: the per-IP `throttle` limits on login become one shared
// bucket for the whole site. '*' trusts only the direct hop.
$middleware->trustProxies(at: '*');
// Enforces core's session registry: a session revoked via
// UserSessionService is logged out on its next request. Core leaves
// registering this to the app.
$middleware->web(append: [
\Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
-2
View File
@@ -1,11 +1,9 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\CheckoutModuleServiceProvider;
use App\Providers\PanelServiceProvider;
return [
AppServiceProvider::class,
PanelServiceProvider::class,
CheckoutModuleServiceProvider::class,
];
+4 -4
View File
@@ -12,9 +12,6 @@
"artesaos/seotools": "^1.4",
"boboko/core": "0.*",
"jacobjoergensen/laravel-paper": "^1.12",
"open-telemetry/exporter-otlp": "^1.4",
"open-telemetry/opentelemetry-auto-laravel": "^1.7",
"open-telemetry/sdk": "^1.14",
"php-http/guzzle7-adapter": "^1.1",
"spatie/laravel-sitemap": "^8.1",
"spatie/laravel-webhook-server": "^3.10",
@@ -100,5 +97,8 @@
}
},
"minimum-stability": "stable",
"prefer-stable": true
"prefer-stable": true,
"require-dev": {
"laravel/envoy": "^2.12"
}
}
Generated
+322 -770
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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
View File
@@ -2,17 +2,43 @@
/*
* Per-site settings for the cart + checkout module (see
* App\Providers\CheckoutModuleServiceProvider). Becomes the package's
* publishable config when the module moves to boboko-core.
* Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
* artisan vendor:publish --tag=core-config.
*/
return [
/*
* Name of the storefront's login route. The checkout's login tab and the
* confirmation page link to it with `?redirect=<checkout path>`, so the
* login page must send the shopper back there afterwards (3dealer's
* Auth\LoginController does). null: no login offered in checkout at all.
* login page must send the shopper back there afterwards. null: no login
* offered in checkout at all.
*/
'login_route' => 'login',
/*
* Name of the storefront's product-listing route — where confirmation()
* redirects a visit with no placed order to look at (session expired,
* direct navigation, a bookmark). route($this, $locale) must resolve.
*/
'products_route' => 'products',
/*
* ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden
* field, forced server-side — no country picker shown at all). null (the
* default) gives the full country/region picker, for a multi-country
* store. 3dealer is Greece-only for now.
*/
'store_country_iso3' => 'GRC',
/*
* The `purpose` tag CartController expects a product custom field's
* `file` answer to already carry (see Modules\Core\File\Models\File) —
* matches whatever purpose string the host's own upload endpoint
* (extending Modules\Core\File\Http\Controllers\UploadFileController)
* tags its stored files with. This module never reaches into that
* host controller directly; this config value is the one shared
* source of truth between the two.
*/
'custom_field_upload_purpose' => 'custom-field-upload',
];
+122
View File
@@ -16,4 +16,126 @@
'auto_create_customer_for_user' => true,
/*
|--------------------------------------------------------------------------
| Privacy / GDPR data-subject requests
|--------------------------------------------------------------------------
|
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
| that should be consulted for right-of-access/right-of-erasure requests. A
| module never needs to be known to core in advance — it just adds its own
| provider class here, the same way config('lunar.search.indexers') maps a
| model to its indexer. See docs/privacy.md.
|
| 'grace_period_days' is how long an erasure request stays cancellable
| (account deactivated, not yet erased) before it's actually processed by
| the privacy:process-erasure-requests scheduled command.
|
*/
'privacy' => [
'providers' => [
// ActivityLogDataProvider MUST run before AddressDataProvider —
// it resolves which activity_log rows belong to this customer
// (including ones keyed by an Address id) before
// AddressDataProvider hard-deletes those Address rows. See that
// provider's own class docblock.
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
\Modules\Core\Order\Privacy\OrderDataProvider::class,
\Modules\Core\Cart\Privacy\CartDataProvider::class,
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
],
'grace_period_days' => 30,
],
/*
|--------------------------------------------------------------------------
| Cart Abandonment Threshold
|--------------------------------------------------------------------------
|
| How long a cart (that hasn't converted to a placed order) can go without
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
*/
'cart' => [
'abandoned_after' => '1 hour',
/*
|----------------------------------------------------------------------
| Unrecoverable Cap
|----------------------------------------------------------------------
|
| Beyond this age, a stale cart stops being treated as an active
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
| CartLifecycleService) — too old to be a realistic recovery target
| (pricing/stock/tax likely stale by then). This is about the
| abandoned-cart pipeline only, not data retention — no rows are
| deleted or pruned based on this value.
|
*/
'unrecoverable_after' => '90 days',
],
/*
|--------------------------------------------------------------------------
| Order Return Window
|--------------------------------------------------------------------------
|
| How many days after a carrier order is delivered (Order::fulfillment_status
| becomes 'return_window_open') before Modules\Core\Order\Commands\
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
| Store-pickup orders have no return-window step and are unaffected by
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
*/
'order' => [
'return_window_days' => 14,
],
/*
|--------------------------------------------------------------------------
| Storefront OTP Login
|--------------------------------------------------------------------------
|
| Modules\Core\Auth\Services\UserOtpService's passwordless login.
| max_attempts caps how many wrong codes a shopper can guess against ONE
| generated code before it's invalidated outright. generation_limit/
| generation_decay_minutes cap how often a NEW code can be requested for
| the same email — independent of max_attempts, since generating a fresh
| code also resets the guess count, so an attempt cap alone doesn't stop
| an attacker from just requesting a new code every few tries. This same
| limit is also what stands between a malicious/careless caller and
| mail-bombing one inbox.
|
*/
'auth' => [
'otp' => [
'max_attempts' => 5,
'generation_limit' => 3,
'generation_decay_minutes' => 10,
],
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
// shape/reasoning as auth.otp above, independent limits since this
// is a separate flow (changing an existing account's login email,
// not logging in).
'email_change' => [
'max_attempts' => 5,
'generation_limit' => 3,
'generation_decay_minutes' => 10,
'expiry_minutes' => 10,
],
],
];
+1 -1
View File
@@ -74,7 +74,7 @@
// ones that already worked, like collection_ids), not just the
// new order_count one. Caught in practice, reverted.
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class
],
];
+7 -3
View File
@@ -35,9 +35,13 @@
],
],
// Where contact-form messages are sent (ContactController).
'contact' => [
'email' => env('CONTACT_EMAIL'),
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
// local dev use hCaptcha's test keys, the real ones reject localhost:
// sitekey 10000000-ffff-ffff-ffff-000000000001,
// secret 0x0000000000000000000000000000000000000000.
'hcaptcha' => [
'sitekey' => env('HCAPTCHA_SITEKEY'),
'secret' => env('HCAPTCHA_SECRET'),
],
'stoic' => [
+50
View File
@@ -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),
];
+61
View File
@@ -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'],
];
}
}
+4
View File
@@ -10,3 +10,7 @@ services:
scheduler:
volumes:
- ../boboko-core:/var/www/boboko-core
vite:
volumes:
- ../boboko-core:/boboko-core
+21 -8
View File
@@ -47,6 +47,7 @@ services:
queue:
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
entrypoint: ["/entrypoint-worker.sh"]
command: php artisan queue:work --tries=3 --max-jobs=500 --memory=256
restart: unless-stopped
env_file: .env
@@ -68,6 +69,7 @@ services:
scheduler:
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
entrypoint: ["/entrypoint-worker.sh"]
command: php artisan schedule:work
restart: unless-stopped
env_file: .env
@@ -129,14 +131,6 @@ services:
timeout: 5s
retries: 10
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
restart: unless-stopped
environment:
SIGNOZ_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
volumes:
- ./docker/otel/collector.yml:/etc/otelcol-contrib/config.yaml:ro
stoic:
image: ghcr.io/lexx27/stoic:latest
ports:
@@ -153,6 +147,24 @@ services:
- storage:/var/www/html/storage/app
restart: unless-stopped
vector:
image: timberio/vector:latest-alpine
restart: unless-stopped
environment:
APP_ENV: ${APP_ENV:-production}
VICTORIALOGS_ENDPOINT: ${VICTORIALOGS_ENDPOINT:?VICTORIALOGS_ENDPOINT is required}
VICTORIALOGS_USER: ${VICTORIALOGS_USER:?VICTORIALOGS_USER is required}
VICTORIALOGS_PASSWORD: ${VICTORIALOGS_PASSWORD:?VICTORIALOGS_PASSWORD is required}
VECTOR_DANGEROUSLY_ALLOW_ENV_VAR_INTERPOLATION: "true"
volumes:
- ./docker/vector/vector.yaml:/etc/vector/vector.yaml:ro
- logs:/var/www/html/storage/logs:ro
- vector_data:/var/lib/vector
depends_on:
app:
condition: service_healthy
volumes:
pgdata:
storage:
@@ -160,3 +172,4 @@ volumes:
framework:
valkeydata:
meilidata:
vector_data:
+26
View File
@@ -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 "$@"
+23 -12
View File
@@ -41,19 +41,17 @@ echo "[entrypoint] Caches cleared"
php artisan storage:link --quiet 2>/dev/null || true
if [ "$APP_ENV" != "production" ]; then
# Dev-only for the same reason as the composer step above — production's
# image already has these published at build time.
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
php artisan vendor:publish --tag=public --force --ansi --quiet
php artisan filament:assets --ansi --quiet
# Dev-only for the same reason as the composer step above — production's
# image already has these published at build time.
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
php artisan vendor:publish --tag=public --force --ansi --quiet
php artisan filament:assets --ansi --quiet
# No --force: core-views publishes editable Blade templates (order
# notification emails), meant to be hand-customized per app — unlike
# core-assets above, republishing must not silently wipe local edits.
# Only fills in the vendor/core views directory if it doesn't exist yet.
php artisan vendor:publish --tag=core-views --ansi --quiet
fi
# No --force: core-views publishes editable Blade templates (order
# notification emails), meant to be hand-customized per app — unlike
# core-assets above, republishing must not silently wipe local edits.
# Only fills in the vendor/core views directory if it doesn't exist yet.
php artisan vendor:publish --tag=core-views --ansi --quiet
# Everything below is universal, in both dev and production: application STATE
# that must be current on every boot, not a build-time concern composer/assets
@@ -79,6 +77,19 @@ echo "[entrypoint] Touched migrated file"
echo "[entrypoint] Trying Lunar install"
php artisan lunar:install --quiet || true
# Same idempotent per-key upsert as lunar:install's own seeding above, just kept
# as separate seeder classes rather than folded into InstallLunarCommand — these
# are Spatie translation-loader `validation`/`checkout` groups, not Lunar store
# data, and each is safe to re-run on every boot.
echo "[entrypoint] Seeding validation translations..."
php artisan db:seed --class="Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder" --force --quiet || true
echo "[entrypoint] Seeding checkout translations..."
php artisan db:seed --class="Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder" --force --quiet || true
echo "[entrypoint] Seeding storefront translations..."
php artisan db:seed --class="Modules\Core\Localization\Database\Seeders\StorefrontTranslationsSeeder" --force --quiet || true
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
# every boot rather than flushing and rebuilding the whole index each time. Runs
# in production too — a deploy that changed an indexer's field list needs this
+3 -16
View File
@@ -1,5 +1,3 @@
fastcgi_cache_path /tmp/fcgi_cache levels=1:2 keys_zone=app_cache:10m max_size=256m inactive=10m use_temp_path=off;
upstream php_fpm {
server app:9000;
keepalive 16;
@@ -19,12 +17,9 @@ server {
open_file_cache_min_uses 2;
open_file_cache_errors on;
# --- FastCGI cache bypass rules ---
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($request_uri ~* "^/(boboko|up)") { set $skip_cache 1; }
if ($query_string) { set $skip_cache 1; }
# No FastCGI page cache: every Laravel response is per-visitor (session
# cookie, CSRF token, cart, login state), so a shared cache keyed on the
# URL replays one visitor's page and session cookie to everyone else.
# Vite build assets — content-hashed filenames, safe to cache forever
location /build/ {
@@ -73,14 +68,6 @@ server {
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
fastcgi_keep_conn on;
fastcgi_cache app_cache;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_valid 200 10m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
add_header X-Cache-Status $upstream_cache_status;
}
location /stoic/ {
-30
View File
@@ -1,30 +0,0 @@
receivers:
otlp:
protocols:
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 512
exporters:
otlphttp:
endpoint: ${env:SIGNOZ_ENDPOINT}
compression: gzip
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlphttp]
+8
View File
@@ -9,6 +9,14 @@ content_path: /content
media_path: /media
thumbs_path: /var/www/html/storage/app/public/stoic/
repository_path: /app
# First locale is Stoic's default: it holds every field, the others only the
# translatable ones (content/{locale}/...). Codes match app.available_locales.
i18n:
locales:
- code: el
label: Ελληνικά
- code: en
label: English
presets:
- code: large
mode: resize
+41
View File
@@ -0,0 +1,41 @@
sources:
laravel_logs:
type: file
include:
- /var/www/html/storage/logs/laravel.log
read_from: beginning
transforms:
laravel_json:
type: remap
inputs:
- laravel_logs
source: |
. = parse_json!(.message)
.service = "boboko-app"
.env = "${APP_ENV}"
sinks:
victorialogs:
type: http
inputs:
- laravel_json
uri: "${VICTORIALOGS_ENDPOINT}/insert/jsonline?_stream_fields=service,env,channel,level_name&_msg_field=message&_time_field=datetime"
encoding:
codec: json
framing:
method: newline_delimited
compression: gzip
auth:
strategy: basic
user: "${VICTORIALOGS_USER}"
password: "${VICTORIALOGS_PASSWORD}"
batch:
max_bytes: 1048576
timeout_secs: 5
buffer:
type: disk
max_size: 268435488
when_full: block
healthcheck:
enabled: false
+18
View File
@@ -0,0 +1,18 @@
<?php
/**
* lunarphp/table-rate-shipping v1.5.0 ships no Greek translations for this
* namespace at all (only 'en' and a handful of other locales — no 'el'
* directory exists in the package), so this override is the only source
* for these keys in Greek, not a merge over an existing package file.
* ManageShippingRates.php:58 calls the 'prices_inc_tax' key specifically
* (missing the 'l' the package's own English file uses — see the 'en'
* override's docblock), so that's the one included here.
*/
return [
'shipping_rates' => [
'notices' => [
'prices_inc_tax' => 'Όλες οι τιμές περιλαμβάνουν ΦΠΑ, το οποίο θα ληφθεί υπόψη στον υπολογισμό της ελάχιστης αξίας παραγγελίας.',
],
],
];
+19
View File
@@ -0,0 +1,19 @@
<?php
/**
* Override for lunarphp/table-rate-shipping v1.5.0's own relationmanagers.php
* (vendor/lunarphp/table-rate-shipping/resources/lang/en/relationmanagers.php)
* — that file defines 'prices_incl_tax', but ManageShippingRates.php:58 calls
* __('lunarpanel.shipping::relationmanagers.shipping_rates.notices.prices_inc_tax')
* (missing the 'l'), so the key is genuinely never found. Merged over the
* package's own file (array_replace_recursive, see Illuminate\Translation\
* FileLoader::loadNamespaceOverrides()), so only the broken key needs
* restating here — everything else in that file still applies.
*/
return [
'shipping_rates' => [
'notices' => [
'prices_inc_tax' => 'All prices include tax, which will be considered when calculating minimum spend.',
],
],
];
+22 -62
View File
@@ -1,15 +1,15 @@
{
"name": "boboko-starter",
"name": "app",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo": "^8.0.23",
"@phosphor-icons/web": "^2.1.2",
"axios": "^1.15.2",
"flatpickr": "^4.6.13"
"leaflet": "^1.9.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
@@ -19,11 +19,21 @@
"vite": "^8.0.0"
}
},
"node_modules/@boboko/core": {
"version": "0.27.2",
"resolved": "git+https://code.radical-elements.com/boboko/core.git#332bf92e4429cf5b787e106b9e8613d912dfcc4a",
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"leaflet": "^1.9.4"
},
"peerDependencies": {
"vite": "^8.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -35,7 +45,6 @@
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -46,7 +55,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -122,7 +130,6 @@
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -141,18 +148,11 @@
"version": "0.138.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"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": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
@@ -160,7 +160,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -177,7 +176,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -194,7 +192,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -211,7 +208,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -228,7 +224,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -245,7 +240,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -262,7 +256,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -279,7 +272,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -296,7 +288,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -313,7 +304,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -330,7 +320,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -347,7 +336,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -364,7 +352,6 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -383,7 +370,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -400,7 +386,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -414,7 +399,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
@@ -693,7 +677,6 @@
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -901,7 +884,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1001,7 +983,6 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -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": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -1061,7 +1036,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -1223,7 +1197,7 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -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": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -1293,7 +1272,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1314,7 +1292,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1335,7 +1312,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1356,7 +1332,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1377,7 +1352,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1398,7 +1372,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1419,7 +1392,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1440,7 +1412,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1461,7 +1432,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1482,7 +1452,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1503,7 +1472,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1567,7 +1535,6 @@
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -1586,14 +1553,12 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -1606,7 +1571,6 @@
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -1654,7 +1618,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.138.0",
@@ -1711,7 +1674,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -1786,7 +1748,6 @@
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -1813,14 +1774,13 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"devOptional": true,
"license": "0BSD"
},
"node_modules/vite": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
+7 -1
View File
@@ -14,8 +14,14 @@
"vite": "^8.0.0"
},
"dependencies": {
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo": "^8.0.23",
"axios": "^1.15.2"
"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"
}
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-SP7Z6XGK.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-DR6K5BQD.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-A2J6H3EX.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-ZABHMKQG.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-VWEHJHBA.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-ZIT2UBIY.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-DIHYUR35.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
+1
View File
@@ -0,0 +1 @@
(()=>{var o=({livewireId:s})=>({actionNestingIndex:null,shouldOverlayParentActions:!1,closedActionNestingIndexes:[],focusTargetsByNestingIndex:{},boundSyncActionModals:null,boundOnModalClosed:null,init(){this.boundSyncActionModals=e=>{e.detail.id===s&&this.syncActionModals(e.detail.newActionNestingIndex,e.detail.shouldOverlayParentActions??!1)},this.boundOnModalClosed=e=>{let t=this.getActionNestingIndexFromModalId(e.detail.id);t!==null&&((this.shouldOverlayParentActions||t===0)&&this.restorePreviouslyFocusedElement(t-1),this.closedActionNestingIndexes.push(t))},window.addEventListener("sync-action-modals",this.boundSyncActionModals),window.addEventListener("modal-closed",this.boundOnModalClosed)},destroy(){this.boundSyncActionModals&&(window.removeEventListener("sync-action-modals",this.boundSyncActionModals),this.boundSyncActionModals=null),this.boundOnModalClosed&&(window.removeEventListener("modal-closed",this.boundOnModalClosed),this.boundOnModalClosed=null)},syncActionModals(e,t=!1){if(this.actionNestingIndex===e){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}let n=this.actionNestingIndex!==null&&e!==null&&e>this.actionNestingIndex,i=this.actionNestingIndex!==null&&e!==null&&e<this.actionNestingIndex,d=this.actionNestingIndex===null&&e!==null;if((n||d)&&this.rememberPreviouslyFocusedElement(),this.actionNestingIndex!==null&&!(t&&n)&&this.closeModal(),this.actionNestingIndex=e,this.actionNestingIndex===null){this.restorePreviouslyFocusedElement(-1),this.closedActionNestingIndexes=[],this.focusTargetsByNestingIndex={},this.shouldOverlayParentActions=!1;return}if(this.shouldOverlayParentActions=t,this.closedActionNestingIndexes=this.closedActionNestingIndexes.filter(l=>l<=this.actionNestingIndex),!this.closedActionNestingIndexes.includes(this.actionNestingIndex)){if(!this.$el.querySelector(`#${this.generateModalId(e)}`)){this.$nextTick(()=>{this.openModal(),i&&this.restorePreviouslyFocusedElement()});return}this.openModal(),i&&this.restorePreviouslyFocusedElement()}},rememberPreviouslyFocusedElement(){let e=this.$focus.focused();if(!e)return;if(this.actionNestingIndex===null){this.focusTargetsByNestingIndex[-1]=e;return}this.$el.querySelector(`#${this.generateModalId(this.actionNestingIndex)}`)?.contains(e)&&(this.focusTargetsByNestingIndex[this.actionNestingIndex]=e)},restorePreviouslyFocusedElement(e=this.actionNestingIndex){let t=this.focusTargetsByNestingIndex[e];if(t){for(let n in this.focusTargetsByNestingIndex)Number(n)>=e&&delete this.focusTargetsByNestingIndex[n];requestAnimationFrame(()=>requestAnimationFrame(()=>this.$nextTick(()=>{t.focus({preventScroll:!0})})))}},generateModalId(e){return`fi-${s}-action-`+e},getActionNestingIndexFromModalId(e){let t=`fi-${s}-action-`;if(!e?.startsWith(t))return null;let n=Number(e.slice(t.length));return Number.isInteger(n)?n:null},openModal(){let e=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:e}}))},closeModal(){let e=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:e}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",o)});})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
function t(){return{search:"",blockLabels:[],observer:null,init(){let e=()=>{this.blockLabels=Array.from(this.$root.querySelectorAll("[data-block-label]"),r=>r.dataset.blockLabel)};e(),this.observer=new MutationObserver(e),this.observer.observe(this.$root,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["data-block-label"]})},destroy(){this.observer?.disconnect()},clearSearch(){this.search="",this.$refs.searchInput&&(this.$refs.searchInput.value="")},handleEscape(e){!this.search&&!this.$refs.searchInput?.value||(e.preventDefault(),this.clearSearch())},isBlockVisible(e){return this.search?e.dataset.blockLabel.includes(this.search.toLowerCase()):!0},get hasNoSearchResults(){return this.search?!this.blockLabels.some(e=>e.includes(this.search.toLowerCase())):!1}}}export{t as default};
+1
View File
@@ -0,0 +1 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",unsubscribeLivewireHook:null,visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))},destroy(){this.unsubscribeLivewireHook?.()}}}export{c as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
function a({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{if(!Array.isArray(e))return;let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{a as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
function r({state:n,splitKeys:i,tagAddedMessage:a,tagRemovedMessage:s}){return{newTag:"",state:n,liveRegionClearTimeout:null,announce(e){let t=this.$refs.liveRegion;t&&(this.liveRegionClearTimeout!==null&&clearTimeout(this.liveRegionClearTimeout),t.textContent=e,this.liveRegionClearTimeout=setTimeout(()=>{t.textContent="",this.liveRegionClearTimeout=null},3e3))},createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.announce(a?.replace(":tag",()=>this.newTag)),this.newTag=""}},deleteTag(e){this.state=this.state.filter(t=>t!==e),this.announce(s?.replace(":tag",()=>e))},reorderTags(e){let t=this.state.splice(e.oldIndex,1)[0];this.state.splice(e.newIndex,0,t),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(e){["Enter",...i].includes(e.key)&&(e.preventDefault(),e.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(i.length===0){this.createTag();return}let e=i.map(t=>t.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(e,"g")).forEach(t=>{this.newTag=t,this.createTag()})})}}}}export{r as default};
+1
View File
@@ -0,0 +1 @@
function n({initialHeight:e,shouldAutosize:s,state:i}){return{state:i,resizeObserver:null,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),s?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let r=this.$el.style.height;this.$el.style.height="0px";let h=this.$el.scrollHeight;this.$el.style.height=r;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),t=Math.max(h,l)+"px";this.wrapperEl.style.height!==t&&(this.wrapperEl.style.height=t)},setUpResizeObserver(){this.resizeObserver=new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}),this.resizeObserver.observe(this.$el)},destroy(){this.resizeObserver?.disconnect()}}}export{n as default};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};
+1
View File
@@ -0,0 +1 @@
function x({activeTab:p,isScrollable:m,isTabPersisted:T,isTabPersistedInQueryString:w,livewireId:g,schemaKey:D,tab:W,tabQueryStringKey:r}){return{boundResizeHandler:null,boundResetHandler:null,isScrollable:m,resizeDebounceTimer:null,tab:W,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);w&&e.has(r)&&t.includes(e.get(r))&&(this.tab=e.get(r)),(!this.tab||!t.includes(this.tab))&&(this.tab=t[p-1]),this.$watch("tab",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.unsubscribeLivewireHook=Livewire.hook("commit",({component:i,commit:d,succeed:c,fail:h,respond:u})=>{c(({snapshot:b,effect:n})=>{this.$nextTick(()=>{if(i.id!==g)return;let o=this.getTabs();o.includes(this.tab)||(this.tab=o[p-1]??this.tab)})})}),this.boundResetHandler=i=>{i.detail.livewireId!==g||i.detail.schemaKey!==D||T||w||this.$nextTick(()=>{this.tab=this.getTabs()[p-1]??this.tab})},window.addEventListener("reset-schema-component-state",this.boundResetHandler),m||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,i,d,c,h){let u=t.map(n=>Math.ceil(n.clientWidth)),b=t.map(n=>{let o=n.querySelector(".fi-tabs-item-label"),s=n.querySelector(".fi-badge"),a=Math.ceil(o.clientWidth),l=s?Math.ceil(s.clientWidth):0;return{label:a,badge:l,total:a+(l>0?d+l:0)}});for(let n=0;n<t.length;n++){let o=u.slice(0,n+1).reduce((f,I)=>f+I,0),s=n*i,a=b.slice(n+1),l=a.length>0,v=l?Math.max(...a.map(f=>f.total)):0,y=l?c+v+d+h+i:0;if(o+s+y>e)return n}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!w)return;let t=new URL(window.location.href);t.searchParams.set(r,this.tab),history.replaceState(null,document.title,t.toString())},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$el.querySelectorAll(".fi-sc-tabs-tab.fi-active [autofocus]");for(let i of e)if(i.focus(),document.activeElement===i)break})},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),i=Array.from(t.children).slice(0,-1),d=i.map(s=>s.style.display);i.forEach(s=>s.style.display=""),t.offsetHeight;let c=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),b=this.calculateTabItemGap(i[0]),n=this.calculateTabItemPadding(i[0]),o=this.findOverflowIndex(i,c,h,b,n,u);i.forEach((s,a)=>s.style.display=d[a]),o!==-1&&(this.withinDropdownIndex=o),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResetHandler&&window.removeEventListener("reset-schema-component-state",this.boundResetHandler),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{x as default};
+1
View File
@@ -0,0 +1 @@
function l({isSkippable:i,isStepPersistedInQueryString:n,key:o,livewireId:h,schemaKey:p,startStep:r,stepQueryStringKey:d}){return{boundResetHandler:null,step:null,init(){this.step=this.getSteps().at(r-1),this.$watch("step",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.boundResetHandler=t=>{t.detail.livewireId!==h||t.detail.schemaKey!==p||n||this.$nextTick(()=>{this.step=this.getSteps().at(r-1)??this.step})},window.addEventListener("reset-schema-component-state",this.boundResetHandler)},async requestNextStep(){await this.$wire.callSchemaComponentMethod(o,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!i&&e>this.getStepIndex(this.step)||(this.step=t,this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$refs[`step-${this.step}`]?.querySelectorAll("[autofocus]")??[];for(let s of e)if(s.focus(),document.activeElement===s)break})},getStepIndex(t){let e=this.getSteps().findIndex(s=>s===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return i||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!n)return;let t=new URL(window.location.href);t.searchParams.set(d,this.step),history.replaceState(null,document.title,t.toString())},destroy(){this.boundResetHandler&&window.removeEventListener("reset-schema-component-state",this.boundResetHandler)}}}export{l as default};
+1
View File
@@ -0,0 +1 @@
(()=>{var c=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let i=this.$el.parentElement;i&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(i),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let i=this.$el.parentElement;if(!i)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=i.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var u=function(i,e,n){let t=i;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)t=t.includes(".")?t.slice(0,t.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(t)?e:["",null,void 0].includes(e)?t:`${t}.${e}`},h=i=>{let e=Alpine.findClosest(i,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire},l=!1;document.addEventListener("invalid",i=>{let e=i.target;if(!e.closest("[data-field-wrapper]")||e.offsetParent!==null&&getComputedStyle(e).visibility!=="hidden"||(i.preventDefault(),l))return;l=!0;let n=e;for(;n;)n.dispatchEvent(new CustomEvent("expand")),n=n.parentNode;requestAnimationFrame(()=>requestAnimationFrame(()=>{e.form?.reportValidity(),setTimeout(()=>l=!1,100)}))},!0);document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:i,schemaKey:e,isLoadingDeferred:n=!1})=>({intersectionObserver:null,isLoadingDeferredSchema:!1,init(){n&&(this.intersectionObserver=new IntersectionObserver(async t=>{if(!(!t[0]?.isIntersecting||this.isLoadingDeferredSchema)){this.isLoadingDeferredSchema=!0;try{await this.$wire.loadDeferredSchema(e),this.intersectionObserver?.disconnect()}catch{}finally{this.isLoadingDeferredSchema=!1}}}),this.intersectionObserver.observe(this.$el))},destroy(){this.intersectionObserver?.disconnect()},handleFormValidationError(t){t.detail.livewireId===i&&this.$nextTick(()=>{let r=this.$el.querySelector("[data-validation-error]");if(!r)return;let s=r;for(;s;)s.dispatchEvent(new CustomEvent("expand")),s=s.parentNode;setTimeout(()=>r.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},handleClientSideStateReset(t){t.detail.livewireId!==i||t.detail.schemaKey!==e||this.$nextTick(()=>{let r=this.$el.querySelectorAll("[autofocus]");for(let s of r)if(s.offsetParent!==null&&(s.focus(),document.activeElement===s))break})},isStateChanged(t,r){if(t===void 0)return!1;try{return JSON.stringify(t)!==JSON.stringify(r)}catch{return t!==r}}})),window.Alpine.data("filamentSchemaComponent",({path:i,containerPath:e,$wire:n})=>({$statePath:i,$get:(t,r)=>n.$get(u(e,t,r)),$set:(t,r,s,o=!1)=>n.$set(u(e,t,s),r,o),get $state(){return n.$get(i)}})),window.Alpine.data("filamentActionsSchemaComponent",c),Livewire.hook("commit",({component:i,commit:e,respond:n,succeed:t,fail:r})=>{t(({snapshot:s,effects:o})=>{o.dispatches?.forEach(a=>{if(!a.params?.awaitSchemaComponent)return;let d=Array.from(i.el.querySelectorAll(`[wire\\:partial="schema-component::${a.params.awaitSchemaComponent}"]`)).filter(f=>h(f)===i);if(d.length!==1){if(d.length>1)throw`Multiple schema components found with key [${a.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${i.id}-${a.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(a.name,{detail:a.params}))},{once:!0})}})})})});})();
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
function o({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:i,succeed:a,fail:u,respond:h})=>{a(({snapshot:d,effect:f})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let i=await this.$wire.updateTableColumnState(r,s,this.state);this.error=i?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};

Some files were not shown because too many files have changed in this diff Show More