@endsection
diff --git a/resources/views/product/partials/gallery/slider.blade.php b/resources/views/product/partials/gallery/slider.blade.php
index 62b7003b9..b2382f057 100644
--- a/resources/views/product/partials/gallery/slider.blade.php
+++ b/resources/views/product/partials/gallery/slider.blade.php
@@ -6,10 +6,11 @@ class="flex h-[440px] cursor-zoom-in items-center justify-center rounded border
>
diff --git a/resources/views/product/partials/gallery/thumbnails.blade.php b/resources/views/product/partials/gallery/thumbnails.blade.php
index dc3197966..98714cc99 100644
--- a/resources/views/product/partials/gallery/thumbnails.blade.php
+++ b/resources/views/product/partials/gallery/thumbnails.blade.php
@@ -31,9 +31,9 @@
images))
- src="/storage/{{ config('rapidez.store') }}/resizes/80x80/magento/catalog/product/{{ $product->images[$imageId] }}.webp"
+ src="/storage/{{ config('rapidez.store') }}/resizes/80x80/magento/catalog/product{{ $product->images[$imageId] }}.webp"
@endif
- v-bind:src="'/storage/{{ config('rapidez.store') }}/resizes/80x80/magento/catalog/product/' + images[{{ $imageId }}] + '.webp'"
+ v-bind:src="'/storage/{{ config('rapidez.store') }}/resizes/80x80/magento/catalog/product' + images[{{ $imageId }}] + '.webp'"
alt="{{ $product->name }}"
class="block max-h-full w-auto object-contain"
width="80"
diff --git a/resources/views/search/overview.blade.php b/resources/views/search/overview.blade.php
index 292df215f..0293288a1 100644
--- a/resources/views/search/overview.blade.php
+++ b/resources/views/search/overview.blade.php
@@ -2,42 +2,27 @@
@section('robots', 'NOINDEX,NOFOLLOW')
+@pushOnce('head', 'search-overview')
+ @vite(vite_filename_paths(['StateResults.vue']))
+@endPushOnce
+
@section('content')
-
-
@lang('Search for'): @{{ $root.queryParams.get('q') }}
-
+
+
+
+
+
+
+
+ @lang('Search for'): @{{ query }}
+
+
+ @lang('Search')
+
+
+
+
+
+
@endsection
diff --git a/routes/api.php b/routes/api.php
index 98467c891..fa18acd34 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -2,24 +2,21 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
+use Illuminate\Support\Facades\Route;
use Rapidez\Core\Http\Controllers\GetSignedCheckoutController;
+use Rapidez\Core\Http\Controllers\IndexController;
use Rapidez\Core\Http\Controllers\OrderController;
+use Rapidez\Core\Http\Controllers\SearchController;
use Rapidez\Core\Http\Middleware\VerifyAdminToken;
Route::middleware('api')->prefix('api')->group(function () {
- Route::get('attributes', function () {
- $attributeModel = config('rapidez.models.attribute');
-
- return $attributeModel::getCachedWhere(function ($attribute) {
- return $attribute['filter'] || $attribute['sorting'];
- });
- });
-
- Route::get('swatches', function () {
- $optionswatchModel = config('rapidez.models.option_swatch');
-
- return $optionswatchModel::getCachedSwatchValues();
- });
+ Route::post('search', [SearchController::class, 'store'])
+ ->middleware([
+ \Illuminate\Cookie\Middleware\EncryptCookies::class,
+ \Illuminate\Session\Middleware\StartSession::class,
+ \Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
+ 'throttle:search-analytics',
+ ]);
Route::get('order', OrderController::class);
@@ -33,5 +30,11 @@
'store' => $request->store ?: false,
]);
});
+
+ Route::post('index/{model}', [IndexController::class, 'store'])
+ ->where('model', '[a-zA-Z0-9_]+');
+
+ Route::delete('index/{model}', [IndexController::class, 'destroy'])
+ ->where('model', '[a-zA-Z0-9_]+');
});
});
diff --git a/routes/web.php b/routes/web.php
index 27231a2ef..0951a3ebe 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,12 +1,17 @@
middleware(AuthenticateHealthCheck::class);
Route::get('robots.txt', fn () => response(Rapidez::config('design/search_engine_robots/custom_instructions'))
->header('Content-Type', 'text/plain; charset=UTF-8'));
+Route::middleware('cache.headers:public;max_age=3600;s_maxage=3600;stale_while_revalidate=3600;etag')->group(function () {
+ Route::get('config.js', ConfigController::class)->name('config');
+});
Route::middleware('web')->group(function () {
Route::view('cart', 'rapidez::cart.overview')->name('cart');
diff --git a/src/Commands/ElasticsearchIndexCommand.php b/src/Commands/ElasticsearchIndexCommand.php
deleted file mode 100644
index ccae869d1..000000000
--- a/src/Commands/ElasticsearchIndexCommand.php
+++ /dev/null
@@ -1,62 +0,0 @@
-indexer = $indexer;
- }
-
- public function indexAllStores(string $indexName, callable|iterable $items, callable|array|null $dataFilter = null, callable|string $id = 'entity_id'): void
- {
- $this->indexStores(Rapidez::getStores(), $indexName, $items, $dataFilter, $id);
- }
-
- public function indexStores(array $stores, string $indexName, callable|iterable $items, callable|array|null $dataFilter = null, callable|string $id = 'entity_id'): void
- {
- foreach ($stores as $store) {
- $this->indexStore($store, $indexName, $items, $dataFilter, $id);
- }
- }
-
- public function indexStore(Store|array $store, string $indexName, callable|iterable $items, callable|array|null $dataFilter = null, callable|string $id = 'entity_id'): void
- {
- $storeName = $store['name'] ?? $store['code'] ?? reset($store);
- $this->line('Indexing `' . $indexName . '` for store ' . $storeName);
-
- try {
- $this->prepareIndexerWithStore($store, $indexName, $this->mapping, $this->settings, $this->synonymsFor);
- $this->indexer->index($this->dataFrom($items), $dataFilter, $id);
- $this->indexer->finish();
- } catch (Exception $e) {
- $this->indexer->abort();
-
- throw $e;
- }
- }
-
- public function prepareIndexerWithStore(Store|array $store, string $indexName, array $mapping = [], array $settings = [], array $synonymsFor = []): void
- {
- Rapidez::setStore($store);
- $this->indexer->prepare(config('rapidez.es_prefix') . '_' . $indexName . '_' . $store['store_id'], $mapping, $settings, $synonymsFor);
- }
-
- public function dataFrom(callable|iterable $items)
- {
- return value($items, config()->get('rapidez.store_code'));
- }
-}
diff --git a/src/Commands/ElasticsearchIndexer.php b/src/Commands/ElasticsearchIndexer.php
deleted file mode 100644
index 7556110b3..000000000
--- a/src/Commands/ElasticsearchIndexer.php
+++ /dev/null
@@ -1,151 +0,0 @@
-elasticsearch = $elasticsearch;
- }
-
- public function deleteIndex(string $index): void
- {
- $this->elasticsearch->indices()->delete(['index' => $index]);
- }
-
- public function index(iterable|object $data, callable|array|null $dataFilter, callable|string $id = 'entity_id'): void
- {
- if (is_iterable($data)) {
- $this->indexItems($data, $dataFilter, $id);
- } else {
- $this->indexItem($data, $dataFilter, $id);
- }
- }
-
- public function indexItems(iterable $items, callable|array|null $dataFilter, callable|string $id = 'entity_id'): void
- {
- foreach ($items as $item) {
- $this->indexItem($item, $dataFilter, $id);
- }
- }
-
- public function indexItem(object $item, callable|array|null $dataFilter, callable|string $id = 'entity_id'): void
- {
- if (is_null($item)) {
- return;
- }
-
- $currentValues = match (true) {
- is_callable($dataFilter) => $dataFilter($item),
- is_null($dataFilter) => $item,
- default => Arr::only($item instanceof Arrayable ? $item->toArray() : (array) $item, $dataFilter),
- };
-
- if (is_null($currentValues)) {
- return;
- }
-
- $currentId = is_callable($id)
- ? $id($item)
- : data_get($item, $id);
-
- if (is_null($currentId)) {
- return;
- }
-
- IndexJob::dispatch($this->index, $currentId, $currentValues);
- }
-
- public function prepare(string $indexName, array $mapping = [], array $settings = [], array $synonymsFor = []): void
- {
- data_set($settings, 'index.analysis.analyzer.default', [
- 'filter' => ['lowercase', 'asciifolding'],
- 'tokenizer' => 'standard',
- ]);
-
- if (count($synonymsFor)) {
- $synonyms = config('rapidez.models.search_synonym')::whereIn('store_id', [0, config('rapidez.store')])
- ->get()
- ->map(fn ($synonym) => $synonym->synonyms)
- ->toArray();
-
- data_set($settings, 'index.analysis.filter.synonym', ['type' => 'synonym_graph', 'synonyms' => $synonyms]);
- data_set($settings, 'index.analysis.analyzer.synonym', [
- 'filter' => ['lowercase', 'asciifolding', 'synonym'],
- 'tokenizer' => 'standard',
- ]);
-
- foreach ($synonymsFor as $property) {
- data_set($mapping, 'properties.' . $property . '.type', 'text');
- data_set($mapping, 'properties.' . $property . '.analyzer', 'synonym');
- data_set($mapping, 'properties.' . $property . '.fields', [
- 'keyword' => [
- 'type' => 'keyword',
- 'ignore_above' => 256,
- ],
- ]);
- }
- }
-
- $this->createAlias($indexName);
- $this->createIndex($this->index, $mapping, $settings);
- }
-
- public function finish(): void
- {
- $this->switchAlias($this->alias, $this->index);
- }
-
- public function abort(): void
- {
- $this->deleteIndex($this->index);
- }
-
- public function createAlias(string $indexName): void
- {
- $this->alias = $indexName;
- $this->index = $this->alias . '_' . Carbon::now()->format('YmdHis');
- }
-
- public function createIndex(string $index, array $mapping = [], array $settings = []): void
- {
- $this->elasticsearch->indices()->create([
- 'index' => $index,
- 'body' => array_filter([
- 'mappings' => $mapping,
- 'settings' => $settings,
- ]),
- ]);
- }
-
- public function switchAlias(string $alias, string $index): void
- {
- $this->elasticsearch->indices()->putAlias([
- 'index' => $index,
- 'name' => $alias,
- ]);
-
- $aliases = $this->elasticsearch->indices()->getAlias(['name' => $alias]);
- foreach ($aliases as $indexLinkedToAlias => $aliasData) {
- if ($indexLinkedToAlias != $index) {
- $this->elasticsearch->indices()->deleteAlias([
- 'index' => $indexLinkedToAlias,
- 'name' => $alias,
- ]);
- $this->elasticsearch->indices()->delete(['index' => $indexLinkedToAlias]);
- }
- }
- }
-}
diff --git a/src/Commands/IndexCategoriesCommand.php b/src/Commands/IndexCategoriesCommand.php
deleted file mode 100644
index 6bdaa4e51..000000000
--- a/src/Commands/IndexCategoriesCommand.php
+++ /dev/null
@@ -1,37 +0,0 @@
-synonymsFor = ['name'];
-
- $this->indexStores(
- stores: Rapidez::getStores($this->argument('store')),
- indexName: 'categories',
- items: $this->getCategories(...),
- dataFilter: fn ($data) => Eventy::filter('index.category.data', $data),
- );
-
- return 0;
- }
-
- public function getCategories()
- {
- return config('rapidez.models.category')::withEventyGlobalScopes('index.categories.scopes')
- ->select((new (config('rapidez.models.category')))->qualifyColumns(['entity_id', 'name', 'url_path']))
- ->whereNotNull('url_key')
- ->whereNot('url_key', 'default-category')
- ->has('products')
- ->get() ?? [];
- }
-}
diff --git a/src/Commands/IndexCommand.php b/src/Commands/IndexCommand.php
new file mode 100644
index 000000000..6d26b0ea4
--- /dev/null
+++ b/src/Commands/IndexCommand.php
@@ -0,0 +1,61 @@
+filter(fn ($class) => in_array(Searchable::class, class_uses_recursive($class)));
+
+ $types = $this->option('types')
+ ? $baseSearchableModels->filter(fn ($model) => in_array($model::getIndexName(), explode(',', $this->option('types'))))
+ : $baseSearchableModels;
+
+ $stores = $this->option('store')
+ ? Rapidez::getStores(explode(',', $this->option('store')))
+ : Rapidez::getStores();
+
+ $this->call('cache:clear');
+
+ IndexBeforeEvent::dispatch($this);
+
+ foreach ($stores as $store) {
+ Rapidez::setStore($store);
+
+ $this->line('Store: ' . $store['name']);
+
+ foreach ($types as $model) {
+ $searchableAs = (new $model)->searchableAs();
+
+ $indexMappings = Eventy::filter('index.' . $model::getIndexName() . '.mapping', $model::getIndexMappings());
+ if ($indexMappings) {
+ config()->set('elasticsearch.indices.mappings.' . $searchableAs, $indexMappings);
+ }
+
+ $indexSettings = Eventy::filter('index.' . $model::getIndexName() . '.settings', $model::getIndexSettings());
+ if ($indexSettings) {
+ config()->set('elasticsearch.indices.settings.' . $searchableAs, $indexSettings);
+ }
+
+ $this->call('scout:import', [
+ 'searchable' => $model,
+ ]);
+ }
+ }
+
+ IndexAfterEvent::dispatch($this);
+ }
+}
diff --git a/src/Commands/IndexProductsCommand.php b/src/Commands/IndexProductsCommand.php
deleted file mode 100644
index 53e535319..000000000
--- a/src/Commands/IndexProductsCommand.php
+++ /dev/null
@@ -1,133 +0,0 @@
-call('cache:clear');
-
- IndexBeforeEvent::dispatch($this);
-
- $productModel = config('rapidez.models.product');
- $stores = Rapidez::getStores($this->argument('store'));
- foreach ($stores as $store) {
- $this->line('Store: ' . $store['name']);
- $this->prepareIndexerWithStore($store, 'products', Eventy::filter('index.product.mapping', [
- 'properties' => [
- 'price' => [
- 'type' => 'double',
- ],
- 'children' => [
- 'type' => 'flattened',
- ],
- 'grouped' => [
- 'type' => 'flattened',
- ],
- 'positions' => [
- 'type' => 'flattened',
- ],
- ],
- ]), Eventy::filter('index.product.settings', []), ['name']);
- try {
- $maxPositions = CategoryProduct::query()
- ->selectRaw('GREATEST(MAX(position), 0) as position')
- ->addSelect('category_id')
- ->groupBy('category_id')
- ->pluck('position', 'category_id');
-
- $productQuery = $productModel::selectOnlyIndexable()
- ->with(['categoryProducts', 'reviewSummary'])
- ->withEventyGlobalScopes('index.product.scopes')
- ->withExists('options AS has_options');
-
- $bar = $this->output->createProgressBar($productQuery->getQuery()->getCountForPagination());
- $bar->start();
-
- $categories = Category::withEventyGlobalScopes('index.category.scopes')
- ->where('catalog_category_flat_store_' . config('rapidez.store') . '.entity_id', '<>', Rapidez::config('catalog/category/root_id', 2))
- ->pluck('name', 'entity_id');
-
- $showOutOfStock = (bool) Rapidez::config('cataloginventory/options/show_out_of_stock');
- $indexVisibility = config('rapidez.indexer.visibility');
-
- $productQuery->chunk($this->chunkSize, function ($products) use ($store, $bar, $categories, $showOutOfStock, $indexVisibility, $maxPositions) {
- $this->indexer->index($products, function ($product) use ($store, $categories, $showOutOfStock, $indexVisibility, $maxPositions) {
- if (! in_array($product->visibility, $indexVisibility)) {
- return;
- }
-
- if (! $showOutOfStock && ! $product->in_stock) {
- return;
- }
-
- $data = array_merge(['store' => $store['store_id']], $product->toArray());
-
- foreach ($product->super_attributes ?: [] as $superAttribute) {
- $data['super_' . $superAttribute->code] = $superAttribute->text_swatch || $superAttribute->visual_swatch
- ? array_keys((array) $product->{'super_' . $superAttribute->code})
- : Arr::pluck($product->{'super_' . $superAttribute->code} ?: [], 'label');
- }
-
- $data = $this->withCategories($data, $categories);
-
- $data['positions'] = $product->categoryProducts
- ->pluck('position', 'category_id')
- // Turn all positions positive
- ->mapWithKeys(fn ($position, $category_id) => [$category_id => $maxPositions[$category_id] - $position]);
-
- return Eventy::filter('index.product.data', $data, $product);
- });
-
- $bar->advance($products->count());
- });
-
- $this->indexer->finish();
- } catch (Exception $e) {
- $this->indexer->abort();
-
- throw $e;
- }
-
- $bar->finish();
- $this->line('');
- }
-
- IndexAfterEvent::dispatch($this);
- $this->info('Done!');
- }
-
- public function withCategories(array $data, Collection $categories): array
- {
- foreach ($data['category_paths'] as $categoryPath) {
- $category = [];
- foreach (explode('/', $categoryPath) as $categoryId) {
- if (isset($categories[$categoryId])) {
- $category[] = $categoryId . '::' . $categories[$categoryId];
- }
- }
- if (! empty($category)) {
- $data['categories'][] = implode(' /// ', $category);
- }
- }
-
- return $data;
- }
-}
diff --git a/src/Commands/UpdateIndexCommand.php b/src/Commands/UpdateIndexCommand.php
new file mode 100644
index 000000000..b4d0752a1
--- /dev/null
+++ b/src/Commands/UpdateIndexCommand.php
@@ -0,0 +1,78 @@
+getLatestIndexDate()) {
+ $this->error(__('No latest index date has been found yet, please run php artisan rapidez:index first.'));
+
+ return;
+ }
+
+ $baseSearchableModels = collect(config('rapidez.models'))
+ ->filter(fn ($class) => in_array(Searchable::class, class_uses_recursive($class)) && (new $class)->getQualifiedUpdatedAtColumn());
+
+ $types = $this->option('types')
+ ? $baseSearchableModels->filter(fn ($model) => in_array((new $model)->getIndexName(), explode(',', $this->option('types'))))
+ : $baseSearchableModels;
+
+ $stores = $this->option('store')
+ ? Rapidez::getStores(explode(',', $this->option('store')))
+ : Rapidez::getStores();
+
+ IndexBeforeEvent::dispatch($this);
+
+ foreach ($stores as $store) {
+ Rapidez::setStore($store);
+
+ $this->line('Store: ' . $store['name']);
+
+ foreach ($types as $type => $model) {
+ $this->updateSearchable($model);
+ }
+ }
+
+ IndexAfterEvent::dispatch($this);
+ }
+
+ protected function updateSearchable(string $model): void
+ {
+ $model::where(fn ($query) => $model::makeAllSearchableUsing($query))
+ ->where(
+ (new $model)->getQualifiedUpdatedAtColumn(),
+ '>=',
+ $this->getLatestIndexDate()
+ )
+ ->searchable();
+ }
+
+ protected function getLatestIndexDate(): ?Carbon
+ {
+ if ($this->latestIndexDate) {
+ return $this->latestIndexDate;
+ }
+
+ if (! Storage::disk('local')->exists('/.last-index')) {
+ return null;
+ }
+
+ return $this->latestIndexDate ??= Carbon::parse(Storage::disk('local')->get('/.last-index'));
+ }
+}
diff --git a/src/Http/Controllers/CategoryController.php b/src/Http/Controllers/CategoryController.php
index 615c8bbb7..17772347f 100644
--- a/src/Http/Controllers/CategoryController.php
+++ b/src/Http/Controllers/CategoryController.php
@@ -10,7 +10,6 @@ public function show(int $categoryId)
$category = $categoryModel::findOrFail($categoryId);
config(['frontend.category' => $category->only('entity_id')]);
- config(['frontend.subcategories' => $category->subcategories->pluck('name', 'entity_id')]);
session(['latest_category_path' => $category->path]);
$response = response()->view('rapidez::category.overview', compact('category'));
diff --git a/src/Http/Controllers/CheckoutController.php b/src/Http/Controllers/CheckoutController.php
index 7701679f3..846ac2ca0 100644
--- a/src/Http/Controllers/CheckoutController.php
+++ b/src/Http/Controllers/CheckoutController.php
@@ -8,8 +8,7 @@ class CheckoutController
{
public function __invoke(Request $request, ?string $step = null)
{
- $checkoutSteps = config('rapidez.frontend.checkout_steps.' . config('rapidez.store_code'))
- ?: config('rapidez.frontend.checkout_steps.default');
+ $checkoutSteps = config('rapidez.frontend.checkout_steps');
if (! $step) {
$step = $checkoutSteps[0];
diff --git a/src/Http/Controllers/ConfigController.php b/src/Http/Controllers/ConfigController.php
new file mode 100644
index 000000000..d488e6c6b
--- /dev/null
+++ b/src/Http/Controllers/ConfigController.php
@@ -0,0 +1,259 @@
+getExposedConfigValues(),
+ $this->getConfig(),
+ );
+
+ return response()->view(
+ view: 'rapidez::layouts.config',
+ headers: ['Content-Type' => 'text/javascript'],
+ data: ['config' => $config],
+ );
+ }
+
+ public function getExposedConfigValues(): array
+ {
+ return Arr::only(
+ array_merge_recursive(
+ config('rapidez'),
+ config('rapidez.frontend'),
+ ),
+ array_merge(
+ config('rapidez.frontend.exposed'),
+ ['store_code', 'index', 'searchkit'],
+ ),
+ );
+ }
+
+ public function getConfig(): array
+ {
+ return [
+ 'cachekey' => Cache::rememberForever('cachekey', fn () => md5(Str::random())),
+ 'translations' => __('rapidez::frontend'),
+
+ 'index' => $this->getIndexNames(),
+ 'filterable_attributes' => $this->getFilterableAttributes(),
+ 'fragments' => $this->getGraphqlQueryFragments(),
+ 'max_category_level' => $this->getMaxCategoryLevel(),
+ 'queries' => $this->getGraphqlQueries(),
+ 'searchkit' => $this->getSearchkitConfig(),
+ 'show_customer_address_fields' => $this->getCustomerAddressFields(),
+ 'swatches' => $this->getSwatches(),
+
+ // Magento config data
+ 'currency' => Rapidez::config('currency/options/default'),
+ 'default_country' => Rapidez::config('general/country/default', 'NL'),
+ 'grid_per_page' => Rapidez::config('catalog/frontend/grid_per_page', 12),
+ 'grid_per_page_values' => explode(',', Rapidez::config('catalog/frontend/grid_per_page_values', '12,24,36')),
+ 'locale' => Rapidez::config('general/locale/code', 'en_US'),
+ 'recaptcha' => Rapidez::config('recaptcha_frontend/type_recaptcha_v3/public_key', null, true),
+ 'redirect_cart' => (bool) Rapidez::config('checkout/cart/redirect_to_cart'),
+ 'street_lines' => Rapidez::config('customer/address/street_lines', 2),
+ 'show_swatches' => (bool) Rapidez::config('catalog/frontend/show_swatches_in_product_list'),
+ 'show_tax' => in_array(Rapidez::config('tax/display/type', 1), [2, 3]),
+ ];
+ }
+
+ public function getSwatches(): array
+ {
+ $optionswatchModel = config('rapidez.models.option_swatch');
+
+ return $optionswatchModel::getCachedSwatchValues();
+ }
+
+ public function getIndexNames(): array
+ {
+ return collect(config('rapidez.models'))
+ ->filter(fn ($class) => in_array(Searchable::class, class_uses_recursive($class)))
+ ->map(fn ($class) => (new $class)->searchableAs())
+ ->toArray();
+ }
+
+ public function getCustomerAddressFields(): array
+ {
+ return [
+ 'prefix' => strlen(Rapidez::config('customer/address/prefix_options', '')) ? Rapidez::config('customer/address/prefix_show', 'opt') : 'opt',
+ 'firstname' => 'req',
+ 'middlename' => Rapidez::config('customer/address/middlename_show', 0) ? 'opt' : false,
+ 'lastname' => 'req',
+ 'suffix' => strlen(Rapidez::config('customer/address/suffix_options', '')) ? Rapidez::config('customer/address/suffix_show', 'opt') : 'opt',
+ 'postcode' => 'req',
+ 'housenumber' => Rapidez::config('customer/address/street_lines', 2) >= 2 ? 'req' : false,
+ 'addition' => Rapidez::config('customer/address/street_lines', 2) >= 3 ? 'opt' : false,
+ 'street' => 'req',
+ 'city' => 'req',
+ 'country_id' => 'req',
+ 'telephone' => Rapidez::config('customer/address/telephone_show', 'req'),
+ 'company' => Rapidez::config('customer/address/company_show', 'opt'),
+ 'vat_id' => Rapidez::config('customer/address/taxvat_show', 'opt'),
+ 'fax' => Rapidez::config('customer/address/fax_show', 'opt'),
+ ];
+ }
+
+ public function getFilterableAttributes(): array
+ {
+ $attributes = config('rapidez.models.attribute')::getCachedWhere(function ($attribute) {
+ return $attribute['filter'] || $attribute['sorting'];
+ });
+
+ return collect($attributes)
+ ->map(fn ($attribute) => [
+ ...$attribute,
+ 'code' => ($attribute['prefix'] ?? '') . $attribute['code'],
+ 'base_code' => $attribute['code'],
+ ])
+ ->sortBy('position')
+ ->values()
+ ->toArray();
+ }
+
+ public function getMaxCategoryLevel(): int
+ {
+ return Cache::rememberForever('max_category_level_' . config('rapidez.store'), fn () => Category::withoutGlobalScopes()->max('level'));
+ }
+
+ public function getGraphqlQueries(): array
+ {
+ $checkoutQueries = [
+ 'setGuestEmailOnCart',
+ 'setNewShippingAddressesOnCart',
+ 'setExistingShippingAddressesOnCart',
+ 'setNewBillingAddressOnCart',
+ 'setExistingBillingAddressOnCart',
+ 'setShippingMethodsOnCart',
+ 'setPaymentMethodOnCart',
+ 'placeOrder',
+ ];
+
+ $queries = Arr::mapWithKeys($checkoutQueries, fn ($query) => [$query => view('rapidez::checkout.queries.' . $query)->renderOneliner()]);
+ $queries['customer'] = view('rapidez::customer.queries.customer')->renderOneliner();
+
+ return $queries;
+ }
+
+ public function getGraphqlQueryFragments(): array
+ {
+ return [
+ 'cart' => view('rapidez::cart.queries.fragments.cart')->renderOneliner(),
+ 'order' => view('rapidez::checkout.queries.fragments.order')->renderOneliner(),
+ 'orderV2' => view('rapidez::checkout.queries.fragments.orderV2')->renderOneliner(),
+ ];
+ }
+
+ public function getSearchkitConfig(): array
+ {
+ return array_merge(
+ config('rapidez.searchkit'),
+ [
+ 'facet_attributes' => $this->getSearchkitFacetAttributes(),
+ 'search_attributes' => $this->getSearchkitSearchAttributes(),
+ 'sorting' => $this->getSearchkitSorting(),
+ ]
+ );
+
+ }
+
+ public function getSearchkitFacetAttributes(): array
+ {
+ // Get the filterable attributes and category levels
+ $filterableAttributes = collect($this->getFilterableAttributes())
+ ->map(function ($attribute) {
+ $isNumeric = $attribute['super'] || in_array($attribute['input'], ['boolean', 'price']);
+
+ return [
+ 'attribute' => $attribute['code'],
+ 'field' => $attribute['code'] . ($isNumeric ? '' : '.keyword'),
+ 'type' => $isNumeric ? 'numeric' : 'string',
+ ];
+ });
+
+ $maxLevel = $this->getMaxCategoryLevel();
+ $categoryLevels = collect(array_fill(1, $maxLevel, 'category_lvl'))
+ ->map(fn ($value, $key) => [
+ 'attribute' => $value . $key,
+ 'field' => $value . $key . '.keyword',
+ 'type' => 'string',
+ ]);
+
+ $facetAttributes = $filterableAttributes
+ ->concat($categoryLevels)
+ ->concat(config('rapidez.searchkit.facet_attributes'));
+
+ return $facetAttributes->toArray();
+ }
+
+ public function getSearchkitSearchAttributes(): array
+ {
+ $attributeModel = config('rapidez.models.attribute');
+
+ // Get all searchable attributes from Magento.
+ $searchableAttributes = $attributeModel::getCachedWhere(function ($attribute) {
+ return $attribute['search']
+ && in_array($attribute['type'], ['text', 'varchar', 'static']);
+ });
+
+ // Map and merge them with the config.
+ $searchableAttributes = collect($searchableAttributes)->map(fn ($attribute) => [
+ 'field' => $attribute['code'],
+ 'weight' => $attribute['search_weight'],
+ ])->merge(config('rapidez.searchkit.search_attributes'))->values()->toArray();
+
+ return $searchableAttributes;
+ }
+
+ public function getSearchkitSorting(): array
+ {
+ $attributeModel = config('rapidez.models.attribute');
+
+ // Get all sortable attributes from Magento and any that have been set manually in the config
+ $sortableAttributes = $attributeModel::getCachedWhere(function ($attribute) {
+ return $attribute['sorting'] || in_array($attribute['code'], array_keys(config('rapidez.searchkit.sorting')));
+ });
+
+ // Add `direction` to custom sortings
+ foreach (config('rapidez.searchkit.sorting') as $code => $directions) {
+ $attribute = collect($sortableAttributes)->search(fn ($attribute) => $attribute['code'] == $code);
+ if ($attribute) {
+ $sortableAttributes[$attribute]['directions'] = $directions;
+ }
+ }
+
+ $index = (new (config('rapidez.models.product')))->searchableAs();
+ $sortableAttributes = collect($sortableAttributes)
+ ->flatMap(fn ($attribute) => Arr::map(($attribute['directions'] ?? null) ?: ['asc', 'desc'], fn ($direction) => [
+ 'label' => trans_fallback(
+ "rapidez::frontend.sorting.{$attribute['code']}.{$direction}",
+ trans_fallback("rapidez::frontend.{$attribute['code']}", $attribute['code']) . ' ' . trans_fallback("rapidez::frontend.{$direction}", $direction),
+ ),
+ 'field' => $attribute['code'] . ($attribute['input'] == 'text' ? '.keyword' : ''),
+ 'order' => $direction,
+ 'value' => "{$index}_{$attribute['code']}_{$direction}",
+ 'key' => "_{$attribute['code']}_{$direction}",
+ ]));
+
+ // Add default relevance sort
+ $sortableAttributes->prepend([
+ 'label' => __('Relevance'),
+ 'field' => '_score',
+ 'order' => 'desc',
+ 'value' => $index,
+ 'key' => 'default',
+ ]);
+
+ return $sortableAttributes->keyBy('key')->toArray();
+ }
+}
diff --git a/src/Http/Controllers/IndexController.php b/src/Http/Controllers/IndexController.php
new file mode 100644
index 000000000..e8252a0bf
--- /dev/null
+++ b/src/Http/Controllers/IndexController.php
@@ -0,0 +1,104 @@
+validate([
+ 'ids' => 'array|required',
+ 'ids.*' => 'integer',
+ ]);
+
+ abort_if(! ($model = $this->resolveModel($model)), 404);
+
+ $passedStores = $request->input('stores');
+ if (! $passedStores) {
+ $this->updateSearchable($model, $request->input('ids'));
+
+ return;
+ }
+
+ $passedStores = Arr::wrap($passedStores);
+
+ if ($passedStores[0] === 'all' || $passedStores[0] === '*') {
+ $stores = Rapidez::getStores();
+ } else {
+ $stores = Rapidez::getStores($passedStores);
+ }
+
+ foreach ($stores as $store) {
+ Rapidez::setStore($store);
+
+ $this->updateSearchable($model, $request->input('ids'));
+ }
+ }
+
+ public function destroy(string $model, Request $request)
+ {
+ $request->validate([
+ 'ids' => 'array|required',
+ 'ids.*' => 'integer',
+ ]);
+
+ abort_if(! ($model = $this->resolveModel($model)), 404);
+
+ $passedStores = $request->input('stores');
+ if (! $passedStores) {
+ $this->deleteSearchable($model, $request->input('ids'));
+
+ return;
+ }
+
+ $passedStores = Arr::wrap($passedStores);
+
+ if ($passedStores[0] === 'all' || $passedStores[0] === '*') {
+ $stores = Rapidez::getStores();
+ } else {
+ $stores = Rapidez::getStores($passedStores);
+ }
+
+ foreach ($stores as $store) {
+ Rapidez::setStore($store);
+
+ $this->deleteSearchable($model, $request->input('ids'));
+ }
+ }
+
+ protected function deleteSearchable(string $model, array $ids): void
+ {
+ if (empty($ids)) {
+ $model::unsearchable();
+ } else {
+ $model::whereIn((new $model)->getQualifiedKeyName(), $ids)->unsearchable();
+ }
+ }
+
+ protected function updateSearchable(string $model, array $ids): void
+ {
+ $query = $model::where(fn ($query) => $model::makeAllSearchableUsing($query));
+
+ if (empty($ids)) {
+ $query->searchable();
+ } else {
+ $query->whereIn((new $model)->getQualifiedKeyName(), $ids)->searchable();
+ }
+ }
+
+ protected function resolveModel(string $model): ?string
+ {
+ $modelClass = Arr::get(config('rapidez.models'), $model);
+
+ if (! $modelClass || ! in_array(Searchable::class, class_uses_recursive($modelClass))) {
+ return null;
+ }
+
+ return $modelClass;
+ }
+}
diff --git a/src/Http/Controllers/SearchController.php b/src/Http/Controllers/SearchController.php
index 5184a01b1..2fc917ea2 100644
--- a/src/Http/Controllers/SearchController.php
+++ b/src/Http/Controllers/SearchController.php
@@ -4,23 +4,18 @@
use Illuminate\Http\Request;
use Illuminate\Support\Str;
+use Rapidez\Core\Models\Scopes\IsActiveScope;
+use Rapidez\Core\Models\SearchQuery;
class SearchController
{
public function __invoke(Request $request)
{
- $searchQuery = config('rapidez.models.search_query')::firstOrNew([
- 'query_text' => Str::lower($request->q),
- 'store_id' => config('rapidez.store'),
- ], ['popularity' => 1]);
-
- if (! $searchQuery->exists) {
- $searchQuery->save();
-
+ if (! $request->q) {
return view('rapidez::search.overview');
}
- $searchQuery->increment('popularity');
+ $searchQuery = $this->track($request);
if ($searchQuery->is_active === 1 && $searchQuery->redirect) {
return redirect($searchQuery->redirect, 301);
@@ -28,4 +23,46 @@ public function __invoke(Request $request)
return view('rapidez::search.overview');
}
+
+ public function store(Request $request)
+ {
+ if (! $request->q) {
+ return response()->json(['success' => true]);
+ }
+
+ $this->track($request);
+
+ return response()->json(['success' => true]);
+ }
+
+ public function track(Request $request): SearchQuery
+ {
+ // Prevent automatic indexing each time it is updated.
+ config('rapidez.models.search_query')::disableSearchSyncing();
+ $searchQuery = config('rapidez.models.search_query')::withoutGlobalScope(IsActiveScope::class)
+ ->firstOrNew(
+ [
+ 'query_text' => Str::lower($request->q),
+ 'store_id' => config('rapidez.store'),
+ ],
+ [
+ 'num_results' => $request->results ?? 0,
+ 'popularity' => 1,
+ ]
+ );
+
+ if (! $searchQuery->exists) {
+ $searchQuery->save();
+
+ return $searchQuery;
+ }
+
+ $searchQuery->popularity++;
+ if ($request->has('results')) {
+ $searchQuery->num_results = $request->results;
+ }
+ $searchQuery->save();
+
+ return $searchQuery;
+ }
}
diff --git a/src/Http/ViewComposers/ConfigComposer.php b/src/Http/ViewComposers/ConfigComposer.php
deleted file mode 100644
index 5c295c492..000000000
--- a/src/Http/ViewComposers/ConfigComposer.php
+++ /dev/null
@@ -1,75 +0,0 @@
-getConfig()
- ));
-
- Config::set('frontend.queries', [
- 'customer' => view('rapidez::customer.queries.customer')->renderOneliner(),
- 'setGuestEmailOnCart' => view('rapidez::checkout.queries.setGuestEmailOnCart')->renderOneliner(),
- 'setNewShippingAddressesOnCart' => view('rapidez::checkout.queries.setNewShippingAddressesOnCart')->renderOneliner(),
- 'setExistingShippingAddressesOnCart' => view('rapidez::checkout.queries.setExistingShippingAddressesOnCart')->renderOneliner(),
- 'setNewBillingAddressOnCart' => view('rapidez::checkout.queries.setNewBillingAddressOnCart')->renderOneliner(),
- 'setExistingBillingAddressOnCart' => view('rapidez::checkout.queries.setExistingBillingAddressOnCart')->renderOneliner(),
- 'setShippingMethodsOnCart' => view('rapidez::checkout.queries.setShippingMethodsOnCart')->renderOneliner(),
- 'setPaymentMethodOnCart' => view('rapidez::checkout.queries.setPaymentMethodOnCart')->renderOneliner(),
- 'placeOrder' => view('rapidez::checkout.queries.placeOrder')->renderOneliner(),
- ]);
-
- Config::set('frontend.fragments', [
- 'cart' => view('rapidez::cart.queries.fragments.cart')->renderOneliner(),
- 'order' => view('rapidez::checkout.queries.fragments.order')->renderOneliner(),
- 'orderV2' => view('rapidez::checkout.queries.fragments.orderV2')->renderOneliner(),
- ]);
-
- Event::dispatch('rapidez:frontend-config-composed');
- }
-
- public function getConfig(): array
- {
- $attributeModel = config('rapidez.models.attribute');
- $searchableAttributes = Arr::pluck(
- $attributeModel::getCachedWhere(fn ($attribute) => $attribute['search'] && in_array($attribute['type'], ['text', 'varchar', 'static'])
- ),
- 'search_weight',
- 'code'
- );
-
- return [
- 'locale' => Rapidez::config('general/locale/code'),
- 'default_country' => Rapidez::config('general/country/default', 'NL'),
- 'currency' => Rapidez::config('currency/options/default'),
- 'cachekey' => Cache::rememberForever('cachekey', fn () => md5(Str::random())),
- 'redirect_cart' => (bool) Rapidez::config('checkout/cart/redirect_to_cart'),
- 'show_swatches' => (bool) Rapidez::config('catalog/frontend/show_swatches_in_product_list'),
- 'translations' => __('rapidez::frontend'),
- 'recaptcha' => Rapidez::config('recaptcha_frontend/type_recaptcha_v3/public_key', null, true),
- 'searchable' => array_merge($searchableAttributes, config('rapidez.indexer.searchable')),
- 'street_lines' => Rapidez::config('customer/address/street_lines'),
- 'show_tax' => in_array(Rapidez::config('tax/display/type'), [2, 3]),
- 'grid_per_page' => Rapidez::config('catalog/frontend/grid_per_page'),
- 'grid_per_page_values' => explode(',', Rapidez::config('catalog/frontend/grid_per_page_values')),
- ];
- }
-}
diff --git a/src/Jobs/IndexJob.php b/src/Jobs/IndexJob.php
deleted file mode 100644
index 4f3214ac9..000000000
--- a/src/Jobs/IndexJob.php
+++ /dev/null
@@ -1,32 +0,0 @@
-index = $index;
- $this->id = $id;
- $this->values = ($values instanceof Arrayable ? $values->toArray() : (array) $values);
- }
-
- public function handle(Elasticsearch $elasticsearch)
- {
- $elasticsearch->index([
- 'index' => $this->index,
- 'id' => $this->id,
- 'body' => $this->values,
- ]);
- }
-}
diff --git a/src/Listeners/UpdateLatestIndexDate.php b/src/Listeners/UpdateLatestIndexDate.php
new file mode 100644
index 000000000..1c83d9cdf
--- /dev/null
+++ b/src/Listeners/UpdateLatestIndexDate.php
@@ -0,0 +1,26 @@
+put(
+ '/.last-index',
+ // With this we're just making sure the comparison
+ // is done within the same timezone in MySQL.
+ DB::scalar('SELECT NOW()')
+ );
+ }
+
+ public static function register()
+ {
+ Event::listen(IndexAfterEvent::class, static::class);
+ }
+}
diff --git a/src/Models/Attribute.php b/src/Models/Attribute.php
index 532d04a8a..d7c2eea7e 100644
--- a/src/Models/Attribute.php
+++ b/src/Models/Attribute.php
@@ -13,6 +13,8 @@ class Attribute extends Model
protected $primaryKey = 'attribute_id';
+ protected $appends = ['prefix'];
+
protected static function booting()
{
static::addGlobalScope(new OnlyProductAttributesScope);
@@ -21,13 +23,30 @@ protected static function booting()
protected function filter(): CastsAttribute
{
return CastsAttribute::make(
- get: fn ($value) => $value || in_array($this->code, config('rapidez.indexer.additional_filters')),
+ get: fn ($value) => $value || in_array($this->code, Arr::pluck(config('rapidez.searchkit.filter_attributes'), 'field')),
+ )->shouldCache();
+ }
+
+ protected function prefix(): CastsAttribute
+ {
+ return CastsAttribute::make(
+ get: function () {
+ if ($this->super) {
+ return 'super_';
+ }
+
+ if ($this->visual_swatch) {
+ return 'visual_';
+ }
+
+ return '';
+ }
)->shouldCache();
}
public static function getCachedWhere(callable $callback): array
{
- $attributes = Cache::store('rapidez:multi')->rememberForever('attributes.' . config('rapidez.store'), function () {
+ $attributes = Cache::memo()->rememberForever('attributes.' . config('rapidez.store'), function () {
return self::all()->toArray();
});
diff --git a/src/Models/Category.php b/src/Models/Category.php
index 9a5863a20..ba4cfa8be 100644
--- a/src/Models/Category.php
+++ b/src/Models/Category.php
@@ -7,10 +7,14 @@
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Rapidez\Core\Models\Scopes\IsActiveScope;
use Rapidez\Core\Models\Traits\HasAlternatesThroughRewrites;
+use Rapidez\Core\Models\Traits\Searchable;
class Category extends Model
{
use HasAlternatesThroughRewrites;
+ use Searchable;
+
+ protected $primaryKey = 'entity_id';
protected $casts = [
self::UPDATED_AT => 'datetime',
@@ -52,11 +56,6 @@ protected static function booting()
});
}
- public function getKeyName()
- {
- return $this->getTable() . '.entity_id';
- }
-
public function getTable()
{
return 'catalog_category_flat_store_' . config('rapidez.store');
@@ -100,8 +99,20 @@ public function getParentcategoriesAttribute()
$categoryIds = explode('/', $this->path);
$categoryIds = array_slice($categoryIds, array_search(config('rapidez.root_category_id'), $categoryIds) + 1);
- return ! $categoryIds ? [] : Category::whereIn($this->getTable() . '.entity_id', $categoryIds)
- ->orderByRaw('FIELD(' . $this->getTable() . '.entity_id,' . implode(',', $categoryIds) . ')')
+ return ! $categoryIds ? [] : Category::whereIn($this->getQualifiedKeyName(), $categoryIds)
+ ->orderByRaw('FIELD(' . $this->getQualifiedKeyName() . ',' . implode(',', $categoryIds) . ')')
->get();
}
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function makeAllSearchableUsing(Builder $query)
+ {
+ return $query->withEventyGlobalScopes('index.categories.scopes')
+ ->select((new (config('rapidez.models.category')))->qualifyColumns(['entity_id', 'name']))
+ ->whereNotNull('url_key')
+ ->whereNot('url_key', 'default-category')
+ ->has('products');
+ }
}
diff --git a/src/Models/Config.php b/src/Models/Config.php
index b16d9ee08..3c3535ae7 100644
--- a/src/Models/Config.php
+++ b/src/Models/Config.php
@@ -86,7 +86,7 @@ public static function getValue(
}
if ($options['cache'] ?? true) {
- $configCache = Cache::driver('rapidez:multi')->get('magento.config', []);
+ $configCache = Cache::memo()->get('magento.config', []);
$cacheKey = implode(
'.',
[
@@ -126,7 +126,7 @@ public static function getValue(
if (($options['cache'] ?? true) && isset($cacheKey)) {
Arr::set($configCache, $cacheKey, $resultObject ? $result : false);
- Cache::driver('rapidez:multi')->set('magento.config', $configCache);
+ Cache::memo()->set('magento.config', $configCache);
}
return (bool) $options['decrypt'] && is_string($result) ? static::decrypt($result) : $result;
diff --git a/src/Models/OptionValue.php b/src/Models/OptionValue.php
index faadc4b89..c74838166 100644
--- a/src/Models/OptionValue.php
+++ b/src/Models/OptionValue.php
@@ -13,7 +13,7 @@ class OptionValue extends Model
public static function getCachedByOptionId(int $optionId): string
{
$cacheKey = 'optionvalues.' . config('rapidez.store');
- $cache = Cache::store('rapidez:multi')->get($cacheKey, []);
+ $cache = Cache::memo()->get($cacheKey, []);
if (! isset($cache[$optionId])) {
$cache[$optionId] = html_entity_decode(self::where('option_id', $optionId)
@@ -21,7 +21,7 @@ public static function getCachedByOptionId(int $optionId): string
->orderByDesc('store_id')
->first('value')
->value ?? false);
- Cache::store('rapidez:multi')->forever($cacheKey, $cache);
+ Cache::memo()->forever($cacheKey, $cache);
}
return $cache[$optionId];
diff --git a/src/Models/Product.php b/src/Models/Product.php
index 99dbeb9e5..77498b0a5 100644
--- a/src/Models/Product.php
+++ b/src/Models/Product.php
@@ -23,6 +23,7 @@
use Rapidez\Core\Models\Traits\HasAlternatesThroughRewrites;
use Rapidez\Core\Models\Traits\Product\CastMultiselectAttributes;
use Rapidez\Core\Models\Traits\Product\CastSuperAttributes;
+use Rapidez\Core\Models\Traits\Product\Searchable;
use Rapidez\Core\Models\Traits\Product\SelectAttributeScopes;
use TorMorten\Eventy\Facades\Eventy;
@@ -31,8 +32,14 @@ class Product extends Model
use CastMultiselectAttributes;
use CastSuperAttributes;
use HasAlternatesThroughRewrites;
+ use Searchable;
use SelectAttributeScopes;
+ public const VISIBILITY_NOT_VISIBLE = 1;
+ public const VISIBILITY_IN_CATALOG = 2;
+ public const VISIBILITY_IN_SEARCH = 3;
+ public const VISIBILITY_BOTH = 4;
+
public array $attributesToSelect = [];
protected $primaryKey = 'entity_id';
diff --git a/src/Models/Scopes/Product/WithProductAttributesScope.php b/src/Models/Scopes/Product/WithProductAttributesScope.php
index 98add83ee..53bfcc135 100644
--- a/src/Models/Scopes/Product/WithProductAttributesScope.php
+++ b/src/Models/Scopes/Product/WithProductAttributesScope.php
@@ -25,7 +25,8 @@ public function apply(Builder $builder, Model $model)
$attributeModel = config('rapidez.models.attribute');
$attributes = $attributeModel::getCachedWhere(function ($attribute) use ($model) {
- return in_array($attribute['code'], $model->attributesToSelect);
+ return in_array($attribute['code'], $model->attributesToSelect)
+ && ! in_array($attribute['code'], ['visibility', 'sku', 'type_id', $model->getKeyName()]);
});
$attributes = array_filter($attributes, fn ($a) => $a['type'] !== 'static');
diff --git a/src/Models/SearchQuery.php b/src/Models/SearchQuery.php
index b43a43ecf..5fa1c4eb4 100644
--- a/src/Models/SearchQuery.php
+++ b/src/Models/SearchQuery.php
@@ -2,8 +2,14 @@
namespace Rapidez\Core\Models;
+use Illuminate\Database\Eloquent\Builder;
+use Rapidez\Core\Models\Scopes\IsActiveScope;
+use Rapidez\Core\Models\Traits\Searchable;
+
class SearchQuery extends Model
{
+ use Searchable;
+
const CREATED_AT = null;
protected $table = 'search_query';
@@ -11,4 +17,19 @@ class SearchQuery extends Model
protected $primaryKey = 'query_id';
protected $guarded = [];
+
+ protected static function booting()
+ {
+ static::addGlobalScope('ForCurrentStore', fn (Builder $query) => $query->where('store_id', config('rapidez.store')));
+ static::addGlobalScope(new IsActiveScope);
+ }
+
+ protected function makeAllSearchableUsing(Builder $query)
+ {
+ return $query
+ ->where(fn ($subQuery) => $subQuery
+ ->where('num_results', '>', 0)
+ ->orWhereNotNull('redirect')
+ );
+ }
}
diff --git a/src/Models/Store.php b/src/Models/Store.php
index bc4377cb4..ea7acde9e 100644
--- a/src/Models/Store.php
+++ b/src/Models/Store.php
@@ -27,7 +27,7 @@ protected static function booting()
public static function getCached(): array
{
- $stores = Cache::store('rapidez:multi')->rememberForever('stores', function () {
+ $stores = Cache::memo()->rememberForever('stores', function () {
return self::select([
'store_id',
'store.name',
diff --git a/src/Models/Traits/Product/Searchable.php b/src/Models/Traits/Product/Searchable.php
new file mode 100644
index 000000000..2266b71a7
--- /dev/null
+++ b/src/Models/Traits/Product/Searchable.php
@@ -0,0 +1,144 @@
+selectOnlyIndexable()
+ ->with(['categoryProducts', 'reviewSummary'])
+ ->withEventyGlobalScopes('index.product.scopes');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function shouldBeSearchable(): bool
+ {
+ if (! in_array($this->visibility, [
+ Product::VISIBILITY_IN_CATALOG,
+ Product::VISIBILITY_IN_SEARCH,
+ Product::VISIBILITY_BOTH,
+ ])) {
+ return false;
+ }
+
+ $showOutOfStock = (bool) Rapidez::config('cataloginventory/options/show_out_of_stock', 0);
+ if (! $showOutOfStock && ! $this->in_stock) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function toSearchableArray(): array
+ {
+ $data = $this->toArray();
+
+ $data['store'] = config('rapidez.store');
+
+ $maxPositions = Cache::driver('array')->rememberForever('max-positions-' . config('rapidez.store'), function () {
+ return CategoryProduct::query()
+ ->selectRaw('GREATEST(MAX(position), 0) as position')
+ ->addSelect('category_id')
+ ->groupBy('category_id')
+ ->pluck('position', 'category_id');
+ });
+
+ foreach ($this->super_attributes ?: [] as $superAttribute) {
+ $data['super_' . $superAttribute->code] = $superAttribute->text_swatch || $superAttribute->visual_swatch
+ ? array_keys((array) $this->{'super_' . $superAttribute->code})
+ : Arr::pluck($this->{'super_' . $superAttribute->code} ?: [], 'label');
+ }
+
+ $data = $this->withCategories($data);
+
+ $data['positions'] = $this->categoryProducts
+ ->pluck('position', 'category_id')
+ // Turn all positions positive
+ ->mapWithKeys(fn ($position, $category_id) => [$category_id => $maxPositions[$category_id] - $position]);
+
+ return Eventy::filter('index.' . static::getIndexName() . '.data', $data, $this);
+ }
+
+ /**
+ * Add the category paths
+ */
+ public function withCategories(array $data): array
+ {
+ $categories = Cache::driver('array')->rememberForever('categories-' . config('rapidez.store'), function () {
+ return Category::withEventyGlobalScopes('index.category.scopes')
+ ->where('catalog_category_flat_store_' . config('rapidez.store') . '.entity_id', '<>', config('rapidez.root_category_id'))
+ ->pluck('name', 'entity_id');
+ });
+
+ foreach ($data['category_paths'] as $categoryPath) {
+ $paths = explode('/', $categoryPath);
+ $paths = array_slice($paths, array_search(config('rapidez.root_category_id'), $paths) + 1);
+
+ $categoryHierarchy = [];
+ $currentPath = '';
+
+ foreach ($paths as $categoryId) {
+ if (isset($categories[$categoryId])) {
+ $currentPath .= ($currentPath ? ' > ' : '') . $categories[$categoryId];
+ $categoryHierarchy[] = $currentPath;
+ }
+ }
+
+ foreach ($categoryHierarchy as $level => $category) {
+ $data['category_lvl' . ($level + 1)][] = $category;
+ }
+ }
+
+ foreach ($data as $key => &$value) {
+ if (str_starts_with($key, 'category_lvl')) {
+ $value = array_values(array_unique($value));
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public static function getIndexMappings(): ?array
+ {
+ return [
+ 'properties' => [
+ 'price' => [
+ 'type' => 'double',
+ ],
+ 'children' => [
+ 'type' => 'flattened',
+ ],
+ 'grouped' => [
+ 'type' => 'flattened',
+ ],
+ 'positions' => [
+ 'type' => 'flattened',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/src/Models/Traits/Searchable.php b/src/Models/Traits/Searchable.php
new file mode 100644
index 000000000..26d86fee9
--- /dev/null
+++ b/src/Models/Traits/Searchable.php
@@ -0,0 +1,46 @@
+toArray(), $this);
+ }
+
+ public static function getIndexName(): string
+ {
+ return static::$indexName ?? Str::snake(Str::pluralStudly(class_basename(static::class)));
+ }
+
+ public function searchableAs(): string
+ {
+ return implode('_', array_values([
+ config('scout.prefix'),
+ static::getIndexName(),
+ config('rapidez.store'),
+ ]));
+ }
+
+ public static function getIndexMappings(): ?array
+ {
+ return null;
+ }
+
+ public static function getIndexSettings(): ?array
+ {
+ return null;
+ }
+}
diff --git a/src/Rapidez.php b/src/Rapidez.php
index d1adef240..d37304409 100644
--- a/src/Rapidez.php
+++ b/src/Rapidez.php
@@ -86,7 +86,7 @@ public function fancyMagentoSyntaxDecoder(string $encodedString): object
return json_decode(str_replace(array_values($mapping), array_keys($mapping), $encodedString));
}
- public function getStores(callable|int|string|null $store = null): array
+ public function getStores(callable|array|int|string|null $store = null): array
{
$storeModel = config('rapidez.models.store');
@@ -94,7 +94,7 @@ public function getStores(callable|int|string|null $store = null): array
return Arr::where($storeModel::getCached(),
fn ($s) => is_callable($store)
? $store($s)
- : $s['store_id'] == $store || $s['code'] == $store
+ : in_array($s['store_id'], Arr::wrap($store)) || in_array($s['code'], Arr::wrap($store))
);
}
@@ -130,6 +130,22 @@ public function setStore(Store|array|callable|int|string $store): void
config()->set('rapidez.root_category_id', $store['root_category_id']);
config()->set('frontend.base_url', url('/'));
+ // This loop goes through all the Rapidez config files and retrieves the store-specific values.
+ // We also remember some `default` values along the way. This allows us to switch stores multiple
+ // times in one session, without losing any data that got overwritten by the store-specific values.
+ foreach (array_keys(config('rapidez')) as $config) {
+ // Reset defaults if they've been set previously
+ foreach (config('rapidez.defaults.' . $config, []) as $key => $value) {
+ config()->set('rapidez.' . $config . '.' . $key, $value);
+ }
+
+ // Set all store-specific values and define the relevant defaults
+ foreach (config('rapidez.stores.' . $store['code'] . '.' . $config, []) as $key => $value) {
+ config()->set('rapidez.defaults.' . $config . '.' . $key, config('rapidez.' . $config . '.' . $key));
+ config()->set('rapidez.' . $config . '.' . $key, $value);
+ }
+ }
+
if (config()->get('rapidez.magento_url_from_db', false)) {
$magentoUrl = trim(
Config::getValue('web/secure/base_url', ConfigScopes::SCOPE_WEBSITE) ?? config()->get('rapidez.magento_url'),
diff --git a/src/RapidezServiceProvider.php b/src/RapidezServiceProvider.php
index aa5bcca3d..bf1c794a0 100644
--- a/src/RapidezServiceProvider.php
+++ b/src/RapidezServiceProvider.php
@@ -2,13 +2,14 @@
namespace Rapidez\Core;
+use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Event;
-use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\Vite;
use Illuminate\Support\ServiceProvider;
@@ -17,12 +18,11 @@
use Lcobucci\JWT\Validation\RequiredConstraintsViolated;
use Rapidez\Core\Auth\MagentoCartTokenGuard;
use Rapidez\Core\Auth\MagentoCustomerTokenGuard;
-use Rapidez\Core\Commands\IndexCategoriesCommand;
-use Rapidez\Core\Commands\IndexProductsCommand;
+use Rapidez\Core\Commands\IndexCommand;
use Rapidez\Core\Commands\InstallCommand;
use Rapidez\Core\Commands\InstallTestsCommand;
+use Rapidez\Core\Commands\UpdateIndexCommand;
use Rapidez\Core\Commands\ValidateCommand;
-use Rapidez\Core\Events\IndexBeforeEvent;
use Rapidez\Core\Events\ProductViewEvent;
use Rapidez\Core\Facades\Rapidez as RapidezFacade;
use Rapidez\Core\Http\Controllers\Fallback\CmsPageController;
@@ -30,11 +30,11 @@
use Rapidez\Core\Http\Controllers\Fallback\UrlRewriteController;
use Rapidez\Core\Http\Middleware\CheckStoreCode;
use Rapidez\Core\Http\Middleware\DetermineAndSetShop;
-use Rapidez\Core\Http\ViewComposers\ConfigComposer;
use Rapidez\Core\Listeners\Healthcheck\ElasticsearchHealthcheck;
use Rapidez\Core\Listeners\Healthcheck\MagentoSettingsHealthcheck;
use Rapidez\Core\Listeners\Healthcheck\ModelsHealthcheck;
use Rapidez\Core\Listeners\ReportProductView;
+use Rapidez\Core\Listeners\UpdateLatestIndexDate;
use Rapidez\Core\ViewComponents\PlaceholderComponent;
use Rapidez\Core\ViewDirectives\WidgetDirective;
use Symfony\Component\HttpKernel\Exception\HttpException;
@@ -44,11 +44,11 @@ class RapidezServiceProvider extends ServiceProvider
protected $configFiles = [
'frontend',
'healthcheck',
- 'indexer',
'jwt',
'magento-defaults',
'models',
'routing',
+ 'searchkit',
'system',
];
@@ -62,6 +62,7 @@ public function boot()
->bootViews()
->bootBladeComponents()
->bootMiddleware()
+ ->bootScout()
->bootTranslations()
->bootListeners()
->bootMacros();
@@ -89,17 +90,13 @@ protected function bootAuth(): self
protected function bootCommands(): self
{
$this->commands([
- IndexProductsCommand::class,
- IndexCategoriesCommand::class,
+ IndexCommand::class,
+ UpdateIndexCommand::class,
ValidateCommand::class,
InstallCommand::class,
InstallTestsCommand::class,
]);
- Event::listen(IndexBeforeEvent::class, function ($event) {
- $event->context->call('rapidez:index:categories');
- });
-
return $this;
}
@@ -139,6 +136,10 @@ protected function bootPublishables(): self
protected function bootRoutes(): self
{
+ RateLimiter::for('search-analytics', function (Request $request) {
+ return Limit::perMinute(30)->by($request->ip());
+ });
+
if (config('rapidez.routing.enabled')) {
$this->loadRoutesFrom(__DIR__ . '/../routes/api.php');
$this->loadRoutesFrom(__DIR__ . '/../routes/magento-redirects.php');
@@ -162,34 +163,10 @@ protected function bootRoutes(): self
return $this;
}
- protected function registerThemes(): self
- {
- if (app()->runningInConsole()) {
- return $this;
- }
-
- $path = config('rapidez.frontend.themes.' . request()->server('MAGE_RUN_CODE', request()->has('_store') && ! app()->isProduction() ? request()->get('_store') : 'default'), false);
-
- if (! $path) {
- return $this;
- }
-
- config([
- 'view.paths' => [
- $path,
- ...config('view.paths'),
- ],
- ]);
-
- return $this;
- }
-
protected function bootViews(): self
{
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'rapidez');
- View::composer('rapidez::layouts.app', ConfigComposer::class);
-
View::addExtension('graphql', 'blade');
Vite::useScriptTagAttributes(fn (string $src, string $url, ?array $chunk, ?array $manifest) => [
@@ -249,6 +226,13 @@ protected function bootMiddleware(): self
return $this;
}
+ protected function bootScout(): self
+ {
+ config()->set('scout.driver', 'Matchish\\ScoutElasticSearch\\Engines\\ElasticSearchEngine');
+
+ return $this;
+ }
+
protected function bootTranslations(): self
{
$this->loadTranslationsFrom(__DIR__ . '/../lang', 'rapidez');
@@ -263,6 +247,7 @@ protected function bootListeners(): self
ModelsHealthcheck::register();
MagentoSettingsHealthcheck::register();
ElasticsearchHealthcheck::register();
+ UpdateLatestIndexDate::register();
return $this;
}
@@ -283,6 +268,25 @@ protected function bootMacros(): self
->squish();
});
+ Vite::macro('getPathsByFilenames', function ($filenames) {
+ /** @var \Illuminate\Foundation\Vite $this */
+ $filenames = is_array($filenames) ? $filenames : func_get_args();
+ $manifest = $this->manifest($this->buildDirectory); // @phpstan-ignore-line False positive, the macro bind allows us to access protected properties.
+
+ return array_filter(
+ array_map(
+ function ($filename) use ($manifest) {
+ foreach ($manifest as $path => $asset) {
+ if (Str::endsWith($asset['name'] ?? '', $filename) || Str::endsWith($path, $filename)) {
+ return $path;
+ }
+ }
+ },
+ $filenames
+ )
+ );
+ });
+
return $this;
}
@@ -293,23 +297,28 @@ protected function registerConfigs(): self
$this->mergeConfigFrom(__DIR__ . '/../config/rapidez/' . $configFile . '.php', 'rapidez.' . $configFile);
}
- if (! config('cache.stores.rapidez:multi', false)) {
- $fallbackDriver = config('cache.default');
- if ($fallbackDriver === 'rapidez:multi') {
- $fallbackDriver = config('cache.multi-fallback', 'file');
- Log::warning('Default cache driver is rapidez:multi, setting fallback driver to ' . $fallbackDriver);
- }
+ return $this;
+ }
+
+ protected function registerThemes(): self
+ {
+ if (app()->runningInConsole()) {
+ return $this;
+ }
+
+ $path = config('rapidez.frontend.theme', false);
- config()->set('cache.stores.rapidez:multi', [
- 'driver' => 'multi',
- 'stores' => [
- 'array',
- $fallbackDriver,
- ],
- 'sync_missed_stores' => true,
- ]);
+ if (! $path) {
+ return $this;
}
+ config([
+ 'view.paths' => [
+ $path,
+ ...config('view.paths'),
+ ],
+ ]);
+
return $this;
}
diff --git a/src/helpers.php b/src/helpers.php
index cb1a89699..485a7c53c 100644
--- a/src/helpers.php
+++ b/src/helpers.php
@@ -1,5 +1,7 @@
$asset) {
- if (Str::endsWith($path, $file)) {
- return $path;
- }
- }
- }
+ return vite_filename_paths($file)[0] ?? null;
+ }
+}
+
+if (! function_exists('vite_filename_paths')) {
+ function vite_filename_paths($file)
+ {
+ return Vite::getPathsByFilenames($file); // @phpstan-ignore-line This is a macro bind, which is not recognized by PHPStan.
+ }
+}
+
+if (! function_exists('trans_fallback')) {
+ function trans_fallback($key, $fallback, $replace = [], $locale = null)
+ {
+ $translator = app('translator');
+
+ return $translator->has($key) ? $translator->get($key, $replace, $locale) : $fallback;
}
}
diff --git a/tailwind.config.js b/tailwind.config.js
index 98e144059..212703a51 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -48,12 +48,14 @@ export default {
},
border: {
+ active: color('--border-active', colors.slate[800]),
emphasis: color('--border-emphasis', colors.slate[400]),
DEFAULT: color('--border', colors.slate[300]),
muted: color('--border-muted', colors.slate[100]),
},
background: {
+ active: color('--background-active', colors.slate[800]),
emphasis: color('--background-emphasis', colors.slate[200]),
DEFAULT: color('--background', colors.slate[100]),
muted: color('--background-muted', colors.slate[50]),
@@ -79,12 +81,18 @@ export default {
'cookie': '140',
},
- textColor: (theme) => theme('colors.foreground'),
+ textColor: (theme) => ({
+ default: theme('colors.foreground'),
+ ...theme('colors.foreground'),
+ }),
borderColor: (theme) => ({
default: theme('colors.border'),
...theme('colors.border'),
}),
- backgroundColor: (theme) => theme('colors.background'),
+ backgroundColor: (theme) => ({
+ default: theme('colors.background'),
+ ...theme('colors.background'),
+ }),
ringColor: (theme) => ({
default: theme('colors.border'),
...theme('colors.border'),
diff --git a/tests/Browser/OnestepCheckoutTest.php b/tests/Browser/OnestepCheckoutTest.php
index 36da369cf..03f545017 100644
--- a/tests/Browser/OnestepCheckoutTest.php
+++ b/tests/Browser/OnestepCheckoutTest.php
@@ -14,7 +14,7 @@ protected function setUp(): void
$this->config = file_get_contents(__DIR__ . '/../../config/rapidez/frontend.php');
$config = config('rapidez.frontend');
- $config['checkout_steps']['default'] = ['onestep'];
+ $config['checkout_steps'] = ['onestep'];
file_put_contents(__DIR__ . '/../../config/rapidez/frontend.php', '