Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions core/components/minishop3/config/routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,11 @@
$controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx);
return $controller->getList($params);
});

$router->get('/filters', function ($params) use ($modx) {
$controller = new \MiniShop3\Controllers\Api\Web\ProductController($modx);
return $controller->filters($params);
});
});

// Public category catalog — no TokenMiddleware (headless nav / PLP)
Expand Down
2 changes: 2 additions & 0 deletions core/components/minishop3/lexicon/en/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@
$_lang['ms3_err_catalog_option_key_invalid'] = 'Invalid option key';
$_lang['ms3_err_catalog_option_value_invalid'] = 'Invalid option value';
$_lang['ms3_err_catalog_option_unknown'] = 'Unknown option key';
$_lang['ms3_err_catalog_facet_keys_invalid'] = 'Invalid facet keys parameter';
$_lang['ms3_err_catalog_facet_keys_limit'] = 'Too many facet option keys';
$_lang['ms3_category_products_reordered'] = 'Products reordered successfully';
$_lang['ms3_category_product_published'] = 'Product published';
$_lang['ms3_category_product_unpublished'] = 'Product unpublished';
Expand Down
2 changes: 2 additions & 0 deletions core/components/minishop3/lexicon/ru/default.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,8 @@
$_lang['ms3_err_catalog_option_key_invalid'] = 'Некорректный ключ опции';
$_lang['ms3_err_catalog_option_value_invalid'] = 'Некорректное значение опции';
$_lang['ms3_err_catalog_option_unknown'] = 'Неизвестный ключ опции';
$_lang['ms3_err_catalog_facet_keys_invalid'] = 'Некорректный параметр keys для facets';
$_lang['ms3_err_catalog_facet_keys_limit'] = 'Слишком много ключей опций для facets';
$_lang['ms3_category_products_reordered'] = 'Порядок товаров успешно изменён';
$_lang['ms3_category_product_published'] = 'Товар опубликован';
$_lang['ms3_category_product_unpublished'] = 'Товар снят с публикации';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

use Phinx\Migration\AbstractMigration;

