generated from boboko/starter
Compare commits
56
Commits
a51ce78538
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
623851bd70 | ||
|
|
39eda9726d | ||
|
|
f718ba57bc | ||
|
|
1de5668032 | ||
|
|
35bf421da8 | ||
|
|
a7cdfbd1cb | ||
|
|
92016256ce | ||
|
|
b96fb2800f | ||
|
|
d16ddd15eb | ||
|
|
aff53371d9 | ||
|
|
425510fb16 | ||
|
|
e3f9b3deb1 | ||
|
|
72ff4e5c76 | ||
|
|
5482ac07c6 | ||
|
|
7e38f05650 | ||
|
|
df29429257 | ||
|
|
f6bcd31bd8 | ||
|
|
77c9117556 | ||
|
|
8fdb399386 | ||
|
|
47f250d27f | ||
|
|
40c6b27ccb | ||
|
|
db01151ca3 | ||
|
|
9fcea8fb09 | ||
|
|
38e71927bc | ||
|
|
70787ace44 | ||
|
|
25021bdafb | ||
|
|
03b46e7bfb | ||
|
|
ec9ac92a95 | ||
|
|
8025bf2692 | ||
|
|
c48d3b1756 | ||
|
|
d053f57b10 | ||
|
|
b522d60954 | ||
|
|
b38e190714 | ||
|
|
d81adb4ddc | ||
|
|
094763fd86 | ||
|
|
a1a98c75c5 | ||
|
|
fb5ec85c9f | ||
|
|
e8054b2292 | ||
|
|
60ae6cf7a6 | ||
|
|
296d6001cd | ||
|
|
aa051525a1 | ||
|
|
b288513bb4 | ||
|
|
74930351f1 | ||
|
|
65d4603793 | ||
|
|
023c11019c | ||
|
|
88d8ae0954 | ||
|
|
ca362f81c8 | ||
|
|
4634e14924 | ||
|
|
69486f65ee | ||
|
|
060b6c647e | ||
|
|
36806cac5b | ||
|
|
b1162761de | ||
|
|
8b362c9436 | ||
|
|
cd45d8f578 | ||
|
|
8e186fc231 | ||
|
|
c7bd1efdaa |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One product on a logged-in user's wishlist. Guests' wishlists live in a
|
|
||||||
* cookie instead — see App\Services\Wishlist.
|
|
||||||
*/
|
|
||||||
class WishlistItem extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = ['user_id', 'product_id'];
|
|
||||||
}
|
|
||||||
@@ -4,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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Spatie\YamlFrontMatter\YamlFrontMatter;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class Stoic
|
class Stoic
|
||||||
@@ -29,6 +30,42 @@ public static function presets(): array
|
|||||||
return static::$presets;
|
return static::$presets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stoic's default locale (first in i18n.locales), or null when i18n is off.
|
||||||
|
// Its content folder holds every field; other locales only translatable ones.
|
||||||
|
public static function defaultLocale(): ?string
|
||||||
|
{
|
||||||
|
static::loadConfig();
|
||||||
|
return static::$config["i18n"]["locales"][0]["code"] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current locale's translated fields for a content file (path relative
|
||||||
|
// to a locale folder, e.g. "pages/home.md"), with the markdown body as
|
||||||
|
// `content`. Empty when the current locale is the default one (or i18n is
|
||||||
|
// off), the file doesn't exist, or a field is left blank — so layering this
|
||||||
|
// over the default-locale values falls back to them field by field.
|
||||||
|
public static function localeOverrides(string $file): array
|
||||||
|
{
|
||||||
|
$default = static::defaultLocale();
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
if ($default === null || $locale === $default) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = resource_path("stoic/content/{$locale}/{$file}");
|
||||||
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$document = YamlFrontMatter::parse(file_get_contents($path));
|
||||||
|
|
||||||
|
return array_filter(
|
||||||
|
[...$document->matter(), "content" => trim($document->body())],
|
||||||
|
fn($value) => filled($value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the public URL for a stoic image at a given preset.
|
// Returns the public URL for a stoic image at a given preset.
|
||||||
public static function image(string $filename, string $preset): string
|
public static function image(string $filename, string $preset): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Spatie\YamlFrontMatter\YamlFrontMatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site-wide, marketing-editable settings from Stoic's `settings` singleton
|
||||||
|
* (resources/stoic/content/{locale}/settings.md). Read directly rather than
|
||||||
|
* through a Paper model: Paper models map to a folder of entries, and this is
|
||||||
|
* one file per locale. Legal and transactional details (address, ΑΦΜ, phone)
|
||||||
|
* live in boboko-core's StoreDetails instead.
|
||||||
|
*
|
||||||
|
* Stoic writes every field to the default locale's file and only the
|
||||||
|
* translatable ones (e.g. meta_description) to the others, so the current
|
||||||
|
* locale's file is layered over the default one; empty values fall through.
|
||||||
|
*
|
||||||
|
* Bound as scoped in AppServiceProvider, so files are parsed at most once per
|
||||||
|
* locale per request (or queued job) and edits in Stoic show up on the next one.
|
||||||
|
* The locale is read per call, not at construction, so a mail rendered with
|
||||||
|
* ->locale() still gets its own locale's values.
|
||||||
|
*/
|
||||||
|
class StoicSettings
|
||||||
|
{
|
||||||
|
private const SOCIALS = [
|
||||||
|
'facebook' => 'Facebook',
|
||||||
|
'instagram' => 'Instagram',
|
||||||
|
'tiktok' => 'TikTok',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> keyed by locale */
|
||||||
|
private array $data = [];
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$value = $this->data()[$key] ?? null;
|
||||||
|
|
||||||
|
return filled($value) ? $value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute og:image URL for a Stoic image field value (the page's own, else
|
||||||
|
* the default from settings.md), or null. Image fields store a
|
||||||
|
* comma-separated list; only the first is used.
|
||||||
|
*/
|
||||||
|
public function ogImageUrl(?string $pageImage = null): ?string
|
||||||
|
{
|
||||||
|
$filename = trim(explode(',', $pageImage ?: (string) $this->get('og_image', ''))[0]);
|
||||||
|
|
||||||
|
return $filename === '' ? null : url(Stoic::image($filename, 'large'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the networks that have a URL set, in display order.
|
||||||
|
*
|
||||||
|
* @return list<array{icon: string, label: string, url: string}>
|
||||||
|
*/
|
||||||
|
public function socialLinks(): array
|
||||||
|
{
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
foreach (self::SOCIALS as $icon => $label) {
|
||||||
|
if ($url = $this->get("{$icon}_url")) {
|
||||||
|
$links[] = ['icon' => $icon, 'label' => $label, 'url' => $url];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function data(): array
|
||||||
|
{
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
return $this->data[$locale] ??= $this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function load(): array
|
||||||
|
{
|
||||||
|
$default = Stoic::defaultLocale();
|
||||||
|
|
||||||
|
if ($default === null) {
|
||||||
|
return $this->parse(resource_path('stoic/content/settings.md'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge(
|
||||||
|
$this->parse(resource_path("stoic/content/{$default}/settings.md")),
|
||||||
|
Stoic::localeOverrides('settings.md'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parse(string $path): array
|
||||||
|
{
|
||||||
|
return is_file($path) ? YamlFrontMatter::parse(file_get_contents($path))->matter() : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\WishlistItem;
|
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Cookie;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current shopper's wishlist, product ids only.
|
|
||||||
*
|
|
||||||
* Logged in: rows in wishlist_items. Guest: a 1-year cookie holding the ids
|
|
||||||
* (encrypted like every cookie, by the web group's EncryptCookies), so nothing
|
|
||||||
* is written to the database for anonymous visitors. On login the cookie is
|
|
||||||
* merged into the account and cleared (MergeGuestWishlistOnLogin).
|
|
||||||
*/
|
|
||||||
class Wishlist
|
|
||||||
{
|
|
||||||
public const COOKIE = 'wishlist';
|
|
||||||
|
|
||||||
private const COOKIE_MINUTES = 60 * 24 * 365;
|
|
||||||
|
|
||||||
// Keeps the cookie well under the 4KB browser limit.
|
|
||||||
private const GUEST_MAX = 100;
|
|
||||||
|
|
||||||
/** @var array<int>|null ids for this request, including a toggle just made */
|
|
||||||
private ?array $guestIds = null;
|
|
||||||
|
|
||||||
/** @return array<int> newest first */
|
|
||||||
public function ids(): array
|
|
||||||
{
|
|
||||||
if ($user = Auth::user()) {
|
|
||||||
return WishlistItem::where('user_id', $user->id)
|
|
||||||
->latest('id')
|
|
||||||
->pluck('product_id')
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->guestIds();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function has(int $productId): bool
|
|
||||||
{
|
|
||||||
return in_array($productId, $this->ids(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return bool whether the product is on the wishlist afterwards
|
|
||||||
*/
|
|
||||||
public function toggle(int $productId): bool
|
|
||||||
{
|
|
||||||
if ($user = Auth::user()) {
|
|
||||||
$deleted = WishlistItem::where('user_id', $user->id)->where('product_id', $productId)->delete();
|
|
||||||
|
|
||||||
if ($deleted) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
WishlistItem::create(['user_id' => $user->id, 'product_id' => $productId]);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ids = $this->guestIds();
|
|
||||||
|
|
||||||
if (in_array($productId, $ids, true)) {
|
|
||||||
$this->storeGuestIds(array_values(array_diff($ids, [$productId])));
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->storeGuestIds(array_slice([$productId, ...$ids], 0, self::GUEST_MAX));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function remove(int $productId): void
|
|
||||||
{
|
|
||||||
if ($this->has($productId)) {
|
|
||||||
$this->toggle($productId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves the guest cookie's products onto $user's wishlist and clears it.
|
|
||||||
*/
|
|
||||||
public function mergeGuestInto(Authenticatable $user): void
|
|
||||||
{
|
|
||||||
$ids = $this->guestIds();
|
|
||||||
|
|
||||||
if ($ids === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Oldest first, so the newest cookie item also ends up newest here.
|
|
||||||
foreach (array_reverse($ids) as $productId) {
|
|
||||||
WishlistItem::firstOrCreate(['user_id' => $user->id, 'product_id' => $productId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->guestIds = [];
|
|
||||||
Cookie::queue(Cookie::forget(self::COOKIE));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int> */
|
|
||||||
private function guestIds(): array
|
|
||||||
{
|
|
||||||
if ($this->guestIds !== null) {
|
|
||||||
return $this->guestIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode((string) request()->cookie(self::COOKIE), true);
|
|
||||||
|
|
||||||
return $this->guestIds = is_array($decoded)
|
|
||||||
? array_values(array_unique(array_filter(array_map('intval', $decoded))))
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<int> $ids */
|
|
||||||
private function storeGuestIds(array $ids): void
|
|
||||||
{
|
|
||||||
$this->guestIds = $ids;
|
|
||||||
|
|
||||||
Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Executable
+146
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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/ {
|
||||||
|
|||||||
@@ -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]
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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' => 'Όλες οι τιμές περιλαμβάνουν ΦΠΑ, το οποίο θα ληφθεί υπόψη στον υπολογισμό της ελάχιστης αξίας παραγγελίας.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
Generated
+14
-47
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+2
File diff suppressed because one or more lines are too long
+1
@@ -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}
|
||||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Executable
+1
@@ -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)});})();
|
||||||
Executable
+1
File diff suppressed because one or more lines are too long
Executable
+13
File diff suppressed because one or more lines are too long
+1
@@ -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};
|
||||||
@@ -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};
|
||||||
+38
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+116
File diff suppressed because one or more lines are too long
+1
@@ -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};
|
||||||
+51
File diff suppressed because one or more lines are too long
+161
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
@@ -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
@@ -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};
|
||||||
+1
File diff suppressed because one or more lines are too long
+1
@@ -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
@@ -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
@@ -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};
|
||||||
Executable
+1
@@ -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})}})})})});})();
|
||||||
Executable
+46
File diff suppressed because one or more lines are too long
@@ -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};
|
||||||
+11
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};
|
||||||
@@ -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};
|
||||||
Executable
+1
File diff suppressed because one or more lines are too long
+31
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 |
@@ -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 |
@@ -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
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -10,13 +10,15 @@ window.Turbo.session.drive = false;
|
|||||||
|
|
||||||
import { Application } from "@hotwired/stimulus";
|
import { Application } from "@hotwired/stimulus";
|
||||||
import { registerControllers } from "./stimulus/index";
|
import { registerControllers } from "./stimulus/index";
|
||||||
import { registerCheckout } from "./checkout";
|
import { registerCheckout, registerWishlist } from "@boboko/core";
|
||||||
|
|
||||||
const application = Application.start();
|
const application = Application.start();
|
||||||
application.debug = false;
|
application.debug = false;
|
||||||
|
|
||||||
registerControllers(application);
|
registerControllers(application);
|
||||||
|
|
||||||
// Portable cart + checkout module (destined for boboko-core). Owns its own
|
// Portable cart + checkout + wishlist modules, installed as a real npm
|
||||||
// bbk-* Stimulus controllers; this is the only wiring line it needs here.
|
// dependency (see package.json). Each owns its own Stimulus controllers;
|
||||||
|
// these are the only wiring lines they need here.
|
||||||
registerCheckout(application);
|
registerCheckout(application);
|
||||||
|
registerWishlist(application);
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus'
|
|
||||||
import { csrfToken } from './csrf'
|
|
||||||
|
|
||||||
// Sits on an <x-checkout::add-to-cart> <form>. Submits the line to the cart
|
|
||||||
// via fetch and hands the server-rendered cart body to the drawer through the
|
|
||||||
// `bbk-cart:changed` window event. No DOM building here — the drawer
|
|
||||||
// (bbk-cart-controller) owns rendering.
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['error']
|
|
||||||
|
|
||||||
async add(event) {
|
|
||||||
event.preventDefault()
|
|
||||||
|
|
||||||
const form = this.element
|
|
||||||
const submit = form.querySelector('[type="submit"]')
|
|
||||||
|
|
||||||
this.clearError()
|
|
||||||
form.setAttribute('data-bbk-add-to-cart-state', 'loading')
|
|
||||||
if (submit) submit.disabled = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(form.action, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body: new FormData(form),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => null)
|
|
||||||
this.showError(data?.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
|
|
||||||
detail: { html: await response.text() },
|
|
||||||
}))
|
|
||||||
} finally {
|
|
||||||
form.removeAttribute('data-bbk-add-to-cart-state')
|
|
||||||
if (submit) submit.disabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
if (!this.hasErrorTarget || !message) return
|
|
||||||
this.errorTarget.textContent = message
|
|
||||||
this.errorTarget.hidden = false
|
|
||||||
}
|
|
||||||
|
|
||||||
clearError() {
|
|
||||||
if (!this.hasErrorTarget) return
|
|
||||||
this.errorTarget.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
import { Controller } from '@hotwired/stimulus'
|
|
||||||
import L from 'leaflet'
|
|
||||||
import 'leaflet/dist/leaflet.css'
|
|
||||||
import { csrfToken } from './csrf'
|
|
||||||
|
|
||||||
// Box Now's own brand green, used for the pin instead of Leaflet's default
|
|
||||||
// blue teardrop — a small SVG data URI rather than another bundled asset.
|
|
||||||
const PIN_COLOR = '#00c389'
|
|
||||||
const PIN_COLOR_SELECTED = '#0a7a52'
|
|
||||||
|
|
||||||
function pinIcon(color) {
|
|
||||||
const svg = `
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="46" viewBox="0 0 34 46">
|
|
||||||
<path
|
|
||||||
d="M17 0C7.6 0 0 7.6 0 17c0 12.75 17 29 17 29s17-16.25 17-29C34 7.6 26.4 0 17 0Z"
|
|
||||||
fill="${color}"
|
|
||||||
stroke="#ffffff"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
<circle cx="17" cy="17" r="7" fill="#ffffff" />
|
|
||||||
</svg>
|
|
||||||
`
|
|
||||||
|
|
||||||
return L.divIcon({
|
|
||||||
className: 'bbk-box-now-pin',
|
|
||||||
html: svg,
|
|
||||||
iconSize: [34, 46],
|
|
||||||
iconAnchor: [17, 46],
|
|
||||||
popupAnchor: [0, -40],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const ICON = pinIcon(PIN_COLOR)
|
|
||||||
const ICON_SELECTED = pinIcon(PIN_COLOR_SELECTED)
|
|
||||||
|
|
||||||
// A self-hosted Leaflet map standing in for Box Now's own Destination Map
|
|
||||||
// JS widget — that widget only talks to Box Now's Production API (see
|
|
||||||
// their Partner API manual §4.1), so it can't be used while developing
|
|
||||||
// against Stage credentials. Same underlying /destinations data, rendered
|
|
||||||
// with OpenStreetMap tiles instead of Box Now's map.
|
|
||||||
//
|
|
||||||
// Visibility is toggled by bbk-checkout-form (see its own
|
|
||||||
// toggleBoxNowLocker()) whenever the "box-now" shipping option becomes
|
|
||||||
// selected/deselected — this controller only owns loading the locker list
|
|
||||||
// once visible, rendering pins, and autosaving the chosen one.
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ['map', 'search', 'status', 'chosen']
|
|
||||||
|
|
||||||
static values = {
|
|
||||||
lockersUrl: String,
|
|
||||||
selectUrl: String,
|
|
||||||
loading: String,
|
|
||||||
selectLabel: String,
|
|
||||||
selectedLabel: String,
|
|
||||||
noResults: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Athens — a reasonable default center before any locker is loaded.
|
|
||||||
static DEFAULT_CENTER = [37.9838, 23.7275]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.map = null
|
|
||||||
this.markers = new Map()
|
|
||||||
this.selectedId = null
|
|
||||||
this.loaded = false
|
|
||||||
|
|
||||||
if (!this.element.hidden) this.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.map?.remove()
|
|
||||||
this.map = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called by bbk-checkout-form right after it un-hides this element.
|
|
||||||
show() {
|
|
||||||
this.element.hidden = false
|
|
||||||
|
|
||||||
// Leaflet measures its container's size on init — doing that while
|
|
||||||
// the element (or an ancestor) is still `hidden` produces a
|
|
||||||
// collapsed/blank map, so this is deferred to the same tick `hidden`
|
|
||||||
// is cleared, then Leaflet is nudged once more via invalidateSize().
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!this.map) this.initMap()
|
|
||||||
this.map.invalidateSize()
|
|
||||||
|
|
||||||
if (!this.loaded) this.loadLockers()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
this.element.hidden = true
|
|
||||||
}
|
|
||||||
|
|
||||||
initMap() {
|
|
||||||
this.map = L.map(this.mapTarget).setView(this.constructor.DEFAULT_CENTER, 10)
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors',
|
|
||||||
maxZoom: 19,
|
|
||||||
}).addTo(this.map)
|
|
||||||
|
|
||||||
// Delegated: popup content is re-inserted by Leaflet on every open,
|
|
||||||
// so a listener bound once on the map's container beats binding (and
|
|
||||||
// losing) one on the button each time a popup renders.
|
|
||||||
this.map.getContainer().addEventListener('click', (event) => {
|
|
||||||
const button = event.target.closest('[data-locker-id]')
|
|
||||||
if (button) this.select(button.dataset.lockerId)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadLockers() {
|
|
||||||
this.setStatus(this.loadingValue)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.lockersUrlValue, {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) return
|
|
||||||
|
|
||||||
const { lockers } = await response.json()
|
|
||||||
this.loaded = true
|
|
||||||
this.lockers = new Map(lockers.map((locker) => [String(locker.id), locker]))
|
|
||||||
this.renderMarkers(lockers)
|
|
||||||
this.setStatus('')
|
|
||||||
} catch {
|
|
||||||
this.setStatus('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderMarkers(lockers) {
|
|
||||||
this.markers.forEach((marker) => marker.remove())
|
|
||||||
this.markers = new Map(lockers.map((locker) => {
|
|
||||||
const marker = L.marker([locker.lat, locker.lng], { icon: ICON })
|
|
||||||
.addTo(this.map)
|
|
||||||
.bindPopup(this.popupHtml(locker), { maxWidth: 260 })
|
|
||||||
|
|
||||||
return [String(locker.id), marker]
|
|
||||||
}))
|
|
||||||
|
|
||||||
if (this.markers.size) {
|
|
||||||
this.map.fitBounds(L.featureGroup([...this.markers.values()]).getBounds().pad(0.2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
popupHtml(locker) {
|
|
||||||
const isSelected = String(locker.id) === this.selectedId
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="bbk-box-now-popup">
|
|
||||||
${locker.image ? `<img class="bbk-box-now-popup-image" src="${locker.image}" alt="">` : ''}
|
|
||||||
<p class="bbk-box-now-popup-name">${locker.name}</p>
|
|
||||||
<p class="bbk-box-now-popup-address">
|
|
||||||
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')}
|
|
||||||
${locker.postalCode ? ` ${locker.postalCode}` : ''}
|
|
||||||
</p>
|
|
||||||
${locker.note ? `<p class="bbk-box-now-popup-note">${locker.note}</p>` : ''}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="bbk-box-now-popup-select${isSelected ? ' bbk-box-now-popup-select--selected' : ''}"
|
|
||||||
data-locker-id="${locker.id}"
|
|
||||||
${isSelected ? 'disabled' : ''}
|
|
||||||
>
|
|
||||||
${isSelected ? this.selectedLabelValue : this.selectLabelValue}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
|
|
||||||
async select(lockerId) {
|
|
||||||
const locker = this.lockers?.get(String(lockerId))
|
|
||||||
if (!locker) return
|
|
||||||
|
|
||||||
const previousId = this.selectedId
|
|
||||||
this.selectedId = String(lockerId)
|
|
||||||
|
|
||||||
this.restyleMarker(previousId, ICON)
|
|
||||||
this.restyleMarker(this.selectedId, ICON_SELECTED)
|
|
||||||
this.markers.get(this.selectedId)?.setPopupContent(this.popupHtml(locker))
|
|
||||||
|
|
||||||
this.chosenTarget.hidden = false
|
|
||||||
this.chosenTarget.textContent = locker.addressLine1
|
|
||||||
? `${locker.name} — ${locker.addressLine1}`
|
|
||||||
: locker.name
|
|
||||||
|
|
||||||
const body = new FormData()
|
|
||||||
body.append('locker_id', locker.id)
|
|
||||||
body.append('locker_name', locker.name ?? '')
|
|
||||||
body.append('locker_address', locker.addressLine1 ?? '')
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(this.selectUrlValue, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': csrfToken(),
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Best-effort autosave, same as the rest of checkout — a failed
|
|
||||||
// save here surfaces later at place-order time via the normal
|
|
||||||
// shipment-creation error path, not as an inline field error.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
restyleMarker(lockerId, icon) {
|
|
||||||
if (!lockerId) return
|
|
||||||
this.markers.get(lockerId)?.setIcon(icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(text) {
|
|
||||||
if (!this.hasStatusTarget) return
|
|
||||||
|
|
||||||
this.statusTarget.textContent = text
|
|
||||||
this.statusTarget.hidden = !text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
|
|
||||||
// boboko host renders in its layout <head>. Kept as its own module so both
|
|
||||||
// checkout controllers share one source.
|
|
||||||
export function csrfToken() {
|
|
||||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
|
||||||
import BbkBoxNowLockerController from './bbk-box-now-locker-controller'
|
|
||||||
import BbkCartController from './bbk-cart-controller'
|
|
||||||
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
|
||||||
import BbkPaymentController from './bbk-payment-controller'
|
|
||||||
|
|
||||||
// Registers the checkout module's Stimulus controllers onto the host app's
|
|
||||||
// Stimulus application. Call once from the host's JS entry point:
|
|
||||||
//
|
|
||||||
// import { registerCheckout } from './checkout'
|
|
||||||
// registerCheckout(application)
|
|
||||||
//
|
|
||||||
// When this module moves to boboko-core this file ships with it unchanged;
|
|
||||||
// only that one import line in the host entry point differs per project.
|
|
||||||
export function registerCheckout(application) {
|
|
||||||
application.register('bbk-add-to-cart', BbkAddToCartController)
|
|
||||||
application.register('bbk-box-now-locker', BbkBoxNowLockerController)
|
|
||||||
application.register('bbk-cart', BbkCartController)
|
|
||||||
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
|
||||||
application.register('bbk-payment', BbkPaymentController)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user