Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d43b615297 | ||
|
|
7f6c1e6307 | ||
|
|
5bbf0aabcd | ||
|
|
fa172860db | ||
|
|
a51e9456d6 | ||
|
|
540da1af24 | ||
|
|
e28faea546 | ||
|
|
8055c003ac | ||
|
|
94803bfb67 | ||
|
|
cf3681260b | ||
|
|
580adac33a | ||
|
|
a4240b6474 | ||
|
|
326e1fac54 | ||
|
|
e3a6267059 | ||
|
|
24851184c4 | ||
|
|
a107184010 | ||
|
|
af0245de43 | ||
|
|
afa1993c53 | ||
|
|
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 |
@@ -59,6 +59,7 @@ MAIL_USERNAME=null
|
|||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
CONTACT_EMAIL=
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
/public/sitemap.xml
|
/public/sitemap.xml
|
||||||
/public/logos/core
|
/public/logos/core
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/public/css/filament
|
||||||
|
/public/js/filament
|
||||||
|
/public/fonts/filament
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/framework/migrated
|
/storage/framework/migrated
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
|||||||
@@ -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,43 @@
|
|||||||
|
<?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}
|
||||||
|
*/
|
||||||
|
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']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,20 +7,21 @@
|
|||||||
use Modules\Core\Catalog\Enums\ProductSort;
|
use Modules\Core\Catalog\Enums\ProductSort;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The parsed state of a category listing request. The query string is the single
|
* The parsed filter/sort/page state of a product-listing request — used by the
|
||||||
* source of truth for sort / filters / page — build one of these from the
|
* category page (scoped to a collection) and the all-products page. The query
|
||||||
* request, read the applied values off it, and use query() to build links (sort
|
* string is the single source of truth; build one of these from the request,
|
||||||
* options, pagination, "clear filter") that carry the rest of the state along.
|
* read the applied values off it, and use query() to build links (sort options,
|
||||||
|
* pagination, "clear filter") that carry the rest of the state along.
|
||||||
*
|
*
|
||||||
* A param is only ever emitted when it differs from its default, so a pristine
|
* A param is only ever emitted when it differs from its default, so a pristine
|
||||||
* listing is just `/category/{id}` with no query string.
|
* listing has no query string at all.
|
||||||
*/
|
*/
|
||||||
final class CategoryListing
|
final class ProductListing
|
||||||
{
|
{
|
||||||
private function __construct(
|
private function __construct(
|
||||||
public readonly ?ProductSort $sort,
|
public readonly ?ProductSort $sort,
|
||||||
public readonly ?int $minPrice,
|
public readonly ?float $minPrice,
|
||||||
public readonly ?int $maxPrice,
|
public readonly ?float $maxPrice,
|
||||||
public readonly bool $inStockOnly,
|
public readonly bool $inStockOnly,
|
||||||
public readonly int $page,
|
public readonly int $page,
|
||||||
) {}
|
) {}
|
||||||
@@ -29,14 +30,17 @@ public static function fromRequest(Request $request): self
|
|||||||
{
|
{
|
||||||
return new self(
|
return new self(
|
||||||
sort: ProductSort::tryFrom((string) $request->query('sort')),
|
sort: ProductSort::tryFrom((string) $request->query('sort')),
|
||||||
minPrice: self::intOrNull($request->query('price_min')),
|
minPrice: self::floatOrNull($request->query('price_min')),
|
||||||
maxPrice: self::intOrNull($request->query('price_max')),
|
maxPrice: self::floatOrNull($request->query('price_max')),
|
||||||
inStockOnly: $request->boolean('in_stock'),
|
inStockOnly: $request->boolean('in_stock'),
|
||||||
page: max(1, (int) $request->query('page', 1)),
|
page: max(1, (int) $request->query('page', 1)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function filters(int $collectionId): ProductFilters
|
/**
|
||||||
|
* @param ?int $collectionId scope to a collection (category page); null = every product
|
||||||
|
*/
|
||||||
|
public function filters(?int $collectionId = null): ProductFilters
|
||||||
{
|
{
|
||||||
return new ProductFilters(
|
return new ProductFilters(
|
||||||
collectionId: $collectionId,
|
collectionId: $collectionId,
|
||||||
@@ -48,8 +52,7 @@ public function filters(int $collectionId): ProductFilters
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The applied params as a clean array (defaults omitted), with `$overrides`
|
* The applied params as a clean array (defaults omitted), with `$overrides`
|
||||||
* merged on top — pass `['key' => null]` to drop one. Feeds straight into
|
* merged on top — pass `['key' => null]` to drop one.
|
||||||
* route('category.show', ['id' => $id] + $listing->query([...])).
|
|
||||||
*
|
*
|
||||||
* @param array<string, string|int|null> $overrides
|
* @param array<string, string|int|null> $overrides
|
||||||
* @return array<string, string|int>
|
* @return array<string, string|int>
|
||||||
@@ -68,7 +71,7 @@ public function query(array $overrides = []): array
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the listing is reordered/narrowed enough that it shouldn't be
|
* Whether the listing is reordered/narrowed enough that it shouldn't be
|
||||||
* indexed as its own page (the canonical still points at the bare category
|
* indexed as its own page (the canonical still points at the bare listing
|
||||||
* URL either way). A plain in-stock toggle is left indexable.
|
* URL either way). A plain in-stock toggle is left indexable.
|
||||||
*/
|
*/
|
||||||
public function isRefined(): bool
|
public function isRefined(): bool
|
||||||
@@ -79,8 +82,8 @@ public function isRefined(): bool
|
|||||||
|| $this->page > 1;
|
|| $this->page > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function intOrNull(mixed $value): ?int
|
private static function floatOrNull(mixed $value): ?float
|
||||||
{
|
{
|
||||||
return is_numeric($value) ? (int) $value : null;
|
return is_numeric($value) ? (float) $value : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Catalog;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Modules\Core\Catalog\Enums\ProductSort;
|
||||||
|
use Modules\Core\Catalog\Services\ProductSearchService;
|
||||||
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles the data the shared shop listing body (shop/partials/listing.blade.php)
|
||||||
|
* needs — the product page, price-slider bounds, sort links and the "clear price"
|
||||||
|
* link. Used by the category page (scoped to a collection), the all-products
|
||||||
|
* page, and the search page (scoped to a query — see $query below); the `$url`
|
||||||
|
* closure turns a query-param array into a URL for whichever page is calling,
|
||||||
|
* so this class never has to know the route.
|
||||||
|
*/
|
||||||
|
final class ProductListingPage
|
||||||
|
{
|
||||||
|
private const PER_PAGE = 40;
|
||||||
|
|
||||||
|
/** Category pages rarely have enough products to fill a page, so this
|
||||||
|
* ceiling is high enough to act as "no pagination" in practice — the
|
||||||
|
* pagination component self-hides via hasPages() when everything fits.
|
||||||
|
* If a category ever exceeds it, pagination reappears as a safety net
|
||||||
|
* rather than silently truncating results. */
|
||||||
|
private const CATEGORY_PER_PAGE = 200;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProductService $products,
|
||||||
|
private readonly ProductSearchService $search,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Closure(array<string, string|int>): string $url
|
||||||
|
* @param ?int $collectionId scope to a collection, or null for every product
|
||||||
|
* @param ?string $query scope to a text search — when given, calls
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function build(ProductListing $listing, Closure $url, ?int $collectionId = null, ?string $query = null): array
|
||||||
|
{
|
||||||
|
$filters = $listing->filters($collectionId);
|
||||||
|
$perPage = $collectionId !== null ? self::CATEGORY_PER_PAGE : self::PER_PAGE;
|
||||||
|
|
||||||
|
// Listing reads from the Meilisearch index via ProductService/
|
||||||
|
// ProductSearchService, not Eloquent. Both return a
|
||||||
|
// ProductListingResult — the product page plus the price-slider
|
||||||
|
// bounds (and available tags) from one call; the controller no
|
||||||
|
// longer stitches list()/search() + priceRange() together itself.
|
||||||
|
// Sort/filter/page all come from $listing (the query string).
|
||||||
|
$result = $query !== null
|
||||||
|
? $this->search->search(
|
||||||
|
query: $query,
|
||||||
|
filters: $filters,
|
||||||
|
sort: $listing->sort,
|
||||||
|
perPage: $perPage,
|
||||||
|
page: $listing->page,
|
||||||
|
)
|
||||||
|
: $this->products->list(
|
||||||
|
filters: $filters,
|
||||||
|
perPage: $perPage,
|
||||||
|
page: $listing->page,
|
||||||
|
sort: $listing->sort,
|
||||||
|
);
|
||||||
|
|
||||||
|
$products = $result->products
|
||||||
|
->through(ProductCard::fromIndexed(...))
|
||||||
|
->appends($listing->query(['page' => null]));
|
||||||
|
|
||||||
|
// Slider bounds — the price span of everything matching the *other*
|
||||||
|
// filters, rounded to whole euros, plus whether the current price params
|
||||||
|
// actually narrow that span. All computed in core
|
||||||
|
$priceBounds = $result->priceBounds;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'listing' => $listing,
|
||||||
|
'products' => $products,
|
||||||
|
'listingAction' => $url([]),
|
||||||
|
'priceFloor' => $priceBounds->floor,
|
||||||
|
'priceCeil' => $priceBounds->ceil,
|
||||||
|
'clearPriceUrl' => $priceBounds->filtered
|
||||||
|
? $url($listing->query(['price_min' => null, 'price_max' => null, 'page' => null]))
|
||||||
|
: null,
|
||||||
|
'sortOptions' => ProductSortOptions::build(
|
||||||
|
$listing->sort,
|
||||||
|
fn (?ProductSort $sort) => $url($listing->query(['sort' => $sort?->value, 'page' => null])),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Catalog;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Modules\Core\Catalog\Enums\ProductSort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the option list for the shared <x-shop.sort> dropdown, so the label
|
||||||
|
* map and "the default sort has no URL param" rule live in one place. Each page
|
||||||
|
* supplies a `$url` closure that turns a sort (or null = default/relevance)
|
||||||
|
* into the right href for that page — category vs. search build their URLs
|
||||||
|
* differently.
|
||||||
|
*/
|
||||||
|
final class ProductSortOptions
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Closure(?ProductSort): string $url
|
||||||
|
* @return array<int, array{label: string, href: string, current: bool}>
|
||||||
|
*/
|
||||||
|
public static function build(?ProductSort $current, Closure $url): array
|
||||||
|
{
|
||||||
|
$sorts = [
|
||||||
|
null => 'storefront.shop.sort_popularity',
|
||||||
|
ProductSort::PriceAsc->value => 'storefront.shop.sort_price_asc',
|
||||||
|
ProductSort::PriceDesc->value => 'storefront.shop.sort_price_desc',
|
||||||
|
ProductSort::Newest->value => 'storefront.shop.sort_newest',
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_map(function (string $key, string $label) use ($current, $url) {
|
||||||
|
$sort = $key === '' ? null : ProductSort::from($key);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'label' => __($label),
|
||||||
|
'href' => $url($sort),
|
||||||
|
'current' => $sort === $current,
|
||||||
|
];
|
||||||
|
}, array_keys($sorts), array_values($sorts));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Lunar\Models\Country;
|
||||||
|
use Lunar\Models\State;
|
||||||
|
use Modules\Core\Cart\Services\CartService;
|
||||||
|
use Modules\Core\Checkout\Services\CheckoutService;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
use Modules\Core\Privacy\Services\PrivacyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile page: name, invoice details, the one address, and account
|
||||||
|
* deletion. The customer keeps a single address, flagged as both the
|
||||||
|
* shipping and billing default, so allowing several later is a UI change
|
||||||
|
* only (the data is already Lunar's own addresses table).
|
||||||
|
*/
|
||||||
|
class AccountController extends Controller
|
||||||
|
{
|
||||||
|
// Single-country store, same as CheckoutController::STORE_COUNTRY_ISO3.
|
||||||
|
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||||
|
|
||||||
|
private const ADDRESS_FIELDS = ['line_one', 'city', 'postcode', 'state', 'contact_phone'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$customer = $this->account->customer($user);
|
||||||
|
|
||||||
|
return view('account.show', [
|
||||||
|
'user' => $user,
|
||||||
|
'customer' => $customer,
|
||||||
|
'address' => $this->defaultAddress($user),
|
||||||
|
'regions' => State::where('country_id', $this->storeCountry()->id)->orderBy('name')->get(['id', 'name']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(string $locale, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$country = $this->storeCountry();
|
||||||
|
|
||||||
|
// The address is all-or-nothing: typing any part of it makes the rest
|
||||||
|
// (and the name, which Lunar requires on every address) required.
|
||||||
|
$anyAddressField = implode(',', self::ADDRESS_FIELDS);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'first_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'last_name' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'invoice' => ['boolean'],
|
||||||
|
'company_name' => ['nullable', 'string', 'max:255', 'required_if_accepted:invoice'],
|
||||||
|
'tax_identifier' => ['nullable', 'digits:9', 'required_if_accepted:invoice'],
|
||||||
|
'line_one' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'city' => ['nullable', 'string', 'max:255', 'required_with:'.$anyAddressField],
|
||||||
|
'postcode' => ['nullable', 'regex:/^\d{3}\s?\d{2}$/', 'required_with:'.$anyAddressField],
|
||||||
|
'state' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'required_with:'.$anyAddressField,
|
||||||
|
Rule::exists((new State)->getTable(), 'name')->where('country_id', $country->id),
|
||||||
|
],
|
||||||
|
'contact_phone' => ['nullable', 'string', 'max:30'],
|
||||||
|
'recovery_consent' => ['boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invoice = $request->boolean('invoice');
|
||||||
|
|
||||||
|
$this->account->updateProfile($user, [
|
||||||
|
'first_name' => $data['first_name'] ?? null,
|
||||||
|
'last_name' => $data['last_name'] ?? null,
|
||||||
|
'company_name' => $invoice ? $data['company_name'] : null,
|
||||||
|
'tax_identifier' => $invoice ? $data['tax_identifier'] : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (filled($data['line_one'] ?? null)) {
|
||||||
|
$addressData = [
|
||||||
|
...collect($data)->only(self::ADDRESS_FIELDS)->all(),
|
||||||
|
'first_name' => $data['first_name'],
|
||||||
|
'last_name' => $data['last_name'],
|
||||||
|
'country_id' => $country->id,
|
||||||
|
'contact_email' => $user->email,
|
||||||
|
'shipping_default' => true,
|
||||||
|
'billing_default' => true,
|
||||||
|
];
|
||||||
|
|
||||||
|
$existing = $this->defaultAddress($user);
|
||||||
|
|
||||||
|
$existing
|
||||||
|
? $this->account->updateAddress($user, $existing->id, $addressData)
|
||||||
|
: $this->account->createAddress($user, $addressData);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->updateRecoveryConsent($user, $request->boolean('recovery_consent'));
|
||||||
|
|
||||||
|
return redirect()->route('account')->with('status', __('storefront.account.saved'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Email me a reminder if I don't finish my order", as a standing
|
||||||
|
* choice — stored on the customer via boboko-core's
|
||||||
|
* CustomerAccountService::setRecoveryConsent(), and applied to the
|
||||||
|
* current cart too, so opting out stops reminders for it right away.
|
||||||
|
*/
|
||||||
|
private function updateRecoveryConsent($user, bool $consent): void
|
||||||
|
{
|
||||||
|
$this->account->setRecoveryConsent($user, $consent);
|
||||||
|
|
||||||
|
// Only an existing cart; never create one just to record this.
|
||||||
|
$cart = app(CartService::class)->current();
|
||||||
|
|
||||||
|
if ($cart && (bool) data_get($cart, 'meta.recovery_consent') !== $consent) {
|
||||||
|
app(CheckoutService::class)->setRecoveryConsent($consent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service deletion: opens core's 30-day grace-period erasure request
|
||||||
|
* (which blocks the login right away) and logs out. Logging back in within
|
||||||
|
* the grace period cancels it; see core's docs/privacy.md.
|
||||||
|
*/
|
||||||
|
public function destroy(string $locale, Request $request, PrivacyService $privacy): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$privacy->requestErasureForUser($user, $user);
|
||||||
|
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('login')->with('status', __('storefront.account.deletion_requested'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultAddress($user)
|
||||||
|
{
|
||||||
|
$addresses = collect($this->account->addresses($user));
|
||||||
|
|
||||||
|
return $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeCountry(): Country
|
||||||
|
{
|
||||||
|
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->firstOrFail();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Auth\Exceptions\OtpThrottledException;
|
||||||
|
use Modules\Core\Customer\Exceptions\EmailAlreadyTakenException;
|
||||||
|
use Modules\Core\Customer\Exceptions\InvalidEmailChangeCodeException;
|
||||||
|
use Modules\Core\Customer\Services\CustomerEmailChangeService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changing the login email — a thin wrapper over boboko-core's
|
||||||
|
* Customer\Services\CustomerEmailChangeService, which owns the actual
|
||||||
|
* request/confirm mechanics, throttling, pending-change storage, and
|
||||||
|
* mailables. This controller's own job is just the storefront's session-
|
||||||
|
* scoped "which email did I just ask to switch to" UI state (so the
|
||||||
|
* .code/.resend pages know which address to show/resend to) and
|
||||||
|
* translating the service's exceptions into the flash-message flow the
|
||||||
|
* views expect.
|
||||||
|
*/
|
||||||
|
class EmailController extends Controller
|
||||||
|
{
|
||||||
|
private const SESSION_KEY = 'pending_email_change';
|
||||||
|
|
||||||
|
public function edit(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
return view('account.email', ['user' => $request->user()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$request->merge(['email' => Str::lower(trim((string) $request->input('email')))]);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::notIn([$user->email]),
|
||||||
|
],
|
||||||
|
], [
|
||||||
|
'email.not_in' => __('storefront.account.email_same'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emailChange->request($user, $validated['email']);
|
||||||
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
return back()->withInput()->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withInput()->withErrors(['email' => __('storefront.auth.too_many_codes')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put(self::SESSION_KEY, $validated['email']);
|
||||||
|
|
||||||
|
return redirect()->route('account.email.code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function code(string $locale, Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! $pendingEmail) {
|
||||||
|
return redirect()->route('account.email.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('account.email-code', ['email' => $pendingEmail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resend(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! $pendingEmail) {
|
||||||
|
return redirect()->route('account.email.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emailChange->request($request->user(), $pendingEmail);
|
||||||
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return redirect()->route('account.email.edit')
|
||||||
|
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withErrors(['code' => __('storefront.auth.too_many_codes')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', __('storefront.auth.code_resent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $locale, Request $request, CustomerEmailChangeService $emailChange): RedirectResponse
|
||||||
|
{
|
||||||
|
$pendingEmail = $request->session()->get(self::SESSION_KEY);
|
||||||
|
|
||||||
|
if (! $pendingEmail) {
|
||||||
|
return redirect()->route('account.email.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate(['code' => ['required', 'digits:6']]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$emailChange->confirm($request->user(), $validated['code']);
|
||||||
|
} catch (EmailAlreadyTakenException) {
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return redirect()->route('account.email.edit')
|
||||||
|
->withErrors(['email' => __('storefront.account.email_taken')]);
|
||||||
|
} catch (InvalidEmailChangeCodeException) {
|
||||||
|
return back()->withErrors(['code' => __('storefront.auth.invalid_code')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return redirect()->route('account')->with('status', __('storefront.account.email_changed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
|
use Modules\Core\Customer\Exceptions\OrderNotFoundException;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order history. Every lookup goes through CustomerAccountService, which only
|
||||||
|
* ever returns the logged-in user's own placed orders, so an order id from
|
||||||
|
* the URL can't reach someone else's order.
|
||||||
|
*/
|
||||||
|
class OrderController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
return view('account.orders.index', [
|
||||||
|
'orders' => $this->account->orders($request->user(), 15),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $locale, Request $request, int $orderId): View
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$order = $this->account->order($request->user(), $orderId);
|
||||||
|
} catch (OrderNotFoundException) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// By type, like the checkout confirmation: the method may since have
|
||||||
|
// been disabled, but the order still shows what was used.
|
||||||
|
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||||
|
->first()
|
||||||
|
?->translate('name');
|
||||||
|
|
||||||
|
return view('account.orders.show', [
|
||||||
|
'order' => $order,
|
||||||
|
'paymentMethodName' => $paymentMethodName,
|
||||||
|
'shipments' => $order->shipments->whereNull('cancelled_at')->whereNotNull('tracking_reference'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'email' => ['required', 'email', 'max:255'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$email = Str::lower(trim($validated['email']));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$otp->generateAndSend($email);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withInput()->withErrors([
|
||||||
|
'email' => __('storefront.auth.too_many_codes'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->put('login.email', $email);
|
||||||
|
|
||||||
|
return redirect()->route('login.code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function code(string $locale, Request $request): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$email = $request->session()->get('login.email');
|
||||||
|
|
||||||
|
if (! $email) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('auth.login-code', ['email' => $email]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resend(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = $request->session()->get('login.email');
|
||||||
|
|
||||||
|
if (! $email) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$otp->generateAndSend($email);
|
||||||
|
} catch (OtpThrottledException) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'code' => __('storefront.auth.too_many_codes'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', __('storefront.auth.code_resent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $locale, Request $request, UserOtpService $otp): RedirectResponse
|
||||||
|
{
|
||||||
|
$email = $request->session()->get('login.email');
|
||||||
|
|
||||||
|
if (! $email) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'code' => ['required', 'digits:6'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wrong, expired, or locked out after too many guesses — core doesn't
|
||||||
|
// say which, so neither do we; the page offers "resend code" for all three.
|
||||||
|
if (! $otp->validate($email, $validated['code'], $request)) {
|
||||||
|
return back()->withErrors([
|
||||||
|
'code' => __('storefront.auth.invalid_code'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->forget('login.email');
|
||||||
|
|
||||||
|
return redirect()->intended(route('home'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(string $locale, Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect()->route('home');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Catalog\CategoryListing;
|
use App\Catalog\ProductListing;
|
||||||
|
use App\Catalog\ProductListingPage;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Modules\Core\Catalog\Services\CollectionService;
|
use Modules\Core\Catalog\Services\CollectionService;
|
||||||
use Modules\Core\Catalog\Services\ProductService;
|
|
||||||
|
|
||||||
class CategoryController extends Controller
|
class CategoryController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ProductService $products,
|
private readonly ProductListingPage $listingPage,
|
||||||
private readonly CollectionService $collections,
|
private readonly CollectionService $collections,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -19,46 +19,14 @@ public function show(string $locale, int $collection)
|
|||||||
$collectionData = $this->collections->getById($collection);
|
$collectionData = $this->collections->getById($collection);
|
||||||
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
abort_if($collectionData === null, Response::HTTP_NOT_FOUND);
|
||||||
|
|
||||||
$listing = CategoryListing::fromRequest(request());
|
$listing = ProductListing::fromRequest(request());
|
||||||
$filters = $listing->filters($collectionData['id']);
|
|
||||||
$perPage = 12;
|
|
||||||
|
|
||||||
// Listing/filtering reads from the Meilisearch index via ProductService,
|
$data = $this->listingPage->build(
|
||||||
// not Eloquent — see Modules\Core\Catalog\Services\ProductService. list() returns a
|
$listing,
|
||||||
// real LengthAwarePaginator of plain arrays (already localized/flattened),
|
fn (array $query) => route('category.show', ['id' => $collectionData['id']] + $query),
|
||||||
// not Product models. Sort/filter/page all come from the query string via
|
$collectionData['id'],
|
||||||
// CategoryListing, which is the single source of truth for that state.
|
);
|
||||||
$products = $this->products->list(
|
|
||||||
filters: $filters,
|
|
||||||
perPage: $perPage,
|
|
||||||
page: $listing->page,
|
|
||||||
sort: $listing->sort,
|
|
||||||
)->through(fn (array $product) => [
|
|
||||||
'name' => $product['name'],
|
|
||||||
'price' => $product['price'],
|
|
||||||
'image' => $product['media'][0]['url'] ?? null,
|
|
||||||
'href' => route('product.show', ['id' => $product['id']]),
|
|
||||||
])->appends($listing->query(['page' => null]));
|
|
||||||
|
|
||||||
// Slider bounds — the price span of everything matching the *other*
|
return view('category.show', [...$data, 'collection' => $collectionData]);
|
||||||
// filters (priceRange() drops the price filter itself, so the handles
|
|
||||||
// don't collapse to whatever's already selected). Whole euros.
|
|
||||||
$priceRange = $this->products->priceRange($filters);
|
|
||||||
$priceFloor = $priceRange['min'] !== null ? (int) floor($priceRange['min']) : null;
|
|
||||||
$priceCeil = $priceRange['max'] !== null ? (int) ceil($priceRange['max']) : null;
|
|
||||||
|
|
||||||
// A price param is only a real filter if it's tighter than the bounds —
|
|
||||||
// drives whether the "clear" link shows.
|
|
||||||
$priceFiltered = ($listing->minPrice !== null && $listing->minPrice > ($priceFloor ?? PHP_INT_MIN))
|
|
||||||
|| ($listing->maxPrice !== null && $listing->maxPrice < ($priceCeil ?? PHP_INT_MAX));
|
|
||||||
|
|
||||||
return view('category.show', [
|
|
||||||
'collection' => $collectionData,
|
|
||||||
'products' => $products,
|
|
||||||
'listing' => $listing,
|
|
||||||
'priceFloor' => $priceFloor,
|
|
||||||
'priceCeil' => $priceCeil,
|
|
||||||
'priceFiltered' => $priceFiltered,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Checkout;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Controllers\CustomFieldUploadController;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Lunar\Exceptions\Carts\CartException;
|
||||||
|
use Lunar\Models\CartLine;
|
||||||
|
use Lunar\Models\ProductVariant;
|
||||||
|
use Illuminate\Support\Facades\App;
|
||||||
|
use Modules\Core\Cart\Exceptions\InvalidCouponException;
|
||||||
|
use Modules\Core\Cart\Services\CartService;
|
||||||
|
use Modules\Core\File\Models\File;
|
||||||
|
use Modules\Core\File\Services\FileService;
|
||||||
|
use Modules\Core\Localization\Services\LanguageCache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin storefront cart endpoints for the checkout module. Every action mutates
|
||||||
|
* the session cart via boboko-core's CartService and returns the same
|
||||||
|
* server-rendered `cart-body` partial — the drawer's Stimulus controller swaps
|
||||||
|
* that fragment in place (no JSON, no client-side templating). $cart / $lines
|
||||||
|
* for the partial come from the view composer in CheckoutModuleServiceProvider.
|
||||||
|
*/
|
||||||
|
class CartController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CartService $cart,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CartException here is Lunar's own add_to_cart validation pipeline
|
||||||
|
* (CartLineQuantity/CartLineStock) rejecting the line — most commonly
|
||||||
|
* "not enough stock at this quantity" for a tracked (purchasable =
|
||||||
|
* in_stock) variant. Its own message is an untranslated, hardcoded
|
||||||
|
* English string not meant for storefront display, so this returns our
|
||||||
|
* own translated one instead rather than passing it through — a
|
||||||
|
* storefront.* key rather than checkout.*, since this is a catalog/stock
|
||||||
|
* concern the storefront owns, not something specific to the portable
|
||||||
|
* checkout module.
|
||||||
|
*/
|
||||||
|
public function add(string $locale, Request $request): View|JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'purchasable_id' => ['required', 'integer'],
|
||||||
|
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'custom_fields' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$variant = ProductVariant::findOrFail($data['purchasable_id']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []);
|
||||||
|
} catch (ValidationException $e) {
|
||||||
|
return response()->json(['error' => collect($e->errors())->flatten()->first()], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->cart->addLine($variant, $data['quantity'] ?? 1, $meta);
|
||||||
|
} catch (CartException) {
|
||||||
|
return $this->stockError($variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('checkout::partials.cart-body');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shopper's answers to the product's custom fields (boboko-core's
|
||||||
|
* Product::$custom_fields — {key, type: text|textarea|file, label,
|
||||||
|
* required}), as cart line meta. Lunar copies CartLine.meta onto the
|
||||||
|
* OrderLine at order creation, so this is also what the order keeps.
|
||||||
|
*
|
||||||
|
* Only keys the product actually defines are kept, nested under
|
||||||
|
* `custom_fields` — line meta also carries behavior flags (core's
|
||||||
|
* `saved_for_later` zeroes the line's price), so shopper input must never
|
||||||
|
* be merged into it directly. Label and type are snapshotted alongside
|
||||||
|
* each value so the cart/order still reads correctly if the product's
|
||||||
|
* fields are edited later.
|
||||||
|
*
|
||||||
|
* A `file` answer is the id of a File row the host's own upload endpoint
|
||||||
|
* (CustomFieldUploadController) already created via boboko-core's
|
||||||
|
* FileService — never the file's bytes, disk, or path, all of which
|
||||||
|
* FileService alone is the source of truth for. A shopper can't point
|
||||||
|
* this at someone else's file: the id must resolve to a File that is
|
||||||
|
* BOTH unowned (isFileAnswerValid()) and tagged with
|
||||||
|
* CustomFieldUploadController::PURPOSE, so an id belonging to an
|
||||||
|
* already-ordered file (owned, and/or a different purpose entirely) is
|
||||||
|
* rejected. Attaching the File to the real CartLine it belongs to
|
||||||
|
* happens afterward, in boboko-core's own Modules\Core\File\Listeners\
|
||||||
|
* AttachCustomFieldFileToCartLine (listening for Cart\Events\
|
||||||
|
* CartLineAdded) — not here, since this method only builds the meta
|
||||||
|
* $this->cart->addLine() is about to receive, before any CartLine
|
||||||
|
* actually exists to own anything.
|
||||||
|
*
|
||||||
|
* Two adds with identical answers merge into one line (Lunar matches
|
||||||
|
* existing lines on meta); different answers stay separate lines.
|
||||||
|
*/
|
||||||
|
private function customFieldsMeta(ProductVariant $variant, array $input): array
|
||||||
|
{
|
||||||
|
$fields = collect($variant->product?->custom_fields ?? [])
|
||||||
|
->keyBy('key')
|
||||||
|
->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]);
|
||||||
|
|
||||||
|
if ($fields->isEmpty()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = Validator::make(
|
||||||
|
$input,
|
||||||
|
$fields->map(fn (array $field) => [
|
||||||
|
($field['required'] ?? false) ? 'required' : 'nullable',
|
||||||
|
...match ($field['type']) {
|
||||||
|
'textarea' => ['string', 'max:2000'],
|
||||||
|
'file' => [function (string $attribute, mixed $value, Closure $fail) {
|
||||||
|
if (! $this->isFileAnswerValid($value)) {
|
||||||
|
$fail('validation.uploaded')->translate();
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
default => ['string', 'max:255'],
|
||||||
|
},
|
||||||
|
])->all(),
|
||||||
|
[],
|
||||||
|
$fields->map(fn (array $field) => $field['label'])->all(),
|
||||||
|
)->validate();
|
||||||
|
|
||||||
|
$answers = $fields
|
||||||
|
->filter(fn (array $field) => filled($validated[$field['key']] ?? null))
|
||||||
|
->map(fn (array $field) => [
|
||||||
|
'key' => $field['key'],
|
||||||
|
'label' => $field['label'],
|
||||||
|
'type' => $field['type'],
|
||||||
|
...($field['type'] === 'file'
|
||||||
|
? ['file_id' => (int) $validated[$field['key']]]
|
||||||
|
: ['value' => $validated[$field['key']]]),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $answers === [] ? [] : ['custom_fields' => $answers];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product::$custom_fields stores `label` as {locale: string} (see
|
||||||
|
* boboko-core's Catalog\Filament\Pages\ManageProductCustomFields) — this
|
||||||
|
* resolves it to the single current-locale string cart/order line meta
|
||||||
|
* actually needs, the same filled()-over-?? fallback boboko-core's
|
||||||
|
* ProductDocumentLocalizer uses for every other translated field (an
|
||||||
|
* empty string for the current locale still falls through to the
|
||||||
|
* store's default language, rather than showing blank). A product
|
||||||
|
* saved before labels became translatable still has a plain string
|
||||||
|
* here, returned as-is.
|
||||||
|
*/
|
||||||
|
private function resolveLabel(mixed $label): string
|
||||||
|
{
|
||||||
|
if (! is_array($label)) {
|
||||||
|
return (string) $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
$locale = App::getLocale();
|
||||||
|
$fallbackLocale = app(LanguageCache::class)->defaultLocale();
|
||||||
|
|
||||||
|
return filled($label[$locale] ?? null)
|
||||||
|
? $label[$locale]
|
||||||
|
: ($label[$fallbackLocale] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isFileAnswerValid(mixed $fileId): bool
|
||||||
|
{
|
||||||
|
$file = File::find($fileId);
|
||||||
|
|
||||||
|
return $file !== null
|
||||||
|
&& $file->purpose === CustomFieldUploadController::PURPOSE
|
||||||
|
&& $file->owner_id === null
|
||||||
|
&& app(FileService::class)->exists($file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateLine(string $locale, Request $request, int $line): View|JsonResponse
|
||||||
|
{
|
||||||
|
$quantity = (int) $request->validate([
|
||||||
|
'quantity' => ['required', 'integer', 'min:0'],
|
||||||
|
])['quantity'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$quantity === 0
|
||||||
|
? $this->cart->removeLine($line)
|
||||||
|
: $this->cart->updateLine($line, $quantity);
|
||||||
|
} catch (CartException) {
|
||||||
|
$variant = CartLine::find($line)?->purchasable;
|
||||||
|
|
||||||
|
return $this->stockError($variant instanceof ProductVariant ? $variant : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('checkout::partials.cart-body');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getTotalInventory() is the same number canBeFulfilledAtQuantity()
|
||||||
|
* checked against (stock, for a tracked in_stock variant) — telling the
|
||||||
|
* shopper how many are actually left beats a generic "not enough stock"
|
||||||
|
* they'd otherwise have to guess around by trial and error.
|
||||||
|
*/
|
||||||
|
private function stockError(?ProductVariant $variant): JsonResponse
|
||||||
|
{
|
||||||
|
$available = $variant?->getTotalInventory() ?? 0;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(string $locale, int $line): View
|
||||||
|
{
|
||||||
|
$this->cart->removeLine($line);
|
||||||
|
|
||||||
|
return view('checkout::partials.cart-body');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bad code is a normal, expected outcome here (typo, expired code), not
|
||||||
|
* an error state for the request — it re-renders the same cart-body
|
||||||
|
* partial with $couponError set, rather than a 4xx/redirect, so the fetch
|
||||||
|
* + swap in bbk-cart-controller stays the one code path for every cart
|
||||||
|
* mutation.
|
||||||
|
*/
|
||||||
|
public function applyCoupon(string $locale, Request $request): View
|
||||||
|
{
|
||||||
|
$code = $request->validate([
|
||||||
|
'code' => ['required', 'string'],
|
||||||
|
])['code'];
|
||||||
|
|
||||||
|
$couponError = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->cart->applyCoupon($code);
|
||||||
|
} catch (InvalidCouponException) {
|
||||||
|
$couponError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('checkout::partials.cart-body', ['couponError' => $couponError]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeCoupon(string $locale): View
|
||||||
|
{
|
||||||
|
$this->cart->removeCoupon();
|
||||||
|
|
||||||
|
return view('checkout::partials.cart-body');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Checkout;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Lunar\Exceptions\Carts\CartException;
|
||||||
|
use Lunar\Exceptions\FingerprintMismatchException;
|
||||||
|
use Lunar\Facades\CartSession;
|
||||||
|
use Lunar\Models\Cart;
|
||||||
|
use Lunar\Models\Country;
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Lunar\Models\State;
|
||||||
|
use Modules\Core\Cart\Services\CartService;
|
||||||
|
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||||
|
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
||||||
|
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||||
|
use Modules\Core\Checkout\Services\CheckoutService;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||||
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The checkout page — one page, sections (contact / billing / shipping /
|
||||||
|
* shipping method), reviewed against boboko-core's CheckoutService. Stops
|
||||||
|
* short of payment for this slice (see project memory).
|
||||||
|
*
|
||||||
|
* The address form and the shipping-method radios **autosave** — no submit
|
||||||
|
* buttons. `saveAddress()` / `selectShippingOption()` are called by
|
||||||
|
* bbk-checkout-form (debounced fetch) and return a JSON envelope of
|
||||||
|
* server-rendered fragments (shipping options + order summary) plus any
|
||||||
|
* field errors, rather than redirecting. Address validation is deliberately
|
||||||
|
* lenient — nothing is rejected mid-typing; required-field enforcement is the
|
||||||
|
* job of the (not-yet-built) "Continue to payment" gate.
|
||||||
|
*
|
||||||
|
* Guests type their email; the login tab links to the storefront's own
|
||||||
|
* `login` route and back. Logged in: the email is the account's (forced in
|
||||||
|
* saveAddress()), the first visit prefills addresses from the account
|
||||||
|
* (prefillFromAccount()), and Lunar's Login listener has already attached the
|
||||||
|
* cart, so the placed order lands in the account's history.
|
||||||
|
*
|
||||||
|
* Single-country store: STORE_COUNTRY_ISO3 fixes the country to Greece (hidden
|
||||||
|
* field, forced server-side). Set it to null for the full country picker (the
|
||||||
|
* portable path for a multi-country boboko store).
|
||||||
|
*/
|
||||||
|
class CheckoutController extends Controller
|
||||||
|
{
|
||||||
|
private const STORE_COUNTRY_ISO3 = 'GRC';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CartService $cart,
|
||||||
|
private readonly CheckoutService $checkout,
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(string $locale): View
|
||||||
|
{
|
||||||
|
$cart = $this->cart->current();
|
||||||
|
$lines = $cart ? $this->cart->activeLines($cart) : collect();
|
||||||
|
$storeCountry = $this->storeCountry();
|
||||||
|
|
||||||
|
$shippingOptions = collect();
|
||||||
|
|
||||||
|
// Captured before prefillFromAccount(), which may recreate the address
|
||||||
|
// row (dropping its shipping_option) — same reason as in saveAddress().
|
||||||
|
$previousOption = $cart?->shippingAddress?->shipping_option;
|
||||||
|
|
||||||
|
if ($cart && Auth::check()) {
|
||||||
|
$cart = $this->prefillFromAccount($cart);
|
||||||
|
|
||||||
|
// Nothing chosen on this cart yet: carry over the account's standing
|
||||||
|
// opt-in (an explicit earlier choice, recorded with its own
|
||||||
|
// timestamp/policy version). Never opts anyone in by default.
|
||||||
|
if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? [])
|
||||||
|
&& data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) {
|
||||||
|
$cart = $this->checkout->setRecoveryConsent(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart?->shippingAddress) {
|
||||||
|
$shippingOptions = $this->syncShipping($cart, $previousOption);
|
||||||
|
|
||||||
|
// Cart's CachesProperties::refresh() explicitly nulls total/
|
||||||
|
// subTotal/shippingTotal/etc. back to their defaults — every
|
||||||
|
// Lunar call site pairs it with recalculate() for exactly that
|
||||||
|
// reason. Bare refresh() here was leaving $cart->total null on
|
||||||
|
// reload, which fed a 0 amount straight into the Stripe Element.
|
||||||
|
$cart->refresh()->recalculate();
|
||||||
|
}
|
||||||
|
|
||||||
|
$paymentMethods = $this->checkout->getPaymentMethods();
|
||||||
|
|
||||||
|
// Nothing checked yet (fresh cart), or the shopper's earlier pick is
|
||||||
|
// no longer offered (method disabled/removed since) — auto-select
|
||||||
|
// the first one, same as a manual click would, so the payment
|
||||||
|
// section (and the Stripe Element mounting under it) isn't sitting
|
||||||
|
// inert behind an unchecked radio. A still-valid previous choice is
|
||||||
|
// left alone.
|
||||||
|
$firstMethod = $paymentMethods->first();
|
||||||
|
|
||||||
|
if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) {
|
||||||
|
$cart = $this->checkout->selectPaymentMethod($firstMethod->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('checkout::page', [
|
||||||
|
'cart' => $cart,
|
||||||
|
'lines' => $lines,
|
||||||
|
'billingAddress' => $cart?->billingAddress,
|
||||||
|
'shippingAddress' => $cart?->shippingAddress,
|
||||||
|
'shippingOptions' => $shippingOptions,
|
||||||
|
'paymentMethods' => $paymentMethods,
|
||||||
|
'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true),
|
||||||
|
'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false),
|
||||||
|
'storeCountry' => $storeCountry,
|
||||||
|
'countries' => $storeCountry
|
||||||
|
? collect()
|
||||||
|
: Country::orderBy('name')->get(['id', 'name']),
|
||||||
|
'regions' => $storeCountry
|
||||||
|
? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name'])
|
||||||
|
: collect(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveAddress(string $locale, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$storeCountry = $this->storeCountry();
|
||||||
|
$sameAsBilling = $request->boolean('same_as_billing');
|
||||||
|
|
||||||
|
// Only the fields shipping rates resolve against — if none of these
|
||||||
|
// changed (shopper edited their name, phone, email, …) there's no point
|
||||||
|
// re-quoting shipping or re-rendering the summary.
|
||||||
|
$addressBefore = $this->cart->current()?->shippingAddress;
|
||||||
|
$rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']);
|
||||||
|
|
||||||
|
// setShippingAddress() below always deletes + recreates this row (see
|
||||||
|
// syncShipping()'s docblock) — capture what was selected NOW, before
|
||||||
|
// it's gone, so it can be carried forward onto the fresh row.
|
||||||
|
$previousOption = $addressBefore?->shipping_option;
|
||||||
|
|
||||||
|
$stateRule = $storeCountry
|
||||||
|
? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)]
|
||||||
|
: ['nullable', 'string', 'max:255'];
|
||||||
|
$countryRule = $storeCountry
|
||||||
|
? ['nullable']
|
||||||
|
: ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id'];
|
||||||
|
|
||||||
|
// Lenient — only format checks. Anything that fails is simply left out
|
||||||
|
// of what gets persisted, and reported back for inline display.
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'contact_email' => ['nullable', 'email'],
|
||||||
|
|
||||||
|
'billing_first_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'billing_last_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'billing_company_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'billing_tax_identifier' => ['nullable', 'string', 'max:255'],
|
||||||
|
'billing_line_one' => ['nullable', 'string', 'max:255'],
|
||||||
|
'billing_city' => ['nullable', 'string', 'max:255'],
|
||||||
|
'billing_state' => $stateRule,
|
||||||
|
'billing_postcode' => ['nullable', 'string', 'max:20'],
|
||||||
|
'billing_country_id' => $countryRule,
|
||||||
|
'billing_contact_phone' => ['nullable', 'string', 'max:50'],
|
||||||
|
|
||||||
|
'shipping_first_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'shipping_last_name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'shipping_line_one' => ['nullable', 'string', 'max:255'],
|
||||||
|
'shipping_city' => ['nullable', 'string', 'max:255'],
|
||||||
|
'shipping_state' => $stateRule,
|
||||||
|
'shipping_postcode' => ['nullable', 'string', 'max:20'],
|
||||||
|
'shipping_country_id' => $countryRule,
|
||||||
|
'shipping_contact_phone' => ['nullable', 'string', 'max:50'],
|
||||||
|
'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$errors = $validator->errors()->toArray();
|
||||||
|
$data = $validator->valid();
|
||||||
|
|
||||||
|
// Logged in: the order email is always the account's. It isn't a field
|
||||||
|
// on the page then, and a submitted value isn't trusted.
|
||||||
|
if ($user = Auth::user()) {
|
||||||
|
$data['contact_email'] = $user->email;
|
||||||
|
}
|
||||||
|
|
||||||
|
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
|
||||||
|
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
|
||||||
|
|
||||||
|
// Company/ΑΦΜ only count when "I want an invoice" is ticked; the fields
|
||||||
|
// stay in the DOM (just hidden) when it isn't, so ignore what they send.
|
||||||
|
$wantsInvoice = $request->boolean('wants_invoice');
|
||||||
|
|
||||||
|
$billing = [
|
||||||
|
'first_name' => $data['billing_first_name'] ?? null,
|
||||||
|
'last_name' => $data['billing_last_name'] ?? null,
|
||||||
|
'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null,
|
||||||
|
'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null,
|
||||||
|
'line_one' => $data['billing_line_one'] ?? null,
|
||||||
|
'city' => $data['billing_city'] ?? null,
|
||||||
|
'state' => $data['billing_state'] ?? null,
|
||||||
|
'postcode' => $data['billing_postcode'] ?? null,
|
||||||
|
'country_id' => $billingCountryId,
|
||||||
|
'contact_email' => $data['contact_email'] ?? null,
|
||||||
|
'contact_phone' => $data['billing_contact_phone'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$shipping = $sameAsBilling
|
||||||
|
? [
|
||||||
|
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
||||||
|
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
'first_name' => $data['shipping_first_name'] ?? null,
|
||||||
|
'last_name' => $data['shipping_last_name'] ?? null,
|
||||||
|
'line_one' => $data['shipping_line_one'] ?? null,
|
||||||
|
'city' => $data['shipping_city'] ?? null,
|
||||||
|
'state' => $data['shipping_state'] ?? null,
|
||||||
|
'postcode' => $data['shipping_postcode'] ?? null,
|
||||||
|
'country_id' => $shippingCountryId,
|
||||||
|
'contact_email' => $data['contact_email'] ?? null,
|
||||||
|
'contact_phone' => $data['shipping_contact_phone'] ?? null,
|
||||||
|
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->checkout->setBillingAddress($billing);
|
||||||
|
$cart = $this->checkout->setShippingAddress($shipping);
|
||||||
|
|
||||||
|
$cart->meta = [
|
||||||
|
...($cart->meta?->toArray() ?? []),
|
||||||
|
'ship_to_billing' => $sameAsBilling,
|
||||||
|
'wants_invoice' => $wantsInvoice,
|
||||||
|
];
|
||||||
|
$cart->save();
|
||||||
|
|
||||||
|
// Abandoned-cart-recovery opt-in — boboko-core owns the record (bool +
|
||||||
|
// timestamp + policy version on Cart::meta, RecoveryConsentSet event).
|
||||||
|
// Deliberately its own scope, not merged with any future newsletter opt-in.
|
||||||
|
$this->checkout->setRecoveryConsent($request->boolean('recovery_consent'));
|
||||||
|
|
||||||
|
if (Auth::check()) {
|
||||||
|
$this->account->setRecoveryConsent(Auth::user(), $request->boolean('recovery_consent'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']);
|
||||||
|
$rateChanged = $rateKeyAfter != $rateKeyBefore;
|
||||||
|
|
||||||
|
// setShippingAddress() above always deletes and recreates the
|
||||||
|
// CartAddress row (Lunar's AddAddress action), which drops whatever
|
||||||
|
// shipping_option was previously selected — regardless of whether the
|
||||||
|
// rate-determining fields actually changed. So this always has to run
|
||||||
|
// to restore/re-validate it, even on a save that only touched e.g. the
|
||||||
|
// phone number. Only the fragment RE-RENDER is skippable when nothing
|
||||||
|
// rate-relevant moved — the re-select itself is not optional.
|
||||||
|
$options = $this->syncShipping($cart, $previousOption);
|
||||||
|
|
||||||
|
if (! $rateChanged) {
|
||||||
|
return $this->fragments($cart, null, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fragments($cart, $options, $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectShippingOption(string $locale, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$identifier = (string) $request->input('shipping_option');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->checkout->selectShippingOption($identifier);
|
||||||
|
} catch (InvalidShippingOptionException) {
|
||||||
|
// Re-render with whatever is currently valid; no hard error surfaced.
|
||||||
|
}
|
||||||
|
|
||||||
|
$cart = $this->cart->current();
|
||||||
|
$options = $cart?->shippingAddress
|
||||||
|
? $this->checkout->getShippingOptions()
|
||||||
|
: collect();
|
||||||
|
|
||||||
|
return $this->fragments($cart, $options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Autosave-select a payment method (radio change). Persists it via
|
||||||
|
* CheckoutService (which also records it on Cart::meta and re-snapshots
|
||||||
|
* the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary.
|
||||||
|
*/
|
||||||
|
public function selectPaymentMethod(string $locale, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$type = (string) $request->input('payment_type');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->checkout->selectPaymentMethod($type);
|
||||||
|
} catch (UnknownPaymentTypeException) {
|
||||||
|
// Radio value out of sync with what's offered — ignore, the summary
|
||||||
|
// just won't reflect a method fee. place-order re-checks properly.
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'summaryHtml' => view('checkout::partials.cart-body')->render(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real submit — the hard gate. Re-selects the payment method (fresh
|
||||||
|
* fingerprint), then hands off to CheckoutService::initiatePayment(), which
|
||||||
|
* creates the draft order, records terms acceptance, and charges the driver.
|
||||||
|
* Returns JSON the bbk-payment controller routes on:
|
||||||
|
* { redirect } — placed, go to confirmation
|
||||||
|
* { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls
|
||||||
|
* { status: 'failed', message } — declined
|
||||||
|
* { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection
|
||||||
|
*/
|
||||||
|
public function placeOrder(string $locale, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if (! $request->boolean('terms_accepted')) {
|
||||||
|
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->checkout->selectPaymentMethod((string) $request->input('payment_type'));
|
||||||
|
} catch (UnknownPaymentTypeException) {
|
||||||
|
return response()->json(['error' => __('checkout.page.choose_payment_method')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cart = $this->cart->current();
|
||||||
|
|
||||||
|
// Captured now, before initiatePayment() can place the order — Lunar's
|
||||||
|
// CartSessionManager::fetchOrCreate() silently swaps the session onto a
|
||||||
|
// BRAND NEW empty cart the moment the current one hasCompletedOrders()
|
||||||
|
// (i.e. has an order with placed_at set), which happens synchronously
|
||||||
|
// for an immediately-captured payment. Any later $this->cart->current()
|
||||||
|
// call in this same flow (here, or in a subsequent orderStatus() poll
|
||||||
|
// once the 3-D Secure webhook sets placed_at) would then resolve to
|
||||||
|
// that fresh, order-less cart instead of the one that was just placed.
|
||||||
|
// Storing the real cart id ourselves, under our own session key,
|
||||||
|
// sidesteps CartSession entirely for the rest of the placement flow.
|
||||||
|
session(['checkout.cart_id' => $cart?->id]);
|
||||||
|
|
||||||
|
// Lunar's own ValidateCartForOrderCreation (order_create validator)
|
||||||
|
// never checks for this — an empty cart with a valid billing address
|
||||||
|
// sails straight through it and would place a real, zero-line order.
|
||||||
|
// The disabled "place order" button is only the client-side half of
|
||||||
|
// this fix; this is the half that actually matters.
|
||||||
|
if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'invalid',
|
||||||
|
'message' => __('checkout.page.cart_empty'),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lenient autosave never requires these; this is the gate.
|
||||||
|
if (data_get($cart, 'meta.wants_invoice')
|
||||||
|
&& (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'invalid',
|
||||||
|
'message' => __('checkout.page.invoice_required'),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same check Lunar's own ValidateCartForOrderCreation runs inside
|
||||||
|
// initiatePayment() (a product unpublished/deleted after it was
|
||||||
|
// added to the cart) — checked here first so the shopper is told
|
||||||
|
// which product is the problem, rather than falling into the
|
||||||
|
// catch-all "complete your billing/shipping details" message below,
|
||||||
|
// which is what actually happened and is generic to every
|
||||||
|
// CartException reason, misleading when the real cause is a line,
|
||||||
|
// not an address.
|
||||||
|
$unavailableLines = $this->cart->activeLines($cart)->filter(
|
||||||
|
fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($unavailableLines->isNotEmpty()) {
|
||||||
|
$names = $unavailableLines
|
||||||
|
->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier())
|
||||||
|
->filter()
|
||||||
|
->implode(', ');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'invalid',
|
||||||
|
'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one incomplete-cart case worth a specific message + pointing the
|
||||||
|
// shopper at the right section: a region resolving 2+ methods needs an
|
||||||
|
// explicit pick (no auto-select), easy to miss since nothing else on
|
||||||
|
// the page demands it. Everything else CartException catches below.
|
||||||
|
if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'invalid',
|
||||||
|
'message' => __('checkout.page.shipping_method_required'),
|
||||||
|
'field' => 'shipping_option',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? '');
|
||||||
|
|
||||||
|
$data = $request->filled('payment_method')
|
||||||
|
? ['payment_method' => (string) $request->input('payment_method')]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->checkout->initiatePayment(
|
||||||
|
$fingerprint,
|
||||||
|
termsAccepted: true,
|
||||||
|
policyVersion: (string) config('legal.terms_version'),
|
||||||
|
data: $data,
|
||||||
|
);
|
||||||
|
} catch (FingerprintMismatchException) {
|
||||||
|
return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409);
|
||||||
|
} catch (CartException $e) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'invalid',
|
||||||
|
'message' => __('checkout.page.payment_incomplete_details'),
|
||||||
|
'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(),
|
||||||
|
], 422);
|
||||||
|
} catch (TermsNotAcceptedException) {
|
||||||
|
return response()->json(['error' => __('checkout.page.terms_required')], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending with no continuation (cash-on-delivery, or any other
|
||||||
|
// deferred/offline method) means CheckoutService::initiatePayment()
|
||||||
|
// already created the placed order — money just hasn't changed
|
||||||
|
// hands yet. Only a Pending WITH a continuation (Stripe's client
|
||||||
|
// secret) means the shopper still has something to do before the
|
||||||
|
// order exists as far as the storefront is concerned.
|
||||||
|
return match (true) {
|
||||||
|
$result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale),
|
||||||
|
$result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale),
|
||||||
|
$result->status === PaymentResultStatus::Pending => response()->json([
|
||||||
|
'status' => 'pending',
|
||||||
|
'clientSecret' => $result->continuation?->value,
|
||||||
|
]),
|
||||||
|
default => response()->json([
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => $result->failureReason ?: __('checkout.page.payment_failed'),
|
||||||
|
'retriable' => $result->retriable,
|
||||||
|
], 422),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll target for the 3-D Secure path: has the webhook placed the order yet?
|
||||||
|
* boboko-core's StripeWebhookController -> handleCallback -> PaymentCaptured
|
||||||
|
* -> ApplyResolvedPaymentStatus sets placed_at.
|
||||||
|
*/
|
||||||
|
public function orderStatus(string $locale): JsonResponse
|
||||||
|
{
|
||||||
|
$order = $this->placedOrder();
|
||||||
|
|
||||||
|
if (! $order) {
|
||||||
|
return response()->json(['placed' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
session(['checkout.order_id' => $order->id]);
|
||||||
|
CartSession::forget();
|
||||||
|
|
||||||
|
return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmation(string $locale): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$orderId = session('checkout.order_id');
|
||||||
|
|
||||||
|
$order = $orderId
|
||||||
|
? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (! $order) {
|
||||||
|
return redirect()->route('products', $locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looked up by type rather than a stored relation — the method may since
|
||||||
|
// have been disabled/deleted, but the order still needs to show what was
|
||||||
|
// actually used at the time.
|
||||||
|
$paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null)
|
||||||
|
->first()
|
||||||
|
?->translate('name');
|
||||||
|
|
||||||
|
return view('checkout::confirmation', [
|
||||||
|
'order' => $order,
|
||||||
|
'paymentMethodName' => $paymentMethodName,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function orderPlacedResponse(string $locale): JsonResponse
|
||||||
|
{
|
||||||
|
if ($order = $this->placedOrder()) {
|
||||||
|
session(['checkout.order_id' => $order->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
CartSession::forget();
|
||||||
|
|
||||||
|
return response()->json(['redirect' => route('checkout.confirmation', $locale)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function placedOrder(): ?Order
|
||||||
|
{
|
||||||
|
$cartId = session('checkout.cart_id');
|
||||||
|
|
||||||
|
if ($cartId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Order::where('cart_id', $cartId)
|
||||||
|
->whereNotNull('placed_at')
|
||||||
|
->latest('placed_at')
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve shipping options for the cart's current address and keep the
|
||||||
|
* selection sane: auto-select when exactly one resolves, or carry a
|
||||||
|
* previous pick forward when it's still among the resolved options.
|
||||||
|
*
|
||||||
|
* $previousOption must be captured by the CALLER before setShippingAddress()
|
||||||
|
* runs — Lunar's AddAddress action always deletes and recreates the
|
||||||
|
* CartAddress row on every save (see saveAddress()), so by the time this
|
||||||
|
* runs, $address->shipping_option is unconditionally null regardless of
|
||||||
|
* what was selected a moment ago. There is nothing meaningful left to read
|
||||||
|
* off $address itself; $previousOption is the only source of truth for
|
||||||
|
* "what was chosen before this save wiped the row." show() passes the
|
||||||
|
* address's own (not-just-wiped) current value, since nothing recreated
|
||||||
|
* anything in that path.
|
||||||
|
*
|
||||||
|
* Always (re-)applies the resolved target via selectShippingOption() rather
|
||||||
|
* than comparing against the (always-blank, post-recreation) current value
|
||||||
|
* — the fresh row needs the write regardless of whether the decision
|
||||||
|
* "which option" actually changed.
|
||||||
|
*
|
||||||
|
* @return Collection<int, \Lunar\DataTypes\ShippingOption>
|
||||||
|
*/
|
||||||
|
private function syncShipping(Cart $cart, ?string $previousOption): Collection
|
||||||
|
{
|
||||||
|
if (! $cart->shippingAddress) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = $this->checkout->getShippingOptions();
|
||||||
|
|
||||||
|
$target = match (true) {
|
||||||
|
$options->count() === 1 => $options->first()->identifier,
|
||||||
|
$previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($target !== null) {
|
||||||
|
try {
|
||||||
|
$this->checkout->selectShippingOption($target);
|
||||||
|
} catch (InvalidShippingOptionException) {
|
||||||
|
// $target came from $options itself — shouldn't happen, stay defensive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* $options === null means "nothing money-relevant changed" — acknowledge the
|
||||||
|
* save (and any field errors) without re-rendering the shipping options or
|
||||||
|
* the order summary, so a plain name/phone edit is a cheap round-trip.
|
||||||
|
*/
|
||||||
|
private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'errors' => collect($errors)
|
||||||
|
->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages)
|
||||||
|
->all(),
|
||||||
|
'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [
|
||||||
|
'shippingAddress' => $cart?->shippingAddress,
|
||||||
|
'shippingOptions' => $options,
|
||||||
|
])->render(),
|
||||||
|
// Composer (CheckoutModuleServiceProvider) fills $cart / $lines.
|
||||||
|
'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logged-in shopper: fills any BLANK cart address field from the account
|
||||||
|
* (name, saved default address, phone, email), on every checkout load, so
|
||||||
|
* an account filled in after checkout started still shows up. Never
|
||||||
|
* overwrites anything already in the cart.
|
||||||
|
*
|
||||||
|
* Company/ΑΦΜ (and ticking "I want an invoice") only on the first pass
|
||||||
|
* (meta.account_prefilled): someone who then clears them or unticks the
|
||||||
|
* box for this order shouldn't get them back on the next reload.
|
||||||
|
*
|
||||||
|
* Writes only when something actually changes, so a normal reload costs
|
||||||
|
* nothing extra.
|
||||||
|
*/
|
||||||
|
private function prefillFromAccount(Cart $cart): Cart
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
$customer = $this->account->customer($user);
|
||||||
|
$addresses = collect($this->account->addresses($user));
|
||||||
|
$saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first();
|
||||||
|
$firstPass = ! data_get($cart, 'meta.account_prefilled');
|
||||||
|
|
||||||
|
$fromAccount = array_filter([
|
||||||
|
'first_name' => $customer?->first_name ?: $saved?->first_name,
|
||||||
|
'last_name' => $customer?->last_name ?: $saved?->last_name,
|
||||||
|
'line_one' => $saved?->line_one,
|
||||||
|
'city' => $saved?->city,
|
||||||
|
'state' => $saved?->state,
|
||||||
|
'postcode' => $saved?->postcode,
|
||||||
|
'country_id' => $this->storeCountry()?->id ?? $saved?->country_id,
|
||||||
|
'contact_email' => $user->email,
|
||||||
|
'contact_phone' => $saved?->contact_phone,
|
||||||
|
], 'filled');
|
||||||
|
|
||||||
|
$invoice = $firstPass
|
||||||
|
? array_filter([
|
||||||
|
'company_name' => $customer?->company_name,
|
||||||
|
'tax_identifier' => $customer?->tax_identifier,
|
||||||
|
], 'filled')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city',
|
||||||
|
'state', 'postcode', 'country_id', 'contact_email', 'contact_phone'];
|
||||||
|
|
||||||
|
$fillBlanks = function (?array $current, array $values) {
|
||||||
|
$current ??= [];
|
||||||
|
|
||||||
|
foreach ($values as $key => $value) {
|
||||||
|
if (blank($current[$key] ?? null)) {
|
||||||
|
$current[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $current;
|
||||||
|
};
|
||||||
|
|
||||||
|
$billingBefore = $cart->billingAddress?->only($fields);
|
||||||
|
$billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]);
|
||||||
|
|
||||||
|
// Shipping has no company/ΑΦΜ (same shape saveAddress() writes).
|
||||||
|
$shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true);
|
||||||
|
$shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions'];
|
||||||
|
|
||||||
|
$shippingBefore = $cart->shippingAddress?->only($shippingFields);
|
||||||
|
$shipping = $shipToBilling
|
||||||
|
? [
|
||||||
|
...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]),
|
||||||
|
'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null,
|
||||||
|
]
|
||||||
|
: $fillBlanks($shippingBefore, $fromAccount);
|
||||||
|
|
||||||
|
if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) {
|
||||||
|
$this->checkout->setBillingAddress($billing);
|
||||||
|
$cart = $this->checkout->setShippingAddress($shipping);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($firstPass) {
|
||||||
|
$cart->meta = [
|
||||||
|
...($cart->meta?->toArray() ?? []),
|
||||||
|
'account_prefilled' => true,
|
||||||
|
'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [],
|
||||||
|
];
|
||||||
|
$cart->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storeCountry(): ?Country
|
||||||
|
{
|
||||||
|
if (self::STORE_COUNTRY_ISO3 === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,71 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\ContactRequest;
|
||||||
|
use App\Mail\ContactConfirmationMail;
|
||||||
|
use App\Mail\ContactMessageMail;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact form. Nothing is stored: the message is emailed to the store
|
||||||
|
* (CONTACT_EMAIL) and the sender gets a generic confirmation. Both are sent
|
||||||
|
* synchronously so a failed store email can be reported back on the form.
|
||||||
|
*
|
||||||
|
* Limited per IP in here rather than with throttle middleware, so the limit
|
||||||
|
* shows as a message on the form instead of a bare 429 page.
|
||||||
|
*/
|
||||||
class ContactController extends Controller
|
class ContactController extends Controller
|
||||||
{
|
{
|
||||||
public function index(string $locale)
|
private const SEND_LIMIT = 3;
|
||||||
|
private const SEND_DECAY_SECONDS = 600;
|
||||||
|
|
||||||
|
public function index(string $locale): View
|
||||||
{
|
{
|
||||||
return view('contact');
|
return view('contact');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function send(string $locale, ContactRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$key = 'contact:'.$request->ip();
|
||||||
|
|
||||||
|
if (RateLimiter::tooManyAttempts($key, self::SEND_LIMIT)) {
|
||||||
|
return back()->withInput()->with('contact_error', __('storefront.contact.too_many'));
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::hit($key, self::SEND_DECAY_SECONDS);
|
||||||
|
|
||||||
|
$data = $request->validated();
|
||||||
|
$to = config('services.contact.email');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (blank($to)) {
|
||||||
|
throw new \RuntimeException('CONTACT_EMAIL is not set.');
|
||||||
|
}
|
||||||
|
|
||||||
|
Mail::to($to)->send(new ContactMessageMail(
|
||||||
|
$data['name'],
|
||||||
|
$data['email'],
|
||||||
|
$data['message'],
|
||||||
|
$locale,
|
||||||
|
));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('Contact form: store email failed', ['exception' => $e]);
|
||||||
|
|
||||||
|
return back()->withInput()->with('contact_error', __('storefront.contact.send_failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The store already has the message, so a failed confirmation is only logged.
|
||||||
|
try {
|
||||||
|
Mail::to($data['email'])->locale($locale)->send(new ContactConfirmationMail);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('Contact form: confirmation email failed', ['exception' => $e]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->route('contact')->with('status', __('storefront.contact.sent'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Modules\Core\File\Http\Controllers\UploadFileController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the shopper's photo for a product custom field of type `file` (see
|
||||||
|
* boboko-core's Product::$custom_fields) the moment it's picked on the product
|
||||||
|
* page — before add-to-cart, see custom-field-upload-controller.js.
|
||||||
|
*
|
||||||
|
* Which files are acceptable (extensions, size) is a per-site decision — this
|
||||||
|
* site: photographs — so it lives here as this app's own policy, extending
|
||||||
|
* boboko-core's Modules\Core\File\Http\Controllers\UploadFileController for
|
||||||
|
* the actual store()/validate()/respond() mechanics. The module's add-to-cart
|
||||||
|
* endpoint only ever receives the stored File row's own `id` — FileService is
|
||||||
|
* the single source of truth for disk/path/name/mime, never duplicated into
|
||||||
|
* cart/order line meta (see Checkout\CartController::customFieldsMeta()). A
|
||||||
|
* shopper can't point a cart line at someone else's file: CartController only
|
||||||
|
* accepts an id that is both unowned and tagged with this exact PURPOSE.
|
||||||
|
*
|
||||||
|
* Stored on the private `local` disk: these are customers' personal photos,
|
||||||
|
* never reachable by a public URL except through FileService's own signed
|
||||||
|
* download route. Uploads nobody adds to a cart are removed by core's
|
||||||
|
* `boboko:file:prune-unowned custom-field-upload` command.
|
||||||
|
*/
|
||||||
|
class CustomFieldUploadController extends UploadFileController
|
||||||
|
{
|
||||||
|
public const PURPOSE = 'custom-field-upload';
|
||||||
|
|
||||||
|
public const MAX_KILOBYTES = 10240;
|
||||||
|
|
||||||
|
public const EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the file input's `accept` attribute — same list the server enforces.
|
||||||
|
*/
|
||||||
|
public static function accept(): string
|
||||||
|
{
|
||||||
|
return '.'.implode(',.', self::EXTENSIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function purpose(): string
|
||||||
|
{
|
||||||
|
return self::PURPOSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function validationRules(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => ['required', 'file', 'mimes:'.implode(',', self::EXTENSIONS), 'max:'.self::MAX_KILOBYTES],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// `label` is the admin-authored field label, only used as the
|
||||||
|
// :attribute in the validation message shown next to that field.
|
||||||
|
protected function validationAttributes(Request $request): array
|
||||||
|
{
|
||||||
|
return ['file' => (string) $request->input('label', 'file')];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\ProductCard;
|
||||||
use App\Models\StoicPage;
|
use App\Models\StoicPage;
|
||||||
use Lunar\Models\Product;
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
|
||||||
class HomeController extends Controller
|
class HomeController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly ProductService $products) {}
|
||||||
|
|
||||||
public function index(string $locale)
|
public function index(string $locale)
|
||||||
{
|
{
|
||||||
$page = StoicPage::firstWhere('slug', 'home');
|
$page = StoicPage::firstWhere('slug', 'home');
|
||||||
@@ -15,16 +18,8 @@ public function index(string $locale)
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$products = Product::with(['variants.prices.currency', 'media'])
|
$products = collect($this->products->random(13))
|
||||||
->inRandomOrder()
|
->map(fn (array $product) => ProductCard::fromIndexed($product));
|
||||||
->limit(13)
|
|
||||||
->get()
|
|
||||||
->map(fn (Product $product) => [
|
|
||||||
'name' => $product->translateAttribute('name'),
|
|
||||||
'price' => $product->variants->first()?->prices->first()?->price->decimal,
|
|
||||||
'image' => $product->media->first()?->getUrl(),
|
|
||||||
'href' => route('product.show', ['id' => $product->id]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return view('home', [
|
return view('home', [
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
|
|||||||
@@ -2,37 +2,245 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\ProductListing;
|
||||||
|
use App\Catalog\ProductListingPage;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
use Lunar\Models\ProductVariant;
|
||||||
use Modules\Core\Catalog\Services\ProductService;
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
use Modules\Core\Review\Models\ProductReview;
|
||||||
|
|
||||||
class ProductController extends Controller
|
class ProductController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly ProductService $products) {}
|
public function __construct(
|
||||||
|
private readonly ProductService $products,
|
||||||
|
private readonly ProductListingPage $listingPage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All products — the category page without a collection scope. Filters,
|
||||||
|
* sort, pagination and the shared listing body all work identically.
|
||||||
|
*/
|
||||||
|
public function index(string $locale)
|
||||||
|
{
|
||||||
|
$listing = ProductListing::fromRequest(request());
|
||||||
|
|
||||||
|
$data = $this->listingPage->build(
|
||||||
|
$listing,
|
||||||
|
fn (array $query) => route('products', $query),
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('products.index', $data);
|
||||||
|
}
|
||||||
|
|
||||||
public function show(string $locale, int $id)
|
public function show(string $locale, int $id)
|
||||||
{
|
{
|
||||||
$product = $this->products->getById($id);
|
$product = $this->products->getById($id);
|
||||||
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
abort_if($product === null, Response::HTTP_NOT_FOUND);
|
||||||
|
|
||||||
|
$product = $this->mergeJustSubmittedReview($product);
|
||||||
|
|
||||||
$collection = $product['collections'][0] ?? null;
|
$collection = $product['collections'][0] ?? null;
|
||||||
|
// dd($product);
|
||||||
$variantsData = collect($product['variants'])
|
[$productOptions, $variantsData] = $this->buildOptionPicker($id);
|
||||||
->map(fn (array $variant) => [
|
|
||||||
'id' => $variant['id'],
|
|
||||||
'price' => $variant['prices'][0]['price'] ?? null,
|
|
||||||
'image' => $variant['media'][0]['url'] ?? null,
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$firstVariant = $product['variants'][0] ?? null;
|
|
||||||
$option = $firstVariant['options'][0]['option'] ?? null;
|
|
||||||
|
|
||||||
return view('product.show', [
|
return view('product.show', [
|
||||||
'collection' => $collection,
|
'collection' => $collection,
|
||||||
'product' => $product,
|
'product' => $product,
|
||||||
'option' => $option,
|
'productOptions' => $productOptions,
|
||||||
'variantsData' => $variantsData,
|
'variantsData' => $variantsData,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One button/swatch group per product option (a product can have several
|
||||||
|
* — e.g. a custom-photo product with size + style + person-count, each
|
||||||
|
* combination resolved client-side to one exact ProductVariant, see
|
||||||
|
* product-form-controller.js) plus the per-variant data the picker
|
||||||
|
* resolves a selection against.
|
||||||
|
*
|
||||||
|
* Reads live Eloquent rather than the Meilisearch-indexed $product array
|
||||||
|
* the rest of the page uses (same reasoning as checkStock() below): the
|
||||||
|
* index has no concept of option/value display order (Lunar's
|
||||||
|
* `position` column) or a stable value id, both of which the picker
|
||||||
|
* needs — to render values in the merchant's intended order, and to
|
||||||
|
* match a combination back to one exact variant without relying on
|
||||||
|
* translated label strings staying unique.
|
||||||
|
*
|
||||||
|
* @return array{0: array, 1: array}
|
||||||
|
*/
|
||||||
|
private function buildOptionPicker(int $productId): array
|
||||||
|
{
|
||||||
|
$variants = ProductVariant::query()
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->with(['values.option', 'prices', 'images'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$productOptions = $variants
|
||||||
|
->flatMap(fn (ProductVariant $variant) => $variant->values)
|
||||||
|
->unique('id')
|
||||||
|
->groupBy(fn ($value) => $value->option->handle)
|
||||||
|
->map(function ($values, $handle) {
|
||||||
|
$sorted = $values->sortBy([['position', 'asc'], ['id', 'asc']]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'handle' => $handle,
|
||||||
|
'label' => $sorted->first()->option->translate('name'),
|
||||||
|
// The "Color" option type is the only one that writes a
|
||||||
|
// hex code into meta (see boboko/core's ColorOptionType)
|
||||||
|
// — its presence is how we tell a color option (swatches)
|
||||||
|
// from any other option (buttons).
|
||||||
|
'isColor' => $sorted->contains(fn ($v) => !empty($v->meta['hex'] ?? null)),
|
||||||
|
'values' => $sorted->map(fn ($v) => [
|
||||||
|
'id' => $v->id,
|
||||||
|
'label' => $v->translate('name'),
|
||||||
|
'hex' => $v->meta['hex'] ?? null,
|
||||||
|
])->values()->all(),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$variantsData = $variants->map(fn (ProductVariant $variant) => [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'price' => $variant->prices->first()?->price?->decimal(),
|
||||||
|
'image' => $variant->images->first()?->getUrl(),
|
||||||
|
// handle => selected value id, for matching a combination of
|
||||||
|
// selections back to this variant — see selectVariant() in
|
||||||
|
// product-form-controller.js.
|
||||||
|
'options' => $variant->values->mapWithKeys(fn ($v) => [$v->option->handle => $v->id])->all(),
|
||||||
|
])->values()->all();
|
||||||
|
|
||||||
|
return [$productOptions, $variantsData];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meilisearch's own write API is itself async — addDocuments() enqueues an
|
||||||
|
* indexing task and returns immediately, and Laravel\Scout\Engines\
|
||||||
|
* MeilisearchEngine::update() never waits on that task, so even
|
||||||
|
* $product->searchableSync() (which only skips OUR queue) can still land
|
||||||
|
* the shopper back on this page before Meilisearch has actually processed
|
||||||
|
* the write. storeReview() flashes the review it just created for exactly
|
||||||
|
* this one next request; splice it in here rather than trust the index is
|
||||||
|
* already caught up. Guarded by id so a race the other way — the index
|
||||||
|
* DID catch up in time — doesn't show the same review twice.
|
||||||
|
*/
|
||||||
|
private function mergeJustSubmittedReview(array $product): array
|
||||||
|
{
|
||||||
|
$justSubmitted = session('justSubmittedReview');
|
||||||
|
|
||||||
|
if (! $justSubmitted || (string) ($justSubmitted['product_id'] ?? null) !== (string) $product['id']) {
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $product['reviews']['items'] ?? [];
|
||||||
|
|
||||||
|
if (collect($items)->contains('id', $justSubmitted['id'])) {
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = [$justSubmitted, ...$items];
|
||||||
|
|
||||||
|
$product['reviews']['items'] = $items;
|
||||||
|
$product['reviews']['count'] = count($items);
|
||||||
|
$product['reviews']['average_rating'] = round(collect($items)->avg('rating'), 1);
|
||||||
|
|
||||||
|
return $product;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A storefront-owned, checkout-module-independent stock check — the
|
||||||
|
* product page's "Add to cart" calls this first and only submits to the
|
||||||
|
* checkout module's own add-to-cart endpoint once this says `ok`. Reads
|
||||||
|
* the live Eloquent ProductVariant directly (not the Meilisearch index
|
||||||
|
* ProductService otherwise reads from, which can lag behind an actual
|
||||||
|
* sale until the next reindex) via the SAME method Lunar's own
|
||||||
|
* CartLineStock validator calls, so this can never disagree with what
|
||||||
|
* the module's own server-side check would decide.
|
||||||
|
*/
|
||||||
|
public function checkStock(string $locale, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'variant' => ['required', 'integer'],
|
||||||
|
'quantity' => ['nullable', 'integer', 'min:1'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$variant = ProductVariant::find($data['variant']);
|
||||||
|
$quantity = $data['quantity'] ?? 1;
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => $variant->canBeFulfilledAtQuantity($quantity),
|
||||||
|
'stock' => $variant->purchasable === 'always' ? null : $variant->getTotalInventory(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* boboko/core's product_reviews table has no moderation/status column, so
|
||||||
|
* this goes live immediately — no approval queue to land in.
|
||||||
|
*/
|
||||||
|
public function storeReview(string $locale, Request $request, Product $product): RedirectResponse
|
||||||
|
{
|
||||||
|
$reviewsUrl = route('product.show', [
|
||||||
|
'locale' => $locale,
|
||||||
|
'id' => $product->id,
|
||||||
|
'tab' => 'reviews',
|
||||||
|
]).'#product-tabs';
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'rating' => ['required', 'integer', 'between:1,5'],
|
||||||
|
'content' => ['required', 'string'],
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return redirect($reviewsUrl)->withErrors($validator)->withInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $validator->validated();
|
||||||
|
|
||||||
|
// Not $product->reviews()->create(...): that relation only exists via a
|
||||||
|
// Product::macro() registered in CorePlugin::register(Panel $panel), which
|
||||||
|
// Filament calls solely when the /boboko admin panel boots — never on a
|
||||||
|
// plain storefront request, where the macro is simply undefined.
|
||||||
|
$review = ProductReview::create([
|
||||||
|
'product_id' => $product->id,
|
||||||
|
'rating' => $data['rating'],
|
||||||
|
'body' => $data['content'],
|
||||||
|
'reviewer_name' => $data['name'] ?? null,
|
||||||
|
'reviewer_email' => $data['email'],
|
||||||
|
'reviewed_at' => now(),
|
||||||
|
'source' => 'storefront',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ReviewServiceProvider also reindexes on the model's `created` event, but
|
||||||
|
// queued (SCOUT_QUEUE=true) — it wouldn't land before this redirect's page
|
||||||
|
// load. Syncing here skips our queue too, but Meilisearch's own write API
|
||||||
|
// is itself async on top of that (see mergeJustSubmittedReview()), so this
|
||||||
|
// alone still isn't a guarantee — it's the flash below that actually is.
|
||||||
|
$product->searchableSync();
|
||||||
|
|
||||||
|
return redirect($reviewsUrl)
|
||||||
|
->with('reviewSubmitted', true)
|
||||||
|
->with('justSubmittedReview', [
|
||||||
|
'id' => $review->id,
|
||||||
|
'product_id' => $review->product_id,
|
||||||
|
'title' => $review->title,
|
||||||
|
'body' => $review->body,
|
||||||
|
'rating' => $review->rating,
|
||||||
|
'reviewed_at' => $review->reviewed_at?->timestamp,
|
||||||
|
'reviewer_name' => $review->reviewer_name,
|
||||||
|
'reply' => null,
|
||||||
|
'replied_at' => null,
|
||||||
|
'media' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\ProductListing;
|
||||||
|
use App\Catalog\ProductListingPage;
|
||||||
|
|
||||||
|
class SearchController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProductListingPage $listingPage) {}
|
||||||
|
|
||||||
|
public function show(string $locale)
|
||||||
|
{
|
||||||
|
$query = trim((string) request()->query('q', ''));
|
||||||
|
|
||||||
|
if ($query === '') {
|
||||||
|
return redirect()->route('products');
|
||||||
|
}
|
||||||
|
|
||||||
|
$listing = ProductListing::fromRequest(request());
|
||||||
|
|
||||||
|
$data = $this->listingPage->build(
|
||||||
|
$listing,
|
||||||
|
fn (array $overrides) => route('search', ['q' => $query] + $overrides),
|
||||||
|
collectionId: null,
|
||||||
|
query: $query,
|
||||||
|
);
|
||||||
|
|
||||||
|
return view('search.index', [...$data, 'query' => $query]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Catalog\ProductCard;
|
||||||
|
use App\Services\Wishlist;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Lunar\Models\Product;
|
||||||
|
use Modules\Core\Catalog\Services\ProductService;
|
||||||
|
|
||||||
|
class WishlistController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Wishlist $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)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds or removes a product, for guests and logged-in shoppers alike. The
|
||||||
|
* heart button's Stimulus controller asks for JSON; without JS the form
|
||||||
|
* posts normally and comes back to the same page.
|
||||||
|
*/
|
||||||
|
public function toggle(string $locale, Request $request, int $productId): JsonResponse|RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless(Product::whereKey($productId)->exists(), 404);
|
||||||
|
|
||||||
|
$active = $this->wishlist->toggle($productId);
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['active' => $active]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Product cards for the current wishlist, newest first. Products no longer
|
||||||
|
* 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,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Services\Wishlist;
|
||||||
|
use Modules\Core\Auth\Events\UserAuthenticated;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picked up by Laravel's listener discovery (app/Listeners), no manual
|
||||||
|
* registration. Runs inside the login request, so it can read the guest
|
||||||
|
* wishlist cookie and queue its removal.
|
||||||
|
*/
|
||||||
|
class MergeGuestWishlistOnLogin
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly Wishlist $wishlist,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(UserAuthenticated $event): void
|
||||||
|
{
|
||||||
|
$this->wishlist->mergeGuestInto($event->user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use Lunar\Models\Order;
|
||||||
|
use Modules\Core\Checkout\Events\OrderPlaced;
|
||||||
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a logged-in shopper places an order and their account has no saved
|
||||||
|
* address yet, the order's shipping address (and their name, if the profile
|
||||||
|
* has none) becomes the account's. Never overwrites anything already saved.
|
||||||
|
*
|
||||||
|
* Uses the order's own user, not Auth: OrderPlaced can fire from a payment
|
||||||
|
* webhook, where nobody is logged in. Picked up by listener discovery.
|
||||||
|
*/
|
||||||
|
class SaveAddressFromFirstOrder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CustomerAccountService $account,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(OrderPlaced $event): void
|
||||||
|
{
|
||||||
|
$order = $event->order;
|
||||||
|
$user = $order->user;
|
||||||
|
$shipping = $order->shippingAddress;
|
||||||
|
|
||||||
|
if (! $user || ! $shipping || ! $shipping->line_one) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = $this->account->customer($user);
|
||||||
|
|
||||||
|
if (! $customer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $customer->first_name && ! $customer->last_name) {
|
||||||
|
$this->account->updateProfile($user, [
|
||||||
|
'first_name' => $shipping->first_name,
|
||||||
|
'last_name' => $shipping->last_name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collect($this->account->addresses($user))->isNotEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->account->createAddress($user, [
|
||||||
|
...$shipping->only(['first_name', 'last_name', 'line_one', 'city', 'state', 'postcode', 'country_id', 'contact_phone']),
|
||||||
|
'contact_email' => $user->email,
|
||||||
|
'shipping_default' => true,
|
||||||
|
'billing_default' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sent to whoever submitted the contact form. Deliberately generic: it never
|
||||||
|
* repeats what they wrote, so the form can't be used to deliver arbitrary text
|
||||||
|
* to someone else's inbox.
|
||||||
|
*/
|
||||||
|
class ContactConfirmationMail extends Mailable
|
||||||
|
{
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(subject: __('storefront.contact.confirmation_subject'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'emails.contact-confirmation');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Address;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A contact-form message, sent to the store's CONTACT_EMAIL. Reply-To is the
|
||||||
|
* sender, so replying from the inbox goes straight to them.
|
||||||
|
*/
|
||||||
|
class ContactMessageMail extends Mailable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $senderName,
|
||||||
|
public readonly string $senderEmail,
|
||||||
|
public readonly string $body,
|
||||||
|
public readonly string $senderLocale,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
replyTo: [new Address($this->senderEmail, $this->senderName)],
|
||||||
|
subject: "Νέο μήνυμα επικοινωνίας από {$this->senderName}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(view: 'emails.contact-message');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,6 @@ class User extends Authenticatable implements LunarUserInterface
|
|||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
protected $dispatchesEvents = [
|
|
||||||
'created' => UserCreated::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One product on a logged-in user's wishlist. Guests' wishlists live in a
|
||||||
|
* cookie instead — see App\Services\Wishlist.
|
||||||
|
*/
|
||||||
|
class WishlistItem extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['user_id', 'product_id'];
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@
|
|||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
// One instance per request: it caches the guest cookie's ids, so a
|
||||||
|
// toggle and a later has() in the same request agree.
|
||||||
|
$this->app->scoped(\App\Services\Wishlist::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Telemetry::optOut();
|
Telemetry::optOut();
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\View\View as ViewInstance;
|
||||||
|
use Modules\Core\Cart\Services\CartService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The seam for the (currently in-repo) cart + checkout module.
|
||||||
|
*
|
||||||
|
* Everything the module needs to boot inside a host app lives here: its view /
|
||||||
|
* component namespaces, its routes, and the composer that feeds the
|
||||||
|
* always-present cart drawer. Strings (__('checkout.cart.*')) are NOT wired up
|
||||||
|
* here — same as storefront.* elsewhere in this app, they resolve through
|
||||||
|
* Lunar's DB-backed translation UI (spatie/laravel-translation-loader), added
|
||||||
|
* manually there rather than shipped as lang/ files. The module's own files
|
||||||
|
* (resources/views/checkout/**, resources/js/checkout/**, resources/css/checkout.css,
|
||||||
|
* routes/checkout.php, app/Http/Controllers/Checkout/**) contain nothing
|
||||||
|
* 3dealer-specific.
|
||||||
|
*
|
||||||
|
* When the module is extracted to boboko-core, this class is deleted and its
|
||||||
|
* body becomes the package's own ServiceProvider. The only other host wiring
|
||||||
|
* is the one registerCheckout() call in resources/js/app.js and the two lines
|
||||||
|
* in the layout (the checkout.css @vite entry and @include('checkout::drawer')).
|
||||||
|
*/
|
||||||
|
class CheckoutModuleServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->loadViewsFrom(resource_path('views/checkout'), 'checkout');
|
||||||
|
Blade::anonymousComponentNamespace('checkout::components', 'checkout');
|
||||||
|
|
||||||
|
Route::middleware('web')->group(base_path('routes/checkout.php'));
|
||||||
|
|
||||||
|
// The drawer is rendered on every page (from the layout) and its body
|
||||||
|
// partial is re-rendered on every cart mutation — both need the current
|
||||||
|
// cart without a controller in the loop.
|
||||||
|
View::composer(
|
||||||
|
['checkout::drawer', 'checkout::partials.cart-body'],
|
||||||
|
function (ViewInstance $view) {
|
||||||
|
$service = app(CartService::class);
|
||||||
|
$cart = $service->current();
|
||||||
|
|
||||||
|
$view->with('cart', $cart);
|
||||||
|
$view->with('lines', $cart ? $service->activeLines($cart) : collect());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\WishlistItem;
|
||||||
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Cookie;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current shopper's wishlist, product ids only.
|
||||||
|
*
|
||||||
|
* Logged in: rows in wishlist_items. Guest: a 1-year cookie holding the ids
|
||||||
|
* (encrypted like every cookie, by the web group's EncryptCookies), so nothing
|
||||||
|
* is written to the database for anonymous visitors. On login the cookie is
|
||||||
|
* merged into the account and cleared (MergeGuestWishlistOnLogin).
|
||||||
|
*/
|
||||||
|
class Wishlist
|
||||||
|
{
|
||||||
|
public const COOKIE = 'wishlist';
|
||||||
|
|
||||||
|
private const COOKIE_MINUTES = 60 * 24 * 365;
|
||||||
|
|
||||||
|
// Keeps the cookie well under the 4KB browser limit.
|
||||||
|
private const GUEST_MAX = 100;
|
||||||
|
|
||||||
|
/** @var array<int>|null ids for this request, including a toggle just made */
|
||||||
|
private ?array $guestIds = null;
|
||||||
|
|
||||||
|
/** @return array<int> newest first */
|
||||||
|
public function ids(): array
|
||||||
|
{
|
||||||
|
if ($user = Auth::user()) {
|
||||||
|
return WishlistItem::where('user_id', $user->id)
|
||||||
|
->latest('id')
|
||||||
|
->pluck('product_id')
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->guestIds();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function has(int $productId): bool
|
||||||
|
{
|
||||||
|
return in_array($productId, $this->ids(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool whether the product is on the wishlist afterwards
|
||||||
|
*/
|
||||||
|
public function toggle(int $productId): bool
|
||||||
|
{
|
||||||
|
if ($user = Auth::user()) {
|
||||||
|
$deleted = WishlistItem::where('user_id', $user->id)->where('product_id', $productId)->delete();
|
||||||
|
|
||||||
|
if ($deleted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WishlistItem::create(['user_id' => $user->id, 'product_id' => $productId]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = $this->guestIds();
|
||||||
|
|
||||||
|
if (in_array($productId, $ids, true)) {
|
||||||
|
$this->storeGuestIds(array_values(array_diff($ids, [$productId])));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->storeGuestIds(array_slice([$productId, ...$ids], 0, self::GUEST_MAX));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(int $productId): void
|
||||||
|
{
|
||||||
|
if ($this->has($productId)) {
|
||||||
|
$this->toggle($productId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the guest cookie's products onto $user's wishlist and clears it.
|
||||||
|
*/
|
||||||
|
public function mergeGuestInto(Authenticatable $user): void
|
||||||
|
{
|
||||||
|
$ids = $this->guestIds();
|
||||||
|
|
||||||
|
if ($ids === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest first, so the newest cookie item also ends up newest here.
|
||||||
|
foreach (array_reverse($ids) as $productId) {
|
||||||
|
WishlistItem::firstOrCreate(['user_id' => $user->id, 'product_id' => $productId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->guestIds = [];
|
||||||
|
Cookie::queue(Cookie::forget(self::COOKIE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int> */
|
||||||
|
private function guestIds(): array
|
||||||
|
{
|
||||||
|
if ($this->guestIds !== null) {
|
||||||
|
return $this->guestIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = json_decode((string) request()->cookie(self::COOKIE), true);
|
||||||
|
|
||||||
|
return $this->guestIds = is_array($decoded)
|
||||||
|
? array_values(array_unique(array_filter(array_map('intval', $decoded))))
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int> $ids */
|
||||||
|
private function storeGuestIds(array $ids): void
|
||||||
|
{
|
||||||
|
$this->guestIds = $ids;
|
||||||
|
|
||||||
|
Cookie::queue(self::COOKIE, json_encode($ids), self::COOKIE_MINUTES);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,18 @@
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
// Laravel's priority list would otherwise run `auth` before core's
|
||||||
|
// `locale` middleware, so the redirects below would build URLs before
|
||||||
|
// URL::defaults(['locale' => …]) is set, throwing a missing-parameter error.
|
||||||
|
$middleware->prependToPriorityList(
|
||||||
|
before: \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
|
||||||
|
prepend: \Modules\Core\Localization\Middleware\LocaleMiddleware::class,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both resolve inside the {locale} group, after the `locale` middleware
|
||||||
|
// has set URL::defaults(['locale' => …]), so route() needs no locale arg.
|
||||||
|
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||||
|
$middleware->redirectUsersTo(fn () => route('home'));
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Providers\AppServiceProvider;
|
use App\Providers\AppServiceProvider;
|
||||||
|
use App\Providers\CheckoutModuleServiceProvider;
|
||||||
use App\Providers\PanelServiceProvider;
|
use App\Providers\PanelServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
PanelServiceProvider::class,
|
PanelServiceProvider::class,
|
||||||
|
CheckoutModuleServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Per-site settings for the cart + checkout module (see
|
||||||
|
* App\Providers\CheckoutModuleServiceProvider). Becomes the package's
|
||||||
|
* publishable config when the module moves to boboko-core.
|
||||||
|
*/
|
||||||
|
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 (3dealer's
|
||||||
|
* Auth\LoginController does). null: no login offered in checkout at all.
|
||||||
|
*/
|
||||||
|
'login_route' => 'login',
|
||||||
|
|
||||||
|
];
|
||||||
@@ -58,11 +58,23 @@
|
|||||||
| make sense for the store you're building.
|
| make sense for the store you're building.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'draft_status' => 'awaiting-payment',
|
'draft_status' => 'awaiting_payment',
|
||||||
|
|
||||||
|
// Two sequences — carrier and store-pickup — see Modules\Core\Order\
|
||||||
|
// Services\OrderStatusFlow, the single source of truth for which
|
||||||
|
// transitions are actually offered from a given status. Payment
|
||||||
|
// timing (prepaid vs. cash-on-delivery) does not affect this
|
||||||
|
// sequence at all — see Order::paid/paid_at instead, an entirely
|
||||||
|
// independent field with no status value of its own.
|
||||||
|
//
|
||||||
|
// 'favourite' controls whether a status gets its own tab in the order
|
||||||
|
// list topbar (Lunar\Admin\...\ListOrders::getDefaultTabs()) — set
|
||||||
|
// only on the main-journey statuses so the topbar isn't cluttered
|
||||||
|
// with every branch/exception value; every status (favourite or not)
|
||||||
|
// remains reachable via the table's own status filter/search.
|
||||||
'statuses' => [
|
'statuses' => [
|
||||||
|
|
||||||
'awaiting-payment' => [
|
'awaiting_payment' => [
|
||||||
'label' => 'Awaiting Payment',
|
'label' => 'Awaiting Payment',
|
||||||
'color' => '#848a8c',
|
'color' => '#848a8c',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
@@ -70,17 +82,25 @@
|
|||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
'payment-offline' => [
|
'processing' => [
|
||||||
'label' => 'Payment Offline',
|
'label' => 'Processing',
|
||||||
'color' => '#0A81D7',
|
'color' => '#0A81D7',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
'notifications' => [],
|
'notifications' => [],
|
||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
'payment-received' => [
|
'ready_for_dispatch' => [
|
||||||
'label' => 'Payment Received',
|
'label' => 'Ready for Dispatch',
|
||||||
'color' => '#6a67ce',
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'ready_for_pickup' => [
|
||||||
|
'label' => 'Ready for Pickup',
|
||||||
|
'color' => '#c98a2e',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
'notifications' => [],
|
'notifications' => [],
|
||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
@@ -88,11 +108,78 @@
|
|||||||
|
|
||||||
'dispatched' => [
|
'dispatched' => [
|
||||||
'label' => 'Dispatched',
|
'label' => 'Dispatched',
|
||||||
|
'color' => '#0A81D7',
|
||||||
'mailers' => [],
|
'mailers' => [],
|
||||||
'notifications' => [],
|
'notifications' => [],
|
||||||
'favourite' => true,
|
'favourite' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'delivery_failed' => [
|
||||||
|
'label' => 'Delivery Failed',
|
||||||
|
'color' => '#c0392b',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'picked_up' => [
|
||||||
|
'label' => 'Picked Up',
|
||||||
|
'color' => '#3f7a5c',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// The parcel arriving and the return window opening are the same
|
||||||
|
// moment — one status value covers both.
|
||||||
|
'delivered' => [
|
||||||
|
'label' => 'Delivered',
|
||||||
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'completed' => [
|
||||||
|
'label' => 'Completed',
|
||||||
|
'color' => '#3f7a5c',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'return_requested' => [
|
||||||
|
'label' => 'Return Requested',
|
||||||
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'returned' => [
|
||||||
|
'label' => 'Returned',
|
||||||
|
'color' => '#c0392b',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'partially_refunded' => [
|
||||||
|
'label' => 'Partially Refunded',
|
||||||
|
'color' => '#c98a2e',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'refunded' => [
|
||||||
|
'label' => 'Refunded',
|
||||||
|
'color' => '#c0392b',
|
||||||
|
'mailers' => [],
|
||||||
|
'notifications' => [],
|
||||||
|
'favourite' => false,
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -46,6 +46,6 @@
|
|||||||
| to include in the count below.
|
| to include in the count below.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'order_count_statuses' => ['payment-received'],
|
'order_count_statuses' => ['processing'],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,7 +20,17 @@
|
|||||||
Lunar\Models\Collection::class,
|
Lunar\Models\Collection::class,
|
||||||
Lunar\Models\Customer::class,
|
Lunar\Models\Customer::class,
|
||||||
Lunar\Models\Order::class,
|
Lunar\Models\Order::class,
|
||||||
Lunar\Models\Product::class,
|
// Modules\Core\Catalog\Models\Product, not Lunar\Models\Product —
|
||||||
|
// both share the same underlying Meilisearch index name, so
|
||||||
|
// listing the base class here too would make every reindex
|
||||||
|
// (this default list is always merged in, even when a specific
|
||||||
|
// model is passed on the CLI — see Lunar\Console\Commands\
|
||||||
|
// ScoutIndexerCommand::handle()) run the base class's indexing a
|
||||||
|
// second time right after the subclass's, silently overwriting
|
||||||
|
// every document with one missing custom_fields/order_count
|
||||||
|
// (the whole reason Modules\Core\Catalog\Models\Product exists —
|
||||||
|
// see its own docblock). Caught in practice.
|
||||||
|
Modules\Core\Catalog\Models\Product::class,
|
||||||
Lunar\Models\ProductOption::class,
|
Lunar\Models\ProductOption::class,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -49,6 +59,20 @@
|
|||||||
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
|
Lunar\Models\Collection::class => Modules\Core\Catalog\Services\CollectionIndexer::class,
|
||||||
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
|
Lunar\Models\Customer::class => Lunar\Search\CustomerIndexer::class,
|
||||||
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
|
Lunar\Models\Order::class => Lunar\Search\OrderIndexer::class,
|
||||||
|
// Lunar\Models\Product::class, NOT Modules\Core\Catalog\Models\
|
||||||
|
// Product::class — Lunar\Base\Traits\Searchable::indexer() (and
|
||||||
|
// getFilterableAttributes()/getSortableAttributes(), same trait)
|
||||||
|
// reads `$config[self::class]`, and `self::class` inside a TRAIT
|
||||||
|
// METHOD is a compile-time literal bound to whichever class first
|
||||||
|
// `use`s the trait — Lunar\Models\Product, since the subclass
|
||||||
|
// never re-declares indexer() itself — regardless of which
|
||||||
|
// instance actually calls the method at runtime. Keying this by
|
||||||
|
// the subclass here made the lookup miss entirely, silently
|
||||||
|
// falling back to Lunar\Search\ScoutIndexer's own near-empty
|
||||||
|
// filterable/sortable field list — confirmed live: it wiped every
|
||||||
|
// real filterable/sortable attribute the index had (including
|
||||||
|
// ones that already worked, like collection_ids), not just the
|
||||||
|
// new order_count one. Caught in practice, reverted.
|
||||||
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
|
Lunar\Models\Product::class => Modules\Core\Catalog\Services\ProductIndexer::class,
|
||||||
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
|
Lunar\Models\ProductOption::class => Lunar\Search\ProductOptionIndexer::class,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -35,9 +35,27 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Where contact-form messages are sent (ContactController).
|
||||||
|
'contact' => [
|
||||||
|
'email' => env('CONTACT_EMAIL'),
|
||||||
|
],
|
||||||
|
|
||||||
'stoic' => [
|
'stoic' => [
|
||||||
'sso_secret' => env('STOIC_SSO_SECRET'),
|
'sso_secret' => env('STOIC_SSO_SECRET'),
|
||||||
'host' => env('STOIC_HOST'),
|
'host' => env('STOIC_HOST'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Keys read by lunarphp/stripe + Modules\Core\Payment\Drivers\StripePaymentDriver.
|
||||||
|
// `key` is the SECRET key (this ecosystem's convention — StripeManager calls
|
||||||
|
// Stripe::setApiKey(config('services.stripe.key'))); `public_key` is the
|
||||||
|
// publishable key for Stripe.js; `webhooks.lunar` is the signing secret the
|
||||||
|
// webhook route verifies against.
|
||||||
|
'stripe' => [
|
||||||
|
'key' => env('STRIPE_SECRET'),
|
||||||
|
'public_key' => env('STRIPE_PUBLIC_KEY'),
|
||||||
|
'webhooks' => [
|
||||||
|
'lunar' => env('STRIPE_WEBHOOK_SECRET'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('wishlist_items', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->foreignId('product_id')->constrained('lunar_products')->cascadeOnDelete();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'product_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('wishlist_items');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Modules\Core\Localization\Services\TranslationService;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default `checkout` translation lines for the cart drawer and the checkout
|
||||||
|
* page (see the checkout module under resources/views/checkout).
|
||||||
|
*
|
||||||
|
* Additive and idempotent: a group/key that already exists is left untouched,
|
||||||
|
* so anything edited in the Filament Language Lines UI wins on a re-run. Runs
|
||||||
|
* explicitly — `php artisan db:seed --class=CheckoutTranslationsSeeder` — it is
|
||||||
|
* not wired into DatabaseSeeder.
|
||||||
|
*
|
||||||
|
* Greek copy uses the project's informal register (εσύ/σου). When the checkout
|
||||||
|
* module moves to boboko-core this becomes the module's own default-strings
|
||||||
|
* seeder.
|
||||||
|
*/
|
||||||
|
class CheckoutTranslationsSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$translations = app(TranslationService::class);
|
||||||
|
|
||||||
|
foreach ($this->lines() as $key => [$en, $el]) {
|
||||||
|
$exists = LanguageLine::query()
|
||||||
|
->where('group', 'checkout')
|
||||||
|
->where('key', $key)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->command?->warn("checkout.{$key} already exists — skipped");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$translations->create('checkout', $key, ['en' => $en, 'el' => $el]);
|
||||||
|
$this->command?->info("checkout.{$key} added");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* key => [English, Greek].
|
||||||
|
*
|
||||||
|
* @return array<string, array{0: string, 1: string}>
|
||||||
|
*/
|
||||||
|
private function lines(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// ── Cart drawer + order summary ──────────────────────────────
|
||||||
|
'cart.title' => ['Your cart', 'Το καλάθι σου'],
|
||||||
|
'cart.close' => ['Close', 'Κλείσιμο'],
|
||||||
|
'cart.empty' => ['Your cart is empty', 'Το καλάθι σου είναι άδειο'],
|
||||||
|
'cart.quantity' => ['Quantity', 'Ποσότητα'],
|
||||||
|
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
|
||||||
|
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
|
||||||
|
'cart.remove' => ['Remove', 'Αφαίρεση'],
|
||||||
|
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
|
||||||
|
'cart.discount' => ['Discount', 'Έκπτωση'],
|
||||||
|
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
|
||||||
|
'cart.shipping_pending' => ['Not selected yet', 'Δεν έχει επιλεγεί ακόμη'],
|
||||||
|
'cart.tax' => ['VAT', 'ΦΠΑ'],
|
||||||
|
'cart.total' => ['Total', 'Σύνολο'],
|
||||||
|
'cart.checkout' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
|
||||||
|
'cart.coupon_label' => ['Coupon code', 'Κωδικός κουπονιού'],
|
||||||
|
'cart.coupon_placeholder' => ['Coupon code', 'Κωδικός κουπονιού'],
|
||||||
|
'cart.coupon_apply' => ['Apply', 'Εφαρμογή'],
|
||||||
|
'cart.coupon_remove' => ['Remove', 'Αφαίρεση'],
|
||||||
|
'cart.coupon_invalid' => ["That coupon code isn't valid", 'Ο κωδικός κουπονιού δεν είναι έγκυρος'],
|
||||||
|
|
||||||
|
// ── Checkout page ────────────────────────────────────────────
|
||||||
|
'page.title' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
|
||||||
|
'page.contact_heading' => ['Contact', 'Στοιχεία επικοινωνίας'],
|
||||||
|
'page.guest_tab' => ['Guest', 'Ως επισκέπτης'],
|
||||||
|
'page.login_tab' => ['Log in', 'Σύνδεση'],
|
||||||
|
'page.email_label' => ['Email', 'Email'],
|
||||||
|
'page.recovery_consent' => [
|
||||||
|
"Email me a reminder if I don't finish my order",
|
||||||
|
'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου',
|
||||||
|
],
|
||||||
|
'page.login_email_label' => ['Email', 'Email'],
|
||||||
|
'page.send_code' => ['Send code', 'Αποστολή κωδικού'],
|
||||||
|
'page.login_coming_soon' => [
|
||||||
|
'Login is coming soon — continue as a guest for now.',
|
||||||
|
'Η σύνδεση θα είναι διαθέσιμη σύντομα — προς το παρόν συνέχισε ως επισκέπτης.',
|
||||||
|
],
|
||||||
|
'page.billing_heading' => ['Billing information', 'Στοιχεία τιμολόγησης'],
|
||||||
|
'page.shipping_heading' => ['Shipping information', 'Στοιχεία αποστολής'],
|
||||||
|
'page.same_as_billing' => ['Same as billing address', 'Ίδια με τη διεύθυνση τιμολόγησης'],
|
||||||
|
'page.first_name' => ['First name', 'Όνομα'],
|
||||||
|
'page.last_name' => ['Last name', 'Επώνυμο'],
|
||||||
|
'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'],
|
||||||
|
'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'],
|
||||||
|
'page.address_line_one' => ['Address', 'Διεύθυνση'],
|
||||||
|
'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'],
|
||||||
|
'page.city' => ['City', 'Πόλη'],
|
||||||
|
'page.state' => ['Region / Prefecture', 'Νομός / Περιοχή'],
|
||||||
|
'page.state_placeholder' => ['Select a region', 'Επίλεξε νομό'],
|
||||||
|
'page.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'],
|
||||||
|
'page.country' => ['Country', 'Χώρα'],
|
||||||
|
'page.country_placeholder' => ['Select a country', 'Επίλεξε χώρα'],
|
||||||
|
'page.phone' => ['Phone', 'Τηλέφωνο'],
|
||||||
|
'page.delivery_instructions' => ['Delivery notes', 'Σχόλια για την παράδοση'],
|
||||||
|
'page.save_address' => ['Save and continue', 'Αποθήκευση και συνέχεια'],
|
||||||
|
'page.saving' => ['Saving…', 'Αποθήκευση…'],
|
||||||
|
'page.saved' => ['Saved', 'Αποθηκεύτηκε'],
|
||||||
|
'page.save_error' => ["Couldn't save — check your connection", 'Δεν αποθηκεύτηκε — έλεγξε τη σύνδεσή σου'],
|
||||||
|
'page.shipping_method_heading' => ['Shipping method', 'Τρόπος αποστολής'],
|
||||||
|
'page.shipping_method_empty' => [
|
||||||
|
'Add your shipping address to see delivery options.',
|
||||||
|
'Συμπλήρωσε τη διεύθυνση αποστολής για να δεις τις διαθέσιμες επιλογές.',
|
||||||
|
],
|
||||||
|
'page.shipping_method_none' => [
|
||||||
|
'No delivery options are available for this address.',
|
||||||
|
'Δεν υπάρχουν διαθέσιμες επιλογές αποστολής για αυτή τη διεύθυνση.',
|
||||||
|
],
|
||||||
|
'page.select_shipping_method' => ['Continue', 'Συνέχεια'],
|
||||||
|
'page.shipping_option_invalid' => [
|
||||||
|
'That shipping option is no longer available.',
|
||||||
|
'Αυτός ο τρόπος αποστολής δεν είναι πλέον διαθέσιμος.',
|
||||||
|
],
|
||||||
|
'page.continue_to_payment' => ['Continue to payment', 'Συνέχεια στην πληρωμή'],
|
||||||
|
'page.order_summary_heading' => ['Order summary', 'Σύνοψη παραγγελίας'],
|
||||||
|
|
||||||
|
// ── Payment step ────────────────────────────────────────────
|
||||||
|
'page.payment_heading' => ['Payment', 'Πληρωμή'],
|
||||||
|
'page.payment_method_none' => [
|
||||||
|
'No payment methods are available right now.',
|
||||||
|
'Δεν υπάρχουν διαθέσιμοι τρόποι πληρωμής αυτή τη στιγμή.',
|
||||||
|
],
|
||||||
|
'page.terms_accept' => [
|
||||||
|
"I accept the <a href=':terms' target='_blank'>Terms of Sale</a> and the <a href=':privacy' target='_blank'>Privacy Policy</a>",
|
||||||
|
"Αποδέχομαι τους <a href=':terms' target='_blank'>Όρους Πώλησης</a> και την <a href=':privacy' target='_blank'>Πολιτική Απορρήτου</a>",
|
||||||
|
],
|
||||||
|
'page.terms_required' => [
|
||||||
|
'You must accept the terms to place your order.',
|
||||||
|
'Πρέπει να αποδεχτείς τους όρους για να ολοκληρώσεις την παραγγελία.',
|
||||||
|
],
|
||||||
|
'page.withdrawal_notice' => [
|
||||||
|
"You have a 14-day right of withdrawal. <a href=':link' target='_blank'>See details</a>.",
|
||||||
|
"Έχεις δικαίωμα υπαναχώρησης εντός 14 ημερών. <a href=':link' target='_blank'>Δες λεπτομέρειες</a>.",
|
||||||
|
],
|
||||||
|
'page.place_order' => ['Place order — payment obligation', 'Παραγγελία με υποχρέωση πληρωμής'],
|
||||||
|
'page.choose_payment_method' => ['Choose a payment method.', 'Επίλεξε τρόπο πληρωμής.'],
|
||||||
|
'page.shipping_method_required' => [
|
||||||
|
'Choose a shipping method below to continue.',
|
||||||
|
'Επίλεξε τρόπο αποστολής παρακάτω για να συνεχίσεις.',
|
||||||
|
],
|
||||||
|
'page.payment_failed' => ['Payment failed. Please try again.', 'Η πληρωμή απέτυχε. Δοκίμασε ξανά.'],
|
||||||
|
'page.payment_incomplete_details' => [
|
||||||
|
'Complete your billing and shipping details above.',
|
||||||
|
'Συμπλήρωσε τα στοιχεία χρέωσης και αποστολής παραπάνω.',
|
||||||
|
],
|
||||||
|
'page.payment_cart_changed' => [
|
||||||
|
'Your cart changed. Refresh the page and place your order again.',
|
||||||
|
'Το καλάθι σου άλλαξε. Ανανέωσε τη σελίδα και ολοκλήρωσε ξανά.',
|
||||||
|
],
|
||||||
|
'page.payment_processing' => ['Confirming your payment…', 'Επιβεβαίωση πληρωμής…'],
|
||||||
|
'page.payment_processing_slow' => [
|
||||||
|
"Your payment is still processing. You'll get an email once it's confirmed.",
|
||||||
|
'Η πληρωμή σου επεξεργάζεται ακόμη. Θα λάβεις email μόλις επιβεβαιωθεί.',
|
||||||
|
],
|
||||||
|
|
||||||
|
// ── Confirmation page ──────────────────────────────────────
|
||||||
|
'page.confirmation_title' => ['Your order', 'Η παραγγελία σου'],
|
||||||
|
'page.confirmation_heading' => [
|
||||||
|
'Thank you! Your order is confirmed.',
|
||||||
|
'Ευχαριστούμε! Η παραγγελία σου καταχωρήθηκε.',
|
||||||
|
],
|
||||||
|
'page.confirmation_order_number' => ['Order number', 'Αριθμός παραγγελίας'],
|
||||||
|
'page.confirmation_email_note' => [
|
||||||
|
'A confirmation email will follow shortly.',
|
||||||
|
'Θα λάβεις email επιβεβαίωσης σύντομα.',
|
||||||
|
],
|
||||||
|
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'],
|
||||||
|
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
|
||||||
|
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Modules\Core\Localization\Services\TranslationService;
|
||||||
|
use Spatie\TranslationLoader\LanguageLine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default `validation` translation lines — Laravel's own error-message group
|
||||||
|
* (`validation.required`, `validation.email`, `validation.attributes.*`, …),
|
||||||
|
* resolved by every `Validator::make()`/`$request->validate()` call in the app
|
||||||
|
* (see CheckoutController::saveAddress(), CartController, ProductController),
|
||||||
|
* not just the checkout module.
|
||||||
|
*
|
||||||
|
* Spatie's DB loader (spatie/laravel-translation-loader, wired in boboko-core's
|
||||||
|
* LocalizationServiceProvider) merges this group over Laravel's file-based
|
||||||
|
* validation.php, which the project doesn't ship a `lang/` copy of — so without
|
||||||
|
* this, Greek requests fall back to Laravel's untranslated English defaults.
|
||||||
|
* Only the rule keys and field attributes actually in use are seeded; add more
|
||||||
|
* as new rules/fields show up.
|
||||||
|
*
|
||||||
|
* Additive and idempotent: a key that already exists is left untouched, so
|
||||||
|
* anything edited in the Filament Language Lines UI wins on a re-run. Runs
|
||||||
|
* explicitly — `php artisan db:seed --class=ValidationTranslationsSeeder` — it
|
||||||
|
* is not wired into DatabaseSeeder.
|
||||||
|
*
|
||||||
|
* Greek copy uses the project's informal register (εσύ/σου).
|
||||||
|
*/
|
||||||
|
class ValidationTranslationsSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$translations = app(TranslationService::class);
|
||||||
|
|
||||||
|
foreach ($this->lines() as $key => [$en, $el]) {
|
||||||
|
$exists = LanguageLine::query()
|
||||||
|
->where('group', 'validation')
|
||||||
|
->where('key', $key)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$this->command?->warn("validation.{$key} already exists — skipped");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$translations->create('validation', $key, ['en' => $en, 'el' => $el]);
|
||||||
|
$this->command?->info("validation.{$key} added");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* key => [English, Greek].
|
||||||
|
*
|
||||||
|
* @return array<string, array{0: string, 1: string}>
|
||||||
|
*/
|
||||||
|
private function lines(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// ── Rule messages ─────────────────────────────────────────────
|
||||||
|
'required' => ['The :attribute field is required.', 'Το πεδίο :attribute είναι υποχρεωτικό.'],
|
||||||
|
'email' => ['The :attribute field must be a valid email address.', 'Το πεδίο :attribute πρέπει να είναι έγκυρη διεύθυνση email.'],
|
||||||
|
'string' => ['The :attribute field must be a string.', 'Το πεδίο :attribute πρέπει να είναι κείμενο.'],
|
||||||
|
'integer' => ['The :attribute field must be an integer.', 'Το πεδίο :attribute πρέπει να είναι ακέραιος αριθμός.'],
|
||||||
|
'boolean' => ['The :attribute field must be true or false.', 'Το πεδίο :attribute πρέπει να είναι true ή false.'],
|
||||||
|
'min.numeric' => ['The :attribute field must be at least :min.', 'Το πεδίο :attribute πρέπει να είναι τουλάχιστον :min.'],
|
||||||
|
'max.string' => ['The :attribute field must not be greater than :max characters.', 'Το πεδίο :attribute δεν πρέπει να ξεπερνά τους :max χαρακτήρες.'],
|
||||||
|
'between.numeric' => ['The :attribute field must be between :min and :max.', 'Το πεδίο :attribute πρέπει να είναι μεταξύ :min και :max.'],
|
||||||
|
'exists' => ['The selected :attribute is invalid.', 'Η επιλεγμένη τιμή για το πεδίο :attribute δεν είναι έγκυρη.'],
|
||||||
|
// Product custom-field photo uploads (CustomFieldUploadController, CartController).
|
||||||
|
'file' => ['The :attribute field must be a file.', 'Το πεδίο :attribute πρέπει να είναι αρχείο.'],
|
||||||
|
'mimes' => ['The :attribute field must be a file of type: :values.', 'Το πεδίο :attribute πρέπει να είναι αρχείο τύπου: :values.'],
|
||||||
|
'max.file' => ['The :attribute field must not be greater than :max kilobytes.', 'Το αρχείο στο πεδίο :attribute δεν πρέπει να ξεπερνά τα :max kilobytes.'],
|
||||||
|
'uploaded' => ['The :attribute failed to upload.', 'Η μεταφόρτωση στο πεδίο :attribute απέτυχε.'],
|
||||||
|
|
||||||
|
// ── Field names (checkout: billing/shipping address) ──────────
|
||||||
|
'attributes.contact_email' => ['email', 'email'],
|
||||||
|
'attributes.billing_first_name' => ['first name', 'όνομα'],
|
||||||
|
'attributes.billing_last_name' => ['last name', 'επώνυμο'],
|
||||||
|
'attributes.billing_company_name' => ['company name', 'επωνυμία εταιρείας'],
|
||||||
|
'attributes.billing_tax_identifier' => ['tax ID', 'ΑΦΜ'],
|
||||||
|
'attributes.billing_line_one' => ['address', 'διεύθυνση'],
|
||||||
|
'attributes.billing_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
|
||||||
|
'attributes.billing_city' => ['city', 'πόλη'],
|
||||||
|
'attributes.billing_state' => ['region', 'νομό / περιοχή'],
|
||||||
|
'attributes.billing_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
|
||||||
|
'attributes.billing_country_id' => ['country', 'χώρα'],
|
||||||
|
'attributes.billing_contact_phone' => ['phone', 'τηλέφωνο'],
|
||||||
|
'attributes.shipping_first_name' => ['first name', 'όνομα'],
|
||||||
|
'attributes.shipping_last_name' => ['last name', 'επώνυμο'],
|
||||||
|
'attributes.shipping_company_name' => ['company name', 'επωνυμία εταιρείας'],
|
||||||
|
'attributes.shipping_line_one' => ['address', 'διεύθυνση'],
|
||||||
|
'attributes.shipping_line_two' => ['address line 2', 'διεύθυνση (γραμμή 2)'],
|
||||||
|
'attributes.shipping_city' => ['city', 'πόλη'],
|
||||||
|
'attributes.shipping_state' => ['region', 'νομό / περιοχή'],
|
||||||
|
'attributes.shipping_postcode' => ['postcode', 'ταχυδρομικό κώδικα'],
|
||||||
|
'attributes.shipping_country_id' => ['country', 'χώρα'],
|
||||||
|
'attributes.shipping_contact_phone' => ['phone', 'τηλέφωνο'],
|
||||||
|
'attributes.shipping_delivery_instructions' => ['delivery notes', 'σχόλια για την παράδοση'],
|
||||||
|
|
||||||
|
// ── Field names (cart) ─────────────────────────────────────────
|
||||||
|
'attributes.purchasable_id' => ['product', 'προϊόν'],
|
||||||
|
'attributes.quantity' => ['quantity', 'ποσότητα'],
|
||||||
|
'attributes.code' => ['coupon code', 'κωδικό κουπονιού'],
|
||||||
|
|
||||||
|
// ── Field names (product reviews / stock check) ────────────────
|
||||||
|
'attributes.variant' => ['variant', 'παραλλαγή'],
|
||||||
|
'attributes.rating' => ['rating', 'βαθμολογία'],
|
||||||
|
'attributes.content' => ['review text', 'κείμενο κριτικής'],
|
||||||
|
'attributes.name' => ['name', 'όνομα'],
|
||||||
|
'attributes.email' => ['email', 'email'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -114,7 +114,7 @@ services:
|
|||||||
- "${VALKEY_PORT:-6339}:6379"
|
- "${VALKEY_PORT:-6339}:6379"
|
||||||
|
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:v1.10
|
image: getmeili/meilisearch:v1.12
|
||||||
ports:
|
ports:
|
||||||
- "${MEILISEARCH_PORT:-7700}:7700"
|
- "${MEILISEARCH_PORT:-7700}:7700"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ services:
|
|||||||
- valkeydata:/data
|
- valkeydata:/data
|
||||||
|
|
||||||
meilisearch:
|
meilisearch:
|
||||||
image: getmeili/meilisearch:v1.10
|
image: getmeili/meilisearch:v1.12
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
|
MEILI_MASTER_KEY: ${MEILISEARCH_KEY:?MEILISEARCH_KEY is required}
|
||||||
|
|||||||
@@ -5,17 +5,38 @@ set -e
|
|||||||
# by app only) — just wait for app's migration to finish, then start the process.
|
# by app only) — just wait for app's migration to finish, then start the process.
|
||||||
mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs storage/framework bootstrap/cache
|
mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs storage/framework bootstrap/cache
|
||||||
|
|
||||||
if [ "$APP_ENV" != "production" ]; then
|
# Same reasoning as entrypoint.sh: this container gets replaced on every deploy
|
||||||
echo "[entrypoint] Waiting for migrations to complete..."
|
# (dev and production alike), so this is a fresh boot clearing stale artifacts
|
||||||
timeout=60
|
# left on disk (cached config, compiled views), not a running process being
|
||||||
while [ ! -f storage/framework/migrated ] && [ "$timeout" -gt 0 ]; do
|
# told to forget in-memory code. queue:work/schedule:work themselves still
|
||||||
sleep 1
|
# can't pick up a later code change without an actual process restart — this
|
||||||
timeout=$((timeout - 1))
|
# only fixes what's stale on disk at boot.
|
||||||
done
|
echo "[entrypoint] Clearing cached config/routes/views..."
|
||||||
if [ ! -f storage/framework/migrated ]; then
|
php artisan optimize:clear --quiet
|
||||||
echo "[entrypoint] Timed out waiting for migrations" >&2
|
echo "[entrypoint] Caches cleared"
|
||||||
exit 1
|
|
||||||
fi
|
# Universal, in both dev and production — app's entrypoint always writes this
|
||||||
|
# marker after `migrate --force` completes (single app instance, so there's
|
||||||
|
# exactly one writer), and queue/scheduler must never start against a database
|
||||||
|
# schema that migration hasn't finished bringing up to date yet.
|
||||||
|
echo "[entrypoint] Waiting for migrations to complete..."
|
||||||
|
timeout=60
|
||||||
|
while [ ! -f storage/framework/migrated ] && [ "$timeout" -gt 0 ]; do
|
||||||
|
sleep 1
|
||||||
|
timeout=$((timeout - 1))
|
||||||
|
done
|
||||||
|
if [ ! -f storage/framework/migrated ]; then
|
||||||
|
echo "[entrypoint] Timed out waiting for migrations" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$APP_ENV" = "production" ]; then
|
||||||
|
# Same reasoning as entrypoint.sh's own production-only optimize step —
|
||||||
|
# queue:work/schedule:work read config on every job/tick too, so this
|
||||||
|
# avoids paying the same uncached-config cost app pays per request.
|
||||||
|
echo "[entrypoint] Caching config/routes/views for production..."
|
||||||
|
php artisan optimize --quiet
|
||||||
|
echo "[entrypoint] Production caches built"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ set -e
|
|||||||
# Only the app container runs setup; queue/scheduler use entrypoint-worker.sh instead
|
# Only the app container runs setup; queue/scheduler use entrypoint-worker.sh instead
|
||||||
# and just wait on the migrated marker this script writes below.
|
# and just wait on the migrated marker this script writes below.
|
||||||
if [ "$APP_ENV" != "production" ]; then
|
if [ "$APP_ENV" != "production" ]; then
|
||||||
|
# Dev-only: the app dir is bind-mounted from a fresh checkout, so there's no
|
||||||
|
# image-build step that already installed vendor/ or published assets — this
|
||||||
|
# container has to do it at boot instead. In production the Dockerfile already
|
||||||
|
# runs composer install and asset publishing at IMAGE BUILD time (see
|
||||||
|
# Dockerfile's `production` stage), so repeating them here would be wasteful,
|
||||||
|
# not just redundant-but-safe.
|
||||||
echo "[entrypoint] Installing Composer dependencies..."
|
echo "[entrypoint] Installing Composer dependencies..."
|
||||||
git config --global --add safe.directory /var/www/html 2>/dev/null || true
|
git config --global --add safe.directory /var/www/html 2>/dev/null || true
|
||||||
git config --global --add safe.directory /var/www/boboko-core 2>/dev/null || true
|
git config --global --add safe.directory /var/www/boboko-core 2>/dev/null || true
|
||||||
@@ -21,38 +27,80 @@ mkdir -p storage/app/public storage/framework/cache storage/framework/sessions s
|
|||||||
chown -R www-data:www-data storage bootstrap/cache
|
chown -R www-data:www-data storage bootstrap/cache
|
||||||
chmod -R 775 storage bootstrap/cache
|
chmod -R 775 storage bootstrap/cache
|
||||||
|
|
||||||
|
# Composer packages (dev) may have just changed above, or (production) this is a
|
||||||
|
# freshly built image — either way, clear any cached config/routes/compiled views
|
||||||
|
# left over from a previous boot before anything below reads them. Production
|
||||||
|
# runs a single app instance that gets replaced on every deploy, not a running
|
||||||
|
# process being told to forget in-memory code — the actual bug this fixes is
|
||||||
|
# stale artifacts still sitting in bootstrap/cache or storage/framework/views on
|
||||||
|
# a fresh boot (e.g. after the Lunar 1.5/Filament v4 upgrade, a leftover compiled
|
||||||
|
# view referenced a class that upgrade removed).
|
||||||
|
echo "[entrypoint] Clearing cached config/routes/views..."
|
||||||
|
php artisan optimize:clear --quiet
|
||||||
|
echo "[entrypoint] Caches cleared"
|
||||||
|
|
||||||
php artisan storage:link --quiet 2>/dev/null || true
|
php artisan storage:link --quiet 2>/dev/null || true
|
||||||
|
|
||||||
if [ "$APP_ENV" != "production" ]; then
|
if [ "$APP_ENV" != "production" ]; then
|
||||||
# The app dir is bind-mounted from a fresh checkout, so package assets (which the
|
# Dev-only for the same reason as the composer step above — production's
|
||||||
# Dockerfile publishes at build time in production) need to be generated here
|
# image already has these published at build time.
|
||||||
# instead. Cheap and idempotent, safe to repeat on every boot.
|
|
||||||
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
|
php artisan vendor:publish --tag=core-assets --force --ansi --quiet
|
||||||
php artisan vendor:publish --tag=public --force --ansi --quiet
|
php artisan vendor:publish --tag=public --force --ansi --quiet
|
||||||
php artisan filament:assets --ansi --quiet
|
php artisan filament:assets --ansi --quiet
|
||||||
|
|
||||||
echo "[entrypoint] Running migrations..."
|
# No --force: core-views publishes editable Blade templates (order
|
||||||
rm -f storage/framework/migrated
|
# notification emails), meant to be hand-customized per app — unlike
|
||||||
php artisan migrate --force
|
# core-assets above, republishing must not silently wipe local edits.
|
||||||
echo "[entrypoint] Touching migrated file"
|
# Only fills in the vendor/core views directory if it doesn't exist yet.
|
||||||
touch storage/framework/migrated
|
php artisan vendor:publish --tag=core-views --ansi --quiet
|
||||||
echo "[entrypoint] Touched migrated file"
|
fi
|
||||||
|
|
||||||
|
# Everything below is universal, in both dev and production: application STATE
|
||||||
|
# that must be current on every boot, not a build-time concern composer/assets
|
||||||
|
# are. Safe to run unconditionally on every boot because production runs a
|
||||||
|
# single app instance — no concurrent replicas that would race each other
|
||||||
|
# running `migrate --force` at the same time.
|
||||||
|
|
||||||
# boboko/core overrides lunar:install to skip the interactive prompts (migrate
|
echo "[entrypoint] Running migrations..."
|
||||||
# confirm, admin creation, GitHub star) and just seed the idempotent store
|
rm -f storage/framework/migrated
|
||||||
# defaults: countries, channel, language, currency, tax zone, attributes,
|
php artisan migrate --force
|
||||||
# product type. queue/scheduler wait on the marker above rather than running
|
echo "[entrypoint] Touching migrated file"
|
||||||
# this themselves, since the country import's check-then-insert isn't safe to
|
touch storage/framework/migrated
|
||||||
# run concurrently.
|
echo "[entrypoint] Touched migrated file"
|
||||||
echo "[entrypoint] Trying Lunar install"
|
|
||||||
php artisan lunar:install --quiet || true
|
|
||||||
|
|
||||||
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
|
# boboko/core overrides lunar:install to skip the interactive prompts (migrate
|
||||||
# every boot rather than flushing and rebuilding the whole index each time.
|
# confirm, admin creation, GitHub star) and just seed the idempotent store
|
||||||
echo "[entrypoint] Syncing search indexes..."
|
# defaults: countries, channel, language, currency, tax zone, attributes,
|
||||||
php artisan lunar:meilisearch:setup
|
# product type. queue/scheduler wait on the marker above rather than running
|
||||||
php artisan lunar:search:index --quiet || true
|
# this themselves, since the country import's check-then-insert isn't safe to
|
||||||
|
# run concurrently. A fresh production install needs this seeding the same as
|
||||||
|
# a fresh dev one does — and re-running it against an already-seeded store is
|
||||||
|
# a no-op per key (see InstallLunarCommand's idempotent upserts).
|
||||||
|
echo "[entrypoint] Trying Lunar install"
|
||||||
|
php artisan lunar:install --quiet || true
|
||||||
|
|
||||||
|
# Upserts by primary key (no --refresh), so this stays cheap and idempotent on
|
||||||
|
# every boot rather than flushing and rebuilding the whole index each time. Runs
|
||||||
|
# in production too — a deploy that changed an indexer's field list needs this
|
||||||
|
# to keep Meilisearch's index settings and documents in sync with the code that
|
||||||
|
# just shipped, same reasoning as optimize:clear above.
|
||||||
|
echo "[entrypoint] Syncing search indexes..."
|
||||||
|
php artisan lunar:meilisearch:setup
|
||||||
|
php artisan lunar:meilisearch:tune-product-search --quiet || true
|
||||||
|
php artisan lunar:search:index --quiet || true
|
||||||
|
|
||||||
|
if [ "$APP_ENV" = "production" ]; then
|
||||||
|
# The counterpart to optimize:clear above: config/routes/views/events get
|
||||||
|
# compiled once here, at the end of boot, after everything that could
|
||||||
|
# change them (migrations, lunar:install, index sync) has already run —
|
||||||
|
# so production actually gets the request-time performance win caching is
|
||||||
|
# for, rather than staying permanently uncached. Dev deliberately never
|
||||||
|
# does this: caching config here would mean .env/config edits stop taking
|
||||||
|
# effect until the next optimize:clear, which is the opposite of what dev
|
||||||
|
# needs on every iteration.
|
||||||
|
echo "[entrypoint] Caching config/routes/views for production..."
|
||||||
|
php artisan optimize --quiet
|
||||||
|
echo "[entrypoint] Production caches built"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -1 +0,0 @@
|
|||||||
.fi-pagination-items,.fi-pagination-overview,.fi-pagination-records-per-page-select:not(.fi-compact){display:none}@supports (container-type:inline-size){.fi-pagination{container-type:inline-size}@container (min-width: 28rem){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@container (min-width: 56rem){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}@supports not (container-type:inline-size){@media(min-width:640px){.fi-pagination-records-per-page-select.fi-compact{display:none}.fi-pagination-records-per-page-select:not(.fi-compact){display:inline}}@media(min-width:768px){.fi-pagination:not(.fi-simple)>.fi-pagination-previous-btn{display:none}.fi-pagination-overview{display:inline}.fi-pagination:not(.fi-simple)>.fi-pagination-next-btn{display:none}.fi-pagination-items{display:flex}}}.tippy-box[data-animation=fade][data-state=hidden]{opacity:0}[data-tippy-root]{max-width:calc(100vw - 10px)}.tippy-box{position:relative;background-color:#333;color:#fff;border-radius:4px;font-size:14px;line-height:1.4;white-space:normal;outline:0;transition-property:transform,visibility,opacity}.tippy-box[data-placement^=top]>.tippy-arrow{bottom:0}.tippy-box[data-placement^=top]>.tippy-arrow:before{bottom:-7px;left:0;border-width:8px 8px 0;border-top-color:initial;transform-origin:center top}.tippy-box[data-placement^=bottom]>.tippy-arrow{top:0}.tippy-box[data-placement^=bottom]>.tippy-arrow:before{top:-7px;left:0;border-width:0 8px 8px;border-bottom-color:initial;transform-origin:center bottom}.tippy-box[data-placement^=left]>.tippy-arrow{right:0}.tippy-box[data-placement^=left]>.tippy-arrow:before{border-width:8px 0 8px 8px;border-left-color:initial;right:-7px;transform-origin:center left}.tippy-box[data-placement^=right]>.tippy-arrow{left:0}.tippy-box[data-placement^=right]>.tippy-arrow:before{left:-7px;border-width:8px 8px 8px 0;border-right-color:initial;transform-origin:center right}.tippy-box[data-inertia][data-state=visible]{transition-timing-function:cubic-bezier(.54,1.5,.38,1.11)}.tippy-arrow{width:16px;height:16px;color:#333}.tippy-arrow:before{content:"";position:absolute;border-color:transparent;border-style:solid}.tippy-content{position:relative;padding:5px 9px;z-index:1}.tippy-box[data-theme~=light]{color:#26323d;box-shadow:0 0 20px 4px #9aa1b126,0 4px 80px -8px #24282f40,0 4px 4px -2px #5b5e6926;background-color:#fff}.tippy-box[data-theme~=light][data-placement^=top]>.tippy-arrow:before{border-top-color:#fff}.tippy-box[data-theme~=light][data-placement^=bottom]>.tippy-arrow:before{border-bottom-color:#fff}.tippy-box[data-theme~=light][data-placement^=left]>.tippy-arrow:before{border-left-color:#fff}.tippy-box[data-theme~=light][data-placement^=right]>.tippy-arrow:before{border-right-color:#fff}.tippy-box[data-theme~=light]>.tippy-backdrop{background-color:#fff}.tippy-box[data-theme~=light]>.tippy-svg-arrow{fill:#fff}.fi-sortable-ghost{opacity:.3}
|
|
||||||
|
After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 116 KiB |
@@ -1 +0,0 @@
|
|||||||
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-IYF56FF6.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-JEOLYBOO.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-EOVOK2B5.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-IRE366VL.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-CE5GGD3W.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-HA22NDSG.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-NRMW37G5.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
(()=>{var o=({livewireId:s})=>({actionNestingIndex:null,shouldOverlayParentActions:!1,closedActionNestingIndexes:[],focusTargetsByNestingIndex:{},boundSyncActionModals:null,boundOnModalClosed:null,init(){this.boundSyncActionModals=e=>{e.detail.id===s&&this.syncActionModals(e.detail.newActionNestingIndex,e.detail.shouldOverlayParentActions??!1)},this.boundOnModalClosed=e=>{let t=this.getActionNestingIndexFromModalId(e.detail.id);t!==null&&((this.shouldOverlayParentActions||t===0)&&this.restorePreviouslyFocusedElement(t-1),this.closedActionNestingIndexes.push(t))},window.addEventListener("sync-action-modals",this.boundSyncActionModals),window.addEventListener("modal-closed",this.boundOnModalClosed)},destroy(){this.boundSyncActionModals&&(window.removeEventListener("sync-action-modals",this.boundSyncActionModals),this.boundSyncActionModals=null),this.boundOnModalClosed&&(window.removeEventListener("modal-closed",this.boundOnModalClosed),this.boundOnModalClosed=null)},syncActionModals(e,t=!1){if(this.actionNestingIndex===e){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}let n=this.actionNestingIndex!==null&&e!==null&&e>this.actionNestingIndex,i=this.actionNestingIndex!==null&&e!==null&&e<this.actionNestingIndex,d=this.actionNestingIndex===null&&e!==null;if((n||d)&&this.rememberPreviouslyFocusedElement(),this.actionNestingIndex!==null&&!(t&&n)&&this.closeModal(),this.actionNestingIndex=e,this.actionNestingIndex===null){this.restorePreviouslyFocusedElement(-1),this.closedActionNestingIndexes=[],this.focusTargetsByNestingIndex={},this.shouldOverlayParentActions=!1;return}if(this.shouldOverlayParentActions=t,this.closedActionNestingIndexes=this.closedActionNestingIndexes.filter(l=>l<=this.actionNestingIndex),!this.closedActionNestingIndexes.includes(this.actionNestingIndex)){if(!this.$el.querySelector(`#${this.generateModalId(e)}`)){this.$nextTick(()=>{this.openModal(),i&&this.restorePreviouslyFocusedElement()});return}this.openModal(),i&&this.restorePreviouslyFocusedElement()}},rememberPreviouslyFocusedElement(){let e=this.$focus.focused();if(!e)return;if(this.actionNestingIndex===null){this.focusTargetsByNestingIndex[-1]=e;return}this.$el.querySelector(`#${this.generateModalId(this.actionNestingIndex)}`)?.contains(e)&&(this.focusTargetsByNestingIndex[this.actionNestingIndex]=e)},restorePreviouslyFocusedElement(e=this.actionNestingIndex){let t=this.focusTargetsByNestingIndex[e];if(t){for(let n in this.focusTargetsByNestingIndex)Number(n)>=e&&delete this.focusTargetsByNestingIndex[n];requestAnimationFrame(()=>requestAnimationFrame(()=>this.$nextTick(()=>{t.focus({preventScroll:!0})})))}},generateModalId(e){return`fi-${s}-action-`+e},getActionNestingIndexFromModalId(e){let t=`fi-${s}-action-`;if(!e?.startsWith(t))return null;let n=Number(e.slice(t.length));return Number.isInteger(n)?n:null},openModal(){let e=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:e}}))},closeModal(){let e=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:e}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",o)});})();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",unsubscribeLivewireHook:null,visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))},destroy(){this.unsubscribeLivewireHook?.()}}}export{c as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function a({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{if(!Array.isArray(e))return;let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{a as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function r({state:n,splitKeys:i,tagAddedMessage:a,tagRemovedMessage:s}){return{newTag:"",state:n,liveRegionClearTimeout:null,announce(e){let t=this.$refs.liveRegion;t&&(this.liveRegionClearTimeout!==null&&clearTimeout(this.liveRegionClearTimeout),t.textContent=e,this.liveRegionClearTimeout=setTimeout(()=>{t.textContent="",this.liveRegionClearTimeout=null},3e3))},createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.announce(a?.replace(":tag",()=>this.newTag)),this.newTag=""}},deleteTag(e){this.state=this.state.filter(t=>t!==e),this.announce(s?.replace(":tag",()=>e))},reorderTags(e){let t=this.state.splice(e.oldIndex,1)[0];this.state.splice(e.newIndex,0,t),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(e){["Enter",...i].includes(e.key)&&(e.preventDefault(),e.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(i.length===0){this.createTag();return}let e=i.map(t=>t.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(e,"g")).forEach(t=>{this.newTag=t,this.createTag()})})}}}}export{r as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function x({activeTab:p,isScrollable:m,isTabPersisted:T,isTabPersistedInQueryString:w,livewireId:g,schemaKey:D,tab:W,tabQueryStringKey:r}){return{boundResizeHandler:null,boundResetHandler:null,isScrollable:m,resizeDebounceTimer:null,tab:W,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);w&&e.has(r)&&t.includes(e.get(r))&&(this.tab=e.get(r)),(!this.tab||!t.includes(this.tab))&&(this.tab=t[p-1]),this.$watch("tab",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.unsubscribeLivewireHook=Livewire.hook("commit",({component:i,commit:d,succeed:c,fail:h,respond:u})=>{c(({snapshot:b,effect:n})=>{this.$nextTick(()=>{if(i.id!==g)return;let o=this.getTabs();o.includes(this.tab)||(this.tab=o[p-1]??this.tab)})})}),this.boundResetHandler=i=>{i.detail.livewireId!==g||i.detail.schemaKey!==D||T||w||this.$nextTick(()=>{this.tab=this.getTabs()[p-1]??this.tab})},window.addEventListener("reset-schema-component-state",this.boundResetHandler),m||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,i,d,c,h){let u=t.map(n=>Math.ceil(n.clientWidth)),b=t.map(n=>{let o=n.querySelector(".fi-tabs-item-label"),s=n.querySelector(".fi-badge"),a=Math.ceil(o.clientWidth),l=s?Math.ceil(s.clientWidth):0;return{label:a,badge:l,total:a+(l>0?d+l:0)}});for(let n=0;n<t.length;n++){let o=u.slice(0,n+1).reduce((f,I)=>f+I,0),s=n*i,a=b.slice(n+1),l=a.length>0,v=l?Math.max(...a.map(f=>f.total)):0,y=l?c+v+d+h+i:0;if(o+s+y>e)return n}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!w)return;let t=new URL(window.location.href);t.searchParams.set(r,this.tab),history.replaceState(null,document.title,t.toString())},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$el.querySelectorAll(".fi-sc-tabs-tab.fi-active [autofocus]");for(let i of e)if(i.focus(),document.activeElement===i)break})},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),i=Array.from(t.children).slice(0,-1),d=i.map(s=>s.style.display);i.forEach(s=>s.style.display=""),t.offsetHeight;let c=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),b=this.calculateTabItemGap(i[0]),n=this.calculateTabItemPadding(i[0]),o=this.findOverflowIndex(i,c,h,b,n,u);i.forEach((s,a)=>s.style.display=d[a]),o!==-1&&(this.withinDropdownIndex=o),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResetHandler&&window.removeEventListener("reset-schema-component-state",this.boundResetHandler),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{x as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function l({isSkippable:i,isStepPersistedInQueryString:n,key:o,livewireId:h,schemaKey:p,startStep:r,stepQueryStringKey:d}){return{boundResetHandler:null,step:null,init(){this.step=this.getSteps().at(r-1),this.$watch("step",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.boundResetHandler=t=>{t.detail.livewireId!==h||t.detail.schemaKey!==p||n||this.$nextTick(()=>{this.step=this.getSteps().at(r-1)??this.step})},window.addEventListener("reset-schema-component-state",this.boundResetHandler)},async requestNextStep(){await this.$wire.callSchemaComponentMethod(o,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!i&&e>this.getStepIndex(this.step)||(this.step=t,this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$refs[`step-${this.step}`]?.querySelectorAll("[autofocus]")??[];for(let s of e)if(s.focus(),document.activeElement===s)break})},getStepIndex(t){let e=this.getSteps().findIndex(s=>s===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return i||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!n)return;let t=new URL(window.location.href);t.searchParams.set(d,this.step),history.replaceState(null,document.title,t.toString())},destroy(){this.boundResetHandler&&window.removeEventListener("reset-schema-component-state",this.boundResetHandler)}}}export{l as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
(()=>{var d=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let i=this.$el.parentElement;i&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(i),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let i=this.$el.parentElement;if(!i)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=i.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var u=function(i,e,n){let t=i;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)t=t.includes(".")?t.slice(0,t.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(t)?e:["",null,void 0].includes(e)?t:`${t}.${e}`},h=i=>{let e=Alpine.findClosest(i,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:i,schemaKey:e})=>({handleFormValidationError(n){n.detail.livewireId===i&&this.$nextTick(()=>{let t=this.$el.querySelector("[data-validation-error]");if(!t)return;let r=t;for(;r;)r.dispatchEvent(new CustomEvent("expand")),r=r.parentNode;setTimeout(()=>t.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},handleClientSideStateReset(n){n.detail.livewireId!==i||n.detail.schemaKey!==e||this.$nextTick(()=>{let t=this.$el.querySelectorAll("[autofocus]");for(let r of t)if(r.offsetParent!==null&&(r.focus(),document.activeElement===r))break})},isStateChanged(n,t){if(n===void 0)return!1;try{return JSON.stringify(n)!==JSON.stringify(t)}catch{return n!==t}}})),window.Alpine.data("filamentSchemaComponent",({path:i,containerPath:e,$wire:n})=>({$statePath:i,$get:(t,r)=>n.$get(u(e,t,r)),$set:(t,r,a,o=!1)=>n.$set(u(e,t,a),r,o),get $state(){return n.$get(i)}})),window.Alpine.data("filamentActionsSchemaComponent",d),Livewire.hook("commit",({component:i,commit:e,respond:n,succeed:t,fail:r})=>{t(({snapshot:a,effects:o})=>{o.dispatches?.forEach(s=>{if(!s.params?.awaitSchemaComponent)return;let l=Array.from(i.el.querySelectorAll(`[wire\\:partial="schema-component::${s.params.awaitSchemaComponent}"]`)).filter(c=>h(c)===i);if(l.length!==1){if(l.length>1)throw`Multiple schema components found with key [${s.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${i.id}-${s.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(s.name,{detail:s.params}))},{once:!0})}})})})});})();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function o({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:i,succeed:a,fail:u,respond:h})=>{a(({snapshot:d,effect:f})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let i=await this.$wire.updateTableColumnState(r,s,this.state);this.error=i?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function o({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:r,succeed:a,fail:u,respond:d})=>{a(({snapshot:h,effect:l})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||this.getNormalizedState()===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function o({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.hook("commit",({component:e,commit:i,succeed:a,fail:u,respond:h})=>{a(({snapshot:d,effect:f})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let i=await this.$wire.updateTableColumnState(r,s,this.state);this.error=i?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{o as default};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
function d(){return{checkboxClickController:null,collapsedGroups:[],isLoading:!1,selectedRecords:[],shouldCheckUniqueSelection:!0,lastCheckedRecord:null,livewireId:null,init:function(){this.livewireId=this.$root.closest("[wire\\:id]").attributes["wire:id"].value,this.$wire.$on("deselectAllTableRecords",()=>this.deselectAllRecords()),this.$watch("selectedRecords",()=>{if(!this.shouldCheckUniqueSelection){this.shouldCheckUniqueSelection=!0;return}this.selectedRecords=[...new Set(this.selectedRecords)],this.shouldCheckUniqueSelection=!1}),this.$nextTick(()=>this.watchForCheckboxClicks()),Livewire.hook("element.init",({component:e})=>{e.id===this.livewireId&&this.watchForCheckboxClicks()})},mountAction:function(e,t=null){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableAction(e,t)},mountBulkAction:function(e){this.$wire.set("selectedTableRecords",this.selectedRecords,!1),this.$wire.mountTableBulkAction(e)},toggleSelectRecordsOnPage:function(){let e=this.getRecordsOnPage();if(this.areRecordsSelected(e)){this.deselectRecords(e);return}this.selectRecords(e)},toggleSelectRecordsInGroup:async function(e){this.isLoading=!0;let t=await this.$wire.getGroupedSelectableTableRecordKeys(e);this.areRecordsSelected(this.getRecordsInGroupOnPage(e))?this.deselectRecords(t):this.selectRecords(t),this.isLoading=!1},getRecordsInGroupOnPage:function(e){let t=[];for(let s of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])s.dataset.group===e&&t.push(s.value);return t},getRecordsOnPage:function(){let e=[];for(let t of this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[])e.push(t.value);return e},selectRecords:function(e){for(let t of e)this.isRecordSelected(t)||this.selectedRecords.push(t)},deselectRecords:function(e){for(let t of e){let s=this.selectedRecords.indexOf(t);s!==-1&&this.selectedRecords.splice(s,1)}},selectAllRecords:async function(){this.isLoading=!0,this.selectedRecords=await this.$wire.getAllSelectableTableRecordKeys(),this.isLoading=!1},deselectAllRecords:function(){this.selectedRecords=[]},isRecordSelected:function(e){return this.selectedRecords.includes(e)},areRecordsSelected:function(e){return e.every(t=>this.isRecordSelected(t))},toggleCollapseGroup:function(e){if(this.isGroupCollapsed(e)){this.collapsedGroups.splice(this.collapsedGroups.indexOf(e),1);return}this.collapsedGroups.push(e)},isGroupCollapsed:function(e){return this.collapsedGroups.includes(e)},resetCollapsedGroups:function(){this.collapsedGroups=[]},watchForCheckboxClicks:function(){this.checkboxClickController&&this.checkboxClickController.abort(),this.checkboxClickController=new AbortController;let{signal:e}=this.checkboxClickController;this.$root?.addEventListener("click",t=>t.target?.matches(".fi-ta-record-checkbox")&&this.handleCheckboxClick(t,t.target),{signal:e})},handleCheckboxClick:function(e,t){if(!this.lastChecked){this.lastChecked=t;return}if(e.shiftKey){let s=Array.from(this.$root?.getElementsByClassName("fi-ta-record-checkbox")??[]);if(!s.includes(this.lastChecked)){this.lastChecked=t;return}let l=s.indexOf(this.lastChecked),r=s.indexOf(t),o=[l,r].sort((c,n)=>c-n),i=[];for(let c=o[0];c<=o[1];c++)s[c].checked=t.checked,i.push(s[c].value);t.checked?this.selectRecords(i):this.deselectRecords(i)}this.lastChecked=t}}}export{d as default};
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "3dealer",
|
||||||
|
"short_name": "3dealer",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -1,6 +1,12 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "./fonts.css";
|
@import "./fonts.css";
|
||||||
@import "./dropdown.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 '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
@source '../**/*.blade.php';
|
@source '../**/*.blade.php';
|
||||||
@@ -213,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 ────────────────────────── */
|
/* ── Shared underline-slide animation ────────────────────────── */
|
||||||
.underline-slide {
|
.underline-slide {
|
||||||
background-image: linear-gradient(currentColor, currentColor);
|
background-image: linear-gradient(currentColor, currentColor);
|
||||||
|
|||||||
@@ -0,0 +1,856 @@
|
|||||||
|
/*
|
||||||
|
* Cart + checkout module — generic default styling.
|
||||||
|
*
|
||||||
|
* Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version
|
||||||
|
* put these rules in `@layer bbk-checkout`, positioned (via a cross-file
|
||||||
|
* @layer ordering statement) to sit between Tailwind's `base` and
|
||||||
|
* `components` — in theory enough to beat Preflight's element resets while
|
||||||
|
* still losing to a host override. In practice a build tool processing each
|
||||||
|
* CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the
|
||||||
|
* cross-file ordering information that trick depends on, so it silently
|
||||||
|
* didn't work: Preflight's `button { background-color: transparent }`,
|
||||||
|
* `* { border-width: 0 }` etc. (layered, in `base`) were beating every
|
||||||
|
* `.bbk-*` rule below regardless of specificity — buttons with no
|
||||||
|
* background, no border, wrong font-size.
|
||||||
|
*
|
||||||
|
* Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always
|
||||||
|
* beats ANY layered rule (Preflight included), full stop, no ordering tricks,
|
||||||
|
* nothing a bundler can silently invalidate. This file is loaded BEFORE the
|
||||||
|
* host's own stylesheet (see the @vite call in the layout <head>), so:
|
||||||
|
*
|
||||||
|
* - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins —
|
||||||
|
* same specificity, later in source order
|
||||||
|
* - a later host rule with a MORE specific selector wins regardless
|
||||||
|
* - a host rule inside `@layer components`/`@layer utilities` does NOT
|
||||||
|
* win — unlayered always beats layered. Theme this module from plain
|
||||||
|
* rules in app.css, not from inside a Tailwind layer.
|
||||||
|
*
|
||||||
|
* Two ways to theme this, cheapest first:
|
||||||
|
*
|
||||||
|
* 1. Redefine the --bbk-* custom properties below (from :root, or scoped to
|
||||||
|
* .bbk-cart for a cart-only override) — covers colour, radius, shadow,
|
||||||
|
* font without touching a single selector below.
|
||||||
|
*
|
||||||
|
* :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; }
|
||||||
|
*
|
||||||
|
* 2. Override individual `.bbk-*` rules directly (as plain rules, per
|
||||||
|
* above) for anything structural (spacing, layout) the variables don't
|
||||||
|
* cover.
|
||||||
|
*
|
||||||
|
* This file's own look is a deliberately neutral placeholder — inoffensive,
|
||||||
|
* not "designed" — so a project always has something reasonable before it
|
||||||
|
* themes; it is not meant to be edited per project.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
|
||||||
|
--bbk-color-text: #18181b;
|
||||||
|
--bbk-color-muted: #71717a;
|
||||||
|
--bbk-color-bg: #ffffff;
|
||||||
|
--bbk-color-bg-muted: #f4f4f5;
|
||||||
|
--bbk-color-border: #e4e4e7;
|
||||||
|
--bbk-color-accent: #18181b;
|
||||||
|
--bbk-color-accent-text: #ffffff;
|
||||||
|
--bbk-color-danger: #dc2626;
|
||||||
|
|
||||||
|
--bbk-radius: 8px;
|
||||||
|
--bbk-radius-sm: 4px;
|
||||||
|
--bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart[hidden] { display: none; }
|
||||||
|
|
||||||
|
.bbk-cart {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
font-family: var(--bbk-font);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--bbk-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; }
|
||||||
|
|
||||||
|
.bbk-cart-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(420px, 100vw);
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bbk-color-bg);
|
||||||
|
box-shadow: var(--bbk-shadow);
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); }
|
||||||
|
|
||||||
|
.bbk-cart-panel-header {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem 1.5rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--bbk-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-dismiss,
|
||||||
|
.bbk-cart-item-remove,
|
||||||
|
.bbk-cart-qty-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-dismiss {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); }
|
||||||
|
|
||||||
|
.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); }
|
||||||
|
|
||||||
|
.bbk-cart-dismiss:focus-visible,
|
||||||
|
.bbk-cart-item-remove:focus-visible,
|
||||||
|
.bbk-cart-qty-btn:focus-visible,
|
||||||
|
.bbk-cart-qty-input:focus-visible,
|
||||||
|
.bbk-cart-checkout:focus-visible,
|
||||||
|
.bbk-cart-coupon-input:focus-visible,
|
||||||
|
.bbk-cart-coupon-submit:focus-visible,
|
||||||
|
.bbk-cart-coupon-remove:focus-visible {
|
||||||
|
outline: 2px solid var(--bbk-color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-panel-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-items {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 1fr auto;
|
||||||
|
gap: 0.875rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-media img {
|
||||||
|
display: block;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-detail { min-width: 0; }
|
||||||
|
|
||||||
|
.bbk-cart-item-title {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.bbk-cart-item-title:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.bbk-cart-item-variant {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
|
||||||
|
.bbk-line-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-line-field dt {
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-line-field dd {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-line-field-file {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-line-field-file img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-unit {
|
||||||
|
margin: 0 0 0.625rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-item-total { margin: 0; font-weight: 600; }
|
||||||
|
|
||||||
|
.bbk-cart-item-remove {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-qty {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-qty-btn {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); }
|
||||||
|
|
||||||
|
.bbk-cart-qty-input {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-left: 1px solid var(--bbk-color-border);
|
||||||
|
border-right: 1px solid var(--bbk-color-border);
|
||||||
|
text-align: center;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bbk-color-bg);
|
||||||
|
appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-qty-input::-webkit-outer-spin-button,
|
||||||
|
.bbk-cart-qty-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-summary {
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
border-top: 1px solid var(--bbk-color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
|
||||||
|
|
||||||
|
.bbk-cart-summary-pending {
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-summary-row--total {
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
padding-top: 0.875rem;
|
||||||
|
border-top: 1px solid var(--bbk-color-border);
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bbk-color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-submit {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); }
|
||||||
|
|
||||||
|
.bbk-cart-coupon-applied {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-code {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-remove {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); }
|
||||||
|
|
||||||
|
.bbk-cart-coupon-error {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.75rem 1.5rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-add-to-cart-error {
|
||||||
|
margin: 0.375rem 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-checkout {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
border: 1px solid var(--bbk-color-accent);
|
||||||
|
border-radius: var(--bbk-radius);
|
||||||
|
background: var(--bbk-color-accent);
|
||||||
|
color: var(--bbk-color-accent-text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-checkout:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.bbk-cart-checkout:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-cart-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───────────────────────────────────────────────────────────────────
|
||||||
|
Checkout page — two columns: fields on the left, order summary (the
|
||||||
|
same cart-body partial the drawer uses) on the right.
|
||||||
|
─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.bbk-checkout-page {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.5rem 1.5rem 5rem;
|
||||||
|
font-family: var(--bbk-font);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--bbk-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-heading {
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 380px;
|
||||||
|
gap: 3rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.bbk-checkout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-section {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid var(--bbk-color-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-section-heading {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-note {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Contact: "logged in as" line, or the guest login prompt */
|
||||||
|
|
||||||
|
.bbk-checkout-logged-in,
|
||||||
|
.bbk-checkout-login-prompt { margin: 0; }
|
||||||
|
|
||||||
|
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
|
||||||
|
|
||||||
|
/* Fields */
|
||||||
|
|
||||||
|
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||||
|
|
||||||
|
.bbk-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.bbk-field-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-field-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-field-input {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bbk-color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-field-input:focus-visible {
|
||||||
|
outline: 2px solid var(--bbk-color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-field-input:disabled {
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
|
||||||
|
|
||||||
|
.bbk-field-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A fixed, non-editable field value (e.g. the store's single country). */
|
||||||
|
.bbk-field-static {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
background: var(--bbk-color-bg-muted);
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.bbk-field-input { resize: vertical; }
|
||||||
|
|
||||||
|
.bbk-checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For a full-sentence label that can wrap — align the box to the first line. */
|
||||||
|
/* "I want an invoice": company/ΑΦΜ only while ticked */
|
||||||
|
|
||||||
|
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
|
||||||
|
|
||||||
|
.bbk-checkbox--stacked {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.bbk-checkout-shipping-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shipping method */
|
||||||
|
|
||||||
|
.bbk-checkout-shipping-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-shipping-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
||||||
|
|
||||||
|
.bbk-checkout-shipping-option-detail {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-shipping-option-name { font-weight: 600; }
|
||||||
|
|
||||||
|
.bbk-checkout-shipping-option-description {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-shipping-option-price { font-weight: 600; }
|
||||||
|
|
||||||
|
/* The single auto-selected option — a fixed line, not a choosable radio. */
|
||||||
|
.bbk-checkout-shipping-confirmed {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px solid var(--bbk-color-accent);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Autosave status line under the address form. */
|
||||||
|
.bbk-checkout-status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
||||||
|
|
||||||
|
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
||||||
|
|
||||||
|
.bbk-checkout-continue {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
border: 1px solid var(--bbk-color-accent);
|
||||||
|
border-radius: var(--bbk-radius);
|
||||||
|
background: var(--bbk-color-accent);
|
||||||
|
color: var(--bbk-color-accent-text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-continue:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.bbk-checkout-continue:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Order summary column */
|
||||||
|
|
||||||
|
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
|
||||||
|
|
||||||
|
.bbk-checkout-summary {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius);
|
||||||
|
background: var(--bbk-color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-summary-heading {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
|
||||||
|
nowhere further to send you from here. */
|
||||||
|
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
|
||||||
|
|
||||||
|
/* ── Payment ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.bbk-checkout-payment-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-payment-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
|
||||||
|
|
||||||
|
.bbk-checkout-payment-option-name { font-weight: 600; }
|
||||||
|
|
||||||
|
.bbk-payment-element { margin: 0.25rem 0; }
|
||||||
|
|
||||||
|
.bbk-checkout-withdrawal {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-withdrawal a { color: inherit; }
|
||||||
|
|
||||||
|
.bbk-checkout-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--bbk-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Processing overlay — fixed, covers the page while a payment confirms. */
|
||||||
|
.bbk-checkout-processing {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-spinner {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid var(--bbk-color-border);
|
||||||
|
border-top-color: var(--bbk-color-accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bbk-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bbk-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Confirmation page ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.bbk-confirmation {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 1.5rem 5rem;
|
||||||
|
font-family: var(--bbk-font);
|
||||||
|
color: var(--bbk-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-heading {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
|
||||||
|
|
||||||
|
.bbk-confirmation-meta {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-meta-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
|
||||||
|
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
|
||||||
|
|
||||||
|
.bbk-confirmation-body {
|
||||||
|
margin: 2rem 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-lines {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 1fr auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-line-detail { min-width: 0; }
|
||||||
|
|
||||||
|
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
|
||||||
|
|
||||||
|
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
|
||||||
|
|
||||||
|
.bbk-confirmation-addresses {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-confirmation-address-heading {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-address-lines {
|
||||||
|
font-style: normal;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
src: url("/fonts/manrope/manrope-v20-greek_latin-300.woff2")
|
src: url("../fonts/manrope/manrope-v20-greek_latin-300.woff2")
|
||||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-regular - greek_latin */
|
/* manrope-regular - greek_latin */
|
||||||
@@ -13,7 +13,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url("/fonts/manrope/manrope-v20-greek_latin-regular.woff2")
|
src: url("../fonts/manrope/manrope-v20-greek_latin-regular.woff2")
|
||||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-500 - greek_latin */
|
/* manrope-500 - greek_latin */
|
||||||
@@ -22,7 +22,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
src: url("/fonts/manrope/manrope-v20-greek_latin-500.woff2")
|
src: url("../fonts/manrope/manrope-v20-greek_latin-500.woff2")
|
||||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-600 - greek_latin */
|
/* manrope-600 - greek_latin */
|
||||||
@@ -31,7 +31,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
src: url("/fonts/manrope/manrope-v20-greek_latin-600.woff2")
|
src: url("../fonts/manrope/manrope-v20-greek_latin-600.woff2")
|
||||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-700 - greek_latin */
|
/* manrope-700 - greek_latin */
|
||||||
@@ -40,7 +40,7 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
src: url("/fonts/manrope/manrope-v20-greek_latin-700.woff2")
|
src: url("../fonts/manrope/manrope-v20-greek_latin-700.woff2")
|
||||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
/* manrope-800 - greek_latin */
|
/* manrope-800 - greek_latin */
|
||||||
@@ -49,6 +49,6 @@ @font-face {
|
|||||||
font-family: "Manrope";
|
font-family: "Manrope";
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
src: url("/fonts/manrope/manrope-v20-greek_latin-800.woff2")
|
src: url("../fonts/manrope/manrope-v20-greek_latin-800.woff2")
|
||||||
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
|
||||||
}
|
}
|
||||||
|
|||||||