feat(filters): add filters for the main page

This commit is contained in:
2025-10-06 13:49:27 +03:00
parent bfc6ba496b
commit e7e045b695
65 changed files with 1172 additions and 525 deletions

View File

@@ -14,6 +14,12 @@ services:
resources: resources:
limits: limits:
memory: 512M memory: 512M
healthcheck:
test: [ "CMD", "curl" ,"-f", "http://localhost/index.php?route=extension/tgshop/handle&api_action=health" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
environment: environment:
- WEB_DOCUMENT_ROOT=/web/upload - WEB_DOCUMENT_ROOT=/web/upload
- PHP_DISPLAY_ERRORS=1 - PHP_DISPLAY_ERRORS=1
@@ -25,6 +31,8 @@ services:
- PHP_IDE_CONFIG=serverName=orbstack - PHP_IDE_CONFIG=serverName=orbstack
- php.session.gc_maxlifetime=28800 - php.session.gc_maxlifetime=28800
- php.session.cookie_lifetime=0 - php.session.cookie_lifetime=0
depends_on:
- mysql
mysql: mysql:
image: mariadb:10.2.7 image: mariadb:10.2.7
@@ -34,7 +42,7 @@ services:
- MYSQL_ROOT_PASSWORD=secret - MYSQL_ROOT_PASSWORD=secret
- MYSQL_DATABASE=ocstore3 - MYSQL_DATABASE=ocstore3
healthcheck: healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"] test: [ "CMD", "mysqladmin" ,"ping", "-h", "localhost", "-u", "root", "-psecret" ]
timeout: 20s timeout: 20s
retries: 10 retries: 10
ports: ports:

View File

@@ -11,6 +11,7 @@ abstract class BaseRule
public const CRITERIA_OPTION_NUMBER = 'number'; public const CRITERIA_OPTION_NUMBER = 'number';
public const CRITERIA_OPTION_BOOLEAN = 'boolean'; public const CRITERIA_OPTION_BOOLEAN = 'boolean';
public const CRITERIA_OPTION_PRODUCT_CATEGORIES = 'product_categories'; public const CRITERIA_OPTION_PRODUCT_CATEGORIES = 'product_categories';
public const CRITERIA_OPTION_PRODUCT_CATEGORY = 'product_category';
public const CRITERIA_OPTION_PRODUCT_MANUFACTURER = 'product_manufacturer'; public const CRITERIA_OPTION_PRODUCT_MANUFACTURER = 'product_manufacturer';
public const CRITERIA_OPTION_PRODUCT_ATTRIBUTE = 'product_attribute'; public const CRITERIA_OPTION_PRODUCT_ATTRIBUTE = 'product_attribute';
public const CRITERIA_OPTION_PRODUCT_MODEL = 'product_model'; public const CRITERIA_OPTION_PRODUCT_MODEL = 'product_model';

View File

@@ -22,6 +22,7 @@ class Builder
public $joins = []; public $joins = [];
public $wheres = []; public $wheres = [];
public $orders = []; public $orders = [];
public $groupBy = [];
public $limit; public $limit;
public $offset; public $offset;
public $distinct = false; public $distinct = false;
@@ -447,4 +448,11 @@ class Builder
return count($join) > 0; return count($join) > 0;
} }
public function groupBy(array $columns): Builder
{
$this->groupBy = $columns;
return $this;
}
} }

View File

@@ -17,6 +17,7 @@ abstract class Grammar
'orders' => [], 'orders' => [],
'limit' => [], 'limit' => [],
'offset' => [], 'offset' => [],
'groupBy' => [],
]; ];
private function resetCompiled(): void private function resetCompiled(): void
@@ -29,6 +30,7 @@ abstract class Grammar
'orders' => [], 'orders' => [],
'limit' => [], 'limit' => [],
'offset' => [], 'offset' => [],
'groupBy' => [],
]; ];
} }
@@ -194,4 +196,9 @@ abstract class Grammar
return $this->getRawValue($condition['column']) . " $inOperator (" . $inValues . ')'; return $this->getRawValue($condition['column']) . " $inOperator (" . $inValues . ')';
} }
public function compileGroupBy(Builder $builder, array $groupBy): string
{
return 'GROUP BY ' . implode(', ', $groupBy);
}
} }

View File

@@ -12,6 +12,7 @@ class TelegramValidateInitDataMiddleware
'testTgMessage', 'testTgMessage',
'manifest', 'manifest',
'webhook', 'webhook',
'health',
]; ];
public function __construct(SignatureValidator $signatureValidator) public function __construct(SignatureValidator $signatureValidator)

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Filters;
use InvalidArgumentException;
use Openguru\OpenCartFramework\CriteriaBuilder\Criterion;
use Openguru\OpenCartFramework\CriteriaBuilder\Rules\BaseRule;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
class ProductCategory extends BaseRule
{
public const NAME = 'RULE_PRODUCT_CATEGORY';
public static function initWithDefaults(): BaseRule
{
return new static(static::NAME, [
'product_category_id' => new Criterion(static::CRITERIA_OPTION_PRODUCT_CATEGORIES, [
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
'value' => null,
])
]);
}
public function apply(Builder $builder, $operand): void
{
/** @var Criterion $criterion */
foreach ($this->criteria as $criterion) {
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_CATEGORY) {
$operator = $criterion->params['operator'];
$categoryId = $criterion->params['value'];
if (! $categoryId) {
return;
}
$uniqHash = md5(serialize($criterion));
$joinAlias = 'product_category_' . $uniqHash;
if ($builder->hasJoinAlias($joinAlias)) {
return;
}
$builder->join(
db_table('product_to_category') . " AS $joinAlias",
function (JoinClause $join) use ($joinAlias, $categoryId) {
$join
->on('products.product_id', '=', "$joinAlias.product_id")
->where("$joinAlias.category_id", '=', $categoryId);
},
'left'
);
if ($operator === 'contains') {
$builder->whereNotNull("$joinAlias.product_id", $operand);
} elseif ($operator === 'not_contains') {
$builder->whereNull("$joinAlias.product_id", $operand);
} else {
throw new InvalidArgumentException('Invalid operator: ' . $operator);
}
}
}
}
}

View File

