generated from boboko/starter
Compare commits
91
Commits
8a07c772f8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a51ce78538 | ||
|
|
ab7e560a3d | ||
|
|
081893ef57 | ||
|
|
e2d7bbb043 | ||
|
|
3347febb3c | ||
|
|
d43b615297 | ||
|
|
7f6c1e6307 | ||
|
|
5bbf0aabcd | ||
|
|
fa172860db | ||
|
|
a51e9456d6 | ||
|
|
540da1af24 | ||
|
|
e28faea546 | ||
|
|
8055c003ac | ||
|
|
94803bfb67 | ||
|
|
cf3681260b | ||
|
|
580adac33a | ||
|
|
a4240b6474 | ||
|
|
326e1fac54 | ||
|
|
e3a6267059 | ||
|
|
24851184c4 | ||
|
|
a107184010 | ||
|
|
af0245de43 | ||
|
|
afa1993c53 | ||
|
|
78c8165c04 | ||
|
|
fe55cb5f33 | ||
|
|
038ea05d56 | ||
|
|
ba6d68c5b9 | ||
|
|
1598f053ee | ||
|
|
279339a095 | ||
|
|
df9374a070 | ||
|
|
97c12efdb1 | ||
|
|
2cac70c53a | ||
|
|
46b3f29674 | ||
|
|
7a8b9cf4c0 | ||
|
|
0fb606ff3f | ||
|
|
2d85591a43 | ||
|
|
7577426f49 | ||
|
|
c5f7b28aa0 | ||
|
|
9abe45c5f7 | ||
|
|
e57bfe3d42 | ||
|
|
72b9abf92f | ||
|
|
6c7d11ca78 | ||
|
|
1f0612861b | ||
|
|
b2207a622c | ||
|
|
b87e22381f | ||
|
|
53f30ff51a | ||
|
|
210ed3b094 | ||
|
|
5e2ec7a60a | ||
|
|
2d1624bcb2 | ||
|
|
ea6ebe435e | ||
|
|
2d75bb9e01 | ||
|
|
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/*
|
||||||
|
|||||||
+23
-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,12 @@ 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}"
|
||||||
|
|
||||||
|
HCAPTCHA_SITEKEY=
|
||||||
|
HCAPTCHA_SECRET=
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
@@ -68,12 +72,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
OTEL_SERVICE_NAME=boboko-starter
|
|
||||||
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
|
||||||
OTEL_PROPAGATORS=baggage,tracecontext
|
|
||||||
OTEL_PHP_AUTOLOAD_ENABLED=true
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=
|
|
||||||
|
|
||||||
# Stoic is the headless CMS, proxied at /stoic/ behind the app in prod (see docker/nginx/prod.conf)
|
# Stoic 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
|
||||||
STOIC_SSO_SECRET=dev-sso-secret-change-me
|
STOIC_SSO_SECRET=dev-sso-secret-change-me
|
||||||
@@ -84,6 +82,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
|
||||||
@@ -92,3 +93,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,7 +17,6 @@
|
|||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/sitemap.xml
|
/public/sitemap.xml
|
||||||
/public/logos/core
|
|
||||||
/public/storage
|
/public/storage
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/framework/migrated
|
/storage/framework/migrated
|
||||||
|
|||||||
+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'])
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 3dealer
|
||||||
|
|
||||||
|
Storefront for 3dealer.gr: Laravel 12 + Lunar PHP (headless), on top of `boboko/core`. Front-end conventions are in [CLAUDE.md](CLAUDE.md).
|
||||||
|
|
||||||
|
## Scheduled jobs
|
||||||
|
|
||||||
|
These run in the `scheduler` container (`php artisan schedule:work`).
|
||||||
|
|
||||||
|
| Command | When | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `custom-fields:prune-uploads` | Daily, 04:00 | Deletes custom-field photo uploads older than 24 h that no cart line or order line references. |
|
||||||
|
| `lunar:search:index` | Daily, 03:00 | Full product reindex. Registered by `boboko/core`. |
|
||||||
|
|
||||||
|
## Product custom fields
|
||||||
|
|
||||||
|
Admins can add custom fields to a product in the Lunar admin (the product's **Custom Fields** section, from `boboko/core`). The customer fills them in on the product page before adding the product to the cart. The answers are stored on the cart line (`meta.custom_fields`) and carried over to the order line.
|
||||||
|
|
||||||
|
| Field type | Storefront input | Limit |
|
||||||
|
|---|---|---|
|
||||||
|
| Short text | text input | 255 characters |
|
||||||
|
| Long text | textarea | 2,000 characters |
|
||||||
|
| File upload | photo upload | JPG, PNG, WEBP, HEIC/HEIF, up to 10 MB |
|
||||||
|
|
||||||
|
- **Photos upload as soon as they're picked.** They go to `POST /{locale}/custom-field-uploads`, which is limited to 20 per minute per client. They're stored on the private `local` disk under `storage/app/private/custom-field-uploads/`. Only an encrypted reference is sent with add-to-cart. The allowed types and size are set in `App\Http\Controllers\CustomFieldUploadController`.
|
||||||
|
- **Photos are never public.** The cart drawer, checkout summary and order confirmation link to a photo through a signed URL that expires after 2 hours.
|
||||||
|
- **Emails show text answers only**, never photos.
|
||||||
|
- **Photos are cleaned up automatically.** Photos never added to a cart are deleted by `custom-fields:prune-uploads` (see above). A photo on a cart line is kept as long as that cart line exists, and a photo on an order is kept indefinitely.
|
||||||
|
- **Products with custom fields can't be quick-added.** On product cards, the "add to cart" button becomes a link to the product page.
|
||||||
|
- Field labels are entered once in the admin and aren't translated, so they appear as entered in both `/el` and `/en`.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Catalog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presentation shaping — how a storefront product listing/grid card is built:
|
||||||
|
* name, price, image, href. Deliberately not in boboko-core: `href` depends on
|
||||||
|
* this storefront's own routes, and another app on the same core package could
|
||||||
|
* want an entirely different card shape. One place, so HomeController /
|
||||||
|
* CategoryController / ProductController / SearchController don't each
|
||||||
|
* hand-write the same name/price/image/href mapping.
|
||||||
|
*
|
||||||
|
* One source: a localized index array — ProductService::list()/getById()/
|
||||||
|
* random(), and now ProductSearchService::search() too, all return the exact
|
||||||
|
* same document shape (see ProductListingResult), so this is the only mapping
|
||||||
|
* every storefront listing page needs.
|
||||||
|
*/
|
||||||
|
final class ProductCard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $product one item from ProductService's localized array shape
|
||||||
|
* @return array{name: ?string, price: ?string, image: ?string, href: string, variantId: ?int, hasCustomFields: bool, soldOut: bool}
|
||||||
|
*/
|
||||||
|
public static function fromIndexed(array $product): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $product['name'],
|
||||||
|
'price' => $product['price'],
|
||||||
|
'image' => $product['media'][0]['url'] ?? null,
|
||||||
|
'href' => route('product.show', ['id' => $product['id']]),
|
||||||
|
// The card's quick "Add to cart" always adds this variant, same
|
||||||
|
// default Modules\Core\Catalog\Services\ProductService::
|
||||||
|
// variantSummaries() and product/show.blade.php both use — no
|
||||||
|
// picker at listing-grid scope, unlike the product page's own
|
||||||
|
// color swatches.
|
||||||
|
'variantId' => $product['variants'][0]['id'] ?? null,
|
||||||
|
// A product with custom fields (photo upload, engraving text…)
|
||||||
|
// can't be quick-added from a card — the card links to the
|
||||||
|
// product page instead, even when every field is optional.
|
||||||
|
'hasCustomFields' => ! empty($product['custom_fields']),
|
||||||
|
// Index-time stock (see boboko-core's ProductIndexer `in_stock`),
|
||||||
|
// so only as fresh as the last reindex. A document missing the
|
||||||
|
// field is treated as in stock rather than hiding its cart button.
|
||||||
|
'soldOut' => ! ($product['in_stock'] ?? true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,20 +7,21 @@
|
|||||||
use Modules\Core\Catalog\Enums\ProductSort;
|
use Modules\Core\Catalog\Enums\ProductSort;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The parsed state of a category listing request. The query string is the single
|
* The parsed filter/sort/page state of a product-listing request — used by the
|
||||||
* source of truth for sort / filters / page — build one of these from the
|
* category page (scoped to a collection) and the all-products page. The query
|
||||||
* request, read the applied values off it, and use query() to build links (sort
|
* string is the single source of truth; build one of these from the request,
|
||||||
* options, pagination, "clear filter") that carry the rest of the state along.
|
* read the applied values off it, and use query() to build links (sort options,
|
||||||
|
* pagination, "clear filter") that carry the rest of the state along.
|
||||||
*
|
*
|
||||||
* A param is only ever emitted when it differs from its default, so a pristine
|
* A param is only ever emitted when it differs from its default, so a pristine
|
||||||
* listing is just `/category/{id}` with no query string.
|
* listing has no query string at all.
|
||||||
*/
|
*/
|
||||||
final class CategoryListing
|
final class ProductListing
|
||||||
{
|
{
|
||||||
private function __construct(
|
private function __construct(
|
||||||
public readonly ?ProductSort $sort,
|
public readonly ?ProductSort $sort,
|
||||||
public readonly ?int $minPrice,
|
public readonly ?float $minPrice,
|
||||||
public readonly ?int $maxPrice,
|
public readonly ?float $maxPrice,
|
||||||
public readonly bool $inStockOnly,
|
public readonly bool $inStockOnly,
|
||||||
public readonly int $page,
|
public readonly int $page,
|
||||||
) {}
|
) {}
|
||||||
@@ -29,14 +30,17 @@ public static function fromRequest(Request $request): self
|
|||||||
{
|
{
|
||||||
return new self(
|
return new self(
|
||||||
sort: ProductSort::tryFrom((string) $request->query('sort')),
|
sort: ProductSort::tryFrom((string) $request->query('sort')),
|
||||||
minPrice: self::intOrNull($request->query('price_min')),
|
minPrice: self::floatOrNull($request->query('price_min')),
|
||||||
maxPrice: self::intOrNull($request->query('price_max')),
|
maxPrice: self::floatOrNull($request->query('price_max')),
|
||||||
inStockOnly: $request->boolean('in_stock'),
|
inStockOnly: $request->boolean('in_stock'),
|
||||||
page: max(1, (int) $request->query('page', 1)),
|
page: max(1, (int) $request->query('page', 1)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function filters(int $collectionId): ProductFilters
|
/**
|
||||||
|
* @param ?int $collectionId scope to a collection (category page); null = every product
|
||||||
|
*/
|
||||||
|
public function filters(?int $collectionId = null): ProductFilters
|
||||||
{
|
{
|
||||||
return new ProductFilters(
|
return new ProductFilters(
|
||||||
collectionId: $collectionId,
|
collectionId: $collectionId,
|
||||||
@@ -48,8 +52,7 @@ public function filters(int $collectionId): ProductFilters
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The applied params as a clean array (defaults omitted), with `$overrides`
|
* The applied params as a clean array (defaults omitted), with `$overrides`
|
||||||
* merged on top — pass `['key' => null]` to drop one. Feeds straight into
|
* merged on top — pass `['key' => null]` to drop one.
|
||||||
* route('category.show', ['id' => $id] + $listing->query([...])).
|
|
||||||
*
|
*
|
||||||
* @param array<string, string|int|null> $overrides
|
* @param array<string, string|int|null> $overrides
|
||||||
* @return array<string, string|int>
|
* @return array<string, string|int>
|
||||||
@@ -68,7 +71,7 @@ public function query(array $overrides = []): array
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the listing is reordered/narrowed enough that it shouldn't be
|
* Whether the listing is reordered/narrowed enough that it shouldn't be
|
||||||
* indexed as its own page (the canonical still points at the bare category
|
* indexed as its own page (the canonical still points at the bare listing
|
||||||
* URL either way). A plain in-stock toggle is left indexable.
|
* URL either way). A plain in-stock toggle is left indexable.
|
||||||
*/
|
*/
|
||||||
public function isRefined(): bool
|
public function isRefined(): bool
|
||||||
@@ -79,8 +82,8 @@ public function isRefined(): bool
|
|||||||
|| $this->page > 1;
|
|| $this->page > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function intOrNull(mixed $value): ?int
|
private static function floatOrNull(mixed $value): ?float
|
||||||
{
|
{
|
||||||
return is_numeric($value) ? (int) $value : null;
|
return is_numeric($value) ? (float) $value : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Catalog;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Modules\Core\Catalog\Enums\ProductSort;
|
||||||
|
use Modules\Core\Catalog\Services\ProductSearchService;
|
||||||
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles the data the shared shop listing body (shop/partials/listing.blade.php)
|
||||||
|
* needs — the product page, price-slider bounds, sort links and the "clear price"
|
||||||
|
* link. Used by the category page (scoped to a collection), the all-products
|
||||||
|
* page, and the search page (scoped to a query — see $query below); the `$url`
|
||||||
|
* closure turns a query-param array into a URL for whichever page is calling,
|
||||||
|
* so this class never has to know the route.
|
||||||
|
*/
|
||||||
|
final class ProductListingPage
|
||||||
|
{
|
||||||
|
private const PER_PAGE = 40;
|
||||||
|
|
||||||
|
/** Category pages rarely have enough products to fill a page, so this
|
||||||
|
* ceiling is high enough to act as "no pagination" in practice — the
|
||||||
|
* pagination component self-hides via hasPages() when everything fits.
|
||||||
|
* If a category ever exceeds it, pagination reappears as a safety net
|
||||||
|
* rather than silently truncating results. */
|
||||||
|
private const CATEGORY_PER_PAGE = 200;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProductService $products,
|
||||||
|
private readonly ProductSearchService $search,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Closure(array<string, string|int>): string $url
|
||||||
|
* @param ?int $collectionId scope to a collection, or null for every product
|
||||||
|
* @param ?string $query scope to a text search — when given, calls
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
|
||||||
|
{
|
||||||
|
$filters = $listing->filters($collectionId);
|
||||||
|
$perPage = $collectionId !== null ? self::CATEGORY_PER_PAGE : self::PER_PAGE;
|
||||||
|
|
||||||
|
// Listing reads from the Meilisearch index via ProductService/
|
||||||
|
// ProductSearchService, not Eloquent. Both return a
|
||||||
|
// ProductListingResult — the product page plus the price-slider
|
||||||
|
// bounds (and available tags) from one call; the controller no
|
||||||
|
// longer stitches list()/search() + priceRange() together itself.
|
||||||
|
// Sort/filter/page all come from $listing (the query string).
|
||||||
|
$result = $query !== null
|
||||||
|
? $this->search->search(
|
||||||
|
query: $query,
|
||||||
|
filters: $filters,
|
||||||
|
sort: $listing->sort,
|
||||||
|
perPage: $perPage,
|
||||||
|
page: $listing->page,
|
||||||
|
)
|
||||||
|
: $this->products->list(
|
||||||
|
filters: $filters,
|
||||||
|
perPage: $perPage,
|
||||||
|
page: $listing->page,
|
||||||
|
sort: $listing->sort,
|
||||||
|
);
|
||||||
|
|
||||||
|
$products = $result->products
|
||||||
|
->through(ProductCard::fromIndexed(...))
|
||||||
|
->appends($listing->query(['page' => null]));
|
||||||
|
|
||||||
|
// Slider bounds — the price span of everything matching the *other*
|
||||||
|
// filters, rounded to whole euros, plus whether the current price params
|
||||||
|
// actually narrow that span. All computed in core
|
||||||
|
$priceBounds = $result->priceBounds;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'listing' => $listing,
|
||||||
|
'products' => $products,
|
||||||
|
'listingAction' => $url([]),
|
||||||
|
'priceFloor' => $priceBounds->floor,
|
||||||
|
'priceCeil' => $priceBounds->ceil,
|
||||||
|
'clearPriceUrl' => $priceBounds->filtered
|
||||||
|
? $url($listing->query(['price_min' => null, 'price_max' => null, 'page' => null]))
|
||||||
|
: null,
|
||||||
|
'sortOptions' => ProductSortOptions::build(
|
||||||
|
$listing->sort,
|
||||||
|
fn (?ProductSort $sort) => $url($listing->query(['sort' => $sort?->value, 'page' => null])),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Catalog;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Modules\Core\Catalog\Enums\ProductSort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the option list for the shared <x-shop.sort> dropdown, so the label
|
||||||
|
* map and "the default sort has no URL param" rule live in one place. Each page
|
||||||
|
* supplies a `$url` closure that turns a sort (or null = default/relevance)
|
||||||
|
* into the right href for that page — category vs. search build their URLs
|
||||||
|
* differently.
|
||||||
|
*/
|
||||||
|
final class ProductSortOptions
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Closure(?ProductSort): string $url
|
||||||
|
* @return array<int, array{label: string, href: string, current: bool}>
|
||||||
|
*/
|
||||||
|
public static function build(?ProductSort $current, Closure $url): array
|
||||||
|
{
|
||||||
|
$sorts = [
|
||||||
|
null => 'storefront.shop.sort_popularity',
|
||||||
|
ProductSort::PriceAsc->value => 'storefront.shop.sort_price_asc',
|
||||||
|
ProductSort::PriceDesc->value => 'storefront.shop.sort_price_desc',
|
||||||
|
ProductSort::Newest->value => 'storefront.shop.sort_newest',
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_map(function (string $key, string $label) use ($current, $url) {
|
||||||
|
$sort = $key === '' ? null : ProductSort::from($key);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => __($label),
|
||||||
|
'href' => $url($sort),
|
||||||
|
'current' => $sort === $current,
|
||||||
|
];
|
||||||
|
}, array_keys($sorts), array_values($sorts));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Lunar\Models\Country;
|
||||||
|
use Lunar\Models\State;
|
||||||
|
use Modules\Core\Cart\Services\CartService;
|
||||||
|
use Modules\Core\Checkout\Services\CheckoutService;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
use Modules\Core\Privacy\Services\PrivacyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile page: name, invoice details, the one address, and account
|
||||||
|
* deletion. The customer keeps a single address, flagged as both the
|
||||||
|
* shipping and billing default, so allowing several later is a UI change
|
||||||
|
* only (the data is already Lunar's own addresses table).
|
||||||
|
*/
|
||||||
|
class AccountController extends Controller
|
||||||
|
{
|
||||||
|
// Single-country store, same as CheckoutController::STORE_COUNTRY_ISO3.
|
||||||
|
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||||
|
|
||||||
|
private const ADDRESS_FIELDS = ['line_one', 'city', 'postcode', 'state', 'contact_phone'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$customer = $this->account->customer($user);
|
||||||
|
|
||||||
|
return view('account.show', [
|
||||||
|
'user' => $user,
|
||||||
|
'customer' => $customer,
|
||||||
|
'address' => $this->defaultAddress($user),
|
||||||
|
'regions' => State::where('country_id', $this->storeCountry()->id)->orderBy('name')->get(['id', 'name']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(string $locale, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$country = $this->storeCountry();
|
||||||
|
|
||||||
|
// The address is all-or-nothing: typing any part of it makes the rest
|
||||||
|
// (and the name, which Lunar requires on every address) required.
|
||||||
|
$anyAddressField = implode(',', self::ADDRESS_FIELDS);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'first_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'last_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'invoice' => ['boolean'],
|
||||||
|
'company_name' => ['nullable', 'string', 'max:255', 'required_if_accepted:invoice'],
|
||||||
|
'tax_identifier' => ['nullable', 'digits:9', 'required_if_accepted:invoice'],
|
||||||
|
'line_one' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'city' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'postcode' => ['nullable', 'regex:/^\d{3}\s?\d{2}$/', 'required_with:'.$anyAddressField],
|
||||||
|
'state' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'required_with:'.$anyAddressField,
|
||||||
|
Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id),
|
||||||
|
],
|
||||||
|
'contact_phone' => ['nullable', 'string', 'max:30'],
|
||||||
|
'recovery_consent' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invoice = $request->boolean('invoice');
|
||||||
|
|
||||||
|
$this->account->updateProfile($user, [
|
||||||
|
'first_name' => $data['first_name'] ?? null,
|
||||||
|
'last_name' => $data['last_name'] ?? null,
|
||||||
|
'company_name' => $invoice ? $data['company_name'] : null,
|
||||||
|
'tax_identifier' => $invoice ? $data['tax_identifier'] : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (filled($data['line_one'] ?? null)) {
|
||||||
|
$addressData = [
|
||||||
|
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
|
||||||
|
'first_name' => $data['first_name'],
|
||||||
|
'last_name' => $data['last_name'],
|
||||||
|
'country_id' => $country->id,
|
||||||
|
'contact_email' => $user->email,
|
||||||
|
'shipping_default' => true,
|
||||||
|
'billing_default' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
$existing = $this->defaultAddress($user);
|
||||||
|
|
||||||
|
$existing
|
||||||
|
? $this->account->updateAddress($user, $existing->id, $addressData)
|
||||||
|
: $this->account->createAddress($user, $addressData);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateRecoveryConsent($user, $request->boolean('recovery_consent'));
|
||||||
|
|
||||||
|
return redirect()->route('account')->with('status', __('storefront.account.saved'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Email me a reminder if I don't finish my order", as a standing
|
||||||
|
* choice — stored on the customer via boboko-core's
|
||||||
|
* CustomerAccountService::setRecoveryConsent(), and applied to the
|
||||||
|
* current cart too, so opting out stops reminders for it right away.
|
||||||
|
*/
|
||||||
|
private function updateRecoveryConsent($user, bool $consent): void
|
||||||
|
{
|
||||||
|
$this->account->setRecoveryConsent($user, $consent);
|
||||||
|
|
||||||
|
// Only an existing cart; never create one just to record this.
|
||||||
|
$cart = app(CartService::class)->current();
|
||||||
|
|
||||||
|
if ($cart && (bool) data_get($cart, 'meta.recovery_consent') !== $consent) {
|
||||||
|
app(CheckoutService::class)->setRecoveryConsent($consent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service deletion: opens core's 30-day grace-period erasure request
|
||||||
|
* (which blocks the login right away) and logs out. Logging back in within
|
||||||
|
* the grace period cancels it; see core's docs/privacy.md.
|
||||||
|
*/
|
||||||
|
public function destroy(string $locale, Request $request, PrivacyService $privacy): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$privacy->requestErasureForUser($user, $user);
|
||||||
|
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login')->with('status', __('storefront.account.deletion_requested'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultAddress($user)
|
||||||
|
{
|
||||||
|
$addresses = collect($this->account->addresses($user));
|
||||||
|
|
||||||
|
return $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeCountry(): Country
|
||||||
|
{
|
||||||
|
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->firstOrFail();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||||
|
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
|
||||||
|
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
|
||||||
|
use Modules\Core\Customer\Services\CustomerEmailChangeService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changing the login email — a thin wrapper over boboko-core's
|
||||||
|
* Customer\Services\CustomerEmailChangeService, which owns the actual
|
||||||
|
* request/confirm mechanics, throttling, pending-change storage, and
|
||||||
|
* mailables. This controller's own job is just the storefront's session-
|
||||||
|
* scoped "which email did I just ask to switch to" UI state (so the
|
||||||
|
* .code/.resend pages know which address to show/resend to) and
|
||||||
|
* translating the service's exceptions into the flash-message flow the
|
||||||
|
* views expect.
|
||||||
|
*/
|
||||||
|
class EmailController extends Controller
|
||||||
|
{
|
||||||
|
private const SESSION_KEY = 'pending_email_change';
|
||||||
|
|
||||||
|
public function edit(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
return view('account.email', ['user' => $request->user()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$request->merge(['email' => Str::lower(trim((string) $request->input('email')))]);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::notIn([$user->email]),
|
||||||
|
],
|
||||||
|
], [
|
||||||
|
'email.not_in' => __('storefront.account.email_same'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emailChange->request($user, $validated['email']);
|
||||||
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put(self::SESSION_KEY, $validated['email']);
|
||||||
|
|
||||||
|
return redirect()->route('account.email.code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function code(string $locale, Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! $pendingEmail) {
|
||||||
|
return redirect()->route('account.email.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('account.email-code', ['email' => $pendingEmail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! $pendingEmail) {
|
||||||
|
return redirect()->route('account.email.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emailChange->request($request->user(), $pendingEmail);
|
||||||
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return redirect()->route('account.email.edit')
|
||||||
|
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', __('storefront.auth.code_resent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! $pendingEmail) {
|
||||||
|
return redirect()->route('account.email.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emailChange->confirm($request->user(), $validated['code']);
|
||||||
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return redirect()->route('account.email.edit')
|
||||||
|
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (InvalidEmailChangeCodeException) {
|
||||||
|
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
|
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order history. Every lookup goes through CustomerAccountService, which only
|
||||||
|
* ever returns the logged-in user's own placed orders, so an order id from
|
||||||
|
* the URL can't reach someone else's order.
|
||||||
|
*/
|
||||||
|
class OrderController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
return view('account.orders.index', [
|
||||||
|
'orders' => $this->account->orders($request->user(), 15),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $locale, Request $request, int $orderId): View
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$order = $this->account->order($request->user(), $orderId);
|
||||||
|
} catch (OrderNotFoundException) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// By type, like the checkout confirmation: the method may since have
|
||||||
|
// been disabled, but the order still shows what was used.
|
||||||
|
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||||
|
->first()
|
||||||
|
?->translate('name');
|
||||||
|
|
||||||
|
return view('account.orders.show', [
|
||||||
|
'order' => $order,
|
||||||
|
'paymentMethodName' => $paymentMethodName,
|
||||||
|
'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Rules\HCaptcha;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||||
|
use Modules\Core\Auth\Services\UserOtpService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwordless customer login: email → emailed 6-digit code → logged in.
|
||||||
|
* Login and registration are the same flow — UserOtpService::generateAndSend()
|
||||||
|
* find-or-creates the user (and its Customer).
|
||||||
|
*
|
||||||
|
* All the security lives in UserOtpService: per-email code-request throttling
|
||||||
|
* (OtpThrottledException), wrong-guess lockout, the Auth::login() itself (which
|
||||||
|
* also regenerates the session and merges the guest cart via Lunar's Login
|
||||||
|
* listener) and the session-registry record. This controller only moves the
|
||||||
|
* shopper between the two steps, carrying the email in the session rather
|
||||||
|
* than the URL.
|
||||||
|
*/
|
||||||
|
class LoginController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* `?redirect=/el/checkout` (e.g. from the checkout's login tab) becomes the
|
||||||
|
* intended URL that verify() returns to. Only a same-site path is accepted:
|
||||||
|
* no scheme, no protocol-relative `//host`, so it can't redirect off-site.
|
||||||
|
*/
|
||||||
|
public function create(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
$redirect = (string) $request->query('redirect', '');
|
||||||
|
|
||||||
|
if (preg_match('#^/(?![/\\\\])#', $redirect)) {
|
||||||
|
$request->session()->put('url.intended', url($redirect));
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('auth.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||||
|
{
|
||||||
|
// Captcha only here: verify() and resend() need the email this step
|
||||||
|
// puts in the session, so they can't be reached without passing it.
|
||||||
|
$validated = $request->validate([
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||||
|
], [
|
||||||
|
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$email = Str::lower(trim($validated['email']));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$otp->generateAndSend($email);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withInput()->withErrors([
|
||||||
|
'email' => __('storefront.auth.too_many_codes'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('login.email', $email);
|
||||||
|
|
||||||
|
return redirect()->route('login.code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function code(string $locale, Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$email = $request->session()->get('login.email');
|
||||||
|
|
||||||
|
if (! $email) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('auth.login-code', ['email' => $email]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resend(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = $request->session()->get('login.email');
|
||||||
|
|
||||||
|
if (! $email) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$otp->generateAndSend($email);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'code' => __('storefront.auth.too_many_codes'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', __('storefront.auth.code_resent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = $request->session()->get('login.email');
|
||||||
|
|
||||||
|
if (! $email) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'code' => ['required', 'digits:6'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wrong, expired, or locked out after too many guesses — core doesn't
|
||||||
|
// say which, so neither do we; the page offers "resend code" for all three.
|
||||||
|
if (! $otp->validate($email, $validated['code'], $request)) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'code' => __('storefront.auth.invalid_code'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->forget('login.email');
|
||||||
|
|
||||||
|
return redirect()->intended(route('home'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(string $locale, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('home');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Catalog\CategoryListing;
|
use App\Catalog\ProductListing;
|
||||||
|
use App\Catalog\ProductListingPage;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Modules\Core\Catalog\Services\CollectionService;
|
use Modules\Core\Catalog\Services\CollectionService;
|
||||||
use Modules\Core\Catalog\Services\ProductService;
|
|
||||||
|
|
||||||
class CategoryController extends Controller
|
class CategoryController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProductService $products,
|
private readonly ProductListingPage $listingPage,
|
||||||
private readonly CollectionService $collections,
|
private readonly CollectionService $collections,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -19,46 +19,14 @@ public function show(string $locale, int $collection)
|
|||||||
$collectionData = $this->collections->getById($collection);
|
$collectionData = $this->collections->getById($collection);
|
||||||
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
||||||
|
|
||||||
$listing = CategoryListing::fromRequest(request());
|
$listing = ProductListing::fromRequest(request());
|
||||||
$filters = $listing->filters($collectionData['id']);
|
|
||||||
$perPage = 12;
|
|
||||||
|
|
||||||
// Listing/filtering reads from the Meilisearch index via ProductService,
|
$data = $this->listingPage->build(
|
||||||
// not Eloquent — see Modules\Core\Catalog\Services\ProductService. list() returns a
|
$listing,
|
||||||
// real LengthAwarePaginator of plain arrays (already localized/flattened),
|
fn (array $query) => route('category.show', ['id' => $collectionData['id']] + $query),
|
||||||
// not Product models. Sort/filter/page all come from the query string via
|
$collectionData['id'],
|
||||||
// CategoryListing, which is the single source of truth for that state.
|
);
|
||||||
$products = $this->products->list(
|
|
||||||
filters: $filters,
|
|
||||||
perPage: $perPage,
|
|
||||||
page: $listing->page,
|
|
||||||
sort: $listing->sort,
|
|
||||||
)->through(fn (array $product) => [
|
|
||||||
'name' => $product['name'],
|
|
||||||
'price' => $product['price'],
|
|
||||||
'image' => $product['media'][0]['url'] ?? null,
|
|
||||||
'href' => route('product.show', ['id' => $product['id']]),
|
|
||||||
])->appends($listing->query(['page' => null]));
|
|
||||||
|
|
||||||
// Slider bounds — the price span of everything matching the *other*
|
return view('category.show', [...$data, 'collection' => $collectionData]);
|
||||||
// filters (priceRange() drops the price filter itself, so the handles
|
|
||||||
// don't collapse to whatever's already selected). Whole euros.
|
|
||||||
$priceRange = $this->products->priceRange($filters);
|
|
||||||
$priceFloor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
|
|
||||||
$priceCeil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
|
|
||||||
|
|
||||||
// A price param is only a real filter if it's tighter than the bounds —
|
|
||||||
// drives whether the "clear" link shows.
|
|
||||||
$priceFiltered = ($listing->minPrice !== null && $listing->minPrice > ($priceFloor ?? PHP_INT_MIN))
|
|
||||||
|| ($listing->maxPrice !== null && $listing->maxPrice < ($priceCeil ?? PHP_INT_MAX));
|
|
||||||
|
|
||||||
return view('category.show', [
|
|
||||||
'collection' => $collectionData,
|
|
||||||
'products' => $products,
|
|
||||||
'listing' => $listing,
|
|
||||||
'priceFloor' => $priceFloor,
|
|
||||||
'priceCeil' => $priceCeil,
|
|
||||||
'priceFiltered' => $priceFiltered,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,82 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ContactRequest;
|
||||||
|
use App\Mail\ContactConfirmationMail;
|
||||||
|
use App\Mail\ContactMessageMail;
|
||||||
|
use App\Models\StoicPage;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Store\Services\StoreDetailsService;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact form. Nothing is stored: the message is emailed to the store
|
||||||
|
* (Store Details' contact email) and the sender gets a generic confirmation. Both are sent
|
||||||
|
* synchronously so a failed store email can be reported back on the form.
|
||||||
|
*
|
||||||
|
* Limited per IP in here rather than with throttle middleware, so the limit
|
||||||
|
* shows as a message on the form instead of a bare 429 page.
|
||||||
|
*/
|
||||||
class ContactController extends Controller
|
class ContactController extends Controller
|
||||||
{
|
{
|
||||||
public function index(string $locale)
|
private const SEND_LIMIT = 3;
|
||||||
|
private const SEND_DECAY_SECONDS = 600;
|
||||||
|
|
||||||
|
public function index(string $locale): View
|
||||||
{
|
{
|
||||||
return view('contact');
|
$page = StoicPage::localized('contact');
|
||||||
|
|
||||||
|
if (! $page) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('contact', [
|
||||||
|
'page' => $page,
|
||||||
|
'store' => app(StoreDetailsService::class)->current(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, ContactRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$key = 'contact:'.$request->ip();
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($key, self::SEND_LIMIT)) {
|
||||||
|
return back()->withInput()->with('contact_error', __('storefront.contact.too_many'));
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
$to = app(StoreDetailsService::class)->current()->contact_email;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (blank($to)) {
|
||||||
|
throw new \RuntimeException('Store Details has no contact email.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Mail::to($to)->send(new ContactMessageMail(
|
||||||
|
$data['name'],
|
||||||
|
$data['email'],
|
||||||
|
$data['message'],
|
||||||
|
$locale,
|
||||||
|
));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('Contact form: store email failed', ['exception' => $e]);
|
||||||
|
|
||||||
|
return back()->withInput()->with('contact_error', __('storefront.contact.send_failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The store already has the message, so a failed confirmation is only logged.
|
||||||
|
try {
|
||||||
|
Mail::to($data['email'])->locale($locale)->send(new ContactConfirmationMail);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Contact form: confirmation email failed', ['exception' => $e]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('contact')->with('status', __('storefront.contact.sent'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\Core\File\Http\Controllers\UploadFileController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the shopper's photo for a product custom field of type `file` (see
|
||||||
|
* boboko-core's Product::$custom_fields) the moment it's picked on the product
|
||||||
|
* page — before add-to-cart, see custom-field-upload-controller.js.
|
||||||
|
*
|
||||||
|
* Which files are acceptable (extensions, size) is a per-site decision — this
|
||||||
|
* site: photographs — so it lives here as this app's own policy, extending
|
||||||
|
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
|
||||||
|
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
|
||||||
|
* endpoint only ever receives the stored File row's own `id` — FileService is
|
||||||
|
* the single source of truth for disk/path/name/mime, never duplicated into
|
||||||
|
* cart/order line meta (see Checkout\CartController::customFieldsMeta()). A
|
||||||
|
* shopper can't point a cart line at someone else's file: CartController only
|
||||||
|
* accepts an id that is both unowned and tagged with this exact PURPOSE.
|
||||||
|
*
|
||||||
|
* Stored on the private `local` disk: these are customers' personal photos,
|
||||||
|
* never reachable by a public URL except through FileService's own signed
|
||||||
|
* download route. Uploads nobody adds to a cart are removed by core's
|
||||||
|
* `boboko:file:prune-unowned custom-field-upload` command.
|
||||||
|
*/
|
||||||
|
class CustomFieldUploadController extends UploadFileController
|
||||||
|
{
|
||||||
|
public const PURPOSE = 'custom-field-upload';
|
||||||
|
|
||||||
|
public const MAX_KILOBYTES = 10240;
|
||||||
|
|
||||||
|
public const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the file input's `accept` attribute — same list the server enforces.
|
||||||
|
*/
|
||||||
|
public static function accept(): string
|
||||||
|
{
|
||||||
|
return '.'.implode(',.', self::EXTENSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function purpose(): string
|
||||||
|
{
|
||||||
|
return self::PURPOSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validationRules(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// `label` is the admin-authored field label, only used as the
|
||||||
|
// :attribute in the validation message shown next to that field.
|
||||||
|
protected function validationAttributes(Request $request): array
|
||||||
|
{
|
||||||
|
return ['file' => (string) $request->input('label', 'file')];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,34 +2,74 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\ProductCard;
|
||||||
use App\Models\StoicPage;
|
use App\Models\StoicPage;
|
||||||
use Lunar\Models\Product;
|
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||||
|
use Modules\Core\Catalog\Services\CollectionService;
|
||||||
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
|
||||||
class HomeController extends Controller
|
class HomeController extends Controller
|
||||||
{
|
{
|
||||||
|
/** 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 = Product::with(['variants.prices.currency', 'media'])
|
// Random pool for the classics carousel, and for the hero too when the
|
||||||
->inRandomOrder()
|
// hero collection is missing or empty. Oversized so hero products can be
|
||||||
->limit(13)
|
// excluded from it and still leave enough.
|
||||||
->get()
|
$random = collect($this->products->random(self::HERO_FALLBACK_COUNT + self::CLASSICS_COUNT));
|
||||||
->map(fn (Product $product) => [
|
|
||||||
'name' => $product->translateAttribute('name'),
|
$hero = collect($this->heroCollectionProducts());
|
||||||
'price' => $product->variants->first()?->prices->first()?->price->decimal,
|
|
||||||
'image' => $product->media->first()?->getUrl(),
|
if ($hero->isEmpty()) {
|
||||||
'href' => route('product.show', ['id' => $product->id]),
|
$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,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,37 +2,248 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\ProductListing;
|
||||||
|
use App\Catalog\ProductListingPage;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
use Lunar\Models\ProductVariant;
|
||||||
use Modules\Core\Catalog\Services\ProductService;
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
|
||||||
class ProductController extends Controller
|
class ProductController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ProductService $products) {}
|
public function __construct(
|
||||||
|
private readonly ProductService $products,
|
||||||
|
private readonly ProductListingPage $listingPage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All products — the category page without a collection scope. Filters,
|
||||||
|
* sort, pagination and the shared listing body all work identically.
|
||||||
|
*/
|
||||||
|
public function index(string $locale)
|
||||||
|
{
|
||||||
|
$listing = ProductListing::fromRequest(request());
|
||||||
|
|
||||||
|
$data = $this->listingPage->build(
|
||||||
|
$listing,
|
||||||
|
fn (array $query) => route('products', $query),
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('products.index', $data);
|
||||||
|
}
|
||||||
|
|
||||||
public function show(string $locale, int $id)
|
public function show(string $locale, int $id)
|
||||||
{
|
{
|
||||||
$product = $this->products->getById($id);
|
$product = $this->products->getById($id);
|
||||||
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
||||||
|
|
||||||
|
$product = $this->mergeJustSubmittedReview($product);
|
||||||
|
|
||||||
$collection = $product['collections'][0] ?? null;
|
$collection = $product['collections'][0] ?? null;
|
||||||
|
// dd($product);
|
||||||
$variantsData = collect($product['variants'])
|
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
|
||||||
->map(fn (array $variant) => [
|
|
||||||
'id' => $variant['id'],
|
|
||||||
'price' => $variant['prices'][0]['price'] ?? null,
|
|
||||||
'image' => $variant['media'][0]['url'] ?? null,
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$firstVariant = $product['variants'][0] ?? null;
|
|
||||||
$option = $firstVariant['options'][0]['option'] ?? null;
|
|
||||||
|
|
||||||
return view('product.show', [
|
return view('product.show', [
|
||||||
'collection' => $collection,
|
'collection' => $collection,
|
||||||
'product' => $product,
|
'product' => $product,
|
||||||
'option' => $option,
|
'productOptions' => $productOptions,
|
||||||
'variantsData' => $variantsData,
|
'variantsData' => $variantsData,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One button/swatch group per product option (a product can have several
|
||||||
|
* — e.g. a custom-photo product with size + style + person-count, each
|
||||||
|
* combination resolved client-side to one exact ProductVariant, see
|
||||||
|
* product-form-controller.js) plus the per-variant data the picker
|
||||||
|
* resolves a selection against.
|
||||||
|
*
|
||||||
|
* Reads live Eloquent rather than the Meilisearch-indexed $product array
|
||||||
|
* the rest of the page uses (same reasoning as checkStock() below): the
|
||||||
|
* index has no concept of option/value display order (Lunar's
|
||||||
|
* `position` column) or a stable value id, both of which the picker
|
||||||
|
* needs — to render values in the merchant's intended order, and to
|
||||||
|
* match a combination back to one exact variant without relying on
|
||||||
|
* translated label strings staying unique.
|
||||||
|
*
|
||||||
|
* @return array{0: array, 1: array}
|
||||||
|
*/
|
||||||
|
private function buildOptionPicker(int $productId): array
|
||||||
|
{
|
||||||
|
$variants = ProductVariant::query()
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->with(['values.option', 'prices', 'images'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$productOptions = $variants
|
||||||
|
->flatMap(fn (ProductVariant $variant) => $variant->values)
|
||||||
|
->unique('id')
|
||||||
|
->groupBy(fn ($value) => $value->option->handle)
|
||||||
|
->map(function ($values, $handle) {
|
||||||
|
$sorted = $values->sortBy([['position', 'asc'], ['id', 'asc']]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'handle' => $handle,
|
||||||
|
'label' => $sorted->first()->option->translate('name'),
|
||||||
|
// The "Color" option type is the only one that writes a
|
||||||
|
// hex code into meta (see boboko/core's ColorOptionType)
|
||||||
|
// — its presence is how we tell a color option (swatches)
|
||||||
|
// from any other option (buttons).
|
||||||
|
'isColor' => $sorted->contains(fn ($v) => !empty($v->meta['hex'] ?? null)),
|
||||||
|
'values' => $sorted->map(fn ($v) => [
|
||||||
|
'id' => $v->id,
|
||||||
|
'label' => $v->translate('name'),
|
||||||
|
'hex' => $v->meta['hex'] ?? null,
|
||||||
|
])->values()->all(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$variantsData = $variants->map(fn (ProductVariant $variant) => [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'price' => $variant->prices->first()?->price?->decimal(),
|
||||||
|
'image' => $variant->images->first()?->getUrl(),
|
||||||
|
// Live from the DB (not the index's in_stock), with Lunar's own
|
||||||
|
// purchasability rule — drives the disabled add-to-cart button.
|
||||||
|
'inStock' => $variant->canBeFulfilledAtQuantity(1),
|
||||||
|
// handle => selected value id, for matching a combination of
|
||||||
|
// selections back to this variant — see selectVariant() in
|
||||||
|
// product-form-controller.js.
|
||||||
|
'options' => $variant->values->mapWithKeys(fn ($v) => [$v->option->handle => $v->id])->all(),
|
||||||
|
])->values()->all();
|
||||||
|
|
||||||
|
return [$productOptions, $variantsData];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meilisearch's own write API is itself async — addDocuments() enqueues an
|
||||||
|
* indexing task and returns immediately, and Laravel\Scout\Engines\
|
||||||
|
* MeilisearchEngine::update() never waits on that task, so even
|
||||||
|
* $product->searchableSync() (which only skips OUR queue) can still land
|
||||||
|
* the shopper back on this page before Meilisearch has actually processed
|
||||||
|
* the write. storeReview() flashes the review it just created for exactly
|
||||||
|
* this one next request; splice it in here rather than trust the index is
|
||||||
|
* already caught up. Guarded by id so a race the other way — the index
|
||||||
|
* DID catch up in time — doesn't show the same review twice.
|
||||||
|
*/
|
||||||
|
private function mergeJustSubmittedReview(array $product): array
|
||||||
|
{
|
||||||
|
$justSubmitted = session('justSubmittedReview');
|
||||||
|
|
||||||
|
if (! $justSubmitted || (string) ($justSubmitted['product_id'] ?? null) !== (string) $product['id']) {
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $product['reviews']['items'] ?? [];
|
||||||
|
|
||||||
|
if (collect($items)->contains('id', $justSubmitted['id'])) {
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [$justSubmitted, ...$items];
|
||||||
|
|
||||||
|
$product['reviews']['items'] = $items;
|
||||||
|
$product['reviews']['count'] = count($items);
|
||||||
|
$product['reviews']['average_rating'] = round(collect($items)->avg('rating'), 1);
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A storefront-owned, checkout-module-independent stock check — the
|
||||||
|
* product page's "Add to cart" calls this first and only submits to the
|
||||||
|
* checkout module's own add-to-cart endpoint once this says `ok`. Reads
|
||||||
|
* the live Eloquent ProductVariant directly (not the Meilisearch index
|
||||||
|
* ProductService otherwise reads from, which can lag behind an actual
|
||||||
|
* sale until the next reindex) via the SAME method Lunar's own
|
||||||
|
* CartLineStock validator calls, so this can never disagree with what
|
||||||
|
* the module's own server-side check would decide.
|
||||||
|
*/
|
||||||
|
public function checkStock(string $locale, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'variant' => ['required', 'integer'],
|
||||||
|
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$variant = ProductVariant::find($data['variant']);
|
||||||
|
$quantity = $data['quantity'] ?? 1;
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => $variant->canBeFulfilledAtQuantity($quantity),
|
||||||
|
'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* boboko/core's product_reviews table has no moderation/status column, so
|
||||||
|
* this goes live immediately — no approval queue to land in.
|
||||||
|
*/
|
||||||
|
public function storeReview(string $locale, Request $request, Product $product): RedirectResponse
|
||||||
|
{
|
||||||
|
$reviewsUrl = route('product.show', [
|
||||||
|
'locale' => $locale,
|
||||||
|
'id' => $product->id,
|
||||||
|
'tab' => 'reviews',
|
||||||
|
]).'#product-tabs';
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'rating' => ['required', 'integer', 'between:1,5'],
|
||||||
|
'content' => ['required', 'string'],
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return redirect($reviewsUrl)->withErrors($validator)->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $validator->validated();
|
||||||
|
|
||||||
|
// Not $product->reviews()->create(...): that relation only exists via a
|
||||||
|
// Product::macro() registered in CorePlugin::register(Panel $panel), which
|
||||||
|
// Filament calls solely when the /boboko admin panel boots — never on a
|
||||||
|
// plain storefront request, where the macro is simply undefined.
|
||||||
|
$review = ProductReview::create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'rating' => $data['rating'],
|
||||||
|
'body' => $data['content'],
|
||||||
|
'reviewer_name' => $data['name'] ?? null,
|
||||||
|
'reviewer_email' => $data['email'],
|
||||||
|
'reviewed_at' => now(),
|
||||||
|
'source' => 'storefront',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ReviewServiceProvider also reindexes on the model's `created` event, but
|
||||||
|
// queued (SCOUT_QUEUE=true) — it wouldn't land before this redirect's page
|
||||||
|
// load. Syncing here skips our queue too, but Meilisearch's own write API
|
||||||
|
// is itself async on top of that (see mergeJustSubmittedReview()), so this
|
||||||
|
// alone still isn't a guarantee — it's the flash below that actually is.
|
||||||
|
$product->searchableSync();
|
||||||
|
|
||||||
|
return redirect($reviewsUrl)
|
||||||
|
->with('reviewSubmitted', true)
|
||||||
|
->with('justSubmittedReview', [
|
||||||
|
'id' => $review->id,
|
||||||
|
'product_id' => $review->product_id,
|
||||||
|
'title' => $review->title,
|
||||||
|
'body' => $review->body,
|
||||||
|
'rating' => $review->rating,
|
||||||
|
'reviewed_at' => $review->reviewed_at?->timestamp,
|
||||||
|
'reviewer_name' => $review->reviewer_name,
|
||||||
|
'reply' => null,
|
||||||
|
'replied_at' => null,
|
||||||
|
'media' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\ProductListing;
|
||||||
|
use App\Catalog\ProductListingPage;
|
||||||
|
|
||||||
|
class SearchController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProductListingPage $listingPage) {}
|
||||||
|
|
||||||
|
public function show(string $locale)
|
||||||
|
{
|
||||||
|
$query = trim((string) request()->query('q', ''));
|
||||||
|
|
||||||
|
if ($query === '') {
|
||||||
|
return redirect()->route('products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$listing = ProductListing::fromRequest(request());
|
||||||
|
|
||||||
|
$data = $this->listingPage->build(
|
||||||
|
$listing,
|
||||||
|
fn (array $overrides) => route('search', ['q' => $query] + $overrides),
|
||||||
|
collectionId: null,
|
||||||
|
query: $query,
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('search.index', [...$data, 'query' => $query]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\ProductCard;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
use Modules\Core\Wishlist\Services\WishlistService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page rendering only — the toggle action itself lives in core
|
||||||
|
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
|
||||||
|
* `wishlist.toggle`), since it needs no app-specific presentation.
|
||||||
|
*/
|
||||||
|
class WishlistController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly WishlistService $wishlist,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The account's wishlist page.
|
||||||
|
*/
|
||||||
|
public function index(string $locale, ProductService $products): View
|
||||||
|
{
|
||||||
|
return view('account.wishlist', ['products' => $this->products($products)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same list for a guest, from their cookie. Logged-in users are sent
|
||||||
|
* to the account version.
|
||||||
|
*/
|
||||||
|
public function guest(string $locale, ProductService $products): View|RedirectResponse
|
||||||
|
{
|
||||||
|
if (auth()->check()) {
|
||||||
|
return redirect()->route('account.wishlist');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('wishlist.guest', ['products' => $this->products($products)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product cards for the current wishlist, newest first. Products no longer
|
||||||
|
* in the search index (deleted, unpublished) are simply skipped.
|
||||||
|
*/
|
||||||
|
private function products(ProductService $products): Collection
|
||||||
|
{
|
||||||
|
return collect($this->wishlist->ids())
|
||||||
|
->map(fn (int $id) => $products->getById($id))
|
||||||
|
->filter()
|
||||||
|
->map(fn (array $product) => ['id' => $product['id'], ...ProductCard::fromIndexed($product)])
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use App\Rules\HCaptcha;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ContactRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
'message' => ['required', 'string', 'max:5000'],
|
||||||
|
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a logged-in shopper places an order and their account has no saved
|
||||||
|
* address yet, the order's shipping address (and their name, if the profile
|
||||||
|
* has none) becomes the account's. Never overwrites anything already saved.
|
||||||
|
*
|
||||||
|
* Uses the order's own user, not Auth: OrderPlaced can fire from a payment
|
||||||
|
* webhook, where nobody is logged in. Picked up by listener discovery.
|
||||||
|
*/
|
||||||
|
class SaveAddressFromFirstOrder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(OrderPlaced $event): void
|
||||||
|
{
|
||||||
|
$order = $event->order;
|
||||||
|
$user = $order->user;
|
||||||
|
$shipping = $order->shippingAddress;
|
||||||
|
|
||||||
|
if (! $user || ! $shipping || ! $shipping->line_one) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = $this->account->customer($user);
|
||||||
|
|
||||||
|
if (! $customer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $customer->first_name && ! $customer->last_name) {
|
||||||
|
$this->account->updateProfile($user, [
|
||||||
|
'first_name' => $shipping->first_name,
|
||||||
|
'last_name' => $shipping->last_name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collect($this->account->addresses($user))->isNotEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->account->createAddress($user, [
|
||||||
|
...$shipping->only(['first_name', 'last_name', 'line_one', 'city', 'state', 'postcode', 'country_id', 'contact_phone']),
|
||||||
|
'contact_email' => $user->email,
|
||||||
|
'shipping_default' => true,
|
||||||
|
'billing_default' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent to whoever submitted the contact form. Deliberately generic: it never
|
||||||
|
* repeats what they wrote, so the form can't be used to deliver arbitrary text
|
||||||
|
* to someone else's inbox.
|
||||||
|
*/
|
||||||
|
class ContactConfirmationMail extends Mailable
|
||||||
|
{
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(subject: __('storefront.contact.confirmation_subject'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'emails.contact-confirmation');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Address;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contact-form message, sent to Store Details' contact email. Reply-To is the
|
||||||
|
* sender, so replying from the inbox goes straight to them.
|
||||||
|
*/
|
||||||
|
class ContactMessageMail extends Mailable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $senderName,
|
||||||
|
public readonly string $senderEmail,
|
||||||
|
public readonly string $body,
|
||||||
|
public readonly string $senderLocale,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
replyTo: [new Address($this->senderEmail, $this->senderName)],
|
||||||
|
subject: "Νέο μήνυμα επικοινωνίας από {$this->senderName}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'emails.contact-message');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,32 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ class User extends Authenticatable implements LunarUserInterface
|
|||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
protected $dispatchesEvents = [
|
|
||||||
'created' => UserCreated::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -4,34 +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
|
||||||
|
{
|
||||||
|
$this->app->scoped(StoicSettings::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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Rules;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the `h-captcha-response` token the hCaptcha widget adds to a form.
|
||||||
|
* Use it as `'h-captcha-response' => ['required', new HCaptcha]`.
|
||||||
|
*
|
||||||
|
* Fails closed: if hCaptcha can't be reached the submission is rejected, since
|
||||||
|
* letting it through would reopen the hole this exists to close (bots making
|
||||||
|
* us send email to arbitrary addresses).
|
||||||
|
*/
|
||||||
|
class HCaptcha implements ValidationRule
|
||||||
|
{
|
||||||
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
|
{
|
||||||
|
if (! is_string($value) || $value === '') {
|
||||||
|
$fail(__('storefront.auth.captcha_failed'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::asForm()
|
||||||
|
->timeout(5)
|
||||||
|
->post('https://api.hcaptcha.com/siteverify', [
|
||||||
|
'secret' => config('services.hcaptcha.secret'),
|
||||||
|
'response' => $value,
|
||||||
|
// Rejects tokens solved against someone else's sitekey.
|
||||||
|
'sitekey' => config('services.hcaptcha.sitekey'),
|
||||||
|
'remoteip' => request()->ip(),
|
||||||
|
]);
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
Log::warning('hCaptcha siteverify unreachable', ['error' => $e->getMessage()]);
|
||||||
|
$fail(__('storefront.auth.captcha_failed'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->successful() || $response->json('success') !== true) {
|
||||||
|
// A bad/missing secret or sitekey would otherwise look like every
|
||||||
|
// shopper failing the captcha.
|
||||||
|
$configErrors = array_intersect((array) $response->json('error-codes'), [
|
||||||
|
'missing-input-secret',
|
||||||
|
'invalid-input-secret',
|
||||||
|
'sitekey-secret-mismatch',
|
||||||
|
'invalid-sitekey',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed() || $configErrors) {
|
||||||
|
Log::warning('hCaptcha siteverify error', [
|
||||||
|
'status' => $response->status(),
|
||||||
|
'error-codes' => $response->json('error-codes'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fail(__('storefront.auth.captcha_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Spatie\YamlFrontMatter\YamlFrontMatter;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class Stoic
|
class Stoic
|
||||||
@@ -29,6 +30,42 @@ public static function presets(): array
|
|||||||
return static::$presets;
|
return static::$presets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stoic's default locale (first in i18n.locales), or null when i18n is off.
|
||||||
|
// Its content folder holds every field; other locales only translatable ones.
|
||||||
|
public static function defaultLocale(): ?string
|
||||||
|
{
|
||||||
|
static::loadConfig();
|
||||||
|
return static::$config["i18n"]["locales"][0]["code"] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current locale's translated fields for a content file (path relative
|
||||||
|
// to a locale folder, e.g. "pages/home.md"), with the markdown body as
|
||||||
|
// `content`. Empty when the current locale is the default one (or i18n is
|
||||||
|
// off), the file doesn't exist, or a field is left blank — so layering this
|
||||||
|
// over the default-locale values falls back to them field by field.
|
||||||
|
public static function localeOverrides(string $file): array
|
||||||
|
{
|
||||||
|
$default = static::defaultLocale();
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
if ($default === null || $locale === $default) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = resource_path("stoic/content/{$locale}/{$file}");
|
||||||
|
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$document = YamlFrontMatter::parse(file_get_contents($path));
|
||||||
|
|
||||||
|
return array_filter(
|
||||||
|
[...$document->matter(), "content" => trim($document->body())],
|
||||||
|
fn($value) => filled($value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Returns the public URL for a stoic image at a given preset.
|
// Returns the public URL for a stoic image at a given preset.
|
||||||
public static function image(string $filename, string $preset): string
|
public static function image(string $filename, string $preset): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Spatie\YamlFrontMatter\YamlFrontMatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site-wide, marketing-editable settings from Stoic's `settings` singleton
|
||||||
|
* (resources/stoic/content/{locale}/settings.md). Read directly rather than
|
||||||
|
* through a Paper model: Paper models map to a folder of entries, and this is
|
||||||
|
* one file per locale. Legal and transactional details (address, ΑΦΜ, phone)
|
||||||
|
* live in boboko-core's StoreDetails instead.
|
||||||
|
*
|
||||||
|
* Stoic writes every field to the default locale's file and only the
|
||||||
|
* translatable ones (e.g. meta_description) to the others, so the current
|
||||||
|
* locale's file is layered over the default one; empty values fall through.
|
||||||
|
*
|
||||||
|
* Bound as scoped in AppServiceProvider, so files are parsed at most once per
|
||||||
|
* locale per request (or queued job) and edits in Stoic show up on the next one.
|
||||||
|
* The locale is read per call, not at construction, so a mail rendered with
|
||||||
|
* ->locale() still gets its own locale's values.
|
||||||
|
*/
|
||||||
|
class StoicSettings
|
||||||
|
{
|
||||||
|
private const SOCIALS = [
|
||||||
|
'facebook' => 'Facebook',
|
||||||
|
'instagram' => 'Instagram',
|
||||||
|
'tiktok' => 'TikTok',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var array<string, array<string, mixed>> keyed by locale */
|
||||||
|
private array $data = [];
|
||||||
|
|
||||||
|
public function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$value = $this->data()[$key] ?? null;
|
||||||
|
|
||||||
|
return filled($value) ? $value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute og:image URL for a Stoic image field value (the page's own, else
|
||||||
|
* the default from settings.md), or null. Image fields store a
|
||||||
|
* comma-separated list; only the first is used.
|
||||||
|
*/
|
||||||
|
public function ogImageUrl(?string $pageImage = null): ?string
|
||||||
|
{
|
||||||
|
$filename = trim(explode(',', $pageImage ?: (string) $this->get('og_image', ''))[0]);
|
||||||
|
|
||||||
|
return $filename === '' ? null : url(Stoic::image($filename, 'large'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only the networks that have a URL set, in display order.
|
||||||
|
*
|
||||||
|
* @return list<array{icon: string, label: string, url: string}>
|
||||||
|
*/
|
||||||
|
public function socialLinks(): array
|
||||||
|
{
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
foreach (self::SOCIALS as $icon => $label) {
|
||||||
|
if ($url = $this->get("{$icon}_url")) {
|
||||||
|
$links[] = ['icon' => $icon, 'label' => $label, 'url' => $url];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $links;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function data(): array
|
||||||
|
{
|
||||||
|
$locale = app()->getLocale();
|
||||||
|
|
||||||
|
return $this->data[$locale] ??= $this->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function load(): array
|
||||||
|
{
|
||||||
|
$default = Stoic::defaultLocale();
|
||||||
|
|
||||||
|
if ($default === null) {
|
||||||
|
return $this->parse(resource_path('stoic/content/settings.md'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge(
|
||||||
|
$this->parse(resource_path("stoic/content/{$default}/settings.md")),
|
||||||
|
Stoic::localeOverrides('settings.md'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parse(string $path): array
|
||||||
|
{
|
||||||
|
return is_file($path) ? YamlFrontMatter::parse(file_get_contents($path))->matter() : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -11,7 +11,18 @@
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
// Laravel's priority list would otherwise run `auth` before core's
|
||||||
|
// `locale` middleware, so the redirects below would build URLs before
|
||||||
|
// URL::defaults(['locale' => …]) is set, throwing a missing-parameter error.
|
||||||
|
$middleware->prependToPriorityList(
|
||||||
|
before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
||||||
|
prepend: \Modules\Core\Localization\Middleware\LocaleMiddleware::class,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both resolve inside the {locale} group, after the `locale` middleware
|
||||||
|
// has set URL::defaults(['locale' => …]), so route() needs no locale arg.
|
||||||
|
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||||
|
$middleware->redirectUsersTo(fn () => route('home'));
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->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
+507
-1152
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Modules\Core\Catalog\Recommendations\RandomRule;
|
||||||
|
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Product recommendation rules
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
|
||||||
|
| the first rule that returns at least one product wins. The order here IS
|
||||||
|
| the fallback chain: SameCategoryRule first, then RandomRule as a
|
||||||
|
| last-resort so a product page is never left with zero recommendations
|
||||||
|
| (as long as the store has more than one product). A consuming app can
|
||||||
|
| reorder, add, or remove rules freely — nothing about the chain shape is
|
||||||
|
| hardcoded in the service itself.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'recommendation_rules' => [
|
||||||
|
SameCategoryRule::class,
|
||||||
|
RandomRule::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Per-site settings for the cart + checkout module (see
|
||||||
|
* Modules\Core\Providers\CheckoutModuleServiceProvider). Publishable —
|
||||||
|
* artisan vendor:publish --tag=core-config.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Name of the storefront's login route. The checkout's login tab and the
|
||||||
|
* confirmation page link to it with `?redirect=<checkout path>`, so the
|
||||||
|
* login page must send the shopper back there afterwards. null: no login
|
||||||
|
* offered in checkout at all.
|
||||||
|
*/
|
||||||
|
'login_route' => 'login',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Name of the storefront's product-listing route — where confirmation()
|
||||||
|
* redirects a visit with no placed order to look at (session expired,
|
||||||
|
* direct navigation, a bookmark). route($this, $locale) must resolve.
|
||||||
|
*/
|
||||||
|
'products_route' => 'products',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden
|
||||||
|
* field, forced server-side — no country picker shown at all). null (the
|
||||||
|
* default) gives the full country/region picker, for a multi-country
|
||||||
|
* store. 3dealer is Greece-only for now.
|
||||||
|
*/
|
||||||
|
'store_country_iso3' => 'GRC',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The `purpose` tag CartController expects a product custom field's
|
||||||
|
* `file` answer to already carry (see Modules\Core\File\Models\File) —
|
||||||
|
* matches whatever purpose string the host's own upload endpoint
|
||||||
|
* (extending Modules\Core\File\Http\Controllers\UploadFileController)
|
||||||
|
* tags its stored files with. This module never reaches into that
|
||||||
|
* host controller directly; this config value is the one shared
|
||||||
|
* source of truth between the two.
|
||||||
|
*/
|
||||||
|
'custom_field_upload_purpose' => 'custom-field-upload',
|
||||||
|
|
||||||
|
];
|
||||||
+122
@@ -16,4 +16,126 @@
|
|||||||
|
|
||||||
'auto_create_customer_for_user' => true,
|
'auto_create_customer_for_user' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Privacy / GDPR data-subject requests
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| 'providers' lists every Modules\Core\Privacy\Contracts\PersonalDataProvider
|
||||||
|
| that should be consulted for right-of-access/right-of-erasure requests. A
|
||||||
|
| module never needs to be known to core in advance — it just adds its own
|
||||||
|
| provider class here, the same way config('lunar.search.indexers') maps a
|
||||||
|
| model to its indexer. See docs/privacy.md.
|
||||||
|
|
|
||||||
|
| 'grace_period_days' is how long an erasure request stays cancellable
|
||||||
|
| (account deactivated, not yet erased) before it's actually processed by
|
||||||
|
| the privacy:process-erasure-requests scheduled command.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'privacy' => [
|
||||||
|
'providers' => [
|
||||||
|
// ActivityLogDataProvider MUST run before AddressDataProvider —
|
||||||
|
// it resolves which activity_log rows belong to this customer
|
||||||
|
// (including ones keyed by an Address id) before
|
||||||
|
// AddressDataProvider hard-deletes those Address rows. See that
|
||||||
|
// provider's own class docblock.
|
||||||
|
\Modules\Core\Logging\Privacy\ActivityLogDataProvider::class,
|
||||||
|
\Modules\Core\Customer\Privacy\CustomerDataProvider::class,
|
||||||
|
\Modules\Core\Customer\Privacy\AddressDataProvider::class,
|
||||||
|
\Modules\Core\Order\Privacy\OrderDataProvider::class,
|
||||||
|
\Modules\Core\Cart\Privacy\CartDataProvider::class,
|
||||||
|
\Modules\Core\Review\Privacy\ReviewDataProvider::class,
|
||||||
|
\Modules\Core\Payment\Privacy\PaymentDataProvider::class,
|
||||||
|
\Modules\Core\Auth\Privacy\UserSessionDataProvider::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'grace_period_days' => 30,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cart Abandonment Threshold
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| How long a cart (that hasn't converted to a placed order) can go without
|
||||||
|
| activity before Modules\Core\Cart\Filament\Resources\CartResource treats
|
||||||
|
| it as "Abandoned" rather than "Ongoing". Anything DateInterval::createFromDateString()
|
||||||
|
| accepts works, e.g. '1 hour', '30 minutes', '2 days'.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cart' => [
|
||||||
|
'abandoned_after' => '1 hour',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|----------------------------------------------------------------------
|
||||||
|
| Unrecoverable Cap
|
||||||
|
|----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Beyond this age, a stale cart stops being treated as an active
|
||||||
|
| "Abandoned Cart"/"Abandoned Checkout" (Modules\Core\Cart\Services\
|
||||||
|
| CartLifecycleService) — too old to be a realistic recovery target
|
||||||
|
| (pricing/stock/tax likely stale by then). This is about the
|
||||||
|
| abandoned-cart pipeline only, not data retention — no rows are
|
||||||
|
| deleted or pruned based on this value.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'unrecoverable_after' => '90 days',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Order Return Window
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| How many days after a carrier order is delivered (Order::fulfillment_status
|
||||||
|
| becomes 'return_window_open') before Modules\Core\Order\Commands\
|
||||||
|
| CloseExpiredReturnWindows auto-completes it, if no return was requested.
|
||||||
|
| Store-pickup orders have no return-window step and are unaffected by
|
||||||
|
| this value (see Modules\Core\Order\Listeners\CompleteOrderOnPickedUp).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'order' => [
|
||||||
|
'return_window_days' => 14,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Storefront OTP Login
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Modules\Core\Auth\Services\UserOtpService's passwordless login.
|
||||||
|
| max_attempts caps how many wrong codes a shopper can guess against ONE
|
||||||
|
| generated code before it's invalidated outright. generation_limit/
|
||||||
|
| generation_decay_minutes cap how often a NEW code can be requested for
|
||||||
|
| the same email — independent of max_attempts, since generating a fresh
|
||||||
|
| code also resets the guess count, so an attempt cap alone doesn't stop
|
||||||
|
| an attacker from just requesting a new code every few tries. This same
|
||||||
|
| limit is also what stands between a malicious/careless caller and
|
||||||
|
| mail-bombing one inbox.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'auth' => [
|
||||||
|
'otp' => [
|
||||||
|
'max_attempts' => 5,
|
||||||
|
'generation_limit' => 3,
|
||||||
|
'generation_decay_minutes' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Modules\Core\Customer\Services\CustomerEmailChangeService — same
|
||||||
|
// shape/reasoning as auth.otp above, independent limits since this
|
||||||
|
// is a separate flow (changing an existing account's login email,
|
||||||
|
// not logging in).
|
||||||
|
'email_change' => [
|
||||||
|
'max_attempts' => 5,
|
||||||
|
'generation_limit' => 3,
|
||||||
|
'generation_decay_minutes' => 10,
|
||||||
|
'expiry_minutes' => 10,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+94
-7
@@ -58,11 +58,23 @@
|
|||||||
| make sense for the store you're building.
|
| make sense for the store you're building.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'draft_status' => 'awaiting-payment',
|
'draft_status' => 'awaiting_payment',
|
||||||
|
|
||||||
|
// Two sequences — carrier and store-pickup — see Modules\Core\Order\
|
||||||
|
// Services\OrderStatusFlow, the single source of truth for which
|
||||||
|
// transitions are actually offered from a given status. Payment
|
||||||
|
// timing (prepaid vs. cash-on-delivery) does not affect this
|
||||||
|
// sequence at all — see Order::paid/paid_at instead, an entirely
|
||||||
|
// independent field with no status value of its own.
|
||||||
|
//
|
||||||
|
// 'favourite' controls whether a status gets its own tab in the order
|
||||||
|
// list topbar (Lunar\Admin\...\ListOrders::getDefaultTabs()) — set
|
||||||
|
// only on the main-journey statuses so the topbar isn't cluttered
|
||||||
|
// with every branch/exception value; every status (favourite or not)
|
||||||
|
// remains reachable via the table's own status filter/search.
|
||||||
'statuses' => [
|
'statuses' => [
|
||||||
|
|
||||||
'awaiting-payment' => [
|
'awaiting_payment' => [
|
||||||
'label' => 'Awaiting Payment',
|
'label' => 'Awaiting Payment',
|
||||||
'color' => '#848a8c',
|
'color' => '#848a8c',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
@@ -70,17 +82,25 @@
|
|||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
'payment-offline' => [
|
'processing' => [
|
||||||
'label' => 'Payment Offline',
|
'label' => 'Processing',
|
||||||
'color' => '#0A81D7',
|
'color' => '#0A81D7',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
'notifications' => [],
|
'notifications' => [],
|
||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
'payment-received' => [
|
'ready_for_dispatch' => [
|
||||||
'label' => 'Payment Received',
|
'label' => 'Ready for Dispatch',
|
||||||
'color' => '#6a67ce',
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'ready_for_pickup' => [
|
||||||
|
'label' => 'Ready for Pickup',
|
||||||
|
'color' => '#c98a2e',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
'notifications' => [],
|
'notifications' => [],
|
||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
@@ -88,11 +108,78 @@
|
|||||||
|
|
||||||
'dispatched' => [
|
'dispatched' => [
|
||||||
'label' => 'Dispatched',
|
'label' => 'Dispatched',
|
||||||
|
'color' => '#0A81D7',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
'notifications' => [],
|
'notifications' => [],
|
||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'delivery_failed' => [
|
||||||
|
'label' => 'Delivery Failed',
|
||||||
|
'color' => '#c0392b',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'picked_up' => [
|
||||||
|
'label' => 'Picked Up',
|
||||||
|
'color' => '#3f7a5c',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// The parcel arriving and the return window opening are the same
|
||||||
|
// moment — one status value covers both.
|
||||||
|
'delivered' => [
|
||||||
|
'label' => 'Delivered',
|
||||||
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'completed' => [
|
||||||
|
'label' => 'Completed',
|
||||||
|
'color' => '#3f7a5c',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'return_requested' => [
|
||||||
|
'label' => 'Return Requested',
|
||||||
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'returned' => [
|
||||||
|
'label' => 'Returned',
|
||||||
|
'color' => '#c0392b',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'partially_refunded' => [
|
||||||
|
'label' => 'Partially Refunded',
|
||||||
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'refunded' => [
|
||||||
|
'label' => 'Refunded',
|
||||||
|
'color' => '#c0392b',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -46,6 +46,6 @@
|
|||||||
| to include in the count below.
|
| to include in the count below.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'order_count_statuses' => ['payment-received'],
|
'order_count_statuses' => ['processing'],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
+26
-2
@@ -20,7 +20,17 @@
|
|||||||
Lunar\Models\Collection::class,
|
Lunar\Models\Collection::class,
|
||||||
Lunar\Models\Customer::class,
|
Lunar\Models\Customer::class,
|
||||||
Lunar\Models\Order::class,
|
Lunar\Models\Order::class,
|
||||||
Lunar\Models\Product::class,
|
// Modules\Core\Catalog\Models\Product, not Lunar\Models\Product —
|
||||||
|
// both share the same underlying Meilisearch index name, so
|
||||||
|
// listing the base class here too would make every reindex
|
||||||
|
// (this default list is always merged in, even when a specific
|
||||||
|
// model is passed on the CLI — see Lunar\Console\Commands\
|
||||||
|
// ScoutIndexerCommand::handle()) run the base class's indexing a
|
||||||
|
// second time right after the subclass's, silently overwriting
|
||||||
|
// every document with one missing custom_fields/order_count
|
||||||
|
// (the whole reason Modules\Core\Catalog\Models\Product exists —
|
||||||
|
// see its own docblock). Caught in practice.
|
||||||
|
Modules\Core\Catalog\Models\Product::class,
|
||||||
Lunar\Models\ProductOption::class,
|
Lunar\Models\ProductOption::class,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -49,8 +59,22 @@
|
|||||||
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
|
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
|
||||||
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
|
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
|
||||||
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
|
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
|
||||||
|
// Lunar\Models\Product::class, NOT Modules\Core\Catalog\Models\
|
||||||
|
// Product::class — Lunar\Base\Traits\Searchable::indexer() (and
|
||||||
|
// getFilterableAttributes()/getSortableAttributes(), same trait)
|
||||||
|
// reads `$config[self::class]`, and `self::class` inside a TRAIT
|
||||||
|
// METHOD is a compile-time literal bound to whichever class first
|
||||||
|
// `use`s the trait — Lunar\Models\Product, since the subclass
|
||||||
|
// never re-declares indexer() itself — regardless of which
|
||||||
|
// instance actually calls the method at runtime. Keying this by
|
||||||
|
// the subclass here made the lookup miss entirely, silently
|
||||||
|
// falling back to Lunar\Search\ScoutIndexer's own near-empty
|
||||||
|
// filterable/sortable field list — confirmed live: it wiped every
|
||||||
|
// real filterable/sortable attribute the index had (including
|
||||||
|
// ones that already worked, like collection_ids), not just the
|
||||||
|
// new order_count one. Caught in practice, reverted.
|
||||||
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
|
Lunar\Models\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,9 +35,31 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
|
||||||
|
// local dev use hCaptcha's test keys, the real ones reject localhost:
|
||||||
|
// sitekey 10000000-ffff-ffff-ffff-000000000001,
|
||||||
|
// secret 0x0000000000000000000000000000000000000000.
|
||||||
|
'hcaptcha' => [
|
||||||
|
'sitekey' => env('HCAPTCHA_SITEKEY'),
|
||||||
|
'secret' => env('HCAPTCHA_SECRET'),
|
||||||
|
],
|
||||||
|
|
||||||
'stoic' => [
|
'stoic' => [
|
||||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||||
'host' => env('STOIC_HOST'),
|
'host' => env('STOIC_HOST'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Keys read by lunarphp/stripe + Modules\Core\Payment\Drivers\StripePaymentDriver.
|
||||||
|
// `key` is the SECRET key (this ecosystem's convention — StripeManager calls
|
||||||
|
// Stripe::setApiKey(config('services.stripe.key'))); `public_key` is the
|
||||||
|
// publishable key for Stripe.js; `webhooks.lunar` is the signing secret the
|
||||||
|
// webhook route verifies against.
|
||||||
|
'stripe' => [
|
||||||
|
'key' => env('STRIPE_SECRET'),
|
||||||
|
'public_key' => env('STRIPE_PUBLIC_KEY'),
|
||||||
|
'webhooks' => [
|
||||||
|
'lunar' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| ACS Courier credentials
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| ACS requires two credential mechanisms simultaneously: an AcsApiKey
|
||||||
|
| HTTP header (gates the REST gateway itself) and four account fields
|
||||||
|
| (Company_ID/Company_Password/User_ID/User_Password) sent in every
|
||||||
|
| request body. Both are supplied by ACS when your account is set up.
|
||||||
|
|
|
||||||
|
| Set these via environment variables — never commit real values.
|
||||||
|
|
|
||||||
|
| ACS_BASE_URL Root REST endpoint (unversioned, single URL for
|
||||||
|
| every ACSAlias call).
|
||||||
|
| ACS_API_KEY The AcsApiKey header value.
|
||||||
|
| ACS_COMPANY_ID Company_ID body field.
|
||||||
|
| ACS_COMPANY_PASSWORD Company_Password body field.
|
||||||
|
| ACS_USER_ID User_ID body field.
|
||||||
|
| ACS_USER_PASSWORD User_Password body field.
|
||||||
|
| ACS_BILLING_CODE Your ACS credit/billing code, used for price
|
||||||
|
| calculation and voucher creation.
|
||||||
|
| ACS_SENDER_* Static sender details reused on every voucher.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'base_url' => env('ACS_BASE_URL', 'https://webservices.acscourier.net/ACSRestServices/api/ACSAutoRest'),
|
||||||
|
|
||||||
|
'api_key' => env('ACS_API_KEY'),
|
||||||
|
|
||||||
|
'company_id' => env('ACS_COMPANY_ID'),
|
||||||
|
'company_password' => env('ACS_COMPANY_PASSWORD'),
|
||||||
|
'user_id' => env('ACS_USER_ID'),
|
||||||
|
'user_password' => env('ACS_USER_PASSWORD'),
|
||||||
|
|
||||||
|
'billing_code' => env('ACS_BILLING_CODE'),
|
||||||
|
|
||||||
|
'sender' => [
|
||||||
|
'name' => env('ACS_SENDER_NAME'),
|
||||||
|
'address' => env('ACS_SENDER_ADDRESS'),
|
||||||
|
'zip_code' => env('ACS_SENDER_ZIP'),
|
||||||
|
'phone' => env('ACS_SENDER_PHONE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'timeout' => env('ACS_HTTP_TIMEOUT', 10),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Box Now credentials
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Box Now uses OAuth2 client-credentials: exchange BOXNOW_CLIENT_ID /
|
||||||
|
| BOXNOW_CLIENT_SECRET for a Bearer access token (POST /auth-sessions,
|
||||||
|
| ~1hr expiry), then attach it as an Authorization header on every call.
|
||||||
|
| Unlike ACS, there is no separate per-request credential body — the
|
||||||
|
| token alone authorizes all calls once obtained.
|
||||||
|
|
|
||||||
|
| Set these via environment variables — never commit real values.
|
||||||
|
|
|
||||||
|
| Box Now has two environments (see their Partner API manual, section 2):
|
||||||
|
| Stage/Sandbox for testing, Production once live. Each has its own
|
||||||
|
| client_id/client_secret pair and its own base_url/location_api_url —
|
||||||
|
| there is no shared "switch an env var" flag, since stage credentials
|
||||||
|
| don't work against the production host or vice versa.
|
||||||
|
|
|
||||||
|
| BOXNOW_BASE_URL Root REST endpoint for delivery-requests/parcels.
|
||||||
|
| BOXNOW_LOCATION_API_URL Separate, faster endpoint for origins/destinations
|
||||||
|
| lookups (Box Now recommends this over the main
|
||||||
|
| base URL for those two calls specifically).
|
||||||
|
| BOXNOW_CLIENT_ID OAuth2 client id.
|
||||||
|
| BOXNOW_CLIENT_SECRET OAuth2 client secret.
|
||||||
|
| BOXNOW_PARTNER_ID Numeric partnerId Box Now issues alongside your
|
||||||
|
| credentials. NOT used for REST API authentication
|
||||||
|
| (BoxNowClient authenticates with client_id/
|
||||||
|
| client_secret alone) — this is only consumed by
|
||||||
|
| the client-side Destination Map widget config
|
||||||
|
| (_bn_map_widget_config.partnerId), confirmed
|
||||||
|
| against Box Now's own WooCommerce plugin source.
|
||||||
|
| BOXNOW_ORIGIN_LOCATION_ID Your warehouse's Box Now locationId, used as
|
||||||
|
| the pickup origin on every delivery request.
|
||||||
|
| BOXNOW_SENDER_* Static sender contact details reused on every
|
||||||
|
| delivery request.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'base_url' => env('BOXNOW_BASE_URL', 'https://api-production.boxnow.gr/api/v1'),
|
||||||
|
'location_api_url' => env('BOXNOW_LOCATION_API_URL', 'https://locationapi-production.boxnow.gr/api/v1'),
|
||||||
|
|
||||||
|
'client_id' => env('BOXNOW_CLIENT_ID'),
|
||||||
|
'client_secret' => env('BOXNOW_CLIENT_SECRET'),
|
||||||
|
'partner_id' => env('BOXNOW_PARTNER_ID'),
|
||||||
|
|
||||||
|
'origin_location_id' => env('BOXNOW_ORIGIN_LOCATION_ID'),
|
||||||
|
|
||||||
|
'sender' => [
|
||||||
|
'name' => env('BOXNOW_SENDER_NAME'),
|
||||||
|
'email' => env('BOXNOW_SENDER_EMAIL'),
|
||||||
|
'phone' => env('BOXNOW_SENDER_PHONE'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'timeout' => env('BOXNOW_HTTP_TIMEOUT', 10),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ services:
|
|||||||
- "${VALKEY_PORT:-6339}:6379"
|
- "${VALKEY_PORT:-6339}:6379"
|
||||||
|
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:v1.10
|
image: getmeili/meilisearch:v1.12
|
||||||
ports:
|
ports:
|
||||||
- "${MEILISEARCH_PORT:-7700}:7700"
|
- "${MEILISEARCH_PORT:-7700}:7700"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+29
-9
@@ -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
|
||||||
@@ -116,7 +118,7 @@ services:
|
|||||||
- valkeydata:/data
|
- valkeydata:/data
|
||||||
|
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:v1.10
|
image: getmeili/meilisearch:v1.12
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
|
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
|
||||||
@@ -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 "$@"
|
||||||
+32
-11
@@ -5,17 +5,38 @@ set -e
|
|||||||
# by app only) — just wait for app's migration to finish, then start the process.
|
# by app only) — just wait for app's migration to finish, then start the process.
|
||||||
mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs storage/framework bootstrap/cache
|
mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs storage/framework bootstrap/cache
|
||||||
|
|
||||||
if [ "$APP_ENV" != "production" ]; then
|
# Same reasoning as entrypoint.sh: this container gets replaced on every deploy
|
||||||
echo "[entrypoint] Waiting for migrations to complete..."
|
# (dev and production alike), so this is a fresh boot clearing stale artifacts
|
||||||
timeout=60
|
# left on disk (cached config, compiled views), not a running process being
|
||||||
while [ ! -f storage/framework/migrated ] && [ "$timeout" -gt 0 ]; do
|
# told to forget in-memory code. queue:work/schedule:work themselves still
|
||||||
sleep 1
|
# can't pick up a later code change without an actual process restart — this
|
||||||
timeout=$((timeout - 1))
|
# only fixes what's stale on disk at boot.
|
||||||
done
|
echo "[entrypoint] Clearing cached config/routes/views..."
|
||||||
if [ ! -f storage/framework/migrated ]; then
|
php artisan optimize:clear --quiet
|
||||||
echo "[entrypoint] Timed out waiting for migrations" >&2
|
echo "[entrypoint] Caches cleared"
|
||||||
exit 1
|
|
||||||
fi
|
# Universal, in both dev and production — app's entrypoint always writes this
|
||||||
|
# marker after `migrate --force` completes (single app instance, so there's
|
||||||
|
# exactly one writer), and queue/scheduler must never start against a database
|
||||||
|
# schema that migration hasn't finished bringing up to date yet.
|
||||||
|
echo "[entrypoint] Waiting for migrations to complete..."
|
||||||
|
timeout=60
|
||||||
|
while [ ! -f storage/framework/migrated ] && [ "$timeout" -gt 0 ]; do
|
||||||
|
sleep 1
|
||||||
|
timeout=$((timeout - 1))
|
||||||
|
done
|
||||||
|
if [ ! -f storage/framework/migrated ]; then
|
||||||
|
echo "[entrypoint] Timed out waiting for migrations" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$APP_ENV" = "production" ]; then
|
||||||
|
# Same reasoning as entrypoint.sh's own production-only optimize step —
|
||||||
|
# queue:work/schedule:work read config on every job/tick too, so this
|
||||||
|
# avoids paying the same uncached-config cost app pays per request.
|
||||||
|
echo "[entrypoint] Caching config/routes/views for production..."
|
||||||
|
php artisan optimize --quiet
|
||||||
|
echo "[entrypoint] Production caches built"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
+85
-26
@@ -4,6 +4,12 @@ set -e
|
|||||||
# Only the app container runs setup; queue/scheduler use entrypoint-worker.sh instead
|
# Only the app container runs setup; queue/scheduler use entrypoint-worker.sh instead
|
||||||
# and just wait on the migrated marker this script writes below.
|
# and just wait on the migrated marker this script writes below.
|
||||||
if [ "$APP_ENV" != "production" ]; then
|
if [ "$APP_ENV" != "production" ]; then
|
||||||
|
# Dev-only: the app dir is bind-mounted from a fresh checkout, so there's no
|
||||||
|
# image-build step that already installed vendor/ or published assets — this
|
||||||
|
# container has to do it at boot instead. In production the Dockerfile already
|
||||||
|
# runs composer install and asset publishing at IMAGE BUILD time (see
|
||||||
|
# Dockerfile's `production` stage), so repeating them here would be wasteful,
|
||||||
|
# not just redundant-but-safe.
|
||||||
echo "[entrypoint] Installing Composer dependencies..."
|
echo "[entrypoint] Installing Composer dependencies..."
|
||||||
git config --global --add safe.directory /var/www/html 2>/dev/null || true
|
git config --global --add safe.directory /var/www/html 2>/dev/null || true
|
||||||
git config --global --add safe.directory /var/www/boboko-core 2>/dev/null || true
|
git config --global --add safe.directory /var/www/boboko-core 2>/dev/null || true
|
||||||
@@ -21,38 +27,91 @@ mkdir -p storage/app/public storage/framework/cache storage/framework/sessions s
|
|||||||
chown -R www-data:www-data storage bootstrap/cache
|
chown -R www-data:www-data storage bootstrap/cache
|
||||||
chmod -R 775 storage bootstrap/cache
|
chmod -R 775 storage bootstrap/cache
|
||||||
|
|
||||||
|
# Composer packages (dev) may have just changed above, or (production) this is a
|
||||||
|
# freshly built image — either way, clear any cached config/routes/compiled views
|
||||||
|
# left over from a previous boot before anything below reads them. Production
|
||||||
|
# runs a single app instance that gets replaced on every deploy, not a running
|
||||||
|
# process being told to forget in-memory code — the actual bug this fixes is
|
||||||
|
# stale artifacts still sitting in bootstrap/cache or storage/framework/views on
|
||||||
|
# a fresh boot (e.g. after the Lunar 1.5/Filament v4 upgrade, a leftover compiled
|
||||||
|
# view referenced a class that upgrade removed).
|
||||||
|
echo "[entrypoint] Clearing cached config/routes/views..."
|
||||||
|
php artisan optimize:clear --quiet
|
||||||
|
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
|
||||||
# The app dir is bind-mounted from a fresh checkout, so package assets (which the
|
# image already has these published at build time.
|
||||||
# Dockerfile publishes at build time in production) need to be generated here
|
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
|
||||||
# instead. Cheap and idempotent, safe to repeat on every boot.
|
php artisan vendor:publish --tag=public --force --ansi --quiet
|
||||||
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
|
php artisan filament:assets --ansi --quiet
|
||||||
php artisan vendor:publish --tag=public --force --ansi --quiet
|
|
||||||
php artisan filament:assets --ansi --quiet
|
|
||||||
|
|
||||||
echo "[entrypoint] Running migrations..."
|
# No --force: core-views publishes editable Blade templates (order
|
||||||
rm -f storage/framework/migrated
|
# notification emails), meant to be hand-customized per app — unlike
|
||||||
php artisan migrate --force
|
# core-assets above, republishing must not silently wipe local edits.
|
||||||
echo "[entrypoint] Touching migrated file"
|
# Only fills in the vendor/core views directory if it doesn't exist yet.
|
||||||
touch storage/framework/migrated
|
php artisan vendor:publish --tag=core-views --ansi --quiet
|
||||||
echo "[entrypoint] Touched migrated file"
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# are. Safe to run unconditionally on every boot because production runs a
|
||||||
|
# single app instance — no concurrent replicas that would race each other
|
||||||
|
# running `migrate --force` at the same time.
|
||||||
|
|
||||||
# boboko/core overrides lunar:install to skip the interactive prompts (migrate
|
echo "[entrypoint] Running migrations..."
|
||||||
# confirm, admin creation, GitHub star) and just seed the idempotent store
|
rm -f storage/framework/migrated
|
||||||
# defaults: countries, channel, language, currency, tax zone, attributes,
|
php artisan migrate --force
|
||||||
# product type. queue/scheduler wait on the marker above rather than running
|
echo "[entrypoint] Touching migrated file"
|
||||||
# this themselves, since the country import's check-then-insert isn't safe to
|
touch storage/framework/migrated
|
||||||
# run concurrently.
|
echo "[entrypoint] Touched migrated file"
|
||||||
echo "[entrypoint] Trying Lunar install"
|
|
||||||
php artisan lunar:install --quiet || true
|
|
||||||
|
|
||||||
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
|
# boboko/core overrides lunar:install to skip the interactive prompts (migrate
|
||||||
# every boot rather than flushing and rebuilding the whole index each time.
|
# confirm, admin creation, GitHub star) and just seed the idempotent store
|
||||||
echo "[entrypoint] Syncing search indexes..."
|
# defaults: countries, channel, language, currency, tax zone, attributes,
|
||||||
php artisan lunar:meilisearch:setup
|
# product type. queue/scheduler wait on the marker above rather than running
|
||||||
php artisan lunar:search:index --quiet || true
|
# this themselves, since the country import's check-then-insert isn't safe to
|
||||||
|
# run concurrently. A fresh production install needs this seeding the same as
|
||||||
|
# a fresh dev one does — and re-running it against an already-seeded store is
|
||||||
|
# a no-op per key (see InstallLunarCommand's idempotent upserts).
|
||||||
|
echo "[entrypoint] Trying Lunar install"
|
||||||
|
php artisan lunar:install --quiet || true
|
||||||
|
|
||||||
|
# Same idempotent per-key upsert as lunar:install's own seeding above, just kept
|
||||||
|
# as separate seeder classes rather than folded into InstallLunarCommand — these
|
||||||
|
# are Spatie translation-loader `validation`/`checkout` groups, not Lunar store
|
||||||
|
# data, and each is safe to re-run on every boot.
|
||||||
|
echo "[entrypoint] Seeding validation translations..."
|
||||||
|
php artisan db:seed --class="Modules\Core\Localization\Database\Seeders\ValidationTranslationsSeeder" --force --quiet || true
|
||||||
|
|
||||||
|
echo "[entrypoint] Seeding checkout translations..."
|
||||||
|
php artisan db:seed --class="Modules\Core\Checkout\Database\Seeders\CheckoutTranslationsSeeder" --force --quiet || true
|
||||||
|
|
||||||
|
echo "[entrypoint] Seeding storefront translations..."
|
||||||
|
php artisan db:seed --class="Modules\Core\Localization\Database\Seeders\StorefrontTranslationsSeeder" --force --quiet || true
|
||||||
|
|
||||||
|
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
|
||||||
|
# every boot rather than flushing and rebuilding the whole index each time. Runs
|
||||||
|
# in production too — a deploy that changed an indexer's field list needs this
|
||||||
|
# to keep Meilisearch's index settings and documents in sync with the code that
|
||||||
|
# just shipped, same reasoning as optimize:clear above.
|
||||||
|
echo "[entrypoint] Syncing search indexes..."
|
||||||
|
php artisan lunar:meilisearch:setup
|
||||||
|
php artisan lunar:meilisearch:tune-product-search --quiet || true
|
||||||
|
php artisan lunar:search:index --quiet || true
|
||||||
|
|
||||||
|
if [ "$APP_ENV" = "production" ]; then
|
||||||
|
# The counterpart to optimize:clear above: config/routes/views/events get
|
||||||
|
# compiled once here, at the end of boot, after everything that could
|
||||||
|
# change them (migrations, lunar:install, index sync) has already run —
|
||||||
|
# so production actually gets the request-time performance win caching is
|
||||||
|
# for, rather than staying permanently uncached. Dev deliberately never
|
||||||
|
# does this: caching config here would mean .env/config edits stop taking
|
||||||
|
# effect until the next optimize:clear, which is the opposite of what dev
|
||||||
|
# needs on every iteration.
|
||||||
|
echo "[entrypoint] Caching config/routes/views for production..."
|
||||||
|
php artisan optimize --quiet
|
||||||
|
echo "[entrypoint] Production caches built"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -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
+22
-62
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "boboko-starter",
|
"name": "app",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"@phosphor-icons/web": "^2.1.2",
|
|
||||||
"axios": "^1.15.2",
|
"axios": "^1.15.2",
|
||||||
"flatpickr": "^4.6.13"
|
"leaflet": "^1.9.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -19,11 +19,21 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@boboko/core": {
|
||||||
|
"version": "0.27.2",
|
||||||
|
"resolved": "git+https://code.radical-elements.com/boboko/core.git#332bf92e4429cf5b787e106b9e8613d912dfcc4a",
|
||||||
|
"dependencies": {
|
||||||
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -35,7 +45,6 @@
|
|||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -46,7 +55,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -122,7 +130,6 @@
|
|||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -141,18 +148,11 @@
|
|||||||
"version": "0.138.0",
|
"version": "0.138.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@phosphor-icons/web": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||||
@@ -160,7 +160,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -177,7 +176,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -194,7 +192,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -211,7 +208,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -228,7 +224,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -245,7 +240,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -262,7 +256,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -279,7 +272,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -296,7 +288,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -313,7 +304,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -330,7 +320,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -347,7 +336,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -364,7 +352,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -383,7 +370,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -400,7 +386,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -414,7 +399,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
@@ -693,7 +677,6 @@
|
|||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -901,7 +884,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -1001,7 +983,6 @@
|
|||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -1015,12 +996,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatpickr": {
|
|
||||||
"version": "4.6.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
|
||||||
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
@@ -1061,7 +1036,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -1223,7 +1197,7 @@
|
|||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -1256,11 +1230,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
@@ -1293,7 +1272,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1314,7 +1292,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1335,7 +1312,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1356,7 +1332,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1377,7 +1352,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1398,7 +1372,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1419,7 +1392,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1440,7 +1412,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1461,7 +1432,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1482,7 +1452,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1503,7 +1472,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1567,7 +1535,6 @@
|
|||||||
"version": "3.3.15",
|
"version": "3.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1586,14 +1553,12 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -1606,7 +1571,6 @@
|
|||||||
"version": "8.5.16",
|
"version": "8.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1654,7 +1618,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.138.0",
|
"@oxc-project/types": "=0.138.0",
|
||||||
@@ -1711,7 +1674,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -1786,7 +1748,6 @@
|
|||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -1813,14 +1774,13 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.3",
|
"version": "8.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
|
|||||||
+7
-1
@@ -14,8 +14,14 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"axios": "^1.15.2"
|
"axios": "^1.15.2",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
|
},
|
||||||
|
"_comment_boboko_core": "Mirrors composer.json's repositories/_repositories toggle: swap @boboko/core above with the line in _dependencies below (and back) by hand to switch between the local path-repo checkout and a tagged VCS install. The #semver:0.x range mirrors composer.json's own \"boboko/core\": \"0.*\" constraint exactly — note ^0.23.0 would NOT be equivalent here, since npm's caret locks the minor version too below 1.0.0 (>=0.23.0 <0.24.0). 0.x matches any 0.y.z tag, same as Composer's 0.*. npm reads the repo's git tags, filters to valid semver ones, and resolves the best match, so `npm update @boboko/core` alone picks up a newly pushed tag within range, no manual edit here per release. See docker/entrypoint-vite.sh and CONTRIBUTE.md.",
|
||||||
|
"_dependencies": {
|
||||||
|
"@boboko/core": "file:../boboko-core"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Regular → Executable
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.fi-pagination-items,.fi-pagination-overview,.fi-pagination-records-per-page-select:not(.fi-compact){display:none}@supports (container-type:inline-size){.fi-pagination{container-type:inline-size}@container (min-width: 28rem){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@container (min-width: 56rem){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}@supports not (container-type:inline-size){@media(min-width:640px){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@media(min-width:768px){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}.tippy-box[data-theme~=light]{color:#26323d;box-shadow:0 0 20px 4px #9aa1b126,0 4px 80px -8px #24282f40,0 4px 4px -2px #5b5e6926;background-color:#fff}.tippy-box[data-theme~=light][data-placement^=top]>.tippy-arrow:before{border-top-color:#fff}.tippy-box[data-theme~=light][data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:#fff}.tippy-box[data-theme~=light][data-placement^=left]>.tippy-arrow:before{border-left-color:#fff}.tippy-box[data-theme~=light][data-placement^=right]>.tippy-arrow:before{border-right-color:#fff}.tippy-box[data-theme~=light]>.tippy-backdrop{background-color:#fff}.tippy-box[data-theme~=light]>.tippy-svg-arrow{fill:#fff}.fi-sortable-ghost{opacity:.3}
|
|
||||||
Regular → Executable
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 116 KiB |
Regular → Executable
+1
-1
@@ -1 +1 @@
|
|||||||
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-IYF56FF6.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-JEOLYBOO.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-EOVOK2B5.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-IRE366VL.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-CE5GGD3W.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-HA22NDSG.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-NRMW37G5.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}
|
@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}
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Regular → Executable
Regular → Executable
Regular → Executable
+1
-1
File diff suppressed because one or more lines are too long
Regular → Executable
+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};
|
||||||
Regular → Executable
Regular → Executable
Regular → Executable
+1
-1
File diff suppressed because one or more lines are too long
Regular → Executable
Regular → Executable
+6
-6
File diff suppressed because one or more lines are too long
Regular → Executable
Regular → Executable
+23
-23
File diff suppressed because one or more lines are too long
Regular → Executable
+60
-60
File diff suppressed because one or more lines are too long
Regular → Executable
+1
-1
File diff suppressed because one or more lines are too long
Regular → Executable
Regular → Executable
Regular → Executable
+1
-1
@@ -1 +1 @@
|
|||||||
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?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 t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};
|
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};
|
||||||
|
|||||||
Regular → Executable
+1
-1
File diff suppressed because one or more lines are too long
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+1
-1
@@ -1 +1 @@
|
|||||||
(()=>{var d=()=>({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};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:i,schemaKey:e})=>({handleFormValidationError(n){n.detail.livewireId===i&&this.$nextTick(()=>{let t=this.$el.querySelector("[data-validation-error]");if(!t)return;let r=t;for(;r;)r.dispatchEvent(new CustomEvent("expand")),r=r.parentNode;setTimeout(()=>t.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},handleClientSideStateReset(n){n.detail.livewireId!==i||n.detail.schemaKey!==e||this.$nextTick(()=>{let t=this.$el.querySelectorAll("[autofocus]");for(let r of t)if(r.offsetParent!==null&&(r.focus(),document.activeElement===r))break})},isStateChanged(n,t){if(n===void 0)return!1;try{return JSON.stringify(n)!==JSON.stringify(t)}catch{return n!==t}}})),window.Alpine.data("filamentSchemaComponent",({path:i,containerPath:e,$wire:n})=>({$statePath:i,$get:(t,r)=>n.$get(u(e,t,r)),$set:(t,r,a,o=!1)=>n.$set(u(e,t,a),r,o),get $state(){return n.$get(i)}})),window.Alpine.data("filamentActionsSchemaComponent",d),Livewire.hook("commit",({component:i,commit:e,respond:n,succeed:t,fail:r})=>{t(({snapshot:a,effects:o})=>{o.dispatches?.forEach(s=>{if(!s.params?.awaitSchemaComponent)return;let l=Array.from(i.el.querySelectorAll(`[wire\\:partial="schema-component::${s.params.awaitSchemaComponent}"]`)).filter(c=>h(c)===i);if(l.length!==1){if(l.length>1)throw`Multiple schema components found with key [${s.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${i.id}-${s.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(s.name,{detail:s.params}))},{once:!0})}})})})});})();
|
(()=>{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})}})})})});})();
|
||||||
|
|||||||
Regular → Executable
+6
-6
File diff suppressed because one or more lines are too long
Regular → Executable
Regular → Executable
+1
-1
File diff suppressed because one or more lines are too long
Regular → Executable
Regular → Executable
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user