/**
* Composite indexes on ms3_product_options for storefront facets (#565).
*
* Facet queries filter by key (+ product scope). Product option loads filter by
* product_id (+ key). Single-column indexes are superseded by these composites
* (leftmost prefix covers the old lookups).
*/
final class AddProductOptionsCompositeIndexes extends AbstractMigration
{
private const TABLE = 'ms3_product_options';

private const INDEX_KEY_PRODUCT = 'key_product_id';
private const INDEX_PRODUCT_KEY = 'product_id_key';

public function up(): void
{
$table = $this->table(self::TABLE);

if (!$table->hasIndexByName(self::INDEX_KEY_PRODUCT)) {
$table->addIndex(['key', 'product_id'], [
'name' => self::INDEX_KEY_PRODUCT,
]);
}

if (!$table->hasIndexByName(self::INDEX_PRODUCT_KEY)) {
$table->addIndex(['product_id', 'key'], [
'name' => self::INDEX_PRODUCT_KEY,
]);
}

$table->update();

$table = $this->table(self::TABLE);

// Drop redundant single-column indexes after composites exist.
if ($table->hasIndexByName('key') && $table->hasIndexByName(self::INDEX_KEY_PRODUCT)) {
$table->removeIndexByName('key');
}
if ($table->hasIndexByName('product_id') && $table->hasIndexByName(self::INDEX_PRODUCT_KEY)) {
$table->removeIndexByName('product_id');
}

$table->update();
}

public function down(): void
{
$table = $this->table(self::TABLE);

if (!$table->hasIndexByName('product_id')) {
$table->addIndex(['product_id'], ['name' => 'product_id']);
}
if (!$table->hasIndexByName('key')) {
$table->addIndex(['key'], ['name' => 'key']);
}

$table->update();

$table = $this->table(self::TABLE);

if ($table->hasIndexByName(self::INDEX_KEY_PRODUCT)) {
$table->removeIndexByName(self::INDEX_KEY_PRODUCT);
}
if ($table->hasIndexByName(self::INDEX_PRODUCT_KEY)) {
$table->removeIndexByName(self::INDEX_PRODUCT_KEY);
}

$table->update();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use MiniShop3\Router\Response;
use MiniShop3\Services\Product\ProductCatalogFilterException;
use MiniShop3\Services\Product\ProductCatalogService;
use MiniShop3\Services\Product\ProductFacetService;
use MODX\Revolution\modX;

/**
Expand Down Expand Up @@ -76,11 +77,41 @@ public function getList(array $params = []): Response
return Response::success($result);
}

/**
* GET /api/v1/product/filters
*
* Soft facets for headless PLP (#565). Same filter query params as product/list,
* plus keys, include_price (default 1), include_vendors (default 0).
*
* @param array<string, mixed> $params
*/
public function filters(array $params = []): Response
{
try {
$result = $this->facets()->getFilters($params);
} catch (ProductCatalogFilterException $e) {
return Response::error(
$this->modx->lexicon($e->getLexiconKey()),
HttpStatus::BAD_REQUEST
);
}

return Response::success($result);
}

private function catalog(): ProductCatalogService
{
/** @var ProductCatalogService $service */
$service = $this->modx->services->get('ms3_product_catalog');

return $service;
}

private function facets(): ProductFacetService
{
/** @var ProductFacetService $service */
$service = $this->modx->services->get('ms3_product_facets');

return $service;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class TokenMiddleware implements MiddlewareInterface
private array $publicRoutes = [
'/api/v1/product/get/',
'/api/v1/product/list',
'/api/v1/product/filters',
'/api/v1/category/get/',
'/api/v1/category/list',
'/api/v1/category/tree',
Expand Down
20 changes: 16 additions & 4 deletions core/components/minishop3/src/Model/mysql/msProductOption.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,20 @@ class msProductOption extends \MiniShop3\Model\msProductOption
],
'indexes' =>
[
'product_id' =>
'key_product_id' =>
[
'alias' => 'product_id',
'alias' => 'key_product_id',
'primary' => false,
'unique' => false,
'type' => 'BTREE',
'columns' =>
[
'key' =>
[
'length' => '',
'collation' => 'A',
'null' => false,
],
'product_id' =>
[
'length' => '',
Expand All @@ -62,14 +68,20 @@ class msProductOption extends \MiniShop3\Model\msProductOption
],
],
],
'key' =>
'product_id_key' =>
[
'alias' => 'key',
'alias' => 'product_id_key',
'primary' => false,
'unique' => false,
'type' => 'BTREE',
'columns' =>
[
'product_id' =>
[
'length' => '',
'collation' => 'A',
'null' => false,
],
'key' =>
[
'length' => '',
Expand Down
4 changes: 4 additions & 0 deletions core/components/minishop3/src/ServiceRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ class ServiceRegistry
'class' => \MiniShop3\Services\Product\ProductCatalogService::class,
'interface' => null,
],
'ms3_product_facets' => [
'class' => \MiniShop3\Services\Product\ProductFacetService::class,
'interface' => null,
],
'ms3_category_catalog' => [
'class' => \MiniShop3\Services\Category\CategoryCatalogService::class,
'interface' => null,
Expand Down
1 change: 1 addition & 0 deletions core/components/minishop3/src/ServiceRegistryFactories.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ public static function map(): array
'ms3_product_category_tree' => $modxOnly(),
'ms3_product_link_service' => $modxOnly(),
'ms3_product_catalog' => $modxOnly(),
'ms3_product_facets' => $modxOnly(),
'ms3_category_catalog' => $modxOnly(),
'ms3_delivery_catalog' => $modxOnly(),
'ms3_payment_catalog' => $modxOnly(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
final class ProductCatalogFilterApplier
{
/** Depth when nested=1 (matches ms3_products default depth window). */
private const NESTED_DEPTH = 10;
public const NESTED_DEPTH = 10;

public function __construct(
private modX $modx,
Expand Down Expand Up @@ -95,7 +95,7 @@ private function applyOptionFilters(
return;
}

$this->assertOptionKeysExist(array_keys($filters->options));
$this->assertKnownOptionKeys(array_keys($filters->options));

$index = 0;
foreach ($filters->options as $key => $values) {
Expand All @@ -120,8 +120,12 @@ private function applyOptionFilters(
/**
* @param list<string> $keys
*/
private function assertOptionKeysExist(array $keys): void
public function assertKnownOptionKeys(array $keys): void
{
if ($keys === []) {
return;
}

$c = $this->modx->newQuery(msOption::class);
$c->where(['key:IN' => $keys]);
$c->select('key');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,68 @@ public function hasDataFilters(): bool
|| $this->flagFavorite
|| $this->options !== [];
}

/**
* Soft price facet: bounds ignore selected price_min/price_max so the slider does not collapse.
*/
public function withoutPriceBounds(): self
{
if ($this->priceMin === null && $this->priceMax === null) {
return $this;
}

return $this->with(['priceMin' => null, 'priceMax' => null]);
}

/**
* Soft option facet: buckets for $key ignore selected values of that key only.
*/
public function withoutOptionKey(string $key): self
{
if (!array_key_exists($key, $this->options)) {
return $this;
}

$options = $this->options;
unset($options[$key]);

return $this->with(['options' => $options]);
}

/**
* Soft vendor facet: buckets ignore selected vendor_id filter.
*/
public function withoutVendors(): self
{
if ($this->vendorIds === []) {
return $this;
}

return $this->with(['vendorIds' => []]);
}

/**
* @param array{
* priceMin?: float|null,
* priceMax?: float|null,
* vendorIds?: list<int>,
* options?: array<string, list<string>>
* } $overrides
*/
private function with(array $overrides): self
{
return new self(
parentIds: $this->parentIds,
nested: $this->nested,
priceMin: array_key_exists('priceMin', $overrides) ? $overrides['priceMin'] : $this->priceMin,
priceMax: array_key_exists('priceMax', $overrides) ? $overrides['priceMax'] : $this->priceMax,
inStock: $this->inStock,
stockMin: $this->stockMin,
vendorIds: array_key_exists('vendorIds', $overrides) ? $overrides['vendorIds'] : $this->vendorIds,
flagNew: $this->flagNew,
flagPopular: $this->flagPopular,
flagFavorite: $this->flagFavorite,
options: array_key_exists('options', $overrides) ? $overrides['options'] : $this->options,
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,21 @@ private function countList(array $params, ProductCatalogFilterSpec $filters): in
return (int) $countQuery->stmt->fetchColumn();
}

/**
* Storefront-scoped product query (visibility + context + list filters).
* Shared with product/filters facets (#565).
*
* @param array<string, mixed> $params
* @param bool $dedupeRows GROUP BY for list pages; false for COUNT(DISTINCT) / aggregates
*/
public function buildScopedListQuery(
array $params,
ProductCatalogFilterSpec $filters,
bool $dedupeRows = true,
): xPDOQuery {
return $this->buildListQuery($params, $filters, $dedupeRows);
}

/**
* @param array<string, mixed> $params
* @param bool $dedupeRows GROUP BY for list pages; false for COUNT(DISTINCT) queries
Expand Down
Loading