@@ -42,8 +42,7 @@ class ProductPrice extends BaseRule
*/ */
public function apply(Builder $builder, $operand) public function apply(Builder $builder, $operand)
{ {
$includeSpecials = Arr::get($this->criteria, 'include_specials.value', true); $includeSpecials = $this->criteria['include_specials']->params['value'] ?? true;
$includeDiscounts = Arr::get($this->criteria, 'include_discounts.value', false);
/** @var Criterion|null $productPriceCriterion */ /** @var Criterion|null $productPriceCriterion */
$productPriceCriterion = $this->criteria['product_price'] ?? null; $productPriceCriterion = $this->criteria['product_price'] ?? null;
@@ -67,10 +66,10 @@ class ProductPrice extends BaseRule
$customerGroupId = config('oc_customer_group_id', 1); $customerGroupId = config('oc_customer_group_id', 1);
$sub = $builder->newQuery() $sub2 = $builder->newQuery()
->select([ ->select([
'ps.product_id', 'product_id',
'ps.price', new RawExpression("MIN(CONCAT(LPAD(priority, 5, '0'), LPAD(price, 10, '0'))) AS sort_key"),
]) ])
->from(db_table('product_special'), 'ps') ->from(db_table('product_special'), 'ps')
->where("ps.customer_group_id", '=', $customerGroupId) ->where("ps.customer_group_id", '=', $customerGroupId)
@@ -80,9 +79,18 @@ class ProductPrice extends BaseRule
AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW()) AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())
" "
) )
->orderBy('ps.priority', 'ASC') ->groupBy(['product_id']);
->orderBy('ps.price', 'ASC')
->limit(1); $sub = $builder->newQuery()
->select([
'ps1.product_id',
'ps1.price',
])
->from(db_table('product_special'), 'ps1')
->join(new Table($sub2, 'ps2'), function (JoinClause $join) {
$join->on('ps1.product_id', '=', 'ps2.product_id')
->whereRaw("CONCAT(LPAD(ps1.priority, 5, '0'), LPAD(ps1.price, 10, '0')) = ps2.sort_key");
});
$builder->join(new Table($sub, $joinAlias), function (JoinClause $join) use ($joinAlias) { $builder->join(new Table($sub, $joinAlias), function (JoinClause $join) use ($joinAlias) {
$join->on('products.product_id', '=', "$joinAlias.product_id"); $join->on('products.product_id', '=', "$joinAlias.product_id");

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Handlers;
use App\Filters\ProductCategory;
use App\Filters\ProductForMainPage;
use App\Filters\ProductPrice;
use Openguru\OpenCartFramework\Http\JsonResponse;
class FiltersHandler
{
public function getFiltersForMainPage(): JsonResponse
{
$filters = [
'operand' => 'AND',
'rules' => [
ProductPrice::NAME => [
'criteria' => [
'product_price' => [
'type' => 'number',
'params' => [
'operator' => 'between',
'value' => [
'from' => 0,
'to' => null,
],
],
],
],
],
ProductForMainPage::NAME => [
'criteria' => [
'product_for_main_page' => [
'type' => 'boolean',
'params' => [
'operator' => 'equals',
'value' => true,
],
],
],
],
ProductCategory::NAME => [
'criteria' => [
'product_category_id' => [
'type' => 'product_category',
'params' => [
'operator' => 'contains',
'value' => null,
],
],
]
],
],
];
return new JsonResponse([
'data' => $filters,
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Handlers;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Response;
class HealthCheckHandler
{
public function handle(): JsonResponse
{
return new JsonResponse([
'status' => 'ok',
], Response::HTTP_OK);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\ServiceProviders;
use App\Exceptions\CustomExceptionHandler; use App\Exceptions\CustomExceptionHandler;
use App\Filters\ProductAttribute; use App\Filters\ProductAttribute;
use App\Filters\ProductCategories; use App\Filters\ProductCategories;
use App\Filters\ProductCategory;
use App\Filters\ProductForMainPage; use App\Filters\ProductForMainPage;
use App\Filters\ProductManufacturer; use App\Filters\ProductManufacturer;
use App\Filters\ProductModel; use App\Filters\ProductModel;
@@ -64,6 +65,7 @@ class AppServiceProvider extends ServiceProvider
ProductQuantity::NAME => ProductQuantity::class, ProductQuantity::NAME => ProductQuantity::class,
ProductStatus::NAME => ProductStatus::class, ProductStatus::NAME => ProductStatus::class,
ProductForMainPage::NAME => ProductForMainPage::class, ProductForMainPage::NAME => ProductForMainPage::class,
ProductCategory::NAME => ProductCategory::class,
]); ]);
} }
} }

View File

@@ -59,7 +59,7 @@ class ProductsService
$maxPages = $params['maxPages'] ?? 50; $maxPages = $params['maxPages'] ?? 50;
$filters = $params['filters'] ?? []; $filters = $params['filters'] ?? [];
$customerGroupId = (int) $this->oc->config->get('config_customer_group_id'); $customerGroupId = (int) $this->settings->get('oc_customer_group_id');
$specialPriceSql = "(SELECT price $specialPriceSql = "(SELECT price
FROM oc_product_special ps FROM oc_product_special ps
WHERE ps.product_id = products.product_id WHERE ps.product_id = products.product_id

View File

@@ -1,16 +1,21 @@
<?php <?php
use App\Handlers\CategoriesHandler;
use App\Handlers\CartHandler; use App\Handlers\CartHandler;
use App\Handlers\CategoriesHandler;
use App\Handlers\FiltersHandler;
use App\Handlers\HealthCheckHandler;
use App\Handlers\OrderHandler; use App\Handlers\OrderHandler;
use App\Handlers\ProductsHandler; use App\Handlers\ProductsHandler;
use App\Handlers\SettingsHandler; use App\Handlers\SettingsHandler;
use App\Handlers\TelegramHandler; use App\Handlers\TelegramHandler;
return [ return [
'health' => [HealthCheckHandler::class, 'handle'],
'products' => [ProductsHandler::class, 'index'], 'products' => [ProductsHandler::class, 'index'],
'product_show' => [ProductsHandler::class, 'show'], 'product_show' => [ProductsHandler::class, 'show'],
'storeOrder' => [OrderHandler::class, 'store'], 'storeOrder' => [OrderHandler::class, 'store'],
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
'categoriesList' => [CategoriesHandler::class, 'index'], 'categoriesList' => [CategoriesHandler::class, 'index'],

View File

@@ -498,4 +498,18 @@ class BuilderTest extends TestCase
$this->assertEquals($expected, $query->toRawSql()); $this->assertEquals($expected, $query->toRawSql());
} }
public function testGroupBy(): void
{
$query = $this->builder->newQuery()
->select(['foo', 'bar'])
->from('table_1')
->groupBy(['foo', 'bar']);
$this->assertEquals(
'SELECT foo, bar FROM table_1 GROUP BY foo, bar',
$query->toRawSql(),
);
}
} }

View File

@@ -4,6 +4,7 @@ namespace Tests\Unit;
use App\Filters\ProductAttribute; use App\Filters\ProductAttribute;
use App\Filters\ProductCategories; use App\Filters\ProductCategories;
use App\Filters\ProductCategory;
use App\Filters\ProductForMainPage; use App\Filters\ProductForMainPage;
use App\Filters\ProductManufacturer; use App\Filters\ProductManufacturer;
use App\Filters\ProductModel; use App\Filters\ProductModel;
@@ -89,6 +90,7 @@ class CriteriaBuilderTest extends TestCase
$rulesRegistry->register(ProductQuantity::NAME, ProductQuantity::class); $rulesRegistry->register(ProductQuantity::NAME, ProductQuantity::class);
$rulesRegistry->register(ProductAttribute::NAME, ProductAttribute::class); $rulesRegistry->register(ProductAttribute::NAME, ProductAttribute::class);
$rulesRegistry->register(ProductForMainPage::NAME, ProductForMainPage::class); $rulesRegistry->register(ProductForMainPage::NAME, ProductForMainPage::class);
$rulesRegistry->register(ProductCategory::NAME, ProductCategory::class);
$this->builder = $application->get(Builder::class); $this->builder = $application->get(Builder::class);
$this->criteriaBuilder = $application->get(CriteriaBuilder::class); $this->criteriaBuilder = $application->get(CriteriaBuilder::class);

View File

@@ -219,4 +219,15 @@ class MySqlGrammarTest extends TestCase
['column' => 'bar', 'as' => null], ['column' => 'bar', 'as' => null],
])); ]));
} }
public function testCompileGroupBy(): void
{
$mock = m::mock(Builder::class);
$mock->groupBy = ['foo', 'bar'];
$this->assertEquals(
'GROUP BY foo, bar',
$this->grammar->compileGroupBy($mock, ['foo', 'bar'])
);
}
} }

