56 Commits
Author SHA1 Message Date
arvanitakis 623851bd70 Adding elta-logo.png 2026-10-01 01:54:27 +03:00
arvanitakis 39eda9726d Chore: Bumping boboko/core to 0.31.0 2026-10-01 01:54:04 +03:00
arvanitakis f718ba57bc Chore: Bumping boboko/core to 0.30.0 2026-09-30 22:02:45 +03:00
arvanitakis 1de5668032 Chore: Bumping boboko/core to 0.29.3 2026-09-30 20:59:23 +03:00
arvanitakis 35bf421da8 Chore: Bumping boboko/core to 0.29.2 2026-09-30 19:51:59 +03:00
arvanitakis a7cdfbd1cb Feat: Order Controller updates 2026-09-30 19:50:03 +03:00
arvanitakis 92016256ce Feat: Adding core-mode and deploy scripts 2026-09-30 12:17:52 +03:00
arvanitakis b96fb2800f Chore: Bumping boboko/core to 0.28.0 2026-09-30 12:12:41 +03:00
arvanitakis d16ddd15eb Chore: Bumping boboko/core to 0.27.5 2026-09-30 11:39:09 +03:00
arvanitakis aff53371d9 Feat: Adding SESSION_SECURE_COOKIE for auth 2026-09-30 11:34:33 +03:00
arvanitakis 425510fb16 Fix: Updates on Middlewares, User hiding fields 2026-09-30 11:34:14 +03:00
arvanitakis e3f9b3deb1 Fix: Adding trust proxies, since file downloading cannot work, traffic in Laravel is plain HTTP, the file signed-route generation throws 401 2026-09-30 09:48:12 +03:00
elvira 72ff4e5c76 checkout css updates but still wip 2026-09-29 21:38:44 +03:00
elvira 5482ac07c6 removed repeated product text from view and added styling to trace it within product description 2026-09-29 19:23:40 +03:00
elvira 7e38f05650 product page: only show up down arrows when not all thumbnails are visible 2026-09-29 19:16:07 +03:00
arvanitakis df29429257 Fix: Adding new production nginx config 2026-09-29 17:34:08 +03:00
arvanitakis f6bcd31bd8 FIx: Reverting back root for stoic 2026-09-29 16:50:01 +03:00
arvanitakis 77c9117556 Fix: Removing root privileges from stoic, creating and giving 777 to stoic folder 2026-09-29 16:42:59 +03:00
arvanitakis 8fdb399386 Fix: Running stoic as root 2026-09-29 16:25:52 +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 c7bd1efdaa Feature: Adding Meilisearch to 3dealer 2026-08-05 23:12:22 +03:00
142 changed files with 3311 additions and 3388 deletions
+1
View File
@@ -5,6 +5,7 @@ vendor
public/build public/build
public/hot public/hot
storage/app/public/* storage/app/public/*
storage/app/private/*
storage/framework/cache/* storage/framework/cache/*
storage/framework/sessions/* storage/framework/sessions/*
storage/framework/views/* storage/framework/views/*
+21 -8
View File
@@ -31,7 +31,7 @@ DB_DATABASE=boboko
DB_USERNAME=root DB_USERNAME=root
DB_PASSWORD=dev DB_PASSWORD=dev
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
@@ -41,7 +41,7 @@ BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
CACHE_STORE=database CACHE_STORE=redis
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1 MEMCACHED_HOST=127.0.0.1
@@ -58,8 +58,8 @@ MAIL_PORT=1025
MAIL_USERNAME=null MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" 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}" MAIL_FROM_NAME="${APP_NAME}"
CONTACT_EMAIL=
HCAPTCHA_SITEKEY= HCAPTCHA_SITEKEY=
HCAPTCHA_SECRET= HCAPTCHA_SECRET=
@@ -72,11 +72,7 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
OTEL_SERVICE_NAME=boboko-starter SESSION_SECURE_COOKIE=true
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 is the headless CMS, proxied at /stoic/ behind the app in prod (see docker/nginx/prod.conf)
STOIC_SECRET=dev-secret-change-me STOIC_SECRET=dev-secret-change-me
@@ -88,6 +84,9 @@ SCOUT_QUEUE=true
MEILISEARCH_KEY=dev-meilisearch-key-change-me MEILISEARCH_KEY=dev-meilisearch-key-change-me
MEILISEARCH_HOST=http://meilisearch:7700 MEILISEARCH_HOST=http://meilisearch:7700
# Lunar sets all prices inclusive of tax with these values
LUNAR_STORE_INCLUSIVE_OF_TAX=true
# Host-side ports — change these if another project on this machine already uses the defaults # Host-side ports — change these if another project on this machine already uses the defaults
APP_PORT=8090 APP_PORT=8090
STOIC_PORT=2727 STOIC_PORT=2727
@@ -96,3 +95,17 @@ POSTGRES_HOST_PORT=5436
MAILPIT_PORT=8025 MAILPIT_PORT=8025
VITE_PORT=5173 VITE_PORT=5173
MEILISEARCH_PORT=7700 MEILISEARCH_PORT=7700
STRIPE_SECRET=secret
STRIPE_PUBLIC_KEY=public_key
STRIPE_WEBHOOK_SECRET=webhook_secret
BOXNOW_BASE_URL=https://api-stage.boxnow.gr/api/v1
BOXNOW_LOCATION_API_URL=https://locationapi-stage.boxnow.gr/api/v1
BOXNOW_CLIENT_ID=box-client-id
BOXNOW_CLIENT_SECRET=box-client-secret
BOXNOW_PARTNER_ID=box-partner-id
BOXNOW_ORIGIN_LOCATION_ID=box-location-id
BOXNOW_SENDER_NAME=test
BOXNOW_SENDER_EMAIL=test@test.com
BOXNOW_SENDER_PHONE=+306912345678
-4
View File
@@ -17,11 +17,7 @@
/public/build /public/build
/public/hot /public/hot
/public/sitemap.xml /public/sitemap.xml
/public/logos/core
/public/storage /public/storage
/public/css/filament
/public/js/filament
/public/fonts/filament
/storage/*.key /storage/*.key
/storage/framework/migrated /storage/framework/migrated
/storage/pail /storage/pail
+17 -14
View File
@@ -10,14 +10,25 @@ RUN composer install \
--optimize-autoloader \ --optimize-autoloader \
--ignore-platform-reqs --ignore-platform-reqs
# ── Stage 2a: Vite dev (node_modules only, no build) ───────────────────────── # ── Stage 2a: Vite dev (node_modules only, no build) ──────────────────────────
FROM node:22-alpine AS vite-dev FROM node:22-alpine AS vite-dev
# git: needed for npm to install @boboko/core's git+https tagged-VCS form (see
# package.json's _dependencies and docker/entrypoint-vite.sh). No build-time
# `npm install` here — package.json's boboko/core path-repo form (../boboko-core)
# isn't visible in the build context, only once bind-mounted at container start,
# and entrypoint-vite.sh already runs npm install on every boot, so this would be
# redundant even if it could work.
RUN apk add --no-cache git
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
RUN npm ci --ignore-scripts RUN chmod +x /entrypoint-vite.sh
ENTRYPOINT ["/entrypoint-vite.sh"]
# ── Stage 2b: Node / Vite build ────────────────────────────────────────────── # ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
FROM node:22-alpine AS node-build FROM node:22-alpine AS node-build
# 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 WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --ignore-scripts RUN npm ci --ignore-scripts
@@ -53,6 +64,7 @@ RUN apt-get update \
php8.5-xml \ php8.5-xml \
php8.5-curl \ php8.5-curl \
php8.5-redis \ php8.5-redis \
php8.5-soap \
postgresql-client-18 \ postgresql-client-18 \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& mkdir -p /run/php \ && mkdir -p /run/php \
@@ -83,18 +95,9 @@ COPY . .
RUN chown -R www-data:www-data storage bootstrap/cache \ RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 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 COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
EXPOSE 9000 EXPOSE 9000
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+2 -8
View File
@@ -19,7 +19,6 @@
pull pull
docker_build docker_build
docker_up docker_up
migrate
artisan_optimize artisan_optimize
restart_workers restart_workers
stoic_generate_thumbs stoic_generate_thumbs
@@ -45,12 +44,6 @@
{{ $compose }} up -d --remove-orphans {{ $compose }} up -d --remove-orphans
@endtask @endtask
@task('migrate', ['on' => 'web'])
echo ">>> Running migrations..."
cd {{ $path }}
{{ $compose }} exec -T app php artisan migrate --force < /dev/null
@endtask
@task('artisan_optimize', ['on' => 'web']) @task('artisan_optimize', ['on' => 'web'])
echo ">>> Optimizing..." echo ">>> Optimizing..."
cd {{ $path }} cd {{ $path }}
@@ -61,9 +54,10 @@
@endtask @endtask
@task('restart_workers', ['on' => 'web']) @task('restart_workers', ['on' => 'web'])
echo ">>> Restarting queue workers..." echo ">>> Restarting queue workers and scheduler..."
cd {{ $path }} cd {{ $path }}
{{ $compose }} restart queue {{ $compose }} restart queue
{{ $compose }} restart scheduler
@endtask @endtask
@task('password_protect', ['on' => 'web']) @task('password_protect', ['on' => 'web'])
@@ -44,7 +44,11 @@ public function show(string $locale, Request $request, int $orderId): View
return view('account.orders.show', [ return view('account.orders.show', [
'order' => $order, 'order' => $order,
'paymentMethodName' => $paymentMethodName, 'paymentMethodName' => $paymentMethodName,
'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'), // Active shipments with a number; return vouchers are the shop's
// business, not the customer's.
'shipments' => $order->shipments
->reject(fn ($shipment) => $shipment->isCancelled() || $shipment->isReturn() || blank($shipment->tracking_reference))
->values(),
]); ]);
} }
} }
+15 -4
View File
@@ -5,16 +5,18 @@
use App\Http\Requests\ContactRequest; use App\Http\Requests\ContactRequest;
use App\Mail\ContactConfirmationMail; use App\Mail\ContactConfirmationMail;
use App\Mail\ContactMessageMail; use App\Mail\ContactMessageMail;
use App\Models\StoicPage;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\View\View; use Illuminate\View\View;
use Modules\Core\Store\Services\StoreDetailsService;
use Throwable; use Throwable;
/** /**
* Contact form. Nothing is stored: the message is emailed to the store * 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. * 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 * 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 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 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); RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
$data = $request->validated(); $data = $request->validated();
$to = config('services.contact.email'); $to = app(StoreDetailsService::class)->current()->contact_email;
try { try {
if (blank($to)) { 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( Mail::to($to)->send(new ContactMessageMail(
+51 -6
View File
@@ -4,27 +4,72 @@
use App\Catalog\ProductCard; use App\Catalog\ProductCard;
use App\Models\StoicPage; use App\Models\StoicPage;
use Modules\Core\Catalog\DTOs\ProductFilters;
use Modules\Core\Catalog\Services\CollectionService;
use Modules\Core\Catalog\Services\ProductService; use Modules\Core\Catalog\Services\ProductService;
class HomeController extends Controller 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) public function index(string $locale)
{ {
$page = StoicPage::firstWhere('slug', 'home'); $page = StoicPage::localized('home');
if (! $page) { if (! $page) {
abort(404); abort(404);
} }
$products = collect($this->products->random(13)) // Random pool for the classics carousel, and for the hero too when the
->map(fn (array $product) => ProductCard::fromIndexed($product)); // 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', [ return view('home', [
'page' => $page, 'page' => $page,
'heroProducts' => $products->take(5)->values(), 'heroProducts' => $hero->map(fn (array $product) => ProductCard::fromIndexed($product))->values(),
'classicsProducts' => $products->slice(5)->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; namespace App\Http\Controllers;
use App\Models\StoicPage; use App\Models\StoicPage;
use Modules\Core\Store\Services\StoreDetailsService;
class LegalPageController extends Controller class LegalPageController extends Controller
{ {
/**
* {placeholder} => StoreDetails column, usable in the Stoic legal page
* markdown.
*/
private const PLACEHOLDERS = [
'{store_name}' => 'name',
'{store_address}' => 'address',
'{store_phone}' => 'phone',
'{store_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) public function terms(string $locale)
{ {
return $this->show('terms-and-conditions'); return $this->show('terms-and-conditions');
@@ -28,12 +47,30 @@ public function cookies(string $locale)
private function show(string $slug) private function show(string $slug)
{ {
$page = StoicPage::firstWhere('slug', $slug); $page = StoicPage::localized($slug);
if (! $page) { if (! $page) {
abort(404); abort(404);
} }
return view('legal', ['page' => $page]); return view('legal', [
'page' => $page,
'content' => $this->fillPlaceholders($page->content ?? ''),
]);
}
/**
* Swapped in before the markdown is rendered, so values are escaped: the
* rendered markdown is printed unescaped. An empty field renders as
* nothing rather than leaving the raw placeholder on the page.
*/
private function fillPlaceholders(string $content): string
{
$details = $this->storeDetails->current();
return strtr($content, array_map(
fn (string $column) => e((string) ($details->translate($column) ?? '')),
self::PLACEHOLDERS,
));
} }
} }
+7 -23
View File
@@ -3,19 +3,21 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Catalog\ProductCard; use App\Catalog\ProductCard;
use App\Services\Wishlist;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\View\View; use Illuminate\View\View;
use Lunar\Models\Product;
use Modules\Core\Catalog\Services\ProductService; use Modules\Core\Catalog\Services\ProductService;
use Modules\Core\Wishlist\Services\WishlistService;
/**
* Page rendering only — the toggle action itself lives in core
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
* `wishlist.toggle`), since it needs no app-specific presentation.
*/
class WishlistController extends Controller class WishlistController extends Controller
{ {
public function __construct( public function __construct(
private readonly Wishlist $wishlist, private readonly WishlistService $wishlist,
) {} ) {}
/** /**
@@ -39,24 +41,6 @@ public function guest(string $locale, ProductService $products): View|RedirectRe
return view('wishlist.guest', ['products' => $this->products($products)]); return view('wishlist.guest', ['products' => $this->products($products)]);
} }
/**
* Adds or removes a product, for guests and logged-in shoppers alike. The
* heart button's Stimulus controller asks for JSON; without JS the form
* posts normally and comes back to the same page.
*/
public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse
{
abort_unless(Product::whereKey($productId)->exists(), 404);
$active = $this->wishlist->toggle($productId);
if ($request->expectsJson()) {
return response()->json(['active' => $active]);
}
return back();
}
/** /**
* Product cards for the current wishlist, newest first. Products no longer * Product cards for the current wishlist, newest first. Products no longer
* in the search index (deleted, unpublished) are simply skipped. * in the search index (deleted, unpublished) are simply skipped.
@@ -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; 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. * sender, so replying from the inbox goes straight to them.
*/ */
class ContactMessageMail extends Mailable class ContactMessageMail extends Mailable
+17 -1
View File
@@ -2,16 +2,32 @@
namespace App\Models; namespace App\Models;
use App\Services\Stoic;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use JacobJoergensen\LaravelPaper\Attributes\ContentPath; use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
use JacobJoergensen\LaravelPaper\Attributes\Driver; use JacobJoergensen\LaravelPaper\Attributes\Driver;
use JacobJoergensen\LaravelPaper\Attributes\Timestamps; use JacobJoergensen\LaravelPaper\Attributes\Timestamps;
use JacobJoergensen\LaravelPaper\Paper; use JacobJoergensen\LaravelPaper\Paper;
// Points at the default locale (el), which Stoic writes every field to. Other
// locales hold only the translatable fields — use localized() to get a page
// with those layered on top for the current locale.
#[Driver("markdown")] #[Driver("markdown")]
#[ContentPath("resources/stoic/content/pages")] #[ContentPath("resources/stoic/content/el/pages")]
#[Timestamps] #[Timestamps]
class StoicPage extends Model class StoicPage extends Model
{ {
use Paper; use Paper;
/**
* The page in the current locale: the default-locale entry, with the
* current locale's non-empty fields (and body) swapped in. Untranslated
* fields fall back to the default locale.
*/
public static function localized(string $slug): ?static
{
$page = static::firstWhere('slug', $slug);
return $page?->forceFill(Stoic::localeOverrides("pages/{$slug}.md"));
}
} }
+16 -1
View File
@@ -29,12 +29,26 @@ class User extends Authenticatable implements LunarUserInterface
]; ];
/** /**
* The attributes that should be hidden for serialization. * The attributes that should be hidden for serialization. Every
* secret-bearing OTP/pending-email-change field is included here,
* not just remember_token — otp_code_hash and
* pending_email_code_hash are bcrypt hashes rather than the raw
* codes (see Modules\Core\Auth\Services\UserOtpService and
* Modules\Core\Customer\Services\CustomerEmailChangeService), but a
* hash is still not something any serialized response should leak,
* and otp_attempts/pending_email_attempts reveal in-progress guess
* counts.
* *
* @var list<string> * @var list<string>
*/ */
protected $hidden = [ protected $hidden = [
'remember_token', 'remember_token',
'otp_code_hash',
'otp_expires_at',
'otp_attempts',
'pending_email_code_hash',
'pending_email_expires_at',
'pending_email_attempts',
]; ];
/** /**
@@ -46,6 +60,7 @@ protected function casts(): array
{ {
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'otp_code_hash' => 'hashed',
]; ];
} }
} }
-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\Customer;
use App\Models\Staff; use App\Models\Staff;
use App\Services\StoicSettings;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\View\View as ViewInstance; use Illuminate\View\View as ViewInstance;
use Lunar\Facades\ModelManifest; use Lunar\Facades\ModelManifest;
use Lunar\Facades\Telemetry; use Lunar\Facades\Telemetry;
use Lunar\Models\CollectionGroup;
use Modules\Core\Catalog\DTOs\CollectionFilters; use Modules\Core\Catalog\DTOs\CollectionFilters;
use Modules\Core\Catalog\Enums\CollectionSort; use Modules\Core\Catalog\Enums\CollectionSort;
use Modules\Core\Catalog\Services\CollectionService; use Modules\Core\Catalog\Services\CollectionService;
use Modules\Core\Store\Services\StoreDetailsService;
use Symfony\Component\Mime\Address;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void
{ {
// One instance per request: it caches the guest cookie's ids, so a $this->app->scoped(StoicSettings::class);
// toggle and a later has() in the same request agree.
$this->app->scoped(\App\Services\Wishlist::class);
} }
public function boot(): void public function boot(): void
{ {
Telemetry::optOut(); Telemetry::optOut();
// header.blade.php's category dropdown — root collections only, resolved // header.blade.php's category dropdown — root collections of the catalog
// per-request so the composer runs after `locale` middleware has already // group only, so merchandising groups (e.g. "Homepage") stay out of the
// set App::getLocale(), which CollectionService's name resolution depends on. // 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::composer('components.header', function (ViewInstance $view) {
$view->with('categories', app(CollectionService::class)->list( $groupId = CollectionGroup::where('handle', 'shopify')->value('id');
filters: new CollectionFilters(rootOnly: true),
$view->with('categories', $groupId === null ? [] : app(CollectionService::class)->list(
filters: new CollectionFilters(groupId: $groupId, rootOnly: true),
perPage: 100, perPage: 100,
sort: CollectionSort::Position, sort: CollectionSort::Position,
)->items()); )->items());
}); });
// Site-wide settings edited in Stoic (settings.md): default meta
// description, social links for the footer, emails and schema.org.
View::composer(
['layouts.app', 'components.footer', 'emails.layout', 'home'],
fn (ViewInstance $view) => $view->with('stoicSettings', app(StoicSettings::class)),
);
// 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')) { if ($this->app->environment('production')) {
URL::forceScheme('https'); URL::forceScheme('https');
} }
+37
View File
@@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Spatie\YamlFrontMatter\YamlFrontMatter;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class Stoic class Stoic
@@ -29,6 +30,42 @@ public static function presets(): array
return static::$presets; return static::$presets;
} }
// Stoic's default locale (first in i18n.locales), or null when i18n is off.
// Its content folder holds every field; other locales only translatable ones.
public static function defaultLocale(): ?string
{
static::loadConfig();
return static::$config["i18n"]["locales"][0]["code"] ?? null;
}
// The current locale's translated fields for a content file (path relative
// to a locale folder, e.g. "pages/home.md"), with the markdown body as
// `content`. Empty when the current locale is the default one (or i18n is
// off), the file doesn't exist, or a field is left blank — so layering this
// over the default-locale values falls back to them field by field.
public static function localeOverrides(string $file): array
{
$default = static::defaultLocale();
$locale = app()->getLocale();
if ($default === null || $locale === $default) {
return [];
}
$path = resource_path("stoic/content/{$locale}/{$file}");
if (!is_file($path)) {
return [];
}
$document = YamlFrontMatter::parse(file_get_contents($path));
return array_filter(
[...$document->matter(), "content" => trim($document->body())],
fn($value) => filled($value),
);
}
// Returns the public URL for a stoic image at a given preset. // Returns the public URL for a stoic image at a given preset.
public static function image(string $filename, string $preset): string public static function image(string $filename, string $preset): string
{ {
+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);
}
}
Executable
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# Switch how this app consumes boboko-core, then restart the dev stack so the
# entrypoints re-resolve it.
#
# bin/core-mode local ../boboko-core checkout (Composer path repo + npm file:)
# bin/core-mode repo tagged releases from the forge (Composer VCS repo + npm git+semver)
# bin/core-mode print the current mode
#
# Running it for the mode you're already in skips the file edits but still
# restarts the stack — that's how repo mode picks up a newly pushed tag.
#
# The toggle lives in composer.json ("repositories" <-> "_repositories") and
# package.json (@boboko/core in "dependencies" <-> "_dependencies"). The dev
# entrypoints run `composer update boboko/*` and `npm update @boboko/core` on
# every boot, which rewrites composer.lock / package-lock.json for the mode.
set -euo pipefail
cd "$(dirname "$0")/.."
fail() { echo "!!! $*" >&2; exit 1; }
# Escape a string for use as a sed regex / replacement with "|" as delimiter.
sed_pattern() { printf '%s' "$1" | sed 's/[][\.*^$|]/\\&/g'; }
sed_replacement() { printf '%s' "$1" | sed 's/[\&|]/\\&/g'; }
current_mode() {
local composer_type npm_dep composer_mode npm_mode
# The live block is "repositories" — the leading quote keeps "_repositories" out.
composer_type=$(sed -n '/"repositories": \[/,/"type"/p' composer.json | grep -o '"type": "[a-z]*"' | head -1)
npm_dep=$(sed -n '/"dependencies": {/,/}/p' package.json | grep -o '"@boboko/core": "[^"]*"' | head -1)
case "$composer_type" in
*'"path"') composer_mode=local ;;
*'"vcs"') composer_mode=repo ;;
*) fail "Can't tell the mode from composer.json's \"repositories\" block." ;;
esac
case "$npm_dep" in
*'"file:'*) npm_mode=local ;;
*'"git+'*) npm_mode=repo ;;
*) fail "Can't tell the mode from package.json's @boboko/core dependency." ;;
esac
[ "$composer_mode" = "$npm_mode" ] ||
fail "composer.json ($composer_mode) and package.json ($npm_mode) disagree — fix by hand first."
echo "$composer_mode"
}
swap_files() {
# composer.json: swap the key names, values stay where they are.
sed -i \
-e 's/"_repositories":/"__core_mode_swap__":/' \
-e 's/"repositories":/"_repositories":/' \
-e 's/"__core_mode_swap__":/"repositories":/' \
composer.json
# package.json: swap the two @boboko/core values (live and parked).
local a b
a=$(grep -o '"@boboko/core": "[^"]*"' package.json | sed -n 1p)
b=$(grep -o '"@boboko/core": "[^"]*"' package.json | sed -n 2p)
[ -n "$a" ] && [ -n "$b" ] || fail "Expected two @boboko/core entries in package.json."
sed -i \
-e "s|$(sed_pattern "$a")|__core_mode_swap__|" \
-e "s|$(sed_pattern "$b")|$(sed_replacement "$a")|" \
-e "s|__core_mode_swap__|$(sed_replacement "$b")|" \
package.json
}
wrapper_for() {
if [ "$1" = local ]; then echo bin/dc-core.sh; else echo bin/dc; fi
}
# The app healthcheck (php-fpm -t) passes before the entrypoint finishes, so
# wait on the entrypoints' own log lines instead: the app's composer block is
# done once it clears caches, the vite container's npm block once Vite is up.
wait_for_log() {
local dc=$1 service=$2 pattern=$3 label=$4 waited=0
printf '>>> Waiting for %s' "$label"
until $dc logs --no-color "$service" 2>&1 | grep -q "$pattern"; do
if [ -z "$($dc ps -q --status running "$service")" ]; then
echo
$dc logs --no-color --tail 40 "$service"
fail "$service stopped before finishing (logs above)."
fi
if [ "$waited" -ge 600 ]; then
echo
$dc logs --no-color --tail 40 "$service"
fail "Timed out after 600s waiting for $service (logs above)."
fi
printf '.'
sleep 3
waited=$((waited + 3))
done
echo " done"
}
mode=$(current_mode)
if [ $# -eq 0 ]; then
echo "$mode"
exit 0
fi
target=$1
[ "$target" = local ] || [ "$target" = repo ] || fail "Usage: bin/core-mode [local|repo]"
if [ "$target" = local ] && [ ! -d ../boboko-core ]; then
fail "../boboko-core not found — local mode needs the core checkout next to this repo."
fi
old_dc=$(wrapper_for "$mode")
new_dc=$(wrapper_for "$target")
echo ">>> Stopping stack ($mode mode)..."
$old_dc down
if [ "$mode" != "$target" ]; then
echo ">>> Switching composer.json / package.json: $mode -> $target"
swap_files
[ "$(current_mode)" = "$target" ] || fail "Switch didn't take — check composer.json / package.json."
else
echo ">>> Already in $target mode, restarting to re-resolve boboko-core"
fi
# The entrypoints run `composer install` / `npm install` from the lockfiles
# BEFORE re-resolving core. Local-mode lockfiles point at ../boboko-core,
# which isn't mounted in repo mode, so the install fails. The committed
# lockfiles are always repo mode — restore those and let the entrypoints bump
# them to the newest tag.
if [ "$target" = repo ]; then
if grep -A14 '"name": "boboko/core"' composer.lock | grep -q '"type": "path"' ||
grep -A6 '"node_modules/@boboko/core": {' package-lock.json | grep -q '"link": true'; then
echo ">>> Lockfiles still point at ../boboko-core — restoring the committed ones"
git checkout HEAD -- composer.lock package-lock.json
fi
fi
echo ">>> Starting stack ($target mode)..."
if ! $new_dc up -d; then
$new_dc logs --no-color --tail 40 app
fail "Stack failed to start (app logs above)."
fi
wait_for_log "$new_dc" app '\[entrypoint\] Caches cleared' "composer to re-resolve boboko/core"
wait_for_log "$new_dc" vite 'ready in' "npm to re-resolve @boboko/core"
version=$(grep -A1 '"name": "boboko/core"' composer.lock | grep -o '"version": "[^"]*"' | cut -d'"' -f4)
echo ">>> Now in $target mode — boboko/core $version"
Executable
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Deploy this app to production with the latest boboko-core tag.
#
# 1. Refuses to run with uncommitted work (the server deploys what's pushed,
# nothing else) — only the core wiring files are allowed to be dirty.
# 2. Pulls translation lines added in the Language Lines UI into core's
# seeders (boboko:translations:pull) — in local mode; if any are pulled,
# stops so you can release core first. In repo mode it only checks.
# 3. Switches to repo mode if you're on the local ../boboko-core checkout,
# and restarts the stack either way so the lockfiles pick up the newest
# tag (bin/core-mode repo).
# 4. Commits the lockfile bump, if any, asks for confirmation, pushes.
# 5. Runs `envoy run deploy` (see Envoy.blade.php) against .env.envoy's host.
#
# Leaves you in repo mode — run `bin/core-mode local` to go back.
set -euo pipefail
cd "$(dirname "$0")/.."
fail() { echo "!!! $*" >&2; exit 1; }
[ -f .env.envoy ] || fail ".env.envoy not found — copy .env.envoy.example and fill it in."
branch=$(grep -m1 '^DEPLOY_BRANCH=' .env.envoy | cut -d= -f2-)
host=$(grep -m1 '^DEPLOY_SSH_HOST=' .env.envoy | cut -d= -f2-)
branch=${branch:-master}
[ "$(git branch --show-current)" = "$branch" ] || fail "You're not on $branch (the branch Envoy deploys)."
# Anything tracked and modified other than the four core wiring files would be
# silently left out of the deploy — make that a hard stop instead.
dirty=$(git status --porcelain --untracked-files=no |
grep -vE '^.. (composer\.json|composer\.lock|package\.json|package-lock\.json)$' || true)
if [ -n "$dirty" ]; then
echo "$dirty"
fail "Commit or stash these first — the server only deploys what's pushed."
fi
echo ">>> Fetching origin..."
git fetch origin "$branch"
[ "$(git rev-list --count "HEAD..origin/$branch")" -eq 0 ] || fail "origin/$branch has commits you don't — pull first."
# Translation lines added through the Language Lines UI only reach other apps
# once they're in core's seeders. In local mode, pull them into
# ../boboko-core; in repo mode the command can only report them.
lines_in() { printf '%s\n' "$1" | sed -n 's/^\([0-9][0-9]*\) line(s).*/\1/p' | tail -1; }
if [ "$(bin/core-mode)" = local ]; then
echo ">>> Pulling UI-added translations into ../boboko-core's seeders..."
out=$(bin/dc-core.sh exec -T app php artisan boboko:translations:pull --no-ansi) ||
fail "boboko:translations:pull failed — is the stack running (bin/dc-core.sh up -d)?"
echo "$out"
pulled=$(lines_in "$out")
if [ "${pulled:-0}" -gt 0 ]; then
fail "Pulled $pulled translation line(s) into ../boboko-core. Review, commit, tag and push core, then rerun bin/deploy."
fi
else
echo ">>> Checking for UI-added translations missing from core's seeders..."
if out=$(bin/dc exec -T app php artisan boboko:translations:pull --dry-run --no-ansi 2>&1); then
missing=$(lines_in "$out")
if [ "${missing:-0}" -gt 0 ]; then
echo "$out"
fail "These lines are only in this database — run bin/core-mode local and rerun bin/deploy to pull them into core."
fi
echo " nothing missing"
else
echo " skipped — the installed boboko/core doesn't have boboko:translations:pull yet"
fi
fi
# Switch to repo mode (or just restart if already there) so both lockfiles
# resolve the newest boboko-core tag.
bin/core-mode repo
git diff --quiet HEAD -- composer.json package.json ||
fail "composer.json / package.json differ from HEAD in repo mode — they should only differ by the local/repo toggle."
if grep -A14 '"name": "boboko/core"' composer.lock | grep -q '"type": "path"'; then
fail "composer.lock still points boboko/core at the local path repo."
fi
if grep -A6 '"node_modules/@boboko/core": {' package-lock.json | grep -q '"link": true'; then
fail "package-lock.json still links @boboko/core to the local checkout."
fi
version=$(grep -A1 '"name": "boboko/core"' composer.lock | grep -o '"version": "[^"]*"' | cut -d'"' -f4)
# A tag you created in ../boboko-core but never pushed won't be resolved.
if [ -d ../boboko-core ]; then
latest=$(git -C ../boboko-core tag --sort=-v:refname | head -1)
if [ -n "$latest" ] && [ "$latest" != "v$version" ]; then
echo "!!! Warning: resolved boboko/core $version, but ../boboko-core's newest tag is $latest."
echo "!!! Did you push it? (git -C ../boboko-core push origin $latest)"
fi
fi
if ! git diff --quiet HEAD -- composer.lock package-lock.json; then
echo ">>> Committing lockfiles for boboko/core $version..."
git add composer.lock package-lock.json
git commit -m "Chore: Bumping boboko/core to $version"
fi
echo
echo ">>> Deploying $branch to $host with boboko/core $version."
pending=$(git log --oneline "origin/$branch..HEAD")
if [ -n "$pending" ]; then
echo ">>> Commits to push:"
echo "$pending"
else
echo ">>> Nothing new to push — this redeploys origin/$branch as-is."
fi
echo
read -r -p "Push and deploy? [y/N] " answer
[ "$answer" = y ] || [ "$answer" = Y ] || fail "Aborted."
if [ -n "$pending" ]; then
git push origin "$branch"
fi
vendor/bin/envoy run deploy
echo
echo ">>> Done. You're in repo mode — run bin/core-mode local to go back to your ../boboko-core checkout."
+25 -4
View File
@@ -6,11 +6,23 @@
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__ . '/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__ . '/../routes/console.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// nginx (docker/nginx/prod.conf) only listens on plain HTTP:80.
// Without trusting that layer's X-Forwarded-Proto header, Laravel sees every
// request as http://, so url()/signed-route generation and
// verification (URL::hasValidSignature()) both use the wrong
// scheme — breaks any signed URL whose recipient hits it over
// https (e.g. Modules\Core\Shipping\Http\Controllers\
// DownloadShipmentLabelController's label links) with a 401.
// '*' trusts whatever's immediately upstream, since that's
// container-to-container inside the same deploy, not arbitrary
// public traffic.
$middleware->trustProxies(at: '*');
// Laravel's priority list would otherwise run `auth` before core's // Laravel's priority list would otherwise run `auth` before core's
// `locale` middleware, so the redirects below would build URLs before // `locale` middleware, so the redirects below would build URLs before
// URL::defaults(['locale' => …]) is set, throwing a missing-parameter error. // URL::defaults(['locale' => …]) is set, throwing a missing-parameter error.
@@ -21,8 +33,17 @@
// Both resolve inside the {locale} group, after the `locale` middleware // Both resolve inside the {locale} group, after the `locale` middleware
// has set URL::defaults(['locale' => …]), so route() needs no locale arg. // has set URL::defaults(['locale' => …]), so route() needs no locale arg.
$middleware->redirectGuestsTo(fn () => route('login')); $middleware->redirectGuestsTo(fn() => route('login'));
$middleware->redirectUsersTo(fn () => route('home')); $middleware->redirectUsersTo(fn() => route('home'));
// Core's session registry (Modules\Core\Auth\Services\
// UserSessionService) is enforcement-optional by design — see
// EnsureSessionNotRevoked's own docblock — and this app never
// wired it in. Without this, revokeAllSessions() only flips a DB
// flag that nothing checks per-request: a leaked/stolen session
// cookie keeps working even after being "revoked". Appended to
// `web` (runs after `auth` resolves the user, which it needs).
$middleware->appendToGroup('web', \Modules\Core\Auth\Http\Middleware\EnsureSessionNotRevoked::class);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //
+4 -4
View File
@@ -12,9 +12,6 @@
"artesaos/seotools": "^1.4", "artesaos/seotools": "^1.4",
"boboko/core": "0.*", "boboko/core": "0.*",
"jacobjoergensen/laravel-paper": "^1.12", "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", "php-http/guzzle7-adapter": "^1.1",
"spatie/laravel-sitemap": "^8.1", "spatie/laravel-sitemap": "^8.1",
"spatie/laravel-webhook-server": "^3.10", "spatie/laravel-webhook-server": "^3.10",
@@ -100,5 +97,8 @@
} }
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "prefer-stable": true,
"require-dev": {
"laravel/envoy": "^2.12"
}
} }
Generated
+522 -889
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -74,7 +74,7 @@
// ones that already worked, like collection_ids), not just the // ones that already worked, like collection_ids), not just the
// new order_count one. Caught in practice, reverted. // new order_count one. Caught in practice, reverted.
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class, Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class, Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class
], ],
]; ];
-5
View File
@@ -35,11 +35,6 @@
], ],
], ],
// 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 // 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: // local dev use hCaptcha's test keys, the real ones reject localhost:
// sitekey 10000000-ffff-ffff-ffff-000000000001, // sitekey 10000000-ffff-ffff-ffff-000000000001,
@@ -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,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: scheduler:
volumes: volumes:
- ../boboko-core:/var/www/boboko-core - ../boboko-core:/var/www/boboko-core
vite:
volumes:
- ../boboko-core:/boboko-core
+28 -8
View File
@@ -47,6 +47,7 @@ services:
queue: queue:
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
entrypoint: ["/entrypoint-worker.sh"]
command: php artisan queue:work --tries=3 --max-jobs=500 --memory=256 command: php artisan queue:work --tries=3 --max-jobs=500 --memory=256
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
@@ -68,6 +69,7 @@ services:
scheduler: scheduler:
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
entrypoint: ["/entrypoint-worker.sh"]
command: php artisan schedule:work command: php artisan schedule:work
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
@@ -129,16 +131,15 @@ services:
timeout: 5s timeout: 5s
retries: 10 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: stoic:
image: ghcr.io/lexx27/stoic:latest image: ghcr.io/lexx27/stoic:latest
# Runs as root — the app container's entrypoint.sh chowns the whole
# storage/ tree to www-data on every boot (docker/entrypoint.sh),
# and Stoic's own image user isn't www-data or in its group, so it
# can't write thumbnails into storage/app/public/stoic/ otherwise
# (libvips reports this as "Failed to write VipsImage to file").
# Same fix already applied in docker-compose.dev.yml.
user: root
ports: ports:
- "127.0.0.1:${STOIC_PORT:-2727}:2727" - "127.0.0.1:${STOIC_PORT:-2727}:2727"
environment: environment:
@@ -153,6 +154,24 @@ services:
- storage:/var/www/html/storage/app - storage:/var/www/html/storage/app
restart: unless-stopped 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: volumes:
pgdata: pgdata:
storage: storage:
@@ -160,3 +179,4 @@ volumes:
framework: framework:
valkeydata: valkeydata:
meilidata: 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 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
# Dev-only for the same reason as the composer step above — production's # image already has these published at build time.
# image already has these published at build time. php artisan vendor:publish --tag=core-assets --force --ansi --quiet
php artisan vendor:publish --tag=core-assets --force --ansi --quiet php artisan vendor:publish --tag=public --force --ansi --quiet
php artisan vendor:publish --tag=public --force --ansi --quiet php artisan filament:assets --ansi --quiet
php artisan filament:assets --ansi --quiet
# No --force: core-views publishes editable Blade templates (order # No --force: core-views publishes editable Blade templates (order
# notification emails), meant to be hand-customized per app — unlike # notification emails), meant to be hand-customized per app — unlike
# core-assets above, republishing must not silently wipe local edits. # core-assets above, republishing must not silently wipe local edits.
# Only fills in the vendor/core views directory if it doesn't exist yet. # Only fills in the vendor/core views directory if it doesn't exist yet.
php artisan vendor:publish --tag=core-views --ansi --quiet php artisan vendor:publish --tag=core-views --ansi --quiet
fi
# Everything below is universal, in both dev and production: application STATE # 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 # 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" echo "[entrypoint] Trying Lunar install"
php artisan lunar:install --quiet || true php artisan lunar:install --quiet || true
# Same idempotent per-key upsert as lunar:install's own seeding above, just kept
# as separate seeder classes rather than folded into InstallLunarCommand — these
# are Spatie translation-loader `validation`/`checkout` groups, not Lunar store
# data, and each is safe to re-run on every boot.
echo "[entrypoint] Seeding validation translations..."
php artisan db:seed --class="Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder" --force --quiet || true
echo "[entrypoint] Seeding checkout translations..."
php artisan db:seed --class="Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder" --force --quiet || true
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 # Upserts by primary key (no --refresh), so this stays cheap and idempotent on
# every boot rather than flushing and rebuilding the whole index each time. Runs # every boot rather than flushing and rebuilding the whole index each time. Runs
# in production too — a deploy that changed an indexer's field list needs this # in production too — a deploy that changed an indexer's field list needs this
+11
View File
@@ -25,6 +25,17 @@ server {
if ($request_method = POST) { set $skip_cache 1; } if ($request_method = POST) { set $skip_cache 1; }
if ($request_uri ~* "^/(boboko|up)") { set $skip_cache 1; } if ($request_uri ~* "^/(boboko|up)") { set $skip_cache 1; }
if ($query_string) { set $skip_cache 1; } if ($query_string) { set $skip_cache 1; }
# Any logged-in request must never be served a cached response —
# the cache key is URL-only ($scheme$request_method$host$request_uri,
# see fastcgi_cache_key below), with no per-session variation. Without
# this, the first cached hit on an authenticated GET page (e.g.
# /account) gets replayed verbatim to every other visitor of that same
# URL for up to fastcgi_cache_valid's 10m window — a real account/
# session data leak, not just a staleness bug. Laravel's session
# cookie name is env('SESSION_COOKIE', Str::slug(APP_NAME).'-session')
# (config/session.php) — APP_NAME varies per environment, so this
# matches the fixed '-session' suffix rather than a hardcoded name.
if ($http_cookie ~* "-session") { set $skip_cache 1; }
# Vite build assets — content-hashed filenames, safe to cache forever # Vite build assets — content-hashed filenames, safe to cache forever
location /build/ { location /build/ {
-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 media_path: /media
thumbs_path: /var/www/html/storage/app/public/stoic/ thumbs_path: /var/www/html/storage/app/public/stoic/
repository_path: /app repository_path: /app
# First locale is Stoic's default: it holds every field, the others only the
# translatable ones (content/{locale}/...). Codes match app.available_locales.
i18n:
locales:
- code: el
label: Ελληνικά
- code: en
label: English
presets: presets:
- code: large - code: large
mode: resize mode: resize
+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.',
],
],
];
+14 -47
View File
@@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
"@hotwired/stimulus": "^3.2.2", "@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo": "^8.0.23", "@hotwired/turbo": "^8.0.23",
"axios": "^1.15.2", "axios": "^1.15.2",
@@ -18,11 +19,21 @@
"vite": "^8.0.0" "vite": "^8.0.0"
} }
}, },
"node_modules/@boboko/core": {
"version": "0.31.0",
"resolved": "git+https://code.radical-elements.com/boboko/core.git#207cb133634feddb28efc9675726307ab99df205",
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"leaflet": "^1.9.4"
},
"peerDependencies": {
"vite": "^8.0.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -34,7 +45,6 @@
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -45,7 +55,6 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -121,7 +130,6 @@
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -140,7 +148,6 @@
"version": "0.138.0", "version": "0.138.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
@@ -153,7 +160,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -170,7 +176,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -187,7 +192,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -204,7 +208,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -221,7 +224,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -238,7 +240,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -255,7 +256,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -272,7 +272,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -289,7 +288,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -306,7 +304,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -323,7 +320,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -340,7 +336,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -357,7 +352,6 @@
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -376,7 +370,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -393,7 +386,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -407,7 +399,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
@@ -686,7 +677,6 @@
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -894,7 +884,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -994,7 +983,6 @@
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -1048,7 +1036,6 @@
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1210,7 +1197,7 @@
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
@@ -1253,7 +1240,6 @@
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"dependencies": { "dependencies": {
"detect-libc": "^2.0.3" "detect-libc": "^2.0.3"
@@ -1286,7 +1272,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1307,7 +1292,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1328,7 +1312,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1349,7 +1332,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1370,7 +1352,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1391,7 +1372,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1412,7 +1392,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1433,7 +1412,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1454,7 +1432,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1475,7 +1452,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1496,7 +1472,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@@ -1560,7 +1535,6 @@
"version": "3.3.15", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1579,14 +1553,12 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -1599,7 +1571,6 @@
"version": "8.5.16", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -1647,7 +1618,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.138.0", "@oxc-project/types": "=0.138.0",
@@ -1704,7 +1674,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -1779,7 +1748,6 @@
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -1806,14 +1774,13 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "devOptional": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.1.3", "version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
+5
View File
@@ -14,9 +14,14 @@
"vite": "^8.0.0" "vite": "^8.0.0"
}, },
"dependencies": { "dependencies": {
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
"@hotwired/stimulus": "^3.2.2", "@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo": "^8.0.23", "@hotwired/turbo": "^8.0.23",
"axios": "^1.15.2", "axios": "^1.15.2",
"leaflet": "^1.9.4" "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};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
function o({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:r,succeed:a,fail:u,respond:d})=>{a(({snapshot:h,effect:l})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||this.getNormalizedState()===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
+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};
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="151" height="150" viewBox="0 0 151 150"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><image width="151" height="150" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJcAAACWCAYAAADTwxrcAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAACYRJREFUeAHtnctuG0cWhv8qJuuRgWSAWU1nMrO28gRuP4FkzANIegJZyAXIStQqQOLA8hOY2ieR/QSmnyDMOgnSuwCJAzLrmF05p6spURSvYpNddfp8gO6UxGb9fc6p21+AoiiKoiiKoiiKoiiKoiiKoiiKoiiKoiiKRAyUxVz2d+j9TvH5o3sZlKVQcY1z2d9FjhQW9+GwS99JMBLVbbLizaCHIV6jhS4JbwDlChXXZT+l93skpkPMFtKydOnvXOD/9zpQGiwuFpXDKX2Wono4onXo40WT02jzxLVZUU3iRfbo3hkaSHPE5YvyUxLWY2wfFtnDpkUxiybAhbrD9zUJi0nof/9Cz+MUDUK+uF72D6hhX8H3/OrFoU0Ce4qGIDstcqTgBg2PLr3yj6QPXcgVV7jCGtEr6zCxApOZFr/p7wcuLIbrwEsIRp64LvsJXdVzxEEquQaTlRZ5uIF7hSEU76vg02MXwpAWubirnyA2HEVaPw4nCjni8mNZdY1jrUsCf2OIQo64Yi+O+cbwk+hikCGub/uHiDEdTuJkRS8Z4jJiGiWVFL3iF5dvjARScNiHECRErgPI4kBKzzF+cQm600t2kMu4prjF5VOiuPEhWPsAAohbXPlWVpNuH5dr5KodAxF3+BR2ijnSyIm95pKXEq/ZReTELq7oG2Amefw3TrziEjjRO0GCyIk5ckkXV/Q0Y/ePUgsqLmVjqLiUjRGvuOTvXo5+V1DskUvuvj9bWDRFTeziyiAXjVw18wPk0kPkxC0uhy5k0pOwEztucVmx4noNAcQtLt9j7EIaBi8gAAkrUUXc5WNkUnZfxy8ui3NIGpIwEGNxGb+4fOF7ARlkEJTmpexblBG9nCz3Zxni4gZxeIa4ycoULwY5E9e+YTLECtdawlwG5YiLG8bgCDFiLHvVdyAMWUtufBc+tvSYAbnIQxBkGu5+12dr8BThw9H2I6nLh2QuFmQb7hjqL07jgtelyRSXr78eImSBeWGJmOaZhdxlzhwRwhTYoBRWB8KRvYZ+JLBwJoJHB0x10ACac2rZt/12rQ6ExpDA3VGTTpNt1nmLbO7hjXm3aQPAafCkKdFqnGaeFHvZPyzNbRNsjkExJcUzBw09+7rZZ1x7kbHtZYrqaLyoRugB6oz3wuLDqPZwN6ENyk4DL/0Rsf69ClRck3j3nN3ijW2MLP596zEOf9Irl9HPB8U6/gYfkq4oiqIoiqIoiqIoihIIWxnn+hVfJNO+/y98nkHZCO6XwpB4minxwHywnW14lYqrj6c7b/GWBiDtnoFLDMyuo4+Y/wR6Dsjosa+HGHb/ic+itw7aNoWQhjTD4E8U4QHgBPPdrnlGoUeDwb3CDqGFHgkuQ8VUIq7f8VVK4jigt30S01oW3vQ3MhJbtwV7dg8nGZSpFILKcUgv2B4JJMW6GJppyHFh/osOKmItcb3B14d0mVVP/I5hOiqym5Tp7piE8Bib8eLnzbkd+nixbjS7k7jKSMVLVlJsBRXZFkQ1Ce9iP1snkq0kLq6pcuSnlPoeY8uU6fLsPXzcQcMgYaUkqueo48gWTpcGR3eJYkuLi4S1S8K6XFSgbxoHc/4+Pj5BQyBhcbSq20OCo9gJRbGV9iIsJa43eMLF+vm6xXpVcBSzsA8lp8myYOdotY9QsGhTBFt6d/hCcf2BrzkNthEYkgVWCot3jW9zrf9yrCCwueL6A0+Oqc4J1tZHqsDcz/geYZ8l2TEfLjZ9mblvkVLhfsjCYrj+ozrwFdWDCYRAUespwj+k9JCe58JtelPFxY1FUeEpIoAFRiP7zyEAilin5VBD+ORok8AO5j3ETv+9/FXdvcIVSWnsLY5GmUEx3AC0ERPUi6Xnncz68S1xlQX8zF8IFY60v+HL0NPJVIoG8j3D2Nih1DHzed8QF6fDEHuGy0LFfRSpfArHqGOAtApoXpPS+dSscUNcAmqX1M93xkMZtaJO6cRpOT11gytx8Xwh4nDjmwuNrdRnNnIX8sie73R2pt0gV+IqJ6Kjh+vFWKJXWQwfQgbHk9GrEFc5TpRCDO4AMSAjao3w68vGKMQ1RC7pIpm0TPOhk0ISpvDauML67wm7SE+KgHE/Fs8vgSS45ziWGi0vpYlxXGsRFmYPIWMDWu1QJWOp0VJKjHLgcRE0L7rLixsRLg8gEYv715/CiRQX43ciBYvM1z2/Lke45roPuQTZgFSXiL2hMVZHWhrfSiCUYK/tLUJO12szmsy2Eov5Mf6BEDHCeom3KW4e2YccKHWh4lI2i4pL2RgqLmVjcG8xg1z+RJhsxcKoRjJ+x4OoYi+UesIZQqQV8UHvy1FoiqZ/8AOEEnBUziCXK3M5SouuB6HkyDMESPniZ5AIm8qVcEEvUlwctYJ2KWRHP4mMXZd9H592IbDAZHdChE0XErHX11UMReTAS4jDhX1NrWCORq6SjFJ+d/RFuRLVdSAITonv4ZOgG6+ou5y46NUd/6IQl7TUGEFK9Ljlva6iwN68nqsRehoTegYhsH8qIsD8j24COdGrM2lteSWud/DOuYzRetOJyq9LSvSyt6/jSlzUIAM2tEXE8M0RS9QaISR6daYZ8t6YuC6dkruIFL45onQZbCFmA+FsWtRibq2KaKHFdoQRFvemE6uNON31PNgbp8DYq36Gjfgtcfk730R1oTGmw0nMhziPMD3OPQRhpuHuGzxhP9RjBA4Ja2BhP5JgulvaKbGLc4LQMeia/+DhvIfMXCxIg5BsiXOBwBlieCLFzblIL7ZosNDLkoxP1Vj0oLkrUan+emwCntjOkR/R5HQHgohAYMXzW+a4lrni4uEJixZfaFBTKZwKJQprRFHge4FlCIvessJilj7753d81Q7BIK482OARCV/sOrQRQdVgDs8olbVXOWV2pVPL2POKGvZ5fRtpzQvqFR5xREWDcD+iTRGjrht7QAI/o8He8xV/bzVxMexCSEU0F/tb60n64/DcSegrHTZJLVFsjePw/K/fERbZXxi2qWjbmEVkWVs943nPpkWrWbifiqOHOYol2BQsqmERrbpYgzuLa8QmRKaiWkwhMn7NTYUOihWJ6vrPVQQbrQ2R7+dwaQvmwap1Gae+IdxLA/eiXF+mLEGZLvep4N67g9AGxYYKh5d8rvUqxfoyVCauSVhsbL5mYBOHPMHUf26zHMPeu3g30whVDYX315AE5510plk18QrYAfX8uuaD4IY6FEVRFEVRFEVRFEVRFEVRFEVRFEVRFEVRFKXJ/A2oC/VTZ0o/1AAAAABJRU5ErkJggg=="></image></svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+5 -5
View File
@@ -1,12 +1,12 @@
@import "tailwindcss"; @import "tailwindcss";
@import "./fonts.css"; @import "./fonts.css";
@import "./dropdown.css"; @import "./dropdown.css";
@import "./checkout.css";
/* Note for anyone theming the checkout module (resources/css/checkout.css, /* checkout.css themes the cart/checkout module (.bbk-* classes). Its base
.bbk-* classes): it's deliberately plain, unlayered CSS, not inside any styles (boboko-core's checkout.css) are plain, unlayered CSS, so theme
@layer — so override it with plain rules here too, not from inside overrides must be plain rules too, never inside @layer components/utilities,
@layer components/utilities, which would lose to it. See checkout.css's which would lose to them. */
file-level comment for why. */
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
@source '../**/*.blade.php'; @source '../**/*.blade.php';
+367 -854
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -10,13 +10,15 @@ window.Turbo.session.drive = false;
import { Application } from "@hotwired/stimulus"; import { Application } from "@hotwired/stimulus";
import { registerControllers } from "./stimulus/index"; import { registerControllers } from "./stimulus/index";
import { registerCheckout } from "./checkout"; import { registerCheckout, registerWishlist } from "@boboko/core";
const application = Application.start(); const application = Application.start();
application.debug = false; application.debug = false;
registerControllers(application); registerControllers(application);
// Portable cart + checkout module (destined for boboko-core). Owns its own // Portable cart + checkout + wishlist modules, installed as a real npm
// bbk-* Stimulus controllers; this is the only wiring line it needs here. // dependency (see package.json). Each owns its own Stimulus controllers;
// these are the only wiring lines they need here.
registerCheckout(application); registerCheckout(application);
registerWishlist(application);
@@ -1,57 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
// via fetch and hands the server-rendered cart body to the drawer through the
// `bbk-cart:changed` window event. No DOM building here — the drawer
// (bbk-cart-controller) owns rendering.
export default class extends Controller {
static targets = ['error']
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
this.clearError()
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
if (submit) submit.disabled = true
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (!response.ok) {
const data = await response.json().catch(() => null)
this.showError(data?.error)
return
}
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: await response.text() },
}))
} finally {
form.removeAttribute('data-bbk-add-to-cart-state')
if (submit) submit.disabled = false
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
}
@@ -1,220 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { csrfToken } from './csrf'
// Box Now's own brand green, used for the pin instead of Leaflet's default
// blue teardrop — a small SVG data URI rather than another bundled asset.
const PIN_COLOR = '#00c389'
const PIN_COLOR_SELECTED = '#0a7a52'
function pinIcon(color) {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="46" viewBox="0 0 34 46">
<path
d="M17 0C7.6 0 0 7.6 0 17c0 12.75 17 29 17 29s17-16.25 17-29C34 7.6 26.4 0 17 0Z"
fill="${color}"
stroke="#ffffff"
stroke-width="1.5"
/>
<circle cx="17" cy="17" r="7" fill="#ffffff" />
</svg>
`
return L.divIcon({
className: 'bbk-box-now-pin',
html: svg,
iconSize: [34, 46],
iconAnchor: [17, 46],
popupAnchor: [0, -40],
})
}
const ICON = pinIcon(PIN_COLOR)
const ICON_SELECTED = pinIcon(PIN_COLOR_SELECTED)
// A self-hosted Leaflet map standing in for Box Now's own Destination Map
// JS widget — that widget only talks to Box Now's Production API (see
// their Partner API manual §4.1), so it can't be used while developing
// against Stage credentials. Same underlying /destinations data, rendered
// with OpenStreetMap tiles instead of Box Now's map.
//
// Visibility is toggled by bbk-checkout-form (see its own
// toggleBoxNowLocker()) whenever the "box-now" shipping option becomes
// selected/deselected — this controller only owns loading the locker list
// once visible, rendering pins, and autosaving the chosen one.
export default class extends Controller {
static targets = ['map', 'search', 'status', 'chosen']
static values = {
lockersUrl: String,
selectUrl: String,
loading: String,
selectLabel: String,
selectedLabel: String,
noResults: String,
}
// Athens — a reasonable default center before any locker is loaded.
static DEFAULT_CENTER = [37.9838, 23.7275]
connect() {
this.map = null
this.markers = new Map()
this.selectedId = null
this.loaded = false
if (!this.element.hidden) this.show()
}
disconnect() {
this.map?.remove()
this.map = null
}
// Called by bbk-checkout-form right after it un-hides this element.
show() {
this.element.hidden = false
// Leaflet measures its container's size on init — doing that while
// the element (or an ancestor) is still `hidden` produces a
// collapsed/blank map, so this is deferred to the same tick `hidden`
// is cleared, then Leaflet is nudged once more via invalidateSize().
requestAnimationFrame(() => {
if (!this.map) this.initMap()
this.map.invalidateSize()
if (!this.loaded) this.loadLockers()
})
}
hide() {
this.element.hidden = true
}
initMap() {
this.map = L.map(this.mapTarget).setView(this.constructor.DEFAULT_CENTER, 10)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors',
maxZoom: 19,
}).addTo(this.map)
// Delegated: popup content is re-inserted by Leaflet on every open,
// so a listener bound once on the map's container beats binding (and
// losing) one on the button each time a popup renders.
this.map.getContainer().addEventListener('click', (event) => {
const button = event.target.closest('[data-locker-id]')
if (button) this.select(button.dataset.lockerId)
})
}
async loadLockers() {
this.setStatus(this.loadingValue)
try {
const response = await fetch(this.lockersUrlValue, {
headers: { Accept: 'application/json' },
})
if (!response.ok) return
const { lockers } = await response.json()
this.loaded = true
this.lockers = new Map(lockers.map((locker) => [String(locker.id), locker]))
this.renderMarkers(lockers)
this.setStatus('')
} catch {
this.setStatus('')
}
}
renderMarkers(lockers) {
this.markers.forEach((marker) => marker.remove())
this.markers = new Map(lockers.map((locker) => {
const marker = L.marker([locker.lat, locker.lng], { icon: ICON })
.addTo(this.map)
.bindPopup(this.popupHtml(locker), { maxWidth: 260 })
return [String(locker.id), marker]
}))
if (this.markers.size) {
this.map.fitBounds(L.featureGroup([...this.markers.values()]).getBounds().pad(0.2))
}
}
popupHtml(locker) {
const isSelected = String(locker.id) === this.selectedId
return `
<div class="bbk-box-now-popup">
${locker.image ? `<img class="bbk-box-now-popup-image" src="${locker.image}" alt="">` : ''}
<p class="bbk-box-now-popup-name">${locker.name}</p>
<p class="bbk-box-now-popup-address">
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')}
${locker.postalCode ? ` ${locker.postalCode}` : ''}
</p>
${locker.note ? `<p class="bbk-box-now-popup-note">${locker.note}</p>` : ''}
<button
type="button"
class="bbk-box-now-popup-select${isSelected ? ' bbk-box-now-popup-select--selected' : ''}"
data-locker-id="${locker.id}"
${isSelected ? 'disabled' : ''}
>
${isSelected ? this.selectedLabelValue : this.selectLabelValue}
</button>
</div>
`
}
async select(lockerId) {
const locker = this.lockers?.get(String(lockerId))
if (!locker) return
const previousId = this.selectedId
this.selectedId = String(lockerId)
this.restyleMarker(previousId, ICON)
this.restyleMarker(this.selectedId, ICON_SELECTED)
this.markers.get(this.selectedId)?.setPopupContent(this.popupHtml(locker))
this.chosenTarget.hidden = false
this.chosenTarget.textContent = locker.addressLine1
? `${locker.name} — ${locker.addressLine1}`
: locker.name
const body = new FormData()
body.append('locker_id', locker.id)
body.append('locker_name', locker.name ?? '')
body.append('locker_address', locker.addressLine1 ?? '')
try {
await fetch(this.selectUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
})
} catch {
// Best-effort autosave, same as the rest of checkout — a failed
// save here surfaces later at place-order time via the normal
// shipment-creation error path, not as an inline field error.
}
}
restyleMarker(lockerId, icon) {
if (!lockerId) return
this.markers.get(lockerId)?.setIcon(icon)
}
setStatus(text) {
if (!this.hasStatusTarget) return
this.statusTarget.textContent = text
this.statusTarget.hidden = !text
}
}
@@ -1,161 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the slide-in cart drawer. One instance, on the drawer root in
// checkout/drawer.blade.php.
//
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
// this drawer's own line forms) and swaps in the server-rendered cart body
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react
//
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists.
export default class extends Controller {
static targets = ['panel', 'body', 'error']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
this.updateTimers = new Map() // line id -> pending debounce timer
window.addEventListener('bbk-cart:changed', this.onChanged)
window.addEventListener('bbk-cart:open', this.open.bind(this))
document.addEventListener('keydown', this.onKeydown)
// Prime the host with the count rendered server-side on page load.
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
}
disconnect() {
window.removeEventListener('bbk-cart:changed', this.onChanged)
document.removeEventListener('keydown', this.onKeydown)
this.updateTimers.forEach((timer) => clearTimeout(timer))
}
onChanged(event) {
if (event.detail?.html) this.replaceBody(event.detail.html)
this.open()
}
onKeydown(event) {
if (event.key === 'Escape' && !this.element.hidden) this.close()
}
open() {
if (!this.element.hidden) return
this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start.
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open'))
}
close() {
this.element.removeAttribute('data-bbk-cart-state')
const panel = this.panelTarget
const done = () => {
this.element.hidden = true
panel.removeEventListener('transitionend', done)
}
panel.addEventListener('transitionend', done)
}
// change on a line quantity input, or submit of a line's remove form
submit(event) {
event.preventDefault()
const form = event.target.closest('form')
if (!form) return
// A remove is a deliberate, one-shot action — only the quantity form
// (typing, or the +/- stepper below) benefits from debouncing.
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
}
// +/- stepper buttons inside a line
step(event) {
event.preventDefault()
const form = event.target.closest('form')
const input = form.querySelector('input[type="number"]')
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
input.value = String(next)
this.scheduleSend(form)
}
// Repeated clicks (or spinner nudges) update the input instantly but only
// send once they settle for 300ms — sending on every single click was
// firing overlapping requests that raced each other and made the drawer
// visibly flicker/lag under quick clicking.
scheduleSend(form) {
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
if (!lineId) return this.send(form)
clearTimeout(this.updateTimers.get(lineId))
this.updateTimers.set(lineId, setTimeout(() => {
this.updateTimers.delete(lineId)
this.send(form)
}, 300))
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
this.clearError()
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (response.ok) {
this.replaceBody(await response.text())
return
}
const data = await response.json().catch(() => null)
this.showError(data?.error)
// The rejected quantity (typed, or from a +/- click) is left
// sitting in the input with nothing to correct it — the update
// never reached the cart, so the input must be put back to what
// the cart actually still holds, not just left showing whatever
// was rejected.
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
replaceBody(html) {
this.bodyTarget.innerHTML = html
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
}
emitUpdated(node) {
if (!node) return
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
detail: {
count: parseInt(node.dataset.bbkCartCount || '0', 10),
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
},
}))
}
}
@@ -1,226 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the checkout page's left column: contact tabs, the same-as-billing
// toggle, and — the bulk of it — autosaving the address form and the shipping
// method with no submit buttons.
//
// Flow: any `change` in the address form is debounced ~400ms, then the whole
// form is POSTed to saveUrl. The server persists leniently and returns
// { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options
// block in place and hand the summary fragment to the drawer's bbk-cart
// controller via the `bbk-cart:changed` window event (same mechanism the drawer
// already uses). Shipping-method radios post to selectShippingUrl the same way.
export default class extends Controller {
static targets = [
'sameAsBilling', 'shippingFields',
'form', 'shippingOptions', 'status',
]
static values = {
saveUrl: String,
selectShippingUrl: String,
statusSaving: String,
statusSaved: String,
statusError: String,
}
connect() {
this.saveTimer = null
this.saveController = null
this.statusTimer = null
this.shippingPromise = null
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
}
disconnect() {
clearTimeout(this.saveTimer)
clearTimeout(this.statusTimer)
this.saveController?.abort()
}
// ── Same as billing ────────────────────────────────────────────────
toggleSameAsBilling() {
this.applySameAsBilling()
}
applySameAsBilling() {
const on = this.sameAsBillingTarget.checked
// Checked: shipping *is* billing — copy every value across, then hide +
// disable so the browser doesn't submit them; the server reuses billing.
// Unchecked: reveal them pre-filled from billing wherever still empty.
this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => {
const shippingField = this.element.querySelector(
`[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`,
)
if (shippingField && (on || !shippingField.value)) {
shippingField.value = billingField.value
}
})
this.shippingFieldsTarget.hidden = on
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
field.disabled = on
})
}
// ── Autosave ───────────────────────────────────────────────────────
scheduleSave(event) {
// The shipping-method and payment radios live inside this controller's
// element too, and this action is bound on .bbk-checkout-main to also
// catch the contact email/consent that sit outside the <form>. Only
// react to fields that actually belong to the address form.
const el = event.target
const belongsToForm = el.form?.id === 'bbk-address-form'
if (!belongsToForm) return
// No status during the wait — it only shows once the request is in flight,
// so the indicator isn't flickering "saving" on every keystroke.
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(() => this.save(), 700)
}
// Called by bbk-payment right before place-order — a debounced save (and
// the shipping-option auto-select that happens as part of it) might still
// be pending when the shopper clicks "place order"; this guarantees the
// server has processed the current form state first.
async flush() {
clearTimeout(this.saveTimer)
await this.save()
// A shipping-method radio click fires its own (undebounced) request —
// still async, still racy against an immediate "place order" click.
if (this.shippingPromise) await this.shippingPromise
}
async save() {
this.saveController?.abort()
this.saveController = new AbortController()
this.setStatus('saving')
try {
const response = await fetch(this.saveUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(this.formTarget),
signal: this.saveController.signal,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch (error) {
if (error.name !== 'AbortError') this.setStatus('error')
}
}
async selectShipping(event) {
this.toggleBoxNowLocker(event.target.value)
// Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request.
this.shippingPromise = this.doSelectShipping(event.target.value)
await this.shippingPromise
}
// The dummy Box Now locker <select> (see shipping-options.blade.php)
// lives inside the #bbk-shipping-options fragment this controller
// re-renders wholesale on every shipping-option change — so its own
// Stimulus controller reconnects fresh each time and has no memory of
// which option was previously selected. This is the one place that
// knows the newly-chosen option's identifier, so it also owns
// showing/hiding the picker.
toggleBoxNowLocker(identifier) {
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
if (!picker) return
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
if (identifier === 'box-now') {
controller?.show()
} else {
controller?.hide()
}
}
async doSelectShipping(value) {
this.saveController?.abort()
this.setStatus('saving')
const body = new FormData()
body.append('shipping_option', value)
try {
const response = await fetch(this.selectShippingUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch {
this.setStatus('error')
} finally {
this.shippingPromise = null
}
}
applyResult(data) {
this.applyErrors(data.errors || {})
if (data.shippingOptionsHtml != null) {
this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml
}
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: data.summaryHtml },
}))
}
}
applyErrors(errors) {
this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => {
const message = errors[el.dataset.bbkFieldError]
el.textContent = message || ''
el.hidden = !message
const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`)
field?.classList.toggle('bbk-field-input--error', Boolean(message))
})
}
setStatus(state) {
if (!this.hasStatusTarget) return
const text = {
saving: this.statusSavingValue,
saved: this.statusSavedValue,
error: this.statusErrorValue,
}[state]
this.statusTarget.textContent = text
this.statusTarget.hidden = false
this.statusTarget.dataset.state = state
clearTimeout(this.statusTimer)
if (state === 'saved') {
this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000)
}
}
}
@@ -1,289 +0,0 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
const STRIPE_JS = 'https://js.stripe.com/v3/'
const POLL_INTERVAL = 1500
const POLL_TIMEOUT = 30000
// The payment step of the checkout page. Sits alongside bbk-checkout-form on
// .bbk-checkout-main.
//
// - selectMethod: radio change -> persist via /payment-method, refresh the
// summary (COD fee), mount/unmount the Stripe Payment Element
// - placeOrder: the real submit. For Stripe, builds a PaymentMethod client-side
// and POSTs it to /place-order, then routes on the JSON result:
// { redirect } -> order placed, go to confirmation
// { status:'pending', clientSecret } -> 3-D Secure: handleNextAction, then
// poll /order-status until the webhook places it
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
export default class extends Controller {
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
static values = {
selectUrl: String,
placeOrderUrl: String,
orderStatusUrl: String,
stripeKey: String,
amount: Number,
currency: String,
termsRequired: String,
chooseMethod: String,
genericError: String,
processingSlow: String,
}
connect() {
this.stripe = null
this.elements = null
this.paymentElement = null
this.onSummaryUpdate = (event) => {
const total = event.detail?.total
if (typeof total === 'number' && this.elements) {
this.amountValue = total
this.elements.update({ amount: Math.max(total, 1) })
}
// Removing the last line while sitting on the checkout page (via
// the order summary's own remove form) must not leave "place
// order" clickable with nothing left to charge for — this fires
// from both the drawer and the checkout page's own summary
// instance, whichever the shopper actually used.
const count = event.detail?.count
if (typeof count === 'number' && this.hasSubmitTarget) {
this.submitTarget.disabled = count === 0
}
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
if (this.selectedIsStripe()) this.mountStripe()
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
this.unmountStripe()
}
// ── Method selection ──────────────────────────────────────────────
async selectMethod(event) {
const isStripe = event.target.dataset.paymentDriver === 'stripe'
try {
const response = await fetch(this.selectUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({ payment_type: event.target.value }),
})
if (response.ok) {
const data = await response.json()
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } }))
}
}
} catch {
// summary just won't refresh — non-fatal
}
isStripe ? this.mountStripe() : this.unmountStripe()
}
selectedRadio() {
return this.element.querySelector('input[name="payment_type"]:checked')
}
selectedIsStripe() {
return this.selectedRadio()?.dataset.paymentDriver === 'stripe'
}
// ── Stripe Payment Element ────────────────────────────────────────
async loadStripe() {
if (window.Stripe) return window.Stripe
await new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${STRIPE_JS}"]`)
if (existing) {
existing.addEventListener('load', resolve)
existing.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = STRIPE_JS
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
return window.Stripe
}
async mountStripe() {
if (this.paymentElement || !this.stripeKeyValue) return
const Stripe = await this.loadStripe()
this.stripe = this.stripe || Stripe(this.stripeKeyValue)
this.elements = this.stripe.elements({
mode: 'payment',
amount: Math.max(this.amountValue, 1),
currency: this.currencyValue,
paymentMethodCreation: 'manual',
// Card only — matches the server confirming with
// automatic_payment_methods.allow_redirects = 'never' (no
// return_url in our flow: 3-D Secure resolves in-page via
// handleNextAction, never a full-page redirect).
paymentMethodTypes: ['card'],
})
this.paymentElement = this.elements.create('payment')
this.paymentElement.mount(this.elementTarget)
this.elementTarget.hidden = false
}
unmountStripe() {
this.paymentElement?.unmount()
this.paymentElement = null
this.elements = null
if (this.hasElementTarget) {
this.elementTarget.innerHTML = ''
this.elementTarget.hidden = true
}
}
// ── Place order ──────────────────────────────────────────────────
// Sibling controller on the same element (.bbk-checkout-main) — used to
// flush a pending debounced address autosave before placing the order.
get checkoutForm() {
return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form')
}
async placeOrder() {
this.clearError()
this.submitTarget.disabled = true
// A debounced address save (and the shipping-option auto-select that
// happens as part of it) might still be pending — make sure the
// server has the latest state before we ask it to place the order.
await this.checkoutForm?.flush()
if (!this.termsTarget.checked) {
this.submitTarget.disabled = false
this.showError(this.termsRequiredValue)
return
}
const radio = this.selectedRadio()
if (!radio) {
this.submitTarget.disabled = false
this.showError(this.chooseMethodValue)
return
}
let paymentMethodId = null
if (radio.dataset.paymentDriver === 'stripe') {
const { error: submitError } = await this.elements.submit()
if (submitError) return this.fail(submitError.message)
const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
if (pmError) return this.fail(pmError.message)
paymentMethodId = paymentMethod.id
}
let data
try {
const response = await fetch(this.placeOrderUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({
payment_type: radio.value,
payment_method: paymentMethodId ?? '',
terms_accepted: '1',
}),
})
data = await response.json()
} catch {
return this.fail(this.genericErrorValue)
}
if (data.redirect) {
window.location.assign(data.redirect)
return
}
if (data.status === 'pending' && data.clientSecret) {
await this.resolvePending(data.clientSecret)
return
}
// Points at the section that actually needs attention, rather than
// leaving a generic error and making the shopper hunt for it — e.g. a
// region with 2+ shipping methods needs an explicit pick, easy to miss.
if (data.field === 'shipping_option') {
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
return
}
this.fail(data.message || data.error || this.genericErrorValue)
}
async resolvePending(clientSecret) {
this.processingTarget.hidden = false
const { error } = await this.stripe.handleNextAction({ clientSecret })
if (error) {
this.processingTarget.hidden = true
return this.fail(error.message)
}
// 3-D Secure cleared client-side — the webhook places the order. Poll.
const startedAt = Date.now()
const tick = async () => {
try {
const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } })
const data = await response.json()
if (data.placed && data.redirect) {
window.location.assign(data.redirect)
return
}
} catch {
// keep polling
}
if (Date.now() - startedAt > POLL_TIMEOUT) {
this.processingTextTarget.textContent = this.processingSlowValue
return
}
setTimeout(tick, POLL_INTERVAL)
}
tick()
}
// ── helpers ──────────────────────────────────────────────────────
fail(message, { scroll = true } = {}) {
this.showError(message, { scroll })
this.submitTarget.disabled = false
}
showError(message, { scroll = true } = {}) {
this.errorTarget.textContent = message
this.errorTarget.hidden = false
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
clearError() {
this.errorTarget.textContent = ''
this.errorTarget.hidden = true
}
}
-6
View File
@@ -1,6 +0,0 @@
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
// boboko host renders in its layout <head>. Kept as its own module so both
// checkout controllers share one source.
export function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
-21
View File
@@ -1,21 +0,0 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkBoxNowLockerController from './bbk-box-now-locker-controller'
import BbkCartController from './bbk-cart-controller'
import BbkCheckoutFormController from './bbk-checkout-form-controller'
import BbkPaymentController from './bbk-payment-controller'
// Registers the checkout module's Stimulus controllers onto the host app's
// Stimulus application. Call once from the host's JS entry point:
//
// import { registerCheckout } from './checkout'
// registerCheckout(application)
//
// When this module moves to boboko-core this file ships with it unchanged;
// only that one import line in the host entry point differs per project.
export function registerCheckout(application) {
application.register('bbk-add-to-cart', BbkAddToCartController)
application.register('bbk-box-now-locker', BbkBoxNowLockerController)
application.register('bbk-cart', BbkCartController)
application.register('bbk-checkout-form', BbkCheckoutFormController)
application.register('bbk-payment', BbkPaymentController)
}

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