Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b522d60954 | ||
|
|
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 | ||
|
|
8a07c772f8 | ||
|
|
b070a7d1e6 | ||
|
|
74e554884f | ||
|
|
4387459aed | ||
|
|
adb847f442 | ||
|
|
c7cd1138fe | ||
|
|
defe1dab12 | ||
|
|
86c645fb36 | ||
|
|
0dcaf112be | ||
|
|
b3405c1b60 | ||
|
|
c7bd1efdaa |
@@ -31,7 +31,7 @@ DB_DATABASE=boboko
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=dev
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
@@ -58,8 +58,12 @@ MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
# Fallback only: the sender name comes from Store Details' "Mail from name" when set.
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
HCAPTCHA_SITEKEY=
|
||||
HCAPTCHA_SECRET=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
@@ -84,6 +88,9 @@ SCOUT_QUEUE=true
|
||||
MEILISEARCH_KEY=dev-meilisearch-key-change-me
|
||||
MEILISEARCH_HOST=http://meilisearch:7700
|
||||
|
||||
# Lunar sets all prices inclusive of tax with these values
|
||||
LUNAR_STORE_INCLUSIVE_OF_TAX=true
|
||||
|
||||
# Host-side ports — change these if another project on this machine already uses the defaults
|
||||
APP_PORT=8090
|
||||
STOIC_PORT=2727
|
||||
@@ -92,3 +99,17 @@ POSTGRES_HOST_PORT=5436
|
||||
MAILPIT_PORT=8025
|
||||
VITE_PORT=5173
|
||||
MEILISEARCH_PORT=7700
|
||||
|
||||
STRIPE_SECRET=secret
|
||||
STRIPE_PUBLIC_KEY=public_key
|
||||
STRIPE_WEBHOOK_SECRET=webhook_secret
|
||||
|
||||
BOXNOW_BASE_URL=https://api-stage.boxnow.gr/api/v1
|
||||
BOXNOW_LOCATION_API_URL=https://locationapi-stage.boxnow.gr/api/v1
|
||||
BOXNOW_CLIENT_ID=box-client-id
|
||||
BOXNOW_CLIENT_SECRET=box-client-secret
|
||||
BOXNOW_PARTNER_ID=box-partner-id
|
||||
BOXNOW_ORIGIN_LOCATION_ID=box-location-id
|
||||
BOXNOW_SENDER_NAME=test
|
||||
BOXNOW_SENDER_EMAIL=test@test.com
|
||||
BOXNOW_SENDER_PHONE=+306912345678
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
/public/sitemap.xml
|
||||
/public/logos/core
|
||||
/public/storage
|
||||
/public/css/filament
|
||||
/public/js/filament
|
||||
/public/fonts/filament
|
||||
/storage/*.key
|
||||
/storage/framework/migrated
|
||||
/storage/pail
|
||||
|
||||
@@ -10,11 +10,19 @@ RUN composer install \
|
||||
--optimize-autoloader \
|
||||
--ignore-platform-reqs
|
||||
|
||||
# ── Stage 2a: Vite dev (node_modules only, no build) ─────────────────────────
|
||||
# ── Stage 2a: Vite dev (node_modules only, no build) ──────────────────────────
|
||||
FROM node:22-alpine AS vite-dev
|
||||
# git: needed for npm to install @boboko/core's git+https tagged-VCS form (see
|
||||
# package.json's _dependencies and docker/entrypoint-vite.sh). No build-time
|
||||
# `npm install` here — package.json's boboko/core path-repo form (../boboko-core)
|
||||
# isn't visible in the build context, only once bind-mounted at container start,
|
||||
# and entrypoint-vite.sh already runs npm install on every boot, so this would be
|
||||
# redundant even if it could work.
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
COPY docker/entrypoint-vite.sh /entrypoint-vite.sh
|
||||
RUN chmod +x /entrypoint-vite.sh
|
||||
ENTRYPOINT ["/entrypoint-vite.sh"]
|
||||
|
||||
# ── Stage 2b: Node / Vite build ──────────────────────────────────────────────
|
||||
FROM node:22-alpine AS node-build
|
||||
@@ -94,7 +102,8 @@ RUN php artisan package:discover --ansi \
|
||||
&& php artisan filament:assets --ansi
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
|
||||
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
|
||||
|
||||
EXPOSE 9000
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -119,13 +128,11 @@ COPY docker/php/php.dev.ini /etc/php/8.5/cli/conf.d/99-app.ini
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install \
|
||||
--no-interaction \
|
||||
--no-scripts \
|
||||
--prefer-dist \
|
||||
--ignore-platform-reqs
|
||||
|
||||
# No build-time `composer install` here: composer.json's boboko/core path repo
|
||||
# (../boboko-core) isn't visible in the build context, only once bind-mounted at
|
||||
# container start — entrypoint.sh already runs composer install +
|
||||
# composer update boboko/* on every boot, so this would be redundant even if it
|
||||
# could work.
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
COPY docker/entrypoint-worker.sh /entrypoint-worker.sh
|
||||
RUN chmod +x /entrypoint.sh /entrypoint-worker.sh
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
pull
|
||||
docker_build
|
||||
docker_up
|
||||
migrate
|
||||
artisan_optimize
|
||||
restart_workers
|
||||
stoic_generate_thumbs
|
||||
@@ -45,12 +44,6 @@
|
||||
{{ $compose }} up -d --remove-orphans
|
||||
@endtask
|
||||
|
||||
@task('migrate', ['on' => 'web'])
|
||||
echo ">>> Running migrations..."
|
||||
cd {{ $path }}
|
||||
{{ $compose }} exec -T app php artisan migrate --force < /dev/null
|
||||
@endtask
|
||||
|
||||
@task('artisan_optimize', ['on' => 'web'])
|
||||
echo ">>> Optimizing..."
|
||||
cd {{ $path }}
|
||||
@@ -61,9 +54,10 @@
|
||||
@endtask
|
||||
|
||||
@task('restart_workers', ['on' => 'web'])
|
||||
echo ">>> Restarting queue workers..."
|
||||
echo ">>> Restarting queue workers and scheduler..."
|
||||
cd {{ $path }}
|
||||
{{ $compose }} restart queue
|
||||
{{ $compose }} restart scheduler
|
||||
@endtask
|
||||
|
||||
@task('password_protect', ['on' => 'web'])
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Catalog;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Enums\ProductSort;
|
||||
|
||||
/**
|
||||
* The parsed filter/sort/page state of a product-listing request — used by the
|
||||
* category page (scoped to a collection) and the all-products page. The query
|
||||
* string is the single source of truth; build one of these from the request,
|
||||
* 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
|
||||
* listing has no query string at all.
|
||||
*/
|
||||
final class ProductListing
|
||||
{
|
||||
private function __construct(
|
||||
public readonly ?ProductSort $sort,
|
||||
public readonly ?float $minPrice,
|
||||
public readonly ?float $maxPrice,
|
||||
public readonly bool $inStockOnly,
|
||||
public readonly int $page,
|
||||
) {}
|
||||
|
||||
public static function fromRequest(Request $request): self
|
||||
{
|
||||
return new self(
|
||||
sort: ProductSort::tryFrom((string) $request->query('sort')),
|
||||
minPrice: self::floatOrNull($request->query('price_min')),
|
||||
maxPrice: self::floatOrNull($request->query('price_max')),
|
||||
inStockOnly: $request->boolean('in_stock'),
|
||||
page: max(1, (int) $request->query('page', 1)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ?int $collectionId scope to a collection (category page); null = every product
|
||||
*/
|
||||
public function filters(?int $collectionId = null): ProductFilters
|
||||
{
|
||||
return new ProductFilters(
|
||||
collectionId: $collectionId,
|
||||
minPrice: $this->minPrice,
|
||||
maxPrice: $this->maxPrice,
|
||||
inStockOnly: $this->inStockOnly,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The applied params as a clean array (defaults omitted), with `$overrides`
|
||||
* merged on top — pass `['key' => null]` to drop one.
|
||||
*
|
||||
* @param array<string, string|int|null> $overrides
|
||||
* @return array<string, string|int>
|
||||
*/
|
||||
public function query(array $overrides = []): array
|
||||
{
|
||||
return array_filter([
|
||||
'sort' => $this->sort?->value,
|
||||
'price_min' => $this->minPrice,
|
||||
'price_max' => $this->maxPrice,
|
||||
'in_stock' => $this->inStockOnly ? 1 : null,
|
||||
'page' => $this->page > 1 ? $this->page : null,
|
||||
...$overrides,
|
||||
], fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the listing is reordered/narrowed enough that it shouldn't be
|
||||
* indexed as its own page (the canonical still points at the bare listing
|
||||
* URL either way). A plain in-stock toggle is left indexable.
|
||||
*/
|
||||
public function isRefined(): bool
|
||||
{
|
||||
return $this->sort !== null
|
||||
|| $this->minPrice !== null
|
||||
|| $this->maxPrice !== null
|
||||
|| $this->page > 1;
|
||||
}
|
||||
|
||||
private static function floatOrNull(mixed $value): ?float
|
||||
{
|
||||
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,50 +2,31 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Lunar\Models\Collection;
|
||||
use Modules\Core\Catalog\ProductFilters;
|
||||
use Modules\Core\Catalog\ProductService;
|
||||
use App\Catalog\ProductListing;
|
||||
use App\Catalog\ProductListingPage;
|
||||
use Illuminate\Http\Response;
|
||||
use Modules\Core\Catalog\Services\CollectionService;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductService $products,
|
||||
private readonly ProductListingPage $listingPage,
|
||||
private readonly CollectionService $collections,
|
||||
) {}
|
||||
|
||||
public function show(string $locale, Collection $collection)
|
||||
public function show(string $locale, int $collection)
|
||||
{
|
||||
$perPage = 12;
|
||||
$page = (int) request('page', 1);
|
||||
$collectionData = $this->collections->getById($collection);
|
||||
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
||||
|
||||
// Listing/filtering reads from the Meilisearch index via ProductService,
|
||||
// not Eloquent — see Modules\Core\Catalog\ProductService. It returns plain
|
||||
// arrays (already localized/flattened), not Product models.
|
||||
$result = $this->products->list(
|
||||
filters: new ProductFilters(collectionId: $collection->id),
|
||||
perPage: $perPage,
|
||||
page: $page,
|
||||
$listing = ProductListing::fromRequest(request());
|
||||
|
||||
$data = $this->listingPage->build(
|
||||
$listing,
|
||||
fn (array $query) => route('category.show', ['id' => $collectionData['id']] + $query),
|
||||
$collectionData['id'],
|
||||
);
|
||||
|
||||
$products = new LengthAwarePaginator(
|
||||
items: collect($result['data'])->map(fn (array $product) => [
|
||||
'name' => $product['name'],
|
||||
'price' => $product['price'],
|
||||
'image' => $product['media'][0]['url'] ?? null,
|
||||
'href' => route('product.show', ['product' => $product['id']]),
|
||||
]),
|
||||
total: $result['meta']['total'],
|
||||
perPage: $result['meta']['per_page'],
|
||||
currentPage: $result['meta']['current_page'],
|
||||
options: [
|
||||
'path' => request()->url(),
|
||||
'query' => request()->query(),
|
||||
],
|
||||
);
|
||||
|
||||
return view('category.show', [
|
||||
'collection' => $collection,
|
||||
'products' => $products,
|
||||
]);
|
||||
return view('category.show', [...$data, 'collection' => $collectionData]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,82 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ContactRequest;
|
||||
use App\Mail\ContactConfirmationMail;
|
||||
use App\Mail\ContactMessageMail;
|
||||
use App\Models\StoicPage;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Store\Services\StoreDetailsService;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Contact form. Nothing is stored: the message is emailed to the store
|
||||
* (Store Details' contact email) and the sender gets a generic confirmation. Both are sent
|
||||
* synchronously so a failed store email can be reported back on the form.
|
||||
*
|
||||
* Limited per IP in here rather than with throttle middleware, so the limit
|
||||
* shows as a message on the form instead of a bare 429 page.
|
||||
*/
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function index(string $locale)
|
||||
private const SEND_LIMIT = 3;
|
||||
private const SEND_DECAY_SECONDS = 600;
|
||||
|
||||
public function index(string $locale): View
|
||||
{
|
||||
return view('contact');
|
||||
$page = StoicPage::localized('contact');
|
||||
|
||||
if (! $page) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('contact', [
|
||||
'page' => $page,
|
||||
'store' => app(StoreDetailsService::class)->current(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function send(string $locale, ContactRequest $request): RedirectResponse
|
||||
{
|
||||
$key = 'contact:'.$request->ip();
|
||||
|
||||
if (RateLimiter::tooManyAttempts($key, self::SEND_LIMIT)) {
|
||||
return back()->withInput()->with('contact_error', __('storefront.contact.too_many'));
|
||||
}
|
||||
|
||||
RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
|
||||
|
||||
$data = $request->validated();
|
||||
$to = app(StoreDetailsService::class)->current()->contact_email;
|
||||
|
||||
try {
|
||||
if (blank($to)) {
|
||||
throw new \RuntimeException('Store Details has no contact email.');
|
||||
}
|
||||
|
||||
Mail::to($to)->send(new ContactMessageMail(
|
||||
$data['name'],
|
||||
$data['email'],
|
||||
$data['message'],
|
||||
$locale,
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Contact form: store email failed', ['exception' => $e]);
|
||||
|
||||
return back()->withInput()->with('contact_error', __('storefront.contact.send_failed'));
|
||||
}
|
||||
|
||||
// The store already has the message, so a failed confirmation is only logged.
|
||||
try {
|
||||
Mail::to($data['email'])->locale($locale)->send(new ContactConfirmationMail);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Contact form: confirmation email failed', ['exception' => $e]);
|
||||
}
|
||||
|
||||
return redirect()->route('contact')->with('status', __('storefront.contact.sent'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Core\File\Http\Controllers\UploadFileController;
|
||||
|
||||
/**
|
||||
* Stores the shopper's photo for a product custom field of type `file` (see
|
||||
* boboko-core's Product::$custom_fields) the moment it's picked on the product
|
||||
* page — before add-to-cart, see custom-field-upload-controller.js.
|
||||
*
|
||||
* Which files are acceptable (extensions, size) is a per-site decision — this
|
||||
* site: photographs — so it lives here as this app's own policy, extending
|
||||
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
|
||||
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
|
||||
* endpoint only ever receives the stored File row's own `id` — FileService is
|
||||
* the single source of truth for disk/path/name/mime, never duplicated into
|
||||
* cart/order line meta (see Checkout\CartController::customFieldsMeta()). A
|
||||
* shopper can't point a cart line at someone else's file: CartController only
|
||||
* accepts an id that is both unowned and tagged with this exact PURPOSE.
|
||||
*
|
||||
* Stored on the private `local` disk: these are customers' personal photos,
|
||||
* never reachable by a public URL except through FileService's own signed
|
||||
* download route. Uploads nobody adds to a cart are removed by core's
|
||||
* `boboko:file:prune-unowned custom-field-upload` command.
|
||||
*/
|
||||
class CustomFieldUploadController extends UploadFileController
|
||||
{
|
||||
public const PURPOSE = 'custom-field-upload';
|
||||
|
||||
public const MAX_KILOBYTES = 10240;
|
||||
|
||||
public const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'];
|
||||
|
||||
/**
|
||||
* For the file input's `accept` attribute — same list the server enforces.
|
||||
*/
|
||||
public static function accept(): string
|
||||
{
|
||||
return '.'.implode(',.', self::EXTENSIONS);
|
||||
}
|
||||
|
||||
protected function purpose(): string
|
||||
{
|
||||
return self::PURPOSE;
|
||||
}
|
||||
|
||||
protected function validationRules(Request $request): array
|
||||
{
|
||||
return [
|
||||
'file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES],
|
||||
];
|
||||
}
|
||||
|
||||
// `label` is the admin-authored field label, only used as the
|
||||
// :attribute in the validation message shown next to that field.
|
||||
protected function validationAttributes(Request $request): array
|
||||
{
|
||||
return ['file' => (string) $request->input('label', 'file')];
|
||||
}
|
||||
}
|
||||
@@ -2,34 +2,74 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\ProductCard;
|
||||
use App\Models\StoicPage;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\DTOs\ProductFilters;
|
||||
use Modules\Core\Catalog\Services\CollectionService;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/** Slug of the hero collection in the "Homepage" collection group. */
|
||||
private const HERO_COLLECTION_SLUG = 'hero';
|
||||
|
||||
private const HERO_FALLBACK_COUNT = 5;
|
||||
|
||||
private const CLASSICS_COUNT = 8;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProductService $products,
|
||||
private readonly CollectionService $collections,
|
||||
) {}
|
||||
|
||||
public function index(string $locale)
|
||||
{
|
||||
$page = StoicPage::firstWhere('slug', 'home');
|
||||
$page = StoicPage::localized('home');
|
||||
|
||||
if (! $page) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$products = Product::with(['variants.prices.currency', 'media'])
|
||||
->inRandomOrder()
|
||||
->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', ['product' => $product]),
|
||||
]);
|
||||
// Random pool for the classics carousel, and for the hero too when the
|
||||
// hero collection is missing or empty. Oversized so hero products can be
|
||||
// excluded from it and still leave enough.
|
||||
$random = collect($this->products->random(self::HERO_FALLBACK_COUNT + self::CLASSICS_COUNT));
|
||||
|
||||
$hero = collect($this->heroCollectionProducts());
|
||||
|
||||
if ($hero->isEmpty()) {
|
||||
$hero = $random->take(self::HERO_FALLBACK_COUNT);
|
||||
}
|
||||
|
||||
$heroIds = $hero->pluck('id')->all();
|
||||
|
||||
$classics = $random
|
||||
->reject(fn (array $product) => in_array($product['id'], $heroIds))
|
||||
->take(self::CLASSICS_COUNT);
|
||||
|
||||
return view('home', [
|
||||
'page' => $page,
|
||||
'heroProducts' => $products->take(5)->values(),
|
||||
'classicsProducts' => $products->slice(5)->values(),
|
||||
'heroProducts' => $hero->map(fn (array $product) => ProductCard::fromIndexed($product))->values(),
|
||||
'classicsProducts' => $classics->map(fn (array $product) => ProductCard::fromIndexed($product))->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Products the store owner picked for the hero, via the Lunar collection
|
||||
* with slug "hero". Not in the owner's drag order yet: the product index
|
||||
* doesn't carry per-collection position (pending a boboko-core task).
|
||||
*/
|
||||
private function heroCollectionProducts(): array
|
||||
{
|
||||
$collection = $this->collections->getBySlug(self::HERO_COLLECTION_SLUG);
|
||||
|
||||
if ($collection === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->products
|
||||
->list(new ProductFilters(collectionId: $collection['id']))
|
||||
->products
|
||||
->items();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,28 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\StoicPage;
|
||||
use Modules\Core\Store\Services\StoreDetailsService;
|
||||
|
||||
class LegalPageController extends Controller
|
||||
{
|
||||
/**
|
||||
* {placeholder} => StoreDetails column, usable in the Stoic legal page
|
||||
* markdown.
|
||||
*/
|
||||
private const PLACEHOLDERS = [
|
||||
'{store_name}' => 'name',
|
||||
'{store_address}' => 'address',
|
||||
'{store_phone}' => 'phone',
|
||||
'{store_contact_email}' => 'contact_email',
|
||||
'{store_legal_name}' => 'legal_name',
|
||||
'{store_tax_identifier}' => 'tax_identifier',
|
||||
'{store_registration_number}' => 'registration_number',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly StoreDetailsService $storeDetails,
|
||||
) {}
|
||||
|
||||
public function terms(string $locale)
|
||||
{
|
||||
return $this->show('terms-and-conditions');
|
||||
@@ -28,12 +47,30 @@ public function cookies(string $locale)
|
||||
|
||||
private function show(string $slug)
|
||||
{
|
||||
$page = StoicPage::firstWhere('slug', $slug);
|
||||
$page = StoicPage::localized($slug);
|
||||
|
||||
if (! $page) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return view('legal', ['page' => $page]);
|
||||
return view('legal', [
|
||||
'page' => $page,
|
||||
'content' => $this->fillPlaceholders($page->content ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Swapped in before the markdown is rendered, so values are escaped: the
|
||||
* rendered markdown is printed unescaped. An empty field renders as
|
||||
* nothing rather than leaving the raw placeholder on the page.
|
||||
*/
|
||||
private function fillPlaceholders(string $content): string
|
||||
{
|
||||
$details = $this->storeDetails->current();
|
||||
|
||||
return strtr($content, array_map(
|
||||
fn (string $column) => e((string) ($details->translate($column) ?? '')),
|
||||
self::PLACEHOLDERS,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,44 +2,248 @@
|
||||
|
||||
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\Support\Facades\Validator;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Models\ProductVariant;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
use Modules\Core\Review\Models\ProductReview;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function show(string $locale, Product $product)
|
||||
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)
|
||||
{
|
||||
$product->load([
|
||||
"variants.prices.currency",
|
||||
"variants.values.option",
|
||||
"media",
|
||||
"collections",
|
||||
$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)
|
||||
{
|
||||
$product = $this->products->getById($id);
|
||||
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
||||
|
||||
$product = $this->mergeJustSubmittedReview($product);
|
||||
|
||||
$collection = $product['collections'][0] ?? null;
|
||||
// dd($product);
|
||||
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
|
||||
|
||||
return view('product.show', [
|
||||
'collection' => $collection,
|
||||
'product' => $product,
|
||||
'productOptions' => $productOptions,
|
||||
'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'],
|
||||
]);
|
||||
|
||||
$option = $product->variants->first()?->values->first()?->option;
|
||||
$variant = ProductVariant::find($data['variant']);
|
||||
$quantity = $data['quantity'] ?? 1;
|
||||
|
||||
$variantsData = $product->variants
|
||||
->map(
|
||||
fn($v) => [
|
||||
"id" => $v->id,
|
||||
"price" => $v->prices->first()?->price->decimal,
|
||||
"image" => null, // variant-level media not differentiated yet
|
||||
],
|
||||
)
|
||||
->values()
|
||||
->toArray();
|
||||
if ($variant === null) {
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
$firstImage = $product->media->first()?->getUrl();
|
||||
return response()->json([
|
||||
'ok' => $variant->canBeFulfilledAtQuantity($quantity),
|
||||
'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(),
|
||||
]);
|
||||
}
|
||||
|
||||
// temp categories here
|
||||
$categories = \Lunar\Models\Collection::orderBy("_lft")->get();
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// dd($product);
|
||||
$validator = Validator::make($request->all(), [
|
||||
'rating' => ['required', 'integer', 'between:1,5'],
|
||||
'content' => ['required', 'string'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
return view("product.show", [
|
||||
"categories" => $categories,
|
||||
"product" => $product,
|
||||
"option" => $option,
|
||||
"variantsData" => $variantsData,
|
||||
if ($validator->fails()) {
|
||||
return redirect($reviewsUrl)->withErrors($validator)->withInput();
|
||||
}
|
||||
|
||||
$data = $validator->validated();
|
||||
|
||||
// Not $product->reviews()->create(...): that relation only exists via a
|
||||
// Product::macro() registered in CorePlugin::register(Panel $panel), which
|
||||
// Filament calls solely when the /boboko admin panel boots — never on a
|
||||
// plain storefront request, where the macro is simply undefined.
|
||||
$review = ProductReview::create([
|
||||
'product_id' => $product->id,
|
||||
'rating' => $data['rating'],
|
||||
'body' => $data['content'],
|
||||
'reviewer_name' => $data['name'] ?? null,
|
||||
'reviewer_email' => $data['email'],
|
||||
'reviewed_at' => now(),
|
||||
'source' => 'storefront',
|
||||
]);
|
||||
|
||||
// ReviewServiceProvider also reindexes on the model's `created` event, but
|
||||
// queued (SCOUT_QUEUE=true) — it wouldn't land before this redirect's page
|
||||
// load. Syncing here skips our queue too, but Meilisearch's own write API
|
||||
// is itself async on top of that (see mergeJustSubmittedReview()), so this
|
||||
// alone still isn't a guarantee — it's the flash below that actually is.
|
||||
$product->searchableSync();
|
||||
|
||||
return redirect($reviewsUrl)
|
||||
->with('reviewSubmitted', true)
|
||||
->with('justSubmittedReview', [
|
||||
'id' => $review->id,
|
||||
'product_id' => $review->product_id,
|
||||
'title' => $review->title,
|
||||
'body' => $review->body,
|
||||
'rating' => $review->rating,
|
||||
'reviewed_at' => $review->reviewed_at?->timestamp,
|
||||
'reviewer_name' => $review->reviewer_name,
|
||||
'reply' => null,
|
||||
'replied_at' => null,
|
||||
'media' => [],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\ProductListing;
|
||||
use App\Catalog\ProductListingPage;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ProductListingPage $listingPage) {}
|
||||
|
||||
public function show(string $locale)
|
||||
{
|
||||
$query = trim((string) request()->query('q', ''));
|
||||
|
||||
if ($query === '') {
|
||||
return redirect()->route('products');
|
||||
}
|
||||
|
||||
$listing = ProductListing::fromRequest(request());
|
||||
|
||||
$data = $this->listingPage->build(
|
||||
$listing,
|
||||
fn (array $overrides) => route('search', ['q' => $query] + $overrides),
|
||||
collectionId: null,
|
||||
query: $query,
|
||||
);
|
||||
|
||||
return view('search.index', [...$data, 'query' => $query]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Catalog\ProductCard;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Core\Catalog\Services\ProductService;
|
||||
use Modules\Core\Wishlist\Services\WishlistService;
|
||||
|
||||
/**
|
||||
* Page rendering only — the toggle action itself lives in core
|
||||
* (Modules\Core\Wishlist\Http\Controllers\WishlistController, route
|
||||
* `wishlist.toggle`), since it needs no app-specific presentation.
|
||||
*/
|
||||
class WishlistController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WishlistService $wishlist,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The account's wishlist page.
|
||||
*/
|
||||
public function index(string $locale, ProductService $products): View
|
||||
{
|
||||
return view('account.wishlist', ['products' => $this->products($products)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same list for a guest, from their cookie. Logged-in users are sent
|
||||
* to the account version.
|
||||
*/
|
||||
public function guest(string $locale, ProductService $products): View|RedirectResponse
|
||||
{
|
||||
if (auth()->check()) {
|
||||
return redirect()->route('account.wishlist');
|
||||
}
|
||||
|
||||
return view('wishlist.guest', ['products' => $this->products($products)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Product cards for the current wishlist, newest first. Products no longer
|
||||
* in the search index (deleted, unpublished) are simply skipped.
|
||||
*/
|
||||
private function products(ProductService $products): Collection
|
||||
{
|
||||
return collect($this->wishlist->ids())
|
||||
->map(fn (int $id) => $products->getById($id))
|
||||
->filter()
|
||||
->map(fn (array $product) => ['id' => $product['id'], ...ProductCard::fromIndexed($product)])
|
||||
->values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Rules\HCaptcha;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ContactRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'message' => ['required', 'string', 'max:5000'],
|
||||
'h-captcha-response' => ['bail', 'required', new HCaptcha],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'h-captcha-response.required' => __('storefront.auth.captcha_failed'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||
|
||||
/**
|
||||
* When a logged-in shopper places an order and their account has no saved
|
||||
* address yet, the order's shipping address (and their name, if the profile
|
||||
* has none) becomes the account's. Never overwrites anything already saved.
|
||||
*
|
||||
* Uses the order's own user, not Auth: OrderPlaced can fire from a payment
|
||||
* webhook, where nobody is logged in. Picked up by listener discovery.
|
||||
*/
|
||||
class SaveAddressFromFirstOrder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CustomerAccountService $account,
|
||||
) {}
|
||||
|
||||
public function handle(OrderPlaced $event): void
|
||||
{
|
||||
$order = $event->order;
|
||||
$user = $order->user;
|
||||
$shipping = $order->shippingAddress;
|
||||
|
||||
if (! $user || ! $shipping || ! $shipping->line_one) {
|
||||
return;
|
||||
}
|
||||
|
||||
$customer = $this->account->customer($user);
|
||||
|
||||
if (! $customer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $customer->first_name && ! $customer->last_name) {
|
||||
$this->account->updateProfile($user, [
|
||||
'first_name' => $shipping->first_name,
|
||||
'last_name' => $shipping->last_name,
|
||||
]);
|
||||
}
|
||||
|
||||
if (collect($this->account->addresses($user))->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->account->createAddress($user, [
|
||||
...$shipping->only(['first_name', 'last_name', 'line_one', 'city', 'state', 'postcode', 'country_id', 'contact_phone']),
|
||||
'contact_email' => $user->email,
|
||||
'shipping_default' => true,
|
||||
'billing_default' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Sent to whoever submitted the contact form. Deliberately generic: it never
|
||||
* repeats what they wrote, so the form can't be used to deliver arbitrary text
|
||||
* to someone else's inbox.
|
||||
*/
|
||||
class ContactConfirmationMail extends Mailable
|
||||
{
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: __('storefront.contact.confirmation_subject'));
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.contact-confirmation');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* A contact-form message, sent to Store Details' contact email. Reply-To is the
|
||||
* sender, so replying from the inbox goes straight to them.
|
||||
*/
|
||||
class ContactMessageMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $senderName,
|
||||
public readonly string $senderEmail,
|
||||
public readonly string $body,
|
||||
public readonly string $senderLocale,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
replyTo: [new Address($this->senderEmail, $this->senderName)],
|
||||
subject: "Νέο μήνυμα επικοινωνίας από {$this->senderName}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.contact-message');
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,32 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Stoic;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use JacobJoergensen\LaravelPaper\Attributes\ContentPath;
|
||||
use JacobJoergensen\LaravelPaper\Attributes\Driver;
|
||||
use JacobJoergensen\LaravelPaper\Attributes\Timestamps;
|
||||
use JacobJoergensen\LaravelPaper\Paper;
|
||||
|
||||
// Points at the default locale (el), which Stoic writes every field to. Other
|
||||
// locales hold only the translatable fields — use localized() to get a page
|
||||
// with those layered on top for the current locale.
|
||||
#[Driver("markdown")]
|
||||
#[ContentPath("resources/stoic/content/pages")]
|
||||
#[ContentPath("resources/stoic/content/el/pages")]
|
||||
#[Timestamps]
|
||||
class StoicPage extends Model
|
||||
{
|
||||
use Paper;
|
||||
|
||||
/**
|
||||
* The page in the current locale: the default-locale entry, with the
|
||||
* current locale's non-empty fields (and body) swapped in. Untranslated
|
||||
* fields fall back to the default locale.
|
||||
*/
|
||||
public static function localized(string $slug): ?static
|
||||
{
|
||||
$page = static::firstWhere('slug', $slug);
|
||||
|
||||
return $page?->forceFill(Stoic::localeOverrides("pages/{$slug}.md"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,6 @@ class User extends Authenticatable implements LunarUserInterface
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $dispatchesEvents = [
|
||||
'created' => UserCreated::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
|
||||
@@ -4,18 +4,80 @@
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Staff;
|
||||
use App\Services\StoicSettings;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Mail\Events\MessageSending;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\View\View as ViewInstance;
|
||||
use Lunar\Facades\ModelManifest;
|
||||
use Lunar\Facades\Telemetry;
|
||||
use Lunar\Models\CollectionGroup;
|
||||
use Modules\Core\Catalog\DTOs\CollectionFilters;
|
||||
use Modules\Core\Catalog\Enums\CollectionSort;
|
||||
use Modules\Core\Catalog\Services\CollectionService;
|
||||
use Modules\Core\Store\Services\StoreDetailsService;
|
||||
use Symfony\Component\Mime\Address;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->scoped(StoicSettings::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Telemetry::optOut();
|
||||
|
||||
// header.blade.php's category dropdown — root collections of the catalog
|
||||
// group only, so merchandising groups (e.g. "Homepage") stay out of the
|
||||
// nav. Resolved per-request so the composer runs after `locale` middleware
|
||||
// has already set App::getLocale(), which CollectionService's name
|
||||
// resolution depends on. The group is looked up by handle, not id, since
|
||||
// ids can differ between environments.
|
||||
View::composer('components.header', function (ViewInstance $view) {
|
||||
$groupId = CollectionGroup::where('handle', 'shopify')->value('id');
|
||||
|
||||
$view->with('categories', $groupId === null ? [] : app(CollectionService::class)->list(
|
||||
filters: new CollectionFilters(groupId: $groupId, rootOnly: true),
|
||||
perPage: 100,
|
||||
sort: CollectionSort::Position,
|
||||
)->items());
|
||||
});
|
||||
|
||||
// Site-wide settings edited in Stoic (settings.md): default meta
|
||||
// description, social links for the footer, emails and schema.org.
|
||||
View::composer(
|
||||
['layouts.app', 'components.footer', 'emails.layout', 'home'],
|
||||
fn (ViewInstance $view) => $view->with('stoicSettings', app(StoicSettings::class)),
|
||||
);
|
||||
|
||||
// INTERIM, belongs in boboko-core's Store module: the sender name on
|
||||
// outgoing emails comes from Store Details' "Mail from name", with
|
||||
// MAIL_FROM_NAME as the fallback when it's empty. Done per message
|
||||
// rather than by setting config at boot, so a long-running queue
|
||||
// worker picks up a changed name without a restart. Only the default
|
||||
// from address is renamed; a mailable with its own from is left alone.
|
||||
Event::listen(MessageSending::class, function (MessageSending $event) {
|
||||
$name = app(StoreDetailsService::class)->current()->mail_from_name;
|
||||
|
||||
if (blank($name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultAddress = config('mail.from.address');
|
||||
|
||||
$event->message->from(...array_map(
|
||||
fn (Address $from) => $from->getAddress() === $defaultAddress
|
||||
? new Address($from->getAddress(), $name)
|
||||
: $from,
|
||||
$event->message->getFrom(),
|
||||
));
|
||||
});
|
||||
|
||||
if ($this->app->environment('production')) {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
@@ -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\Support\Facades\Storage;
|
||||
use Spatie\YamlFrontMatter\YamlFrontMatter;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class Stoic
|
||||
@@ -29,6 +30,42 @@ public static function presets(): array
|
||||
return static::$presets;
|
||||
}
|
||||
|
||||
// Stoic's default locale (first in i18n.locales), or null when i18n is off.
|
||||
// Its content folder holds every field; other locales only translatable ones.
|
||||
public static function defaultLocale(): ?string
|
||||
{
|
||||
static::loadConfig();
|
||||
return static::$config["i18n"]["locales"][0]["code"] ?? null;
|
||||
}
|
||||
|
||||
// The current locale's translated fields for a content file (path relative
|
||||
// to a locale folder, e.g. "pages/home.md"), with the markdown body as
|
||||
// `content`. Empty when the current locale is the default one (or i18n is
|
||||
// off), the file doesn't exist, or a field is left blank — so layering this
|
||||
// over the default-locale values falls back to them field by field.
|
||||
public static function localeOverrides(string $file): array
|
||||
{
|
||||
$default = static::defaultLocale();
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if ($default === null || $locale === $default) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$path = resource_path("stoic/content/{$locale}/{$file}");
|
||||
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$document = YamlFrontMatter::parse(file_get_contents($path));
|
||||
|
||||
return array_filter(
|
||||
[...$document->matter(), "content" => trim($document->body())],
|
||||
fn($value) => filled($value),
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the public URL for a stoic image at a given preset.
|
||||
public static function image(string $filename, string $preset): string
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
->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 {
|
||||
//
|
||||
|
||||
@@ -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,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
*/
|
||||
'cart_lines' => [
|
||||
Lunar\Pipelines\CartLine\GetUnitPrice::class,
|
||||
Modules\Core\Cart\Pipelines\ZeroSavedForLaterPrice::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -44,5 +44,5 @@
|
||||
| Determines whether the cart sholud be soft deleted when the user logs out.
|
||||
|
|
||||
*/
|
||||
'delete_on_forget' => true,
|
||||
'delete_on_forget' => false,
|
||||
];
|
||||
|
||||
@@ -58,11 +58,23 @@
|
||||
| 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' => [
|
||||
|
||||
'awaiting-payment' => [
|
||||
'awaiting_payment' => [
|
||||
'label' => 'Awaiting Payment',
|
||||
'color' => '#848a8c',
|
||||
'mailers' => [],
|
||||
@@ -70,17 +82,25 @@
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'payment-offline' => [
|
||||
'label' => 'Payment Offline',
|
||||
'processing' => [
|
||||
'label' => 'Processing',
|
||||
'color' => '#0A81D7',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'payment-received' => [
|
||||
'label' => 'Payment Received',
|
||||
'color' => '#6a67ce',
|
||||
'ready_for_dispatch' => [
|
||||
'label' => 'Ready for Dispatch',
|
||||
'color' => '#c98a2e',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
],
|
||||
|
||||
'ready_for_pickup' => [
|
||||
'label' => 'Ready for Pickup',
|
||||
'color' => '#c98a2e',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'favourite' => true,
|
||||
@@ -88,11 +108,78 @@
|
||||
|
||||
'dispatched' => [
|
||||
'label' => 'Dispatched',
|
||||
'color' => '#0A81D7',
|
||||
'mailers' => [],
|
||||
'notifications' => [],
|
||||
'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.
|
||||
|
|
||||
*/
|
||||
'order_count_statuses' => ['payment-received'],
|
||||
'order_count_statuses' => ['processing'],
|
||||
|
||||
];
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Modules\Core\Payment\Drivers\OfflinePaymentDriver;
|
||||
|
||||
return [
|
||||
|
||||
'default' => env('PAYMENTS_TYPE', 'cash-in-hand'),
|
||||
@@ -7,6 +9,7 @@
|
||||
'types' => [
|
||||
'cash-in-hand' => [
|
||||
'driver' => 'offline',
|
||||
'payment_driver' => OfflinePaymentDriver::class,
|
||||
'authorized' => 'payment-offline',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -20,7 +20,17 @@
|
||||
Lunar\Models\Collection::class,
|
||||
Lunar\Models\Customer::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,
|
||||
|
||||
/*
|
||||
@@ -46,11 +56,25 @@
|
||||
|
||||
'indexers' => [
|
||||
Lunar\Models\Brand::class => Lunar\Search\BrandIndexer::class,
|
||||
Lunar\Models\Collection::class => Lunar\Search\CollectionIndexer::class,
|
||||
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
|
||||
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
|
||||
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
|
||||
Lunar\Models\Product::class => Modules\Core\Search\ProductIndexer::class,
|
||||
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::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\ProductOption::class => Lunar\Search\ProductOptionIndexer::class
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -35,9 +35,31 @@
|
||||
],
|
||||
],
|
||||
|
||||
// Bot check on guest forms (login, contact), verified by App\Rules\HCaptcha. For
|
||||
// local dev use hCaptcha's test keys, the real ones reject localhost:
|
||||
// sitekey 10000000-ffff-ffff-ffff-000000000001,
|
||||
// secret 0x0000000000000000000000000000000000000000.
|
||||
'hcaptcha' => [
|
||||
'sitekey' => env('HCAPTCHA_SITEKEY'),
|
||||
'secret' => env('HCAPTCHA_SECRET'),
|
||||
],
|
||||
|
||||
'stoic' => [
|
||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||
'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:
|
||||
volumes:
|
||||
- ../boboko-core:/var/www/boboko-core
|
||||
|
||||
vite:
|
||||
volumes:
|
||||
- ../boboko-core:/boboko-core
|
||||
|
||||
@@ -114,7 +114,7 @@ services:
|
||||
- "${VALKEY_PORT:-6339}:6379"
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.10
|
||||
image: getmeili/meilisearch:v1.12
|
||||
ports:
|
||||
- "${MEILISEARCH_PORT:-7700}:7700"
|
||||
environment:
|
||||
|
||||
@@ -47,6 +47,7 @@ services:
|
||||
|
||||
queue:
|
||||
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
|
||||
entrypoint: ["/entrypoint-worker.sh"]
|
||||
command: php artisan queue:work --tries=3 --max-jobs=500 --memory=256
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
@@ -68,6 +69,7 @@ services:
|
||||
|
||||
scheduler:
|
||||
image: ${COMPOSE_PROJECT_NAME:-boboko-starter}-app
|
||||
entrypoint: ["/entrypoint-worker.sh"]
|
||||
command: php artisan schedule:work
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
@@ -116,7 +118,7 @@ services:
|
||||
- valkeydata:/data
|
||||
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.10
|
||||
image: getmeili/meilisearch:v1.12
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
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.
|
||||
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
|
||||
echo "[entrypoint] Waiting for migrations to complete..."
|
||||
timeout=60
|
||||
while [ ! -f storage/framework/migrated ] && [ "$timeout" -gt 0 ]; do
|
||||
# Same reasoning as entrypoint.sh: this container gets replaced on every deploy
|
||||
# (dev and production alike), so this is a fresh boot clearing stale artifacts
|
||||
# left on disk (cached config, compiled views), not a running process being
|
||||
# told to forget in-memory code. queue:work/schedule:work themselves still
|
||||
# can't pick up a later code change without an actual process restart — this
|
||||
# only fixes what's stale on disk at boot.
|
||||
echo "[entrypoint] Clearing cached config/routes/views..."
|
||||
php artisan optimize:clear --quiet
|
||||
echo "[entrypoint] Caches cleared"
|
||||
|
||||
# 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
|
||||
done
|
||||
if [ ! -f storage/framework/migrated ]; then
|
||||
echo "[entrypoint] Timed out waiting for migrations" >&2
|
||||
exit 1
|
||||
fi
|
||||
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
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -4,6 +4,12 @@ set -e
|
||||
# Only the app container runs setup; queue/scheduler use entrypoint-worker.sh instead
|
||||
# and just wait on the migrated marker this script writes below.
|
||||
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..."
|
||||
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
|
||||
@@ -21,37 +27,90 @@ mkdir -p storage/app/public storage/framework/cache storage/framework/sessions s
|
||||
chown -R www-data:www-data 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
|
||||
|
||||
if [ "$APP_ENV" != "production" ]; then
|
||||
# The app dir is bind-mounted from a fresh checkout, so package assets (which the
|
||||
# Dockerfile publishes at build time in production) need to be generated here
|
||||
# instead. Cheap and idempotent, safe to repeat on every boot.
|
||||
# Dev-only for the same reason as the composer step above — production's
|
||||
# image already has these published at build time.
|
||||
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
|
||||
php artisan vendor:publish --tag=public --force --ansi --quiet
|
||||
php artisan filament:assets --ansi --quiet
|
||||
|
||||
echo "[entrypoint] Running migrations..."
|
||||
rm -f storage/framework/migrated
|
||||
# php artisan migrate --force
|
||||
echo "[entrypoint] Touching migrated file"
|
||||
touch storage/framework/migrated
|
||||
echo "[entrypoint] Touched migrated file"
|
||||
# No --force: core-views publishes editable Blade templates (order
|
||||
# notification emails), meant to be hand-customized per app — unlike
|
||||
# core-assets above, republishing must not silently wipe local edits.
|
||||
# Only fills in the vendor/core views directory if it doesn't exist yet.
|
||||
php artisan vendor:publish --tag=core-views --ansi --quiet
|
||||
fi
|
||||
|
||||
# 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
|
||||
# confirm, admin creation, GitHub star) and just seed the idempotent store
|
||||
# defaults: countries, channel, language, currency, tax zone, attributes,
|
||||
# product type. queue/scheduler wait on the marker above rather than running
|
||||
# this themselves, since the country import's check-then-insert isn't safe to
|
||||
# run concurrently.
|
||||
echo "[entrypoint] Trying Lunar install"
|
||||
php artisan lunar:install --quiet || true
|
||||
echo "[entrypoint] Running migrations..."
|
||||
rm -f storage/framework/migrated
|
||||
php artisan migrate --force
|
||||
echo "[entrypoint] Touching migrated file"
|
||||
touch storage/framework/migrated
|
||||
echo "[entrypoint] Touched migrated file"
|
||||
|
||||
# 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.
|
||||
echo "[entrypoint] Syncing search indexes..."
|
||||
php artisan lunar:search:index --quiet || true
|
||||
# boboko/core overrides lunar:install to skip the interactive prompts (migrate
|
||||
# confirm, admin creation, GitHub star) and just seed the idempotent store
|
||||
# defaults: countries, channel, language, currency, tax zone, attributes,
|
||||
# product type. queue/scheduler wait on the marker above rather than running
|
||||
# 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
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -9,6 +9,14 @@ content_path: /content
|
||||
media_path: /media
|
||||
thumbs_path: /var/www/html/storage/app/public/stoic/
|
||||
repository_path: /app
|
||||
# First locale is Stoic's default: it holds every field, the others only the
|
||||
# translatable ones (content/{locale}/...). Codes match app.available_locales.
|
||||
i18n:
|
||||
locales:
|
||||
- code: el
|
||||
label: Ελληνικά
|
||||
- code: en
|
||||
label: English
|
||||
presets:
|
||||
- code: large
|
||||
mode: resize
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'nav' => [
|
||||
'home' => 'Αρχική',
|
||||
'products' => 'Προϊόντα',
|
||||
'contact' => 'Επικοινωνία',
|
||||
],
|
||||
|
||||
'product' => [
|
||||
'description' => 'Περιγραφή',
|
||||
'reviews' => 'Αξιολογήσεις',
|
||||
],
|
||||
|
||||
// Laravel pluralization: {0} zero|{1} one|[2,*] many. :count is replaced automatically.
|
||||
'customer_reviews' => '{0} Καμία αξιολόγηση πελάτη|{1} :count αξιολόγηση πελάτη|[2,*] :count αξιολογήσεις πελατών',
|
||||
|
||||
'shop' => [
|
||||
// Laravel pluralization keyed on the total result count.
|
||||
'showing_results' => '{0} Δεν βρέθηκαν προϊόντα|{1} Εμφάνιση :first–:last από :total αποτέλεσμα|[2,*] Εμφάνιση :first–:last από :total αποτελέσματα',
|
||||
'no_products' => 'Δεν βρέθηκαν προϊόντα σε αυτή την κατηγορία.',
|
||||
'search_label' => 'Αναζήτηση προϊόντων',
|
||||
'search_placeholder' => 'Αναζήτησε προϊόντα…',
|
||||
'filter_price' => 'Φίλτρο τιμής',
|
||||
'apply' => 'Εφαρμογή',
|
||||
'availability' => 'Διαθεσιμότητα',
|
||||
'in_stock_only' => 'Μόνο διαθέσιμα προϊόντα',
|
||||
'sort_label' => 'Ταξινόμηση προϊόντων',
|
||||
'sort_default' => 'Προεπιλεγμένη ταξινόμηση',
|
||||
'sort_popularity' => 'Δημοφιλή',
|
||||
'sort_price_asc' => 'Τιμή: Αύξουσα',
|
||||
'sort_price_desc' => 'Τιμή: Φθίνουσα',
|
||||
'sort_newest' => 'Νεότερα',
|
||||
],
|
||||
|
||||
'pagination' => [
|
||||
'nav_label' => 'Σελιδοποίηση',
|
||||
'page' => 'Σελίδα :page',
|
||||
'next' => 'Επόμενη σελίδα',
|
||||
'previous' => 'Προηγούμενη σελίδα',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'nav' => [
|
||||
'home' => 'Home',
|
||||
'products' => 'Products',
|
||||
'contact' => 'Contact',
|
||||
],
|
||||
|
||||
'product' => [
|
||||
'description' => 'Description',
|
||||
'reviews' => 'Reviews',
|
||||
],
|
||||
|
||||
// Laravel pluralization: {0} zero|{1} one|[2,*] many. :count is replaced automatically.
|
||||
'customer_reviews' => '{0} No customer reviews|{1} :count customer review|[2,*] :count customer reviews',
|
||||
|
||||
'shop' => [
|
||||
// Laravel pluralization keyed on the total result count.
|
||||
'showing_results' => '{0} No products found|{1} Showing :first–:last of :total result|[2,*] Showing :first–:last of :total results',
|
||||
'no_products' => 'No products found in this category.',
|
||||
'search_label' => 'Search products',
|
||||
'search_placeholder' => 'Search products…',
|
||||
'filter_price' => 'Filter by price',
|
||||
'apply' => 'Apply',
|
||||
'availability' => 'Availability',
|
||||
'in_stock_only' => 'In-stock products only',
|
||||
'sort_label' => 'Sort products',
|
||||
'sort_default' => 'Default sorting',
|
||||
'sort_popularity' => 'Popularity',
|
||||
'sort_price_asc' => 'Price: Low to High',
|
||||
'sort_price_desc' => 'Price: High to Low',
|
||||
'sort_newest' => 'Newest',
|
||||
],
|
||||
|
||||
'pagination' => [
|
||||
'nav_label' => 'Pagination',
|
||||
'page' => 'Page :page',
|
||||
'next' => 'Next page',
|
||||
'previous' => 'Previous page',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "boboko-starter",
|
||||
"name": "app",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"axios": "^1.15.2",
|
||||
"flatpickr": "^4.6.13"
|
||||
"leaflet": "^1.9.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -19,11 +19,21 @@
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@boboko/core": {
|
||||
"version": "0.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": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -35,7 +45,6 @@
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -46,7 +55,6 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -122,7 +130,6 @@
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -141,18 +148,11 @@
|
||||
"version": "0.138.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz",
|
||||
"integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@phosphor-icons/web": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz",
|
||||
"integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||
@@ -160,7 +160,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -177,7 +176,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -194,7 +192,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -211,7 +208,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -228,7 +224,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -245,7 +240,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -262,7 +256,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -279,7 +272,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -296,7 +288,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -313,7 +304,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -330,7 +320,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -347,7 +336,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -364,7 +352,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -383,7 +370,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -400,7 +386,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -414,7 +399,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
@@ -693,7 +677,6 @@
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -901,7 +884,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -1001,7 +983,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -1015,12 +996,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/flatpickr": {
|
||||
"version": "4.6.13",
|
||||
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
||||
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
@@ -1061,7 +1036,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1223,7 +1197,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
@@ -1256,11 +1230,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
@@ -1293,7 +1272,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1314,7 +1292,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1335,7 +1312,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1356,7 +1332,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1377,7 +1352,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1398,7 +1372,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1419,7 +1392,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1440,7 +1412,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1461,7 +1432,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1482,7 +1452,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1503,7 +1472,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1567,7 +1535,6 @@
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1586,14 +1553,12 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -1606,7 +1571,6 @@
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1654,7 +1618,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
"integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.138.0",
|
||||
@@ -1711,7 +1674,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -1786,7 +1748,6 @@
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -1813,14 +1774,13 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
|
||||
@@ -14,8 +14,14 @@
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boboko/core": "git+https://code.radical-elements.com/boboko/core.git#semver:0.x",
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"@hotwired/turbo": "^8.0.23",
|
||||
"axios": "^1.15.2"
|
||||
"axios": "^1.15.2",
|
||||
"leaflet": "^1.9.4"
|
||||
},
|
||||
"_comment_boboko_core": "Mirrors composer.json's repositories/_repositories toggle: swap @boboko/core above with the line in _dependencies below (and back) by hand to switch between the local path-repo checkout and a tagged VCS install. The #semver:0.x range mirrors composer.json's own \"boboko/core\": \"0.*\" constraint exactly — note ^0.23.0 would NOT be equivalent here, since npm's caret locks the minor version too below 1.0.0 (>=0.23.0 <0.24.0). 0.x matches any 0.y.z tag, same as Composer's 0.*. npm reads the repo's git tags, filters to valid semver ones, and resolves the best match, so `npm update @boboko/core` alone picks up a newly pushed tag within range, no manual edit here per release. See docker/entrypoint-vite.sh and CONTRIBUTE.md.",
|
||||
"_dependencies": {
|
||||
"@boboko/core": "file:../boboko-core"
|
||||
}
|
||||
}
|
||||
|
||||
|
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 @@
|
||||
function r({state:o}){return{state:o,rows:[],shouldUpdateRows:!0,init:function(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(t,e)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(t)===0&&s(e)===0||this.updateRows()})},addRow:function(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow:function(t){this.rows.splice(t,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows:function(t){let e=Alpine.raw(this.rows);this.rows=[];let s=e.splice(t.oldIndex,1)[0];e.splice(t.newIndex,0,s),this.$nextTick(()=>{this.rows=e,this.updateState()})},updateRows:function(){if(!this.shouldUpdateRows){this.shouldUpdateRows=!0;return}let t=[];for(let[e,s]of Object.entries(this.state??{}))t.push({key:e,value:s});this.rows=t},updateState:function(){let t={};this.rows.forEach(e=>{e.key===""||e.key===null||(t[e.key]=e.value)}),this.shouldUpdateRows=!1,this.state=t}}}export{r as default};
|
||||
@@ -1 +0,0 @@
|
||||
function i({state:a,splitKeys:n}){return{newTag:"",state:a,createTag:function(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag:function(t){this.state=this.state.filter(e=>e!==t)},reorderTags:function(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...n].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(n.length===0){this.createTag();return}let t=n.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{i as default};
|
||||
@@ -1 +0,0 @@
|
||||
function r({initialHeight:t,shouldAutosize:i,state:s}){return{state:s,wrapperEl:null,init:function(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight:function(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=t+"rem")},resize:function(){if(this.setInitialHeight(),this.$el.scrollHeight<=0)return;let e=this.$el.scrollHeight+"px";this.wrapperEl.style.height!==e&&(this.wrapperEl.style.height=e)},setUpResizeObserver:function(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r 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 |
@@ -1,5 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "./fonts.css";
|
||||
@import "./dropdown.css";
|
||||
|
||||
/* Note for anyone theming the checkout module (resources/css/checkout.css,
|
||||
.bbk-* classes): it's deliberately plain, unlayered CSS, not inside any
|
||||
@layer — so override it with plain rules here too, not from inside
|
||||
@layer components/utilities, which would lose to it. See checkout.css's
|
||||
file-level comment for why. */
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@@ -20,7 +27,7 @@ @theme {
|
||||
--text-h1: 60px;
|
||||
--text-h2: 48px;
|
||||
--text-h3: 36px;
|
||||
--text-h4: 27px;
|
||||
--text-h4: 26px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
@@ -43,6 +50,12 @@ @layer base {
|
||||
button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* <turbo-frame> is a custom element — inline by default. Give it a box so
|
||||
the grid it wraps on the category page lays out normally. */
|
||||
turbo-frame {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@@ -206,6 +219,28 @@ @layer components {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search overlay (popover) — opacity fade over the header ──── */
|
||||
#search-overlay {
|
||||
/* Fallback height until the `nav-search` controller measures the real
|
||||
header on open; the header has no fixed height below `lg`. */
|
||||
--search-overlay-h: 6.5rem;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
display 0.2s allow-discrete,
|
||||
overlay 0.2s allow-discrete;
|
||||
}
|
||||
|
||||
#search-overlay:popover-open {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
#search-overlay:popover-open {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Shared underline-slide animation ────────────────────────── */
|
||||
.underline-slide {
|
||||
background-image: linear-gradient(currentColor, currentColor);
|
||||
@@ -296,7 +331,7 @@ @layer components {
|
||||
transition: transform 0.35s cubic-bezier(0.2, 0.78, 0.12, 0.86);
|
||||
}
|
||||
|
||||
.btn-primary:hover::after {
|
||||
.btn-primary:not(:disabled):hover::after {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
/* ── Dropdown (Popover API) ──────────────────────────────────── */
|
||||
/* The panel is a [popover] → it renders in the top layer, so its
|
||||
containing block is the viewport, not the .dropdown wrapper, and
|
||||
CSS alone can't tie it to the trigger (anchor positioning isn't
|
||||
everywhere yet). The `dropdown` Stimulus controller measures the
|
||||
trigger on open and writes --dropdown-top/left/width here; the
|
||||
open/close animation below stays pure CSS. Opens on a click, so
|
||||
animating transform + opacity is CLS-safe. */
|
||||
.dropdown-panel {
|
||||
top: var(--dropdown-top, 0);
|
||||
left: var(--dropdown-left, 0);
|
||||
min-width: var(--dropdown-width, 0);
|
||||
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
display 0.2s allow-discrete,
|
||||
overlay 0.2s allow-discrete;
|
||||
}
|
||||
|
||||
.dropdown-panel:popover-open {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.dropdown-panel:popover-open {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Caret flips while the panel is open — :has() is the only route
|
||||
back up from the popover's :popover-open state to the caret. */
|
||||
.dropdown:has(.dropdown-panel:popover-open) .dropdown-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@@ -1,10 +1,24 @@
|
||||
import "./bootstrap";
|
||||
import "./utils/strip-accents";
|
||||
import "./utils/refresh-scroll";
|
||||
|
||||
// Frames only — no site-wide Turbo Drive. <turbo-frame> navigations still work
|
||||
// (that's how the category listing reloads); every other link and form on the
|
||||
// site keeps its normal full-page browser behaviour.
|
||||
import "@hotwired/turbo";
|
||||
window.Turbo.session.drive = false;
|
||||
|
||||
import { Application } from "@hotwired/stimulus";
|
||||
import { registerControllers } from "./stimulus/index";
|
||||
import { registerCheckout, registerWishlist } from "@boboko/core";
|
||||
|
||||
const application = Application.start();
|
||||
application.debug = false;
|
||||
|
||||
registerControllers(application);
|
||||
|
||||
// Portable cart + checkout + wishlist modules, installed as a real npm
|
||||
// dependency (see package.json). Each owns its own Stimulus controllers;
|
||||
// these are the only wiring lines they need here.
|
||||
registerCheckout(application);
|
||||
registerWishlist(application);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// Submits the host <form> a short beat after a control inside it changes,
|
||||
// coalescing a burst — rapid slider nudges, or holding an arrow key on a range
|
||||
// input — into a single submit. Wire it on the <form>:
|
||||
//
|
||||
// <form data-controller="auto-submit"
|
||||
// data-action="change->auto-submit#submit range-slider:change->auto-submit#submit"
|
||||
// data-auto-submit-delay-value="300"> (delay optional, ms)
|
||||
//
|
||||
// `change` covers native inputs (checkbox, select); the range slider emits its
|
||||
// own `range-slider:change` on commit. Uses requestSubmit() (not submit()) so a
|
||||
// <turbo-frame> around the form still captures the navigation and validation runs.
|
||||
export default class extends Controller {
|
||||
static values = { delay: { type: Number, default: 300 } }
|
||||
|
||||
submit() {
|
||||
clearTimeout(this.#timer)
|
||||
this.#timer = setTimeout(() => this.element.requestSubmit(), this.delayValue)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearTimeout(this.#timer)
|
||||
}
|
||||
|
||||
#timer
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// 3dealer-side glue for the checkout module. The module owns the cart and emits
|
||||
// `bbk-cart:updated` {count, total} on window after every change; this reflects
|
||||
// the line count on the header bag icon. How (or whether) that count is shown
|
||||
// is the host's call — hence this lives here, not in the module.
|
||||
export default class extends Controller {
|
||||
static targets = ['badge']
|
||||
|
||||
connect() {
|
||||
this.onUpdate = (event) => this.render(event.detail?.count ?? 0)
|
||||
window.addEventListener('bbk-cart:updated', this.onUpdate)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener('bbk-cart:updated', this.onUpdate)
|
||||
}
|
||||
|
||||
// Header cart icon click — there's no separate cart page, the drawer IS
|
||||
// the cart. `bbk-cart:open` is the module's own event, already listened
|
||||
// for by bbk-cart-controller.
|
||||
open() {
|
||||
window.dispatchEvent(new CustomEvent('bbk-cart:open'))
|
||||
}
|
||||
|
||||
render(count) {
|
||||
if (!this.hasBadgeTarget) return
|
||||
this.badgeTarget.textContent = String(count)
|
||||
this.badgeTarget.hidden = count < 1
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['menu']
|
||||
|
||||
open() {
|
||||
this.menuTarget.classList.add('is-open')
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menuTarget.classList.remove('is-open')
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.menuTarget.classList.toggle('is-open')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// One product custom field of type `file` (see x-product-custom-fields).
|
||||
// Uploads the photo to the storefront's own endpoint (CustomFieldUploadController)
|
||||
// as soon as it's picked, then writes the returned File row's id (boboko-core's
|
||||
// Modules\Core\File\Models\File) into the hidden input the add-to-cart form
|
||||
// actually submits — the checkout module never receives the file itself.
|
||||
//
|
||||
// While uploading, the file input is marked invalid via setCustomValidity(),
|
||||
// so the browser's own form validation blocks add-to-cart until the id is in
|
||||
// place. A failed upload clears the input, so `required` blocks it too.
|
||||
export default class extends Controller {
|
||||
static targets = ['file', 'reference', 'preview', 'error']
|
||||
static values = {
|
||||
url: String,
|
||||
label: String,
|
||||
uploadingMessage: String,
|
||||
failedMessage: String,
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.abortController?.abort()
|
||||
this.revokePreview()
|
||||
}
|
||||
|
||||
async upload() {
|
||||
this.reset()
|
||||
|
||||
const file = this.fileTarget.files[0]
|
||||
if (!file) return
|
||||
|
||||
const abortController = new AbortController()
|
||||
this.abortController = abortController
|
||||
|
||||
this.fileTarget.setCustomValidity(this.uploadingMessageValue)
|
||||
this.fileTarget.setAttribute('aria-busy', 'true')
|
||||
|
||||
const body = new FormData()
|
||||
body.append('file', file)
|
||||
body.append('label', this.labelValue)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.urlValue, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
const data = await response.json().catch(() => null)
|
||||
|
||||
if (!response.ok || !data?.file_id) {
|
||||
this.fail(data?.error)
|
||||
return
|
||||
}
|
||||
|
||||
this.referenceTarget.value = data.file_id
|
||||
this.showPreview(file)
|
||||
} catch (error) {
|
||||
// A newer pick superseded this upload — reset() already handled it.
|
||||
if (error.name === 'AbortError') return
|
||||
this.fail()
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) this.markIdle()
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.abortController?.abort()
|
||||
this.referenceTarget.value = ''
|
||||
this.errorTarget.hidden = true
|
||||
this.markIdle()
|
||||
this.revokePreview()
|
||||
}
|
||||
|
||||
fail(message) {
|
||||
this.fileTarget.value = ''
|
||||
this.errorTarget.textContent = message || this.failedMessageValue
|
||||
this.errorTarget.hidden = false
|
||||
}
|
||||
|
||||
markIdle() {
|
||||
this.fileTarget.setCustomValidity('')
|
||||
this.fileTarget.removeAttribute('aria-busy')
|
||||
}
|
||||
|
||||
showPreview(file) {
|
||||
this.previewUrl = URL.createObjectURL(file)
|
||||
this.previewTarget.src = this.previewUrl
|
||||
this.previewTarget.hidden = false
|
||||
}
|
||||
|
||||
// Formats the browser can't render (HEIC outside Safari) — the file
|
||||
// input's own filename is enough there.
|
||||
hidePreview() {
|
||||
this.previewTarget.hidden = true
|
||||
}
|
||||
|
||||
revokePreview() {
|
||||
if (this.previewUrl) URL.revokeObjectURL(this.previewUrl)
|
||||
this.previewUrl = null
|
||||
this.previewTarget.removeAttribute('src')
|
||||
this.previewTarget.hidden = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// Positions the popover panel directly under its trigger.
|
||||
//
|
||||
// A [popover] renders in the top layer, so its containing block is the
|
||||
// viewport, not the .dropdown wrapper — CSS alone can't tie it to the trigger
|
||||
// without anchor positioning, which isn't in every browser yet. So on each
|
||||
// open we measure the trigger and write the geometry to CSS custom properties
|
||||
// that .dropdown-panel consumes (top / left / min-width). The open/close
|
||||
// animation stays entirely in CSS; the controller only feeds it three numbers.
|
||||
export default class extends Controller {
|
||||
static targets = ['trigger', 'panel']
|
||||
|
||||
|
||||
|
||||
// Wired to `click->dropdown#position` on the trigger, which also fires for
|
||||
// keyboard activation (Enter/Space on a <button>), so this runs before the
|
||||
// native popover toggle paints the panel.
|
||||
position() {
|
||||
const rect = this.triggerTarget.getBoundingClientRect()
|
||||
const style = this.panelTarget.style
|
||||
|
||||
style.setProperty('--dropdown-top', `${rect.bottom + window.scrollY}px`)
|
||||
style.setProperty('--dropdown-left', `${rect.left + window.scrollX}px`)
|
||||
style.setProperty('--dropdown-width', `${rect.width}px`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// Turbo doesn't scroll for <turbo-frame> navigations, so after the frame swaps
|
||||
// its contents (a sort, filter, or pagination link) this brings the top of the
|
||||
// frame back into view — otherwise clicking pagination at the bottom of the
|
||||
// list leaves you stranded down there. The frame's own scroll-margin-top keeps
|
||||
// it clear of the sticky header.
|
||||
//
|
||||
// `turbo:frame-render` fires only on a content swap, not on the initial page
|
||||
// render, and the frame element itself persists across swaps — so the listener
|
||||
// is bound once in connect().
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.addEventListener('turbo:frame-render', this.#toTop)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener('turbo:frame-render', this.#toTop)
|
||||
}
|
||||
|
||||
#toTop = () => {
|
||||
this.element.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
@@ -4,21 +4,37 @@
|
||||
// application.register('hello', HelloController);
|
||||
|
||||
import AppearController from './appear-controller'
|
||||
import AutoSubmitController from './auto-submit-controller'
|
||||
import BackToTopController from './back-to-top-controller'
|
||||
import CartCountController from './cart-count-controller'
|
||||
import CarouselController from './carousel-controller'
|
||||
import CustomFieldUploadController from './custom-field-upload-controller'
|
||||
import DropdownController from './dropdown-controller'
|
||||
import FrameScrollController from './frame-scroll-controller'
|
||||
import NavSearchController from './nav-search-controller'
|
||||
import ProductFormController from './product-form-controller'
|
||||
import ProductGalleryController from './product-gallery-controller'
|
||||
import QuantityController from './quantity-controller'
|
||||
import RangeSliderController from './range-slider-controller'
|
||||
import StarRatingController from './star-rating-controller'
|
||||
import TabLinkController from './tab-link-controller'
|
||||
import TabsController from './tabs-controller'
|
||||
|
||||
export function registerControllers(application) {
|
||||
application.register('appear', AppearController)
|
||||
application.register('auto-submit', AutoSubmitController)
|
||||
application.register('back-to-top', BackToTopController)
|
||||
application.register('cart-count', CartCountController)
|
||||
application.register('carousel', CarouselController)
|
||||
application.register('custom-field-upload', CustomFieldUploadController)
|
||||
application.register('dropdown', DropdownController)
|
||||
application.register('frame-scroll', FrameScrollController)
|
||||
application.register('nav-search', NavSearchController)
|
||||
application.register('product-form', ProductFormController)
|
||||
application.register('product-gallery', ProductGalleryController)
|
||||
application.register('quantity', QuantityController)
|
||||
application.register('range-slider', RangeSliderController)
|
||||
application.register('star-rating', StarRatingController)
|
||||
application.register('tab-link', TabLinkController)
|
||||
application.register('tabs', TabsController)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// The search overlay is a [popover] that must sit exactly over the header. The
|
||||
// header has no fixed height below `lg`, so on open we copy its current height
|
||||
// onto --search-overlay-h and move focus into the field. Escape / click-away
|
||||
// close come from the Popover API; the fade is CSS (#search-overlay).
|
||||
export default class extends Controller {
|
||||
static targets = ['input']
|
||||
|
||||
connect() {
|
||||
this.element.addEventListener('toggle', this.#onToggle)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener('toggle', this.#onToggle)
|
||||
}
|
||||
|
||||
#onToggle = (event) => {
|
||||
if (event.newState !== 'open') return
|
||||
|
||||
const header = this.element.closest('header')
|
||||
if (header) {
|
||||
this.element.style.setProperty('--search-overlay-h', `${header.offsetHeight}px`)
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.inputTarget.focus())
|
||||
}
|
||||
}
|
||||
@@ -2,25 +2,138 @@ import { Controller } from '@hotwired/stimulus'
|
||||
import { formatPrice } from '../utils/format-price'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['price', 'image', 'swatch', 'colorName']
|
||||
static values = { variants: Array, selected: Number }
|
||||
static targets = ['price', 'image', 'swatch', 'colorName', 'stockError', 'submit']
|
||||
static values = {
|
||||
variants: Array,
|
||||
selected: Number,
|
||||
stockCheckUrl: String,
|
||||
// Two pre-rendered translated templates (see product/show.blade.php)
|
||||
// rather than one — this controller doesn't reimplement Laravel's
|
||||
// pluralization rules, it just picks whichever of these two the
|
||||
// count actually needs and fills in the number.
|
||||
stockErrorOne: String,
|
||||
stockErrorMany: String,
|
||||
}
|
||||
|
||||
connect() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlId = parseInt(params.get('variant'))
|
||||
const defaultId = this.variantsValue[0]?.id
|
||||
const urlVariant = this.variantsValue.find(v => v.id === urlId)
|
||||
const initial = urlVariant ?? this.variantsValue[0]
|
||||
|
||||
this.selectedValue = urlId && this.variantsValue.find(v => v.id === urlId)
|
||||
? urlId
|
||||
: defaultId
|
||||
// A product can have several independent options (e.g. size + style
|
||||
// + person-count) — this tracks the currently-picked value id per
|
||||
// option handle, and selectVariant() below resolves the full
|
||||
// combination back to one exact variant on every change.
|
||||
this.selections = { ...initial?.options }
|
||||
this.selectedValue = initial?.id
|
||||
|
||||
// Capture phase, on this controller's own root element (an ancestor
|
||||
// of the checkout module's add-to-cart <form>) — runs BEFORE that
|
||||
// form's own bubble-phase submit handler (bbk-add-to-cart#add), so a
|
||||
// failed check can stop it from ever reaching the module at all. The
|
||||
// module itself is never touched or modified for this: it keeps
|
||||
// validating server-side regardless, this is purely an up-front,
|
||||
// storefront-owned check (see [[project_checkout_module]] for why
|
||||
// that split matters — stock UX is a catalog concern, not something
|
||||
// the portable checkout module should own) — and a REAL, live check
|
||||
// against the backend (ProductController::checkStock(), reading the
|
||||
// Eloquent model directly), not page-load data that can go stale.
|
||||
this.onSubmitCapture = this.checkStock.bind(this)
|
||||
this.element.addEventListener('submit', this.onSubmitCapture, true)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener('submit', this.onSubmitCapture, true)
|
||||
}
|
||||
|
||||
checkStock(event) {
|
||||
const form = event.target
|
||||
if (!form.matches('.bbk-add-to-cart')) return
|
||||
|
||||
// The re-submit this itself triggers below, once the backend has
|
||||
// confirmed the quantity is fine — let that one through to the
|
||||
// module's own submit handler instead of checking a second time.
|
||||
if (form.dataset.bbkStockChecked) {
|
||||
delete form.dataset.bbkStockChecked
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
this.verifyStock(form)
|
||||
}
|
||||
|
||||
async verifyStock(form) {
|
||||
this.clearStockError()
|
||||
|
||||
const submit = form.querySelector('[type="submit"]')
|
||||
if (submit) submit.disabled = true
|
||||
|
||||
const purchasableId = form.querySelector('[data-bbk-purchasable-input]')?.value
|
||||
const quantity = form.querySelector('[name="quantity"]')?.value || '1'
|
||||
|
||||
try {
|
||||
const url = new URL(this.stockCheckUrlValue, window.location.origin)
|
||||
url.searchParams.set('variant', purchasableId)
|
||||
url.searchParams.set('quantity', quantity)
|
||||
|
||||
const response = await fetch(url, { headers: { Accept: 'application/json' } })
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.ok) {
|
||||
this.showStockError(data.stock)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Network hiccup — fall through and let the checkout module's
|
||||
// own server-side check have the final word rather than
|
||||
// silently blocking the shopper here.
|
||||
} finally {
|
||||
if (submit) submit.disabled = !this.selectedVariant?.inStock
|
||||
}
|
||||
|
||||
form.dataset.bbkStockChecked = 'true'
|
||||
form.requestSubmit()
|
||||
}
|
||||
|
||||
showStockError(available) {
|
||||
if (!this.hasStockErrorTarget) return
|
||||
|
||||
this.stockErrorTarget.textContent = available === 1
|
||||
? this.stockErrorOneValue
|
||||
: this.stockErrorManyValue.replace(':count', String(available))
|
||||
this.stockErrorTarget.hidden = false
|
||||
}
|
||||
|
||||
clearStockError() {
|
||||
if (!this.hasStockErrorTarget) return
|
||||
this.stockErrorTarget.hidden = true
|
||||
}
|
||||
|
||||
get selectedVariant() {
|
||||
return this.variantsValue.find(v => v.id === this.selectedValue)
|
||||
}
|
||||
|
||||
selectVariant(event) {
|
||||
const id = parseInt(event.currentTarget.dataset.variantId)
|
||||
this.selectedValue = id
|
||||
const option = event.currentTarget.dataset.option
|
||||
const valueId = parseInt(event.currentTarget.dataset.valueId)
|
||||
this.selections = { ...this.selections, [option]: valueId }
|
||||
|
||||
const match = this.variantsValue.find(variant =>
|
||||
Object.keys(this.selections).every(key => variant.options?.[key] === this.selections[key])
|
||||
)
|
||||
|
||||
// No variant exists for this combination (e.g. an option value that
|
||||
// isn't offered together with another currently-selected value) —
|
||||
// leave the previous selection in place rather than pointing the
|
||||
// add-to-cart form at nothing.
|
||||
if (!match) return
|
||||
|
||||
this.selectedValue = match.id
|
||||
|
||||
const url = new URL(window.location)
|
||||
url.searchParams.set('variant', id)
|
||||
url.searchParams.set('variant', match.id)
|
||||
window.history.pushState({}, '', url)
|
||||
}
|
||||
|
||||
@@ -30,6 +143,8 @@ export default class extends Controller {
|
||||
const variant = this.variantsValue.find(v => v.id === id)
|
||||
if (!variant) return
|
||||
|
||||
this.clearStockError()
|
||||
|
||||
if (this.hasPriceTarget && variant.price !== null) {
|
||||
this.priceTarget.textContent = formatPrice(variant.price)
|
||||
}
|
||||
@@ -38,13 +153,27 @@ export default class extends Controller {
|
||||
this.imageTarget.src = variant.image
|
||||
}
|
||||
|
||||
// Keep the checkout module's add-to-cart form pointed at the chosen
|
||||
// variant. [data-bbk-purchasable-input] is that module's documented
|
||||
// hook (see resources/views/checkout/components/add-to-cart.blade.php);
|
||||
// this is the one place the two touch.
|
||||
const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]')
|
||||
if (purchasableInput) purchasableInput.value = id
|
||||
|
||||
// Out-of-stock variant (as of page load) can't be added at all.
|
||||
if (this.hasSubmitTarget) this.submitTarget.disabled = !variant.inStock
|
||||
|
||||
this.swatchTargets.forEach(swatch => {
|
||||
const isSelected = parseInt(swatch.dataset.variantId) === id
|
||||
const isSelected = this.selections[swatch.dataset.option] === parseInt(swatch.dataset.valueId)
|
||||
swatch.classList.toggle('is-selected', isSelected)
|
||||
swatch.setAttribute('aria-pressed', String(isSelected))
|
||||
|
||||
if (isSelected && this.hasColorNameTarget) {
|
||||
this.colorNameTarget.textContent = swatch.getAttribute('aria-label')
|
||||
if (isSelected) {
|
||||
// Each color-option group has its own colorName echo (see
|
||||
// x-ui.color-swatch) — matched by option handle so a swatch
|
||||
// in one group never overwrites another group's label.
|
||||
const colorName = this.colorNameTargets.find(target => target.dataset.option === swatch.dataset.option)
|
||||
if (colorName) colorName.textContent = swatch.getAttribute('aria-label')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// Dual-thumb range slider.
|
||||
//
|
||||
// Two real <input type="range"> elements stay authoritative — they carry the
|
||||
// value, the form data, native keyboard support and the no-JS fallback. On
|
||||
// connect this controller hides their <label>s and mirrors their state onto a
|
||||
// presentational track: a baseline, a filled span between the two carets, and
|
||||
// the carets themselves, all positioned with the --min / --max percentage
|
||||
// custom properties written on the track element.
|
||||
//
|
||||
// Pointer drag moves the carets (writing back to the inputs); the keyboard
|
||||
// drives the inputs directly. Values can't cross — min stays one step below
|
||||
// max and vice versa. Emits `range-slider:input` while dragging and
|
||||
// `range-slider:change` on commit, both with { min, max }.
|
||||
export default class extends Controller {
|
||||
static targets = ['minInput', 'maxInput', 'field', 'track', 'minThumb', 'maxThumb', 'output']
|
||||
static values = {
|
||||
min: Number,
|
||||
max: Number,
|
||||
step: { type: Number, default: 1 },
|
||||
prefix: { type: String, default: '' },
|
||||
suffix: { type: String, default: '' },
|
||||
separator: { type: String, default: ' – ' },
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.#clamp()
|
||||
this.fieldTargets.forEach((field) => field.classList.add('sr-only'))
|
||||
this.#render()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.#stopDrag()
|
||||
}
|
||||
|
||||
// ── keyboard / programmatic ──────────────────────────────────────
|
||||
|
||||
onInput(event) {
|
||||
this.#clamp(this.#side(event.target))
|
||||
this.#render()
|
||||
this.#emit('input')
|
||||
}
|
||||
|
||||
onChange(event) {
|
||||
this.#clamp(this.#side(event.target))
|
||||
this.#render()
|
||||
this.#emit('change')
|
||||
}
|
||||
|
||||
// The real inputs are visually hidden, so mirror their focus ring onto
|
||||
// the matching caret to keep a visible focus indicator for keyboard use.
|
||||
syncFocus(event) {
|
||||
const thumb = event.target === this.minInputTarget ? this.minThumbTarget : this.maxThumbTarget
|
||||
thumb.classList.toggle('ring-2', event.type === 'focus')
|
||||
thumb.classList.toggle('ring-black', event.type === 'focus')
|
||||
}
|
||||
|
||||
// ── pointer drag ────────────────────────────────────────────────────
|
||||
|
||||
thumbPointerDown(event) {
|
||||
const input = event.currentTarget === this.minThumbTarget ? this.minInputTarget : this.maxInputTarget
|
||||
this.#startDrag(event, input)
|
||||
}
|
||||
|
||||
trackPointerDown(event) {
|
||||
if (event.target.closest('button')) return // a caret handles its own press
|
||||
|
||||
const value = this.#valueAt(event.clientX)
|
||||
const input = Math.abs(value - this.#lo) <= Math.abs(value - this.#hi)
|
||||
? this.minInputTarget
|
||||
: this.maxInputTarget
|
||||
|
||||
input.value = value
|
||||
this.#clamp(this.#side(input))
|
||||
this.#render()
|
||||
this.#startDrag(event, input)
|
||||
}
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────
|
||||
|
||||
#startDrag(event, input) {
|
||||
event.preventDefault()
|
||||
this.#stopDrag()
|
||||
const side = this.#side(input)
|
||||
this.#onMove = (e) => {
|
||||
input.value = this.#valueAt(e.clientX)
|
||||
this.#clamp(side)
|
||||
this.#render()
|
||||
this.#emit('input')
|
||||
}
|
||||
this.#onUp = () => {
|
||||
this.#stopDrag()
|
||||
this.#emit('change')
|
||||
}
|
||||
window.addEventListener('pointermove', this.#onMove)
|
||||
window.addEventListener('pointerup', this.#onUp)
|
||||
}
|
||||
|
||||
#side(input) {
|
||||
return input === this.maxInputTarget ? 'max' : 'min'
|
||||
}
|
||||
|
||||
#stopDrag() {
|
||||
if (this.#onMove) window.removeEventListener('pointermove', this.#onMove)
|
||||
if (this.#onUp) window.removeEventListener('pointerup', this.#onUp)
|
||||
this.#onMove = this.#onUp = null
|
||||
}
|
||||
|
||||
get #lo() { return Number(this.minInputTarget.value) }
|
||||
get #hi() { return Number(this.maxInputTarget.value) }
|
||||
|
||||
// Keep both thumbs inside the group bounds and stop them crossing. When a
|
||||
// thumb is being moved (`side`), only that one gives way, so the other
|
||||
// stays put instead of being dragged along.
|
||||
#clamp(side = null) {
|
||||
const gap = this.stepValue
|
||||
let lo = Math.max(this.minValue, Math.min(this.maxValue, Number(this.minInputTarget.value)))
|
||||
let hi = Math.max(this.minValue, Math.min(this.maxValue, Number(this.maxInputTarget.value)))
|
||||
|
||||
if (side === 'max') hi = Math.max(hi, lo + gap)
|
||||
else if (side === 'min') lo = Math.min(lo, hi - gap)
|
||||
else if (lo > hi - gap) lo = hi - gap
|
||||
|
||||
this.minInputTarget.value = lo
|
||||
this.maxInputTarget.value = hi
|
||||
}
|
||||
|
||||
#valueAt(clientX) {
|
||||
const rect = this.trackTarget.getBoundingClientRect()
|
||||
const ratio = rect.width ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) : 0
|
||||
const raw = this.minValue + ratio * (this.maxValue - this.minValue)
|
||||
const step = this.stepValue
|
||||
return Math.round(raw / step) * step
|
||||
}
|
||||
|
||||
#percent(value) {
|
||||
const span = this.maxValue - this.minValue
|
||||
return span ? ((value - this.minValue) / span) * 100 : 0
|
||||
}
|
||||
|
||||
#render() {
|
||||
const lo = this.#lo
|
||||
const hi = this.#hi
|
||||
|
||||
this.trackTarget.style.setProperty('--min', `${this.#percent(lo)}%`)
|
||||
this.trackTarget.style.setProperty('--max', `${this.#percent(hi)}%`)
|
||||
|
||||
if (this.hasOutputTarget) {
|
||||
const fmt = (v) => `${this.prefixValue}${v}${this.suffixValue}`
|
||||
this.outputTarget.textContent = fmt(lo) + this.separatorValue + fmt(hi)
|
||||
}
|
||||
}
|
||||
|
||||
#emit(name) {
|
||||
const detail = { min: this.#lo, max: this.#hi }
|
||||
const key = `${detail.min},${detail.max}`
|
||||
if (name === 'input' && key === this.#lastInputKey) return // no change since last frame
|
||||
this.#lastInputKey = key
|
||||
this.dispatch(name, { detail })
|
||||
}
|
||||
|
||||
#onMove = null
|
||||
#onUp = null
|
||||
#lastInputKey = null
|
||||
}
|
||||
@@ -1,9 +1,25 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['star', 'input']
|
||||
static targets = ['star', 'input', 'error']
|
||||
static values = { rating: { type: Number, default: 0 } }
|
||||
|
||||
connect() {
|
||||
this.#fill(this.ratingValue)
|
||||
}
|
||||
|
||||
// Bound to the form's submit event (this controller sits on the <form>
|
||||
// itself, not just the star widget) — a plain `required` on the hidden
|
||||
// rating input would never surface: browsers exclude type="hidden" from
|
||||
// constraint validation entirely, so there'd be nothing to see or hear.
|
||||
validate(event) {
|
||||
if (this.ratingValue < 1) {
|
||||
event.preventDefault()
|
||||
this.errorTarget.hidden = false
|
||||
this.starTargets[0]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
hover(event) {
|
||||
this.#fill(parseInt(event.currentTarget.dataset.value))
|
||||
}
|
||||
@@ -16,6 +32,7 @@ export default class extends Controller {
|
||||
const val = parseInt(event.currentTarget.dataset.value)
|
||||
this.ratingValue = val
|
||||
this.inputTarget.value = val
|
||||
this.errorTarget.hidden = true
|
||||
|
||||
this.starTargets.forEach(star => {
|
||||
star.setAttribute('aria-pressed', String(parseInt(star.dataset.value) === val))
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
// Jumps to a tab panel from an element outside the tabs' own markup — the
|
||||
// review count and the "read more" link both sit elsewhere in the DOM, too
|
||||
// far apart from the tabs for a plain data-action, hence the outlet.
|
||||
export default class extends Controller {
|
||||
static outlets = ['tabs']
|
||||
static values = { panel: String }
|
||||
|
||||
activate(event) {
|
||||
event?.preventDefault()
|
||||
this.tabsOutlet.activate(this.panelValue)
|
||||
this.tabsOutlet.element.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@ export default class extends Controller {
|
||||
static targets = ['button', 'panel']
|
||||
|
||||
show(event) {
|
||||
this.#activate(event.currentTarget.dataset.panel)
|
||||
this.activate(event.currentTarget.dataset.panel)
|
||||
}
|
||||
|
||||
#activate(panelId) {
|
||||
activate(panelId) {
|
||||
this.buttonTargets.forEach(btn => {
|
||||
const active = btn.dataset.panel === panelId
|
||||
btn.classList.toggle('is-active', active)
|
||||
|
||||