View File

@@ -0,0 +1,19 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_CATEGORY": {
"criteria": {
"product_category_id": {
"type": "product_category",
"params": {
"operator": "contains",
"value": 58
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,17 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
LEFT JOIN oc_product_to_category AS product_category_a46414dc254a7328c63a5de8a760fa8f ON products.product_id = product_category_a46414dc254a7328c63a5de8a760fa8f.product_id
AND product_category_a46414dc254a7328c63a5de8a760fa8f.category_id = 58
WHERE
product_category_a46414dc254a7328c63a5de8a760fa8f.product_id IS NOT NULL

View File

@@ -0,0 +1,19 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_CATEGORY": {
"criteria": {
"product_category_id": {
"type": "product_category",
"params": {
"operator": "not_contains",
"value": 58
}
}
},
"__meta": {
"group": "other"
}
}
}
}

View File

@@ -0,0 +1,17 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
LEFT JOIN oc_product_to_category AS product_category_fa9affd75942255178dc16d1ec8b424f ON products.product_id = product_category_fa9affd75942255178dc16d1ec8b424f.product_id
AND product_category_fa9affd75942255178dc16d1ec8b424f.category_id = 58
WHERE
product_category_fa9affd75942255178dc16d1ec8b424f.product_id IS NULL

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_4bf5415bae7c037731801559d0410697 ON products.product_id = product_specials_4bf5415bae7c037731801559d0410697.product_id ) AS product_specials_4bf5415bae7c037731801559d0410697 ON products.product_id = product_specials_4bf5415bae7c037731801559d0410697.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_f1ca65a396fc2e41a23a3661b84d1519 ON products.product_id = product_specials_f1ca65a396fc2e41a23a3661b84d1519.product_id ) AS product_specials_f1ca65a396fc2e41a23a3661b84d1519 ON products.product_id = product_specials_f1ca65a396fc2e41a23a3661b84d1519.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_f0c4ece67502916e844720b12abc54a9 ON products.product_id = product_specials_f0c4ece67502916e844720b12abc54a9.product_id ) AS product_specials_f0c4ece67502916e844720b12abc54a9 ON products.product_id = product_specials_f0c4ece67502916e844720b12abc54a9.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_5173bd7b8335ab5a9f4e37227d0c317f ON products.product_id = product_specials_5173bd7b8335ab5a9f4e37227d0c317f.product_id ) AS product_specials_5173bd7b8335ab5a9f4e37227d0c317f ON products.product_id = product_specials_5173bd7b8335ab5a9f4e37227d0c317f.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_7b934dfdfc5b809270875934c10acc06 ON products.product_id = product_specials_7b934dfdfc5b809270875934c10acc06.product_id ) AS product_specials_7b934dfdfc5b809270875934c10acc06 ON products.product_id = product_specials_7b934dfdfc5b809270875934c10acc06.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_51a33d8bcfeece60de797c18e3800075 ON products.product_id = product_specials_51a33d8bcfeece60de797c18e3800075.product_id ) AS product_specials_51a33d8bcfeece60de797c18e3800075 ON products.product_id = product_specials_51a33d8bcfeece60de797c18e3800075.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_7de8997be994ada47d62cdfe2b7369c9 ON products.product_id = product_specials_7de8997be994ada47d62cdfe2b7369c9.product_id ) AS product_specials_7de8997be994ada47d62cdfe2b7369c9 ON products.product_id = product_specials_7de8997be994ada47d62cdfe2b7369c9.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_78ccac1dc1d86997e6eac0613f2eb5f3 ON products.product_id = product_specials_78ccac1dc1d86997e6eac0613f2eb5f3.product_id ) AS product_specials_78ccac1dc1d86997e6eac0613f2eb5f3 ON products.product_id = product_specials_78ccac1dc1d86997e6eac0613f2eb5f3.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_5816f1e65a3d296a26635f070d0439bc ON products.product_id = product_specials_5816f1e65a3d296a26635f070d0439bc.product_id ) AS product_specials_5816f1e65a3d296a26635f070d0439bc ON products.product_id = product_specials_5816f1e65a3d296a26635f070d0439bc.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_51a33d8bcfeece60de797c18e3800075 ON products.product_id = product_specials_51a33d8bcfeece60de797c18e3800075.product_id ) AS product_specials_51a33d8bcfeece60de797c18e3800075 ON products.product_id = product_specials_51a33d8bcfeece60de797c18e3800075.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_51a33d8bcfeece60de797c18e3800075 ON products.product_id = product_specials_51a33d8bcfeece60de797c18e3800075.product_id ) AS product_specials_51a33d8bcfeece60de797c18e3800075 ON products.product_id = product_specials_51a33d8bcfeece60de797c18e3800075.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_cf0f856fa900f18f5c8c0a57357532f5 ON products.product_id = product_specials_cf0f856fa900f18f5c8c0a57357532f5.product_id ) AS product_specials_cf0f856fa900f18f5c8c0a57357532f5 ON products.product_id = product_specials_cf0f856fa900f18f5c8c0a57357532f5.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_08e0a0248dc92591ae55fdd966728ba0 ON products.product_id = product_specials_08e0a0248dc92591ae55fdd966728ba0.product_id ) AS product_specials_08e0a0248dc92591ae55fdd966728ba0 ON products.product_id = product_specials_08e0a0248dc92591ae55fdd966728ba0.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -13,25 +13,38 @@ FROM
AND product_description.language_id = 1 AND product_description.language_id = 1
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
ps.product_id, ps1.product_id,
ps.price ps1.price
FROM FROM
oc_product_special AS ps oc_product_special AS ps1
WHERE INNER JOIN (
ps.customer_group_id = 1 SELECT
AND ( product_id,
ps.date_start = '0000-00-00' MIN(
OR ps.date_start < NOW() CONCAT(
) LPAD(priority, 5, '0'),
AND ( LPAD(price, 10, '0')
ps.date_end = '0000-00-00' )
OR ps.date_end > NOW() ) AS sort_key
) FROM
ORDER BY oc_product_special AS ps
ps.priority ASC, WHERE
ps.price ASC ps.customer_group_id = 1
LIMIT AND (
1 ps.date_start = '0000-00-00'
OR ps.date_start < NOW()
)
AND (
ps.date_end = '0000-00-00'
OR ps.date_end > NOW()
)
GROUP BY
product_id
) AS ps2 ON ps1.product_id = ps2.product_id
AND CONCAT(
LPAD(ps1.priority, 5, '0'),
LPAD(ps1.price, 10, '0')
) = ps2.sort_key
) AS product_specials_47d71067324729b89b13414a6ea869e0 ON products.product_id = product_specials_47d71067324729b89b13414a6ea869e0.product_id ) AS product_specials_47d71067324729b89b13414a6ea869e0 ON products.product_id = product_specials_47d71067324729b89b13414a6ea869e0.product_id
WHERE WHERE
COALESCE( COALESCE(

View File

@@ -0,0 +1,25 @@
{
"operand": "AND",
"rules": {
"RULE_PRODUCT_PRICE": {
"criteria": {
"product_price": {
"type": "number",
"params": {
"operator": "equals",
"value": {
"from": 100,
"to": null
}
}
},
"include_specials": {
"type": "boolean",
"params": {
"value": false
}
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
SELECT
products.product_id AS product_id,
products.image AS image,
product_description.name AS name,
products.model AS model,
products.price AS price,
products.quantity AS quantity,
products.status AS STATUS,
products.noindex AS noindex
FROM
oc_product AS products
INNER JOIN oc_product_description AS product_description ON products.product_id = product_description.product_id
AND product_description.language_id = 1
WHERE
products.price = 100

View File

@@ -5,9 +5,9 @@
<section class="safe-top"> <section class="safe-top">
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/> <FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
<RouterView v-slot="{ Component, route }"> <RouterView v-slot="{ Component, route }">
<Transition name="route" appear> <KeepAlive include="Home" :key="filtersStore.paramsHashForRouter">
<component :is="Component" :key="route.fullPath"/> <component :is="Component" :key="route.fullPath"/>
</Transition> </KeepAlive>
</RouterView> </RouterView>
<CartButton v-if="settings.store_enabled"/> <CartButton v-if="settings.store_enabled"/>
</section> </section>
@@ -21,6 +21,7 @@ import {useMiniApp, FullscreenViewport} from 'vue-tg';
import {useRoute, useRouter} from "vue-router"; import {useRoute, useRouter} from "vue-router";
import CartButton from "@/components/CartButton.vue"; import CartButton from "@/components/CartButton.vue";
import {useSettingsStore} from "@/stores/SettingsStore.js"; import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
const tg = useMiniApp(); const tg = useMiniApp();
const platform = ref(); const platform = ref();
@@ -32,6 +33,7 @@ disableVerticalSwipes();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const settings = useSettingsStore(); const settings = useSettingsStore();
const filtersStore = useProductFiltersStore();
const backButton = window.Telegram.WebApp.BackButton; const backButton = window.Telegram.WebApp.BackButton;
const haptic = window.Telegram.WebApp.HapticFeedback; const haptic = window.Telegram.WebApp.HapticFeedback;
@@ -54,21 +56,3 @@ watch(
{immediate: true} {immediate: true}
); );
</script> </script>
<style scoped>
/* route transitions */
.route-enter-active,
.route-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.route-enter-from {
opacity: 0;
transform: translateY(10px);
}
.route-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@@ -1,14 +1,10 @@
<template> <template>
<div class="flex flex-col items-center justify-center text-center py-16"> <div class="flex flex-col items-center justify-center text-center py-16">
<span class="text-5xl mb-4">🛒</span> <span class="text-5xl mb-4">🛒</span>
<h2 class="text-xl font-semibold mb-2">Здесь пока пусто</h2> <h2 class="text-xl font-semibold mb-2">Здесь нет товаров</h2>
<p class="text-sm mb-4">Мы уже выехали на склад, чтобы найти что-нибудь подходящее.</p> <p class="text-sm mb-4">
<button class="btn btn-primary" @click="goBack"> Попробуйте изменить настройки фильтров
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"> </p>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Назад
</button>
</div> </div>
</template> </template>

View File

@@ -1,12 +1,11 @@
<template> <template>
<div class="border-b mb-5 border-b-base-200 flex justify-between"> <div class="border-b mb-5 border-b-base-200 flex justify-between">
<div class="mb-2 text-base-content/40">Товары для главной страницы</div> <div class="mb-2">Рекомендуемые товары</div>
<div> <div>
<input <input
type="checkbox" type="checkbox"
class="toggle" class="toggle"
disabled v-model="filter.criteria.product_for_main_page.params.value"
:checked="filter.criteria.product_for_main_page.params.value"
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,42 @@
<template>
<div class="border-b mb-5 border-b-base-200">
<div class="mb-2">Категория</div>
<div v-if="categoriesStore.isLoading" class="skeleton h-10 w-full"></div>
<select
v-else
v-model.number="props.filter.criteria.product_category_id.params.value"
class="select w-full"
>
<option :value="null">Любая категория</option>
<SelectOption
v-for="category in categoriesStore.categories"
:key="category.id"
:level="0"
:category="category"
/>
</select>
</div>
</template>
<script setup>
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
import {onMounted} from "vue";
import SelectOption from "@/components/ProductFilters/Components/ProductCategory/SelectOption.vue";
const props = defineProps({
filter: {
required: true,
},
});
const emit = defineEmits(['update:modelValue']);
const categoriesStore = useCategoriesStore();
onMounted(() => {
categoriesStore.fetchCategories();
})
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,31 @@
<template>
<option :value="category.id">
{{ "-".repeat(level) }} {{ category.name }}
</option>
<SelectOption
v-if="category.children"
v-for="child in category.children"
:key="child.id"
:category="child"
:level="level + 1"
/>
</template>
<script setup>
const props = defineProps({
category: {
type: Object,
required: true,
},
level: {
type: Number,
default: 0,
}
})
</script>
<style scoped lang="scss">
</style>

View File

@@ -1,105 +0,0 @@
<template>
<Teleport to="body">
<div
class="fixed top-0 left-0 z-50 w-full h-full bg-base-200 flex flex-col safe-top"
>
<header class="text-center shrink-0 p-3 font-bold text-xl">
Фильтры
</header>
<main class="mt-5 px-5 bg-base-200">
<div
v-if="draft?.rules && Object.keys(draft.rules).length > 0"
v-for="(filter, filterId) in draft.rules"
>
<component
v-if="componentMap[filterId]"
:is="componentMap[filterId]"
:filter="filter"
/>
<p v-else>Not supported {{ filter.type }}</p>
</div>
<div v-else class="bg-base-100 rounded-2xl p-5">
Нет фильтров
</div>
</main>
</div>
</Teleport>
</template>
<script setup>
import ProductPrice from "@/components/ProductFilters/Components/ProductPrice.vue";
import {onMounted, onUnmounted, ref} from "vue";
import ForMainPage from "@/components/ProductFilters/Components/ForMainPage.vue";
const componentMap = {
RULE_PRODUCT_PRICE: ProductPrice,
RULE_PRODUCT_FOR_MAIN_PAGE: ForMainPage,
};
const props = defineProps({
closeOnOverlay: {type: Boolean, default: true},
filters: {
type: Object,
default: () => ({}),
}
})
const emit = defineEmits(['close', 'apply', 'reset']);
const mainButton = window.Telegram.WebApp.MainButton;
const secondaryButton = window.Telegram.WebApp.SecondaryButton;
const backButton = window.Telegram.WebApp.BackButton;
const draft = ref({});
const applyFilters = () => {
emit('apply', draft);
}
const resetFilters = () => {
emit('reset');
}
function closeFiltersWithoutApply() {
emit('close');
}
onMounted(() => {
window.document.body.style.overflow = 'hidden';
// Crete draft of the filters.
draft.value = JSON.parse(JSON.stringify(props.filters));
mainButton.setParams({
text: 'Применить',
is_active: true,
is_visible: true,
});
mainButton.show();
mainButton.onClick(applyFilters);
secondaryButton.setParams({
text: 'Сбросить фильтры',
is_active: true,
is_visible: true,
position: 'top',
});
secondaryButton.show();
secondaryButton.onClick(resetFilters);
backButton.show();
backButton.onClick(closeFiltersWithoutApply);
});
onUnmounted(() => {
mainButton.hide();
secondaryButton.hide();
mainButton.offClick(applyFilters);
secondaryButton.offClick(resetFilters);
backButton.hide();
backButton.offClick(closeFiltersWithoutApply);
window.document.body.style.overflow = '';
});
</script>

View File

@@ -1,31 +0,0 @@
export const FILTERS_MAIN_PAGE_DEFAULT = {
operand: "AND",
rules: {
RULE_PRODUCT_PRICE: {
criteria: {
"product_price":
{
"type": "number",
"params": {
"operator": "between",
"value": {
"from": "",
"to": ""
},
},
},
},
},
RULE_PRODUCT_FOR_MAIN_PAGE: {
"criteria": {
"product_for_main_page": {
"type": "boolean",
"params": {
"operator": "equals",
"value": true
}
}
}
},
},
};

View File

@@ -2,7 +2,7 @@
<swiper-container ref="swiperEl" init="false" pagination-dynamic-bullets="true"> <swiper-container ref="swiperEl" init="false" pagination-dynamic-bullets="true">
<swiper-slide <swiper-slide
v-for="image in images" v-for="image in images"
lazy :key="image.url"
class="bg-base-100 overflow-hidden" class="bg-base-100 overflow-hidden"
style="aspect-ratio:1/1; border-radius:12px;" style="aspect-ratio:1/1; border-radius:12px;"
> >
@@ -18,7 +18,7 @@
</template> </template>
<script setup> <script setup>
import {onMounted, onUnmounted, ref} from "vue"; import {onActivated, onMounted, onUnmounted, ref} from "vue";
const props = defineProps({ const props = defineProps({
images: { images: {
@@ -45,12 +45,43 @@ onUnmounted(() => {
}); });
onMounted(async () => { onMounted(() => {
swiperEl.value?.addEventListener('swiperactiveindexchange', (event) => { const el = swiperEl.value;
window.Telegram.WebApp.HapticFeedback.selectionChanged(); if (!el) return;
el.addEventListener('swiperactiveindexchange', () => {
window.Telegram?.WebApp?.HapticFeedback?.selectionChanged();
}); });
Object.assign(swiperEl.value, params); Object.assign(el, params);
swiperEl.value.initialize(); el.initialize();
// 👇 важно, особенно если картинки подгружаются не сразу
el.addEventListener('swiperinit', () => {
el.swiper.update();
});
}); });
onActivated(() => {
const el = swiperEl.value
if (!el) return;
// Если swiper есть, но pagination потерялся — уничтожаем
if (el.swiper) {
try {
el.swiper.destroy(true, true)
} catch (e) {
console.warn('Failed to destroy swiper', e)
}
}
// Переинициализация с параметрами
Object.assign(el, params)
el.initialize()
// Пересчёт пагинации после инициализации
el.addEventListener('swiperinit', () => {
el.swiper.update()
})
})
</script> </script>

View File

@@ -1,13 +1,13 @@
<template> <template>
<div class="mx-auto max-w-2xl px-4 py-4 sm:px-6 sm:py-6 lg:max-w-7xl lg:px-8"> <div class="mx-auto max-w-2xl px-4 py-4 pb-14">
<h2 class="text-lg font-bold mb-5 text-center">{{ productsStore.products.meta.currentCategoryName }}</h2> <h2 v-if="categoryName" class="text-lg font-bold mb-5 text-center">{{ categoryName }}</h2>
<div v-if="productsStore.products.data.length > 0"> <template v-if="products.length > 0">
<div <div
class="products-grid grid grid-cols-2 gap-x-5 gap-y-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8" class="products-grid grid grid-cols-2 gap-x-5 gap-y-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"
> >
<RouterLink <RouterLink
v-for="product in productsStore.products.data" v-for="product in products"
:key="product.id" :key="product.id"
class="product-grid-card group" class="product-grid-card group"
:to="`/product/${product.id}`" :to="`/product/${product.id}`"
@@ -26,21 +26,16 @@
<div ref="bottom" style="height: 1px;"></div> <div ref="bottom" style="height: 1px;"></div>
</div> </div>
<div v-if="productsStore.isLoading" class="text-center mt-5"> <div v-if="isLoadingMore" class="text-center mt-5">
<span class="loading loading-spinner loading-md"></span> Загрузка... <span class="loading loading-spinner loading-md"></span> Загрузка товаров...
</div> </div>
<div <div v-else-if="hasMore === false" class="text-xs text-center mt-4 pt-4 mb-2 border-t">
v-if="productsStore.products.meta.hasMore === false"
class="text-xs text-center mt-4 pt-4 mb-2 border-t"
>
{{ settings.noMoreProductsMessage }} {{ settings.noMoreProductsMessage }}
</div> </div>
</div> </template>
<NoProducts v-else-if="productsStore.loadFinished"/> <div v-else-if="isLoading === true"
<div v-else
class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8"> class="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 xl:gap-x-8">
<div v-for="n in 8" :key="n" class="animate-pulse space-y-2"> <div v-for="n in 8" :key="n" class="animate-pulse space-y-2">
<div class="aspect-square bg-gray-200 rounded-md"></div> <div class="aspect-square bg-gray-200 rounded-md"></div>
@@ -48,32 +43,71 @@
<div class="h-4 bg-gray-200 rounded w-1/2"></div> <div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div> </div>
</div> </div>
<NoProducts v-else/>
</div> </div>
</template> </template>
<script setup> <script setup>
import NoProducts from "@/components/NoProducts.vue"; import NoProducts from "@/components/NoProducts.vue";
import ProductImageSwiper from "@/components/ProductImageSwiper.vue"; import ProductImageSwiper from "@/components/ProductImageSwiper.vue";
import {useProductsStore} from "@/stores/ProductsStore.js";
import {useInfiniteScroll} from '@vueuse/core';
import {useSettingsStore} from "@/stores/SettingsStore.js"; import {useSettingsStore} from "@/stores/SettingsStore.js";
import {ref} from "vue"; import {ref} from "vue";
import {useIntersectionObserver} from '@vueuse/core';
const productsStore = useProductsStore();
const settings = useSettingsStore(); const settings = useSettingsStore();
const bottom = ref(null); const bottom = ref(null);
const emits = defineEmits(['loadMore']);
const props = defineProps({
products: {
type: Array,
default: () => [],
},
hasMore: {
type: Boolean,
default: false,
},
categoryName: {
type: String,
default: () => '',
},
isLoading: {
type: Boolean,
default: false,
},
isLoadingMore: {
type: Boolean,
default: false,
}
});
function haptic() { function haptic() {
window.Telegram.WebApp.HapticFeedback.selectionChanged(); window.Telegram.WebApp.HapticFeedback.selectionChanged();
// productsStore.savedScrollY = window.scrollY;
// console.log("Store scrollY: ", productsStore.savedScrollY);
} }
useInfiniteScroll( useIntersectionObserver(
bottom, bottom,
async () => await productsStore.loadMore(), ([entry]) => {
{distance: 1000} console.debug('Check Intersection');
) if (entry?.isIntersecting === true
&& props.hasMore === true
&& props.isLoading === false
&& props.isLoadingMore === false
) {
emits('loadMore');
}
},
{
root: null,
rootMargin: '400px 0',
}
);
</script> </script>
<style scoped> <style scoped>

View File

@@ -15,6 +15,7 @@ import { register } from 'swiper/element/bundle';
import 'swiper/element/bundle'; import 'swiper/element/bundle';
import 'swiper/css/bundle'; import 'swiper/css/bundle';
import AppLoading from "@/AppLoading.vue"; import AppLoading from "@/AppLoading.vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
register(); register();
const pinia = createPinia(); const pinia = createPinia();
@@ -30,6 +31,11 @@ const appLoading = createApp(AppLoading);
appLoading.mount('#app'); appLoading.mount('#app');
settings.load() settings.load()
.then(async () => {
console.debug('Load default filters for the main page');
const filtersStore = useProductFiltersStore();
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
})
.then(() => { .then(() => {
if (settings.app_enabled === false) { if (settings.app_enabled === false) {
throw new Error('App disabled (maintenance mode)'); throw new Error('App disabled (maintenance mode)');

View File

@@ -7,6 +7,7 @@ import Products from "@/views/Products.vue";
import Checkout from "@/views/Checkout.vue"; import Checkout from "@/views/Checkout.vue";
import OrderCreated from "@/views/OrderCreated.vue"; import OrderCreated from "@/views/OrderCreated.vue";
import Search from "@/views/Search.vue"; import Search from "@/views/Search.vue";
import Filters from "@/views/Filters.vue";
const routes = [ const routes = [
{ {
@@ -14,6 +15,7 @@ const routes = [
name: 'home', name: 'home',
component: Home, component: Home,
}, },
{path: '/filters', name: 'filters', component: Filters},
{path: '/product/:id', name: 'product.show', component: Product}, {path: '/product/:id', name: 'product.show', component: Product},
{ {
path: '/products/:category_id', path: '/products/:category_id',

View File

@@ -1,19 +1,33 @@
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {getFiltersForMainPage} from "@/utils/ftch.js";
import {md5} from "js-md5";
import {toRaw} from "vue";
export const useProductFiltersStore = defineStore('product_filters', { export const useProductFiltersStore = defineStore('product_filters', {
state: () => ({ state: () => ({
filters: {}, isLoading: false,
draft: {},
applied: {},
fullPath: '', fullPath: '',
}), }),
getters: { getters: {
hasFilters: (state) => state.filters?.rules && Object.keys(state.filters.rules).length > 0, paramsHashForRouter: (state) => md5(JSON.stringify({ filters: state.applied })),
}, },
actions: { actions: {
reset() { async fetchFiltersForMainPage() {
if (this.isLoading) return;
try {
this.isLoading = true;
const response = await getFiltersForMainPage();
return response.data;
} catch (error) {
console.log(error);
} finally {
this.isLoading = false;
}
}, },
clear() { clear() {

View File

@@ -16,6 +16,7 @@ export const useProductsStore = defineStore('products', {
search: '', search: '',
page: 1, page: 1,
isLoading: false, isLoading: false,
isLoadingMore: false,
loadFinished: false, loadFinished: false,
savedScrollY: 0, savedScrollY: 0,
currentLoadedParamsHash: null, currentLoadedParamsHash: null,
@@ -23,6 +24,10 @@ export const useProductsStore = defineStore('products', {
getters: { getters: {
paramsHash: (state) => md5(JSON.stringify(toRaw(state.getParams()))), paramsHash: (state) => md5(JSON.stringify(toRaw(state.getParams()))),
paramsHashForRouter: (state) => md5(JSON.stringify({
search: state.search,
filters: toRaw(state.filters),
})),
}, },
actions: { actions: {
@@ -36,8 +41,6 @@ export const useProductsStore = defineStore('products', {
async fetchProducts() { async fetchProducts() {
try { try {
this.isLoading = true;
console.debug('Current params hash: ', this.currentLoadedParamsHash); console.debug('Current params hash: ', this.currentLoadedParamsHash);
if (this.products.data.length > 0 && this.paramsHash === this.currentLoadedParamsHash) { if (this.products.data.length > 0 && this.paramsHash === this.currentLoadedParamsHash) {
console.debug('Loading products from cache'); console.debug('Loading products from cache');
@@ -61,7 +64,6 @@ export const useProductsStore = defineStore('products', {
console.error("Failed to load products"); console.error("Failed to load products");
console.error(error); console.error(error);
} finally { } finally {
this.isLoading = false;
} }
}, },
@@ -70,8 +72,8 @@ export const useProductsStore = defineStore('products', {
try { try {
console.debug('Load products with filters', filters); console.debug('Load products with filters', filters);
console.debug('Filters for URL: ', this.filtersFullUrl); this.reset();
this.isLoading = false; this.isLoading = true;
this.page = 1; this.page = 1;
this.loadFinished = false; this.loadFinished = false;
this.search = ''; this.search = '';
@@ -86,10 +88,10 @@ export const useProductsStore = defineStore('products', {
}, },
async loadMore() { async loadMore() {
if (this.isLoading || this.products.meta.hasMore === false) return; if (this.isLoading || this.isLoadingMore || this.products.meta.hasMore === false) return;
try { try {
this.isLoading = true; this.isLoadingMore = true;
this.page++; this.page++;
console.debug('Load more products for page: ', this.page); console.debug('Load more products for page: ', this.page);
const response = await this.fetchProducts(); const response = await this.fetchProducts();
@@ -98,8 +100,9 @@ export const useProductsStore = defineStore('products', {
} catch (e) { } catch (e) {
console.error('Ошибка загрузки', e); console.error('Ошибка загрузки', e);
} finally { } finally {
this.isLoading = false; this.isLoadingMore = false;
this.loadFinished = true; this.loadFinished = true;
this.isLoading = false;
} }
}, },

View File

@@ -2,7 +2,6 @@ import {defineStore} from "pinia";
import ftch from "@/utils/ftch.js"; import ftch from "@/utils/ftch.js";
export const useSearchStore = defineStore('search', { export const useSearchStore = defineStore('search', {
state: () => ({ state: () => ({
search: '', search: '',
page: 1, page: 1,

View File

@@ -68,4 +68,8 @@ export async function fetchSettings() {
return await ftch('settings'); return await ftch('settings');
} }
export async function getFiltersForMainPage() {
return await ftch('filtersForMainPage');
}
export default ftch; export default ftch;

108
spa/src/views/Filters.vue Normal file
View File

@@ -0,0 +1,108 @@
<template>
<div ref="goodsRef" class="pb-10">
<div class="flex flex-col">
<header class="text-center shrink-0 p-3 font-bold text-xl">
Фильтры
</header>
<main class="mt-5 px-5 bg-base-200">
<div
v-if="filtersStore.draft?.rules && Object.keys(filtersStore.draft.rules).length > 0"
v-for="(filter, filterId) in filtersStore.draft.rules"
>
<component
v-if="componentMap[filterId]"
:is="componentMap[filterId]"
:filter="filter"
/>
<p v-else>Not supported: {{ filterId }}</p>
</div>
<div v-else>
Нет фильтров
</div>
</main>
</div>
</div>
</template>
<script setup>
import {nextTick, onMounted, onUnmounted} from "vue";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import ProductPrice from "@/components/ProductFilters/Components/ProductPrice.vue";
import ForMainPage from "@/components/ProductFilters/Components/ForMainPage.vue";
import {useRouter} from "vue-router";
import ProductCategory from "@/components/ProductFilters/Components/ProductCategory/ProductCategory.vue";
defineOptions({
name: 'Filters'
});
const componentMap = {
RULE_PRODUCT_PRICE: ProductPrice,
RULE_PRODUCT_FOR_MAIN_PAGE: ForMainPage,
RULE_PRODUCT_CATEGORY: ProductCategory,
};
const router = useRouter();
const emit = defineEmits(['close', 'apply']);
const filtersStore = useProductFiltersStore();
const mainButton = window.Telegram.WebApp.MainButton;
const secondaryButton = window.Telegram.WebApp.SecondaryButton;
const haptic = window.Telegram.WebApp.HapticFeedback;
const applyFilters = async () => {
filtersStore.applied = JSON.parse(JSON.stringify(filtersStore.draft));
console.debug('Filters: apply filters. Hash for router: ', filtersStore.paramsHashForRouter);
haptic.impactOccurred('soft');
await nextTick();
router.back();
}
const resetFilters = async () => {
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
console.debug('Filters: reset filters. Hash for router: ', filtersStore.paramsHashForRouter);
haptic.notificationOccurred('success');
await nextTick();
window.scrollTo(0, 0);
router.back();
}
onMounted(async () => {
console.debug('Filters: OnMounted');
mainButton.setParams({
text: 'Применить',
is_active: true,
is_visible: true,
});
mainButton.show();
mainButton.onClick(applyFilters);
secondaryButton.setParams({
text: 'Сбросить фильтры',
is_active: true,
is_visible: true,
position: 'top',
});
secondaryButton.show();
secondaryButton.onClick(resetFilters);
if (filtersStore.applied?.rules) {
console.debug('Filters: Found applied filters.');
filtersStore.draft = JSON.parse(JSON.stringify(filtersStore.applied));
} else {
console.debug('No filters. Load filters from server');
filtersStore.draft = await filtersStore.fetchFiltersForMainPage();
}
});
onUnmounted(() => {
mainButton.hide();
secondaryButton.hide();
mainButton.offClick(applyFilters);
secondaryButton.offClick(resetFilters);
});
</script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div ref="goodsRef"> <div ref="goodsRef" class="pb-10">
<CategoriesInline/> <CategoriesInline/>
<div class="px-5 fixed z-50 w-full opacity-90" style="bottom: var(--tg-safe-area-inset-bottom);"> <div class="px-5 fixed z-50 w-full opacity-90" style="bottom: var(--tg-safe-area-inset-bottom);">
@@ -11,13 +11,12 @@
</div> </div>
</div> </div>
<ProductsList/> <ProductsList
<Filters :products="products"
v-if="isFiltersShow" :hasMore="hasMore"
:filters="productsStore.filters" :isLoading="isLoading"
@apply="applyFilters" :isLoadingMore="isLoadingMore"
@reset="resetFilters" @loadMore="onLoadMore"
@close="closeFilters"
/> />
</div> </div>
</template> </template>
@@ -26,59 +25,78 @@
import ProductsList from "@/components/ProductsList.vue"; import ProductsList from "@/components/ProductsList.vue";
import CategoriesInline from "../components/CategoriesInline.vue"; import CategoriesInline from "../components/CategoriesInline.vue";
import SearchInput from "@/components/SearchInput.vue"; import SearchInput from "@/components/SearchInput.vue";
import Filters from "@/components/ProductFilters/Filters.vue"; import {onActivated, onMounted, ref, toRaw} from "vue";
import {onMounted, onUnmounted, ref} from "vue";
import {useProductsStore} from "@/stores/ProductsStore.js";
import IconFunnel from "@/components/Icons/IconFunnel.vue"; import IconFunnel from "@/components/Icons/IconFunnel.vue";
import {FILTERS_MAIN_PAGE_DEFAULT} from "@/components/ProductFilters/filters.js"; import {useRouter} from "vue-router";
import {useRoute} from "vue-router"; import ftch from "@/utils/ftch.js";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
const route = useRoute(); defineOptions({
const productsStore = useProductsStore(); name: 'Home'
});
const isFiltersShow = ref(false); const router = useRouter();
const backButton = window.Telegram.WebApp.BackButton; const filtersStore = useProductFiltersStore();
const haptic = window.Telegram.WebApp.HapticFeedback;
const products = ref([]);
const hasMore = ref(false);
const isLoading = ref(false);
const isLoadingMore = ref(false);
const page = ref(1);
function showFilters() { function showFilters() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('soft'); haptic.impactOccurred('soft');
isFiltersShow.value = true; router.push({name: 'filters'});
backButton.show();
} }
function closeFilters() { async function fetchProducts() {
window.Telegram.WebApp.HapticFeedback.impactOccurred('rigid'); try {
isFiltersShow.value = false; isLoading.value = true;
} console.debug('Home: Load products for Main Page.');
console.debug('Home: Fetch products from server using filters: ', toRaw(filtersStore.applied));
async function applyFilters(newFilters) { const response = await ftch('products', null, toRaw({
closeFilters(); page: page.value,
console.log("Load products with new filters: ", newFilters); filters: filtersStore.applied,
productsStore.page = 1; }));
await productsStore.loadProducts(newFilters); products.value = response.data;
} hasMore.value = response.meta.hasMore;
console.debug('Home: Products for main page loaded.');
async function resetFilters() { } catch (error) {
closeFilters(); console.error(error);
productsStore.reset(); } finally {
await productsStore.loadProducts(FILTERS_MAIN_PAGE_DEFAULT); isLoading.value = false;
}
function handleClickOutside(e) {
if (!e.target.closest('input, textarea')) {
document.activeElement?.blur()
} }
} }
onUnmounted(() => document.removeEventListener('click', handleClickOutside)); async function onLoadMore() {
try {
console.debug('Home: onLoadMore');
if (isLoading.value === true || isLoadingMore.value === true || hasMore.value === false) return;
isLoadingMore.value = true;
page.value++;
console.debug('Home: Load more for page ', page.value, ' using filters: ', toRaw(filtersStore.applied));
const response = await ftch('products', null, toRaw({
page: page.value,
filters: filtersStore.applied,
}));
products.value.push(...response.data);
hasMore.value = response.meta.hasMore;
} catch (error) {
console.error(error);
} finally {
isLoadingMore.value = false;
}
}
onMounted(async () => { onMounted(async () => {
document.addEventListener('click', handleClickOutside); console.debug("Home: Home Mounted");
if (productsStore.filtersFullUrl !== route.fullPath) { console.debug("Home: Scroll top");
productsStore.filtersFullUrl = route.fullPath; await fetchProducts();
await productsStore.loadProducts(FILTERS_MAIN_PAGE_DEFAULT); window.scrollTo(0, 0);
} else { });
await productsStore.loadProducts(productsStore.filters ?? FILTERS_MAIN_PAGE_DEFAULT);
}
onActivated(async () => {
console.debug('Home: Activated Home');
}); });
</script> </script>

View File

@@ -1,11 +1,17 @@
<template> <template>
<div ref="goodsRef"> <div ref="goodsRef" class="pb-10">
<div class="px-5 fixed z-50 w-full opacity-90" style="bottom: var(--tg-safe-area-inset-bottom);"> <div class="px-5 fixed z-50 w-full opacity-90" style="bottom: var(--tg-safe-area-inset-bottom);">
<div class="bg-base-300 flex justify-between p-2 rounded-xl shadow-md"> <div class="bg-base-300 flex justify-between p-2 rounded-xl shadow-md">
<SearchInput/> <SearchInput/>
</div> </div>
</div> </div>
<ProductsList/> <ProductsList
:products="productsStore.products.data"
:hasMore="productsStore.products.meta.hasMore"
:isLoading="productsStore.isLoading"
:isLoadingMore="productsStore.isLoadingMore"
@loadMore="productsStore.loadMore"
/>
</div> </div>
</template> </template>
@@ -17,12 +23,17 @@ import {useRoute} from "vue-router";
import {useProductsStore} from "@/stores/ProductsStore.js"; import {useProductsStore} from "@/stores/ProductsStore.js";
import IconFunnel from "@/components/Icons/IconFunnel.vue"; import IconFunnel from "@/components/Icons/IconFunnel.vue";
defineOptions({
name: 'Products'
});
const route = useRoute(); const route = useRoute();
const productsStore = useProductsStore(); const productsStore = useProductsStore();
const categoryId = route.params.category_id ?? null; const categoryId = route.params.category_id ?? null;
onMounted(async () => { onMounted(async () => {
console.debug("Category Products Mounted");
console.debug("Load products for category: ", categoryId); console.debug("Load products for category: ", categoryId);
if (productsStore.filtersFullUrl === route.fullPath) { if (productsStore.filtersFullUrl === route.fullPath) {

View File

@@ -91,6 +91,5 @@ onUnmounted(() => {
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside); document.addEventListener('click', handleClickOutside);
nextTick(() => searchInput.value.focus());
}); });
</script> </script>