Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ae6cf7a6 | ||
|
|
296d6001cd | ||
|
|
aa051525a1 | ||
|
|
b288513bb4 | ||
|
|
74930351f1 | ||
|
|
65d4603793 | ||
|
|
023c11019c | ||
|
|
88d8ae0954 | ||
|
|
ca362f81c8 | ||
|
|
4634e14924 | ||
|
|
69486f65ee | ||
|
|
060b6c647e | ||
|
|
36806cac5b | ||
|
|
b1162761de | ||
|
|
8b362c9436 | ||
|
|
cd45d8f578 | ||
|
|
8e186fc231 | ||
|
|
a51ce78538 | ||
|
|
ab7e560a3d | ||
|
|
081893ef57 | ||
|
|
e2d7bbb043 | ||
|
|
3347febb3c | ||
|
|
d43b615297 | ||
|
|
7f6c1e6307 | ||
|
|
5bbf0aabcd | ||
|
|
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 |
@@ -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=/
|
||||||
@@ -59,6 +59,10 @@ MAIL_USERNAME=null
|
|||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
CONTACT_EMAIL=
|
||||||
|
|
||||||
|
HCAPTCHA_SITEKEY=
|
||||||
|
HCAPTCHA_SECRET=
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
@@ -84,6 +88,9 @@ SCOUT_QUEUE=true
|
|||||||
MEILISEARCH_KEY=dev-meilisearch-key-change-me
|
MEILISEARCH_KEY=dev-meilisearch-key-change-me
|
||||||
MEILISEARCH_HOST=http://meilisearch:7700
|
MEILISEARCH_HOST=http://meilisearch:7700
|
||||||
|
|
||||||
|
# Lunar sets all prices inclusive of tax with these values
|
||||||
|
LUNAR_STORE_INCLUSIVE_OF_TAX=true
|
||||||
|
|
||||||
# Host-side ports — change these if another project on this machine already uses the defaults
|
# Host-side ports — change these if another project on this machine already uses the defaults
|
||||||
APP_PORT=8090
|
APP_PORT=8090
|
||||||
STOIC_PORT=2727
|
STOIC_PORT=2727
|
||||||
@@ -92,3 +99,17 @@ POSTGRES_HOST_PORT=5436
|
|||||||
MAILPIT_PORT=8025
|
MAILPIT_PORT=8025
|
||||||
VITE_PORT=5173
|
VITE_PORT=5173
|
||||||
MEILISEARCH_PORT=7700
|
MEILISEARCH_PORT=7700
|
||||||
|
|
||||||
|
STRIPE_SECRET=secret
|
||||||
|
STRIPE_PUBLIC_KEY=public_key
|
||||||
|
STRIPE_WEBHOOK_SECRET=webhook_secret
|
||||||
|
|
||||||
|
BOXNOW_BASE_URL=https://api-stage.boxnow.gr/api/v1
|
||||||
|
BOXNOW_LOCATION_API_URL=https://locationapi-stage.boxnow.gr/api/v1
|
||||||
|
BOXNOW_CLIENT_ID=box-client-id
|
||||||
|
BOXNOW_CLIENT_SECRET=box-client-secret
|
||||||
|
BOXNOW_PARTNER_ID=box-partner-id
|
||||||
|
BOXNOW_ORIGIN_LOCATION_ID=box-location-id
|
||||||
|
BOXNOW_SENDER_NAME=test
|
||||||
|
BOXNOW_SENDER_EMAIL=test@test.com
|
||||||
|
BOXNOW_SENDER_PHONE=+306912345678
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
/public/sitemap.xml
|
/public/sitemap.xml
|
||||||
/public/logos/core
|
/public/logos/core
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/public/css/filament
|
||||||
|
/public/js/filament
|
||||||
|
/public/fonts/filament
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/framework/migrated
|
/storage/framework/migrated
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
|||||||
@@ -10,11 +10,19 @@ RUN composer install \
|
|||||||
--optimize-autoloader \
|
--optimize-autoloader \
|
||||||
--ignore-platform-reqs
|
--ignore-platform-reqs
|
||||||
|
|
||||||
# ── Stage 2a: Vite dev (node_modules only, no build) ─────────────────────────
|
# ── Stage 2a: Vite dev (node_modules only, no build) ──────────────────────────
|
||||||
FROM node:22-alpine AS vite-dev
|
FROM node:22-alpine AS vite-dev
|
||||||
|
# git: needed for npm to install @boboko/core's git+https tagged-VCS form (see
|
||||||
|
# package.json's _dependencies and docker/entrypoint-vite.sh). No build-time
|
||||||
|
# `npm install` here — package.json's boboko/core path-repo form (../boboko-core)
|
||||||
|
# isn't visible in the build context, only once bind-mounted at container start,
|
||||||
|
# and entrypoint-vite.sh already runs npm install on every boot, so this would be
|
||||||
|
# redundant even if it could work.
|
||||||
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
|
||||||
RUN npm ci --ignore-scripts
|
RUN chmod +x /entrypoint-vite.sh
|
||||||
|
ENTRYPOINT ["/entrypoint-vite.sh"]
|
||||||
|
|
||||||
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
||||||
FROM node:22-alpine AS node-build
|
FROM node:22-alpine AS node-build
|
||||||
@@ -94,7 +102,8 @@ RUN php artisan package:discover --ansi \
|
|||||||
&& php artisan filament:assets --ansi
|
&& php artisan filament:assets --ansi
|
||||||
|
|
||||||
COPY docker/entrypoint.sh /entrypoint.sh
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
|
||||||
|
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
|
||||||
|
|
||||||
EXPOSE 9000
|
EXPOSE 9000
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -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,77 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ContactRequest;
|
||||||
|
use App\Mail\ContactConfirmationMail;
|
||||||
|
use App\Mail\ContactMessageMail;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Store\Services\StoreDetailsService;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact form. Nothing is stored: the message is emailed to the store
|
||||||
|
* (CONTACT_EMAIL) and the sender gets a generic confirmation. Both are sent
|
||||||
|
* 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');
|
// Address and phone come from Store Details; the email is still
|
||||||
|
// CONTACT_EMAIL until Store Details has an email field.
|
||||||
|
return view('contact', [
|
||||||
|
'store' => app(StoreDetailsService::class)->current(),
|
||||||
|
'contactEmail' => config('services.contact.email'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, ContactRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$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 = config('services.contact.email');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (blank($to)) {
|
||||||
|
throw new \RuntimeException('CONTACT_EMAIL is not set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,29 +2,24 @@
|
|||||||
|
|
||||||
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\Services\ProductService;
|
||||||
|
|
||||||
class HomeController extends Controller
|
class HomeController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ProductService $products) {}
|
||||||
|
|
||||||
public function index(string $locale)
|
public function index(string $locale)
|
||||||
{
|
{
|
||||||
$page = StoicPage::firstWhere('slug', 'home');
|
$page = StoicPage::localized('home');
|
||||||
|
|
||||||
if (! $page) {
|
if (! $page) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$products = Product::with(['variants.prices.currency', 'media'])
|
$products = collect($this->products->random(13))
|
||||||
->inRandomOrder()
|
->map(fn (array $product) => ProductCard::fromIndexed($product));
|
||||||
->limit(13)
|
|
||||||
->get()
|
|
||||||
->map(fn (Product $product) => [
|
|
||||||
'name' => $product->translateAttribute('name'),
|
|
||||||
'price' => $product->variants->first()?->prices->first()?->price->decimal,
|
|
||||||
'image' => $product->media->first()?->getUrl(),
|
|
||||||
'href' => route('product.show', ['id' => $product->id]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return view('home', [
|
return view('home', [
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
|
|||||||
@@ -3,9 +3,26 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\StoicPage;
|
use App\Models\StoicPage;
|
||||||
|
use Modules\Core\Store\Services\StoreDetailsService;
|
||||||
|
|
||||||
class LegalPageController extends Controller
|
class LegalPageController extends Controller
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* {placeholder} => StoreDetails column, usable in the Stoic legal page
|
||||||
|
* markdown.
|
||||||
|
*/
|
||||||
|
private const PLACEHOLDERS = [
|
||||||
|
'{store_name}' => 'name',
|
||||||
|
'{store_address}' => 'address',
|
||||||
|
'{store_phone}' => 'phone',
|
||||||
|
'{store_tax_identifier}' => 'tax_identifier',
|
||||||
|
'{store_registration_number}' => 'registration_number',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly StoreDetailsService $storeDetails,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function terms(string $locale)
|
public function terms(string $locale)
|
||||||
{
|
{
|
||||||
return $this->show('terms-and-conditions');
|
return $this->show('terms-and-conditions');
|
||||||
@@ -28,12 +45,30 @@ public function cookies(string $locale)
|
|||||||
|
|
||||||
private function show(string $slug)
|
private function show(string $slug)
|
||||||
{
|
{
|
||||||
$page = StoicPage::firstWhere('slug', $slug);
|
$page = StoicPage::localized($slug);
|
||||||
|
|
||||||
if (! $page) {
|
if (! $page) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('legal', ['page' => $page]);
|
return view('legal', [
|
||||||
|
'page' => $page,
|
||||||
|
'content' => $this->fillPlaceholders($page->content ?? ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Swapped in before the markdown is rendered, so values are escaped: the
|
||||||
|
* rendered markdown is printed unescaped. An empty field renders as
|
||||||
|
* nothing rather than leaving the raw placeholder on the page.
|
||||||
|
*/
|
||||||
|
private function fillPlaceholders(string $content): string
|
||||||
|
{
|
||||||
|
$details = $this->storeDetails->current();
|
||||||
|
|
||||||
|
return strtr($content, array_map(
|
||||||
|
fn (string $column) => e((string) ($details->translate($column) ?? '')),
|
||||||
|
self::PLACEHOLDERS,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 the store's 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,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Staff;
|
use App\Models\Staff;
|
||||||
|
use App\Services\StoicSettings;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
@@ -17,6 +18,11 @@
|
|||||||
|
|
||||||
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();
|
||||||
@@ -32,6 +38,13 @@ public function boot(): void
|
|||||||
)->items());
|
)->items());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Site-wide settings edited in Stoic (settings.md): default meta
|
||||||
|
// description, social links for the footer, emails and schema.org.
|
||||||
|
View::composer(
|
||||||
|
['layouts.app', 'components.footer', 'emails.layout', 'home'],
|
||||||
|
fn (ViewInstance $view) => $view->with('stoicSettings', app(StoicSettings::class)),
|
||||||
|
);
|
||||||
|
|
||||||
if ($this->app->environment('production')) {
|
if ($this->app->environment('production')) {
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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,36 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Where contact-form messages are sent (ContactController).
|
||||||
|
'contact' => [
|
||||||
|
'email' => env('CONTACT_EMAIL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
|
||||||
|
// local dev use hCaptcha's test keys, the real ones reject localhost:
|
||||||
|
// sitekey 10000000-ffff-ffff-ffff-000000000001,
|
||||||
|
// secret 0x0000000000000000000000000000000000000000.
|
||||||
|
'hcaptcha' => [
|
||||||
|
'sitekey' => env('HCAPTCHA_SITEKEY'),
|
||||||
|
'secret' => env('HCAPTCHA_SECRET'),
|
||||||
|
],
|
||||||
|
|
||||||
'stoic' => [
|
'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:
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -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 "$@"
|
||||||
|
|||||||
@@ -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,90 @@ 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
|
if [ "$APP_ENV" != "production" ]; then
|
||||||
# The app dir is bind-mounted from a fresh checkout, so package assets (which the
|
# Dev-only for the same reason as the composer step above — production's
|
||||||
# Dockerfile publishes at build time in production) need to be generated here
|
# image already has these published at build time.
|
||||||
# instead. Cheap and idempotent, safe to repeat on every boot.
|
|
||||||
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
|
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
|
||||||
php artisan vendor:publish --tag=public --force --ansi --quiet
|
php artisan vendor:publish --tag=public --force --ansi --quiet
|
||||||
php artisan filament:assets --ansi --quiet
|
php artisan filament:assets --ansi --quiet
|
||||||
|
|
||||||
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"
|
fi
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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 "$@"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "boboko-starter",
|
"name": "app",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"@phosphor-icons/web": "^2.1.2",
|
|
||||||
"axios": "^1.15.2",
|
"axios": "^1.15.2",
|
||||||
"flatpickr": "^4.6.13"
|
"leaflet": "^1.9.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -19,11 +19,21 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@boboko/core": {
|
||||||
|
"version": "0.26.5",
|
||||||
|
"resolved": "git+https://code.radical-elements.com/boboko/core.git#5f0dbbb734f10f0957eb53554743bd71d6db22e1",
|
||||||
|
"dependencies": {
|
||||||
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -35,7 +45,6 @@
|
|||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -46,7 +55,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -122,7 +130,6 @@
|
|||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -141,18 +148,11 @@
|
|||||||
"version": "0.138.0",
|
"version": "0.138.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@phosphor-icons/web": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||||
@@ -160,7 +160,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -177,7 +176,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -194,7 +192,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -211,7 +208,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -228,7 +224,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -245,7 +240,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -262,7 +256,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -279,7 +272,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -296,7 +288,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -313,7 +304,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -330,7 +320,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -347,7 +336,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -364,7 +352,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -383,7 +370,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -400,7 +386,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -414,7 +399,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
@@ -693,7 +677,6 @@
|
|||||||
"version": "0.10.3",
|
"version": "0.10.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -901,7 +884,6 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -1001,7 +983,6 @@
|
|||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
@@ -1015,12 +996,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatpickr": {
|
|
||||||
"version": "4.6.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
|
||||||
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
@@ -1061,7 +1036,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -1223,7 +1197,7 @@
|
|||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
@@ -1256,11 +1230,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.3"
|
"detect-libc": "^2.0.3"
|
||||||
@@ -1293,7 +1272,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1314,7 +1292,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1335,7 +1312,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1356,7 +1332,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1377,7 +1352,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1398,7 +1372,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1419,7 +1392,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1440,7 +1412,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1461,7 +1432,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1482,7 +1452,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1503,7 +1472,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1567,7 +1535,6 @@
|
|||||||
"version": "3.3.15",
|
"version": "3.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1586,14 +1553,12 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -1606,7 +1571,6 @@
|
|||||||
"version": "8.5.16",
|
"version": "8.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1654,7 +1618,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.138.0",
|
"@oxc-project/types": "=0.138.0",
|
||||||
@@ -1711,7 +1674,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -1786,7 +1748,6 @@
|
|||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -1813,14 +1774,13 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.3",
|
"version": "8.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -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}
|
|
||||||
|
After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -1 +0,0 @@
|
|||||||
@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}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
(()=>{var o=({livewireId:s})=>({actionNestingIndex:null,shouldOverlayParentActions:!1,closedActionNestingIndexes:[],focusTargetsByNestingIndex:{},boundSyncActionModals:null,boundOnModalClosed:null,init(){this.boundSyncActionModals=e=>{e.detail.id===s&&this.syncActionModals(e.detail.newActionNestingIndex,e.detail.shouldOverlayParentActions??!1)},this.boundOnModalClosed=e=>{let t=this.getActionNestingIndexFromModalId(e.detail.id);t!==null&&((this.shouldOverlayParentActions||t===0)&&this.restorePreviouslyFocusedElement(t-1),this.closedActionNestingIndexes.push(t))},window.addEventListener("sync-action-modals",this.boundSyncActionModals),window.addEventListener("modal-closed",this.boundOnModalClosed)},destroy(){this.boundSyncActionModals&&(window.removeEventListener("sync-action-modals",this.boundSyncActionModals),this.boundSyncActionModals=null),this.boundOnModalClosed&&(window.removeEventListener("modal-closed",this.boundOnModalClosed),this.boundOnModalClosed=null)},syncActionModals(e,t=!1){if(this.actionNestingIndex===e){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}let n=this.actionNestingIndex!==null&&e!==null&&e>this.actionNestingIndex,i=this.actionNestingIndex!==null&&e!==null&&e<this.actionNestingIndex,d=this.actionNestingIndex===null&&e!==null;if((n||d)&&this.rememberPreviouslyFocusedElement(),this.actionNestingIndex!==null&&!(t&&n)&&this.closeModal(),this.actionNestingIndex=e,this.actionNestingIndex===null){this.restorePreviouslyFocusedElement(-1),this.closedActionNestingIndexes=[],this.focusTargetsByNestingIndex={},this.shouldOverlayParentActions=!1;return}if(this.shouldOverlayParentActions=t,this.closedActionNestingIndexes=this.closedActionNestingIndexes.filter(l=>l<=this.actionNestingIndex),!this.closedActionNestingIndexes.includes(this.actionNestingIndex)){if(!this.$el.querySelector(`#${this.generateModalId(e)}`)){this.$nextTick(()=>{this.openModal(),i&&this.restorePreviouslyFocusedElement()});return}this.openModal(),i&&this.restorePreviouslyFocusedElement()}},rememberPreviouslyFocusedElement(){let e=this.$focus.focused();if(!e)return;if(this.actionNestingIndex===null){this.focusTargetsByNestingIndex[-1]=e;return}this.$el.querySelector(`#${this.generateModalId(this.actionNestingIndex)}`)?.contains(e)&&(this.focusTargetsByNestingIndex[this.actionNestingIndex]=e)},restorePreviouslyFocusedElement(e=this.actionNestingIndex){let t=this.focusTargetsByNestingIndex[e];if(t){for(let n in this.focusTargetsByNestingIndex)Number(n)>=e&&delete this.focusTargetsByNestingIndex[n];requestAnimationFrame(()=>requestAnimationFrame(()=>this.$nextTick(()=>{t.focus({preventScroll:!0})})))}},generateModalId(e){return`fi-${s}-action-`+e},getActionNestingIndexFromModalId(e){let t=`fi-${s}-action-`;if(!e?.startsWith(t))return null;let n=Number(e.slice(t.length));return Number.isInteger(n)?n:null},openModal(){let e=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:e}}))},closeModal(){let e=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:e}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",o)});})();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",unsubscribeLivewireHook:null,visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))},destroy(){this.unsubscribeLivewireHook?.()}}}export{c as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function a({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{if(!Array.isArray(e))return;let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{a as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function r({state:n,splitKeys:i,tagAddedMessage:a,tagRemovedMessage:s}){return{newTag:"",state:n,liveRegionClearTimeout:null,announce(e){let t=this.$refs.liveRegion;t&&(this.liveRegionClearTimeout!==null&&clearTimeout(this.liveRegionClearTimeout),t.textContent=e,this.liveRegionClearTimeout=setTimeout(()=>{t.textContent="",this.liveRegionClearTimeout=null},3e3))},createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.announce(a?.replace(":tag",()=>this.newTag)),this.newTag=""}},deleteTag(e){this.state=this.state.filter(t=>t!==e),this.announce(s?.replace(":tag",()=>e))},reorderTags(e){let t=this.state.splice(e.oldIndex,1)[0];this.state.splice(e.newIndex,0,t),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(e){["Enter",...i].includes(e.key)&&(e.preventDefault(),e.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(i.length===0){this.createTag();return}let e=i.map(t=>t.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(e,"g")).forEach(t=>{this.newTag=t,this.createTag()})})}}}}export{r as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
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};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function x({activeTab:p,isScrollable:m,isTabPersisted:T,isTabPersistedInQueryString:w,livewireId:g,schemaKey:D,tab:W,tabQueryStringKey:r}){return{boundResizeHandler:null,boundResetHandler:null,isScrollable:m,resizeDebounceTimer:null,tab:W,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);w&&e.has(r)&&t.includes(e.get(r))&&(this.tab=e.get(r)),(!this.tab||!t.includes(this.tab))&&(this.tab=t[p-1]),this.$watch("tab",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.unsubscribeLivewireHook=Livewire.hook("commit",({component:i,commit:d,succeed:c,fail:h,respond:u})=>{c(({snapshot:b,effect:n})=>{this.$nextTick(()=>{if(i.id!==g)return;let o=this.getTabs();o.includes(this.tab)||(this.tab=o[p-1]??this.tab)})})}),this.boundResetHandler=i=>{i.detail.livewireId!==g||i.detail.schemaKey!==D||T||w||this.$nextTick(()=>{this.tab=this.getTabs()[p-1]??this.tab})},window.addEventListener("reset-schema-component-state",this.boundResetHandler),m||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,i,d,c,h){let u=t.map(n=>Math.ceil(n.clientWidth)),b=t.map(n=>{let o=n.querySelector(".fi-tabs-item-label"),s=n.querySelector(".fi-badge"),a=Math.ceil(o.clientWidth),l=s?Math.ceil(s.clientWidth):0;return{label:a,badge:l,total:a+(l>0?d+l:0)}});for(let n=0;n<t.length;n++){let o=u.slice(0,n+1).reduce((f,I)=>f+I,0),s=n*i,a=b.slice(n+1),l=a.length>0,v=l?Math.max(...a.map(f=>f.total)):0,y=l?c+v+d+h+i:0;if(o+s+y>e)return n}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!w)return;let t=new URL(window.location.href);t.searchParams.set(r,this.tab),history.replaceState(null,document.title,t.toString())},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$el.querySelectorAll(".fi-sc-tabs-tab.fi-active [autofocus]");for(let i of e)if(i.focus(),document.activeElement===i)break})},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),i=Array.from(t.children).slice(0,-1),d=i.map(s=>s.style.display);i.forEach(s=>s.style.display=""),t.offsetHeight;let c=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),b=this.calculateTabItemGap(i[0]),n=this.calculateTabItemPadding(i[0]),o=this.findOverflowIndex(i,c,h,b,n,u);i.forEach((s,a)=>s.style.display=d[a]),o!==-1&&(this.withinDropdownIndex=o),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResetHandler&&window.removeEventListener("reset-schema-component-state",this.boundResetHandler),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{x as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function l({isSkippable:i,isStepPersistedInQueryString:n,key:o,livewireId:h,schemaKey:p,startStep:r,stepQueryStringKey:d}){return{boundResetHandler:null,step:null,init(){this.step=this.getSteps().at(r-1),this.$watch("step",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.boundResetHandler=t=>{t.detail.livewireId!==h||t.detail.schemaKey!==p||n||this.$nextTick(()=>{this.step=this.getSteps().at(r-1)??this.step})},window.addEventListener("reset-schema-component-state",this.boundResetHandler)},async requestNextStep(){await this.$wire.callSchemaComponentMethod(o,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!i&&e>this.getStepIndex(this.step)||(this.step=t,this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$refs[`step-${this.step}`]?.querySelectorAll("[autofocus]")??[];for(let s of e)if(s.focus(),document.activeElement===s)break})},getStepIndex(t){let e=this.getSteps().findIndex(s=>s===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return i||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!n)return;let t=new URL(window.location.href);t.searchParams.set(d,this.step),history.replaceState(null,document.title,t.toString())},destroy(){this.boundResetHandler&&window.removeEventListener("reset-schema-component-state",this.boundResetHandler)}}}export{l as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
(()=>{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})}})})})});})();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function o({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:i,succeed:a,fail:u,respond:h})=>{a(({snapshot:d,effect:f})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let i=await this.$wire.updateTableColumnState(r,s,this.state);this.error=i?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function o({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:r,succeed:a,fail:u,respond:d})=>{a(({snapshot:h,effect:l})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||this.getNormalizedState()===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function o({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:i,succeed:a,fail:u,respond:h})=>{a(({snapshot:d,effect:f})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let i=await this.$wire.updateTableColumnState(r,s,this.state);this.error=i?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function d(){return{checkboxClickController:null,collapsedGroups:[],isLoading:!1,selectedRecords:[],shouldCheckUniqueSelection:!0,lastCheckedRecord:null,livewireId:null,init:function(){this.livewireId=this.$root.closest("[wire\\:id]").attributes["wire:id"].value,this.$wire.$on("deselectAllTableRecords",()=>this.deselectAllRecords()),this.$watch("selectedRecords",()=>{if(!this.shouldCheckUniqueSelection){this.shouldCheckUniqueSelection=!0;return}this.selectedRecords=[...new Set(this.selectedRecords)],this.shouldCheckUniqueSelection=!1}),this.$nextTick(()=>this.watchForCheckboxClicks()),Livewire.hook("element.init",({component:e})=>{e.id===this.livewireId&&this.watchForCheckboxClicks()})},mountAction:function(e,t=null){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableAction(e,t)},mountBulkAction:function(e){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableBulkAction(e)},toggleSelectRecordsOnPage:function(){let e=this.getRecordsOnPage();if(this.areRecordsSelected(e)){this.deselectRecords(e);return}this.selectRecords(e)},toggleSelectRecordsInGroup:async function(e){this.isLoading=!0;let t=await this.$wire.getGroupedSelectableTableRecordKeys(e);this.areRecordsSelected(this.getRecordsInGroupOnPage(e))?this.deselectRecords(t):this.selectRecords(t),this.isLoading=!1},getRecordsInGroupOnPage:function(e){let t=[];for(let s of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])s.dataset.group===e&&t.push(s.value);return t},getRecordsOnPage:function(){let e=[];for(let t of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])e.push(t.value);return e},selectRecords:function(e){for(let t of e)this.isRecordSelected(t)||this.selectedRecords.push(t)},deselectRecords:function(e){for(let t of e){let s=this.selectedRecords.indexOf(t);s!==-1&&this.selectedRecords.splice(s,1)}},selectAllRecords:async function(){this.isLoading=!0,this.selectedRecords=await this.$wire.getAllSelectableTableRecordKeys(),this.isLoading=!1},deselectAllRecords:function(){this.selectedRecords=[]},isRecordSelected:function(e){return this.selectedRecords.includes(e)},areRecordsSelected:function(e){return e.every(t=>this.isRecordSelected(t))},toggleCollapseGroup:function(e){if(this.isGroupCollapsed(e)){this.collapsedGroups.splice(this.collapsedGroups.indexOf(e),1);return}this.collapsedGroups.push(e)},isGroupCollapsed:function(e){return this.collapsedGroups.includes(e)},resetCollapsedGroups:function(){this.collapsedGroups=[]},watchForCheckboxClicks:function(){this.checkboxClickController&&this.checkboxClickController.abort(),this.checkboxClickController=new AbortController;let{signal:e}=this.checkboxClickController;this.$root?.addEventListener("click",t=>t.target?.matches(".fi-ta-record-checkbox")&&this.handleCheckboxClick(t,t.target),{signal:e})},handleCheckboxClick:function(e,t){if(!this.lastChecked){this.lastChecked=t;return}if(e.shiftKey){let s=Array.from(this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[]);if(!s.includes(this.lastChecked)){this.lastChecked=t;return}let l=s.indexOf(this.lastChecked),r=s.indexOf(t),o=[l,r].sort((c,n)=>c-n),i=[];for(let c=o[0];c<=o[1];c++)s[c].checked=t.checked,i.push(s[c].value);t.checked?this.selectRecords(i):this.deselectRecords(i)}this.lastChecked=t}}}export{d as default};
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "3dealer",
|
||||||
|
"short_name": "3dealer",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 32 KiB |