feat: UI/UX, add reset cache to admin
This commit is contained in:
@@ -1,819 +0,0 @@
|
|||||||
# Техническое задание: Система баннеров с динамическими фильтрами товаров
|
|
||||||
|
|
||||||
## 1. Описание задачи
|
|
||||||
|
|
||||||
### 1.1. Проблема
|
|
||||||
|
|
||||||
Текущий слайдер на главной странице отображает только изображения, которые не являются кликабельными. Это делает его
|
|
||||||
бесполезным с точки зрения конверсии.
|
|
||||||
|
|
||||||
### 1.2. Бизнес-цель
|
|
||||||
|
|
||||||
Реализовать интерактивные баннеры, которые при клике будут:
|
|
||||||
|
|
||||||
- Открывать товары из определенной категории
|
|
||||||
- Показывать конкретный товар
|
|
||||||
- Отображать товары по заданному фильтру
|
|
||||||
- Позволять администраторам создавать и сохранять фильтры для повторного использования
|
|
||||||
|
|
||||||
### 1.3. Техническое решение
|
|
||||||
|
|
||||||
- Использовать существующую систему фильтрации на основе JSON-объектов
|
|
||||||
- Использовать стандартный функционал баннеров OpenCart
|
|
||||||
- Создать административный интерфейс для управления фильтрами
|
|
||||||
- Связать фильтры с баннерами через специальный формат URL в поле "ссылка" баннера
|
|
||||||
- Добавить отслеживание целей Яндекс.Метрики
|
|
||||||
|
|
||||||
**Важно:** Формат ссылки `#filter:{filter_name}` с префиксом якоря `#` обеспечивает обратную совместимость:
|
|
||||||
- На обычном сайте OpenCart ссылка не будет ломать навигацию (просто не ведет никуда)
|
|
||||||
- В SPA JavaScript перехватывает и обрабатывает такие ссылки специальным образом
|
|
||||||
|
|
||||||
## 2. Архитектура решения
|
|
||||||
|
|
||||||
### 2.1. Компоненты системы
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ ADMIN PANEL │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ 1. Конструктор фильтров (Filter Builder) │
|
|
||||||
│ - Создание JSON фильтров │
|
|
||||||
│ - Сохранение фильтров с именами │
|
|
||||||
│ - Редактирование и удаление фильтров │
|
|
||||||
│ │
|
|
||||||
│ 2. Управление баннерами (расширение OpenCart) │
|
|
||||||
│ - Привязка фильтра к слайду через ссылку │
|
|
||||||
│ - Формат ссылки: #filter:{filter_name} │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ BACKEND API │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ 1. FilterService │
|
|
||||||
│ - Сохранение/редактирование фильтров │
|
|
||||||
│ - Список сохраненных фильтров │
|
|
||||||
│ - Получение фильтра по имени │
|
|
||||||
│ │
|
|
||||||
│ 2. BannerHandler (расширение) │
|
|
||||||
│ - Добавление поля filter_name в ответ │
|
|
||||||
│ - Определение типа ссылки │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ FRONTEND SPA │
|
|
||||||
├─────────────────────────────────────────────────────────────┤
|
|
||||||
│ 1. Banner.vue (обновление) │
|
|
||||||
│ - Обработка клика по слайду │
|
|
||||||
│ - Определение типа ссылки │
|
|
||||||
│ - Переход на соответствующую страницу │
|
|
||||||
│ │
|
|
||||||
│ 2. Router (обновление) │
|
|
||||||
│ - Новый роут: /products-filter │
|
|
||||||
│ - Параметры: filter_name или filter JSON │
|
|
||||||
│ │
|
|
||||||
│ 3. ProductsFilter.vue (новый компонент) │
|
|
||||||
│ - Загрузка товаров по фильтру │
|
|
||||||
│ - Интеграция с YaMetrika │
|
|
||||||
│ │
|
|
||||||
│ 4. YaMetrika Integration │
|
|
||||||
│ - Новая цель: BANNER_CLICK │
|
|
||||||
│ - Трекинг кликов по баннерам │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Детальная спецификация
|
|
||||||
|
|
||||||
### 3.1. Backend: Система управления фильтрами
|
|
||||||
|
|
||||||
#### 3.1.1. Таблица базы данных
|
|
||||||
|
|
||||||
**Создать таблицу:** `oc_telecart_filter`
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE oc_telecart_filter
|
|
||||||
(
|
|
||||||
filter_id INT(11) NOT NULL AUTO_INCREMENT,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
filter_json TEXT NOT NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (filter_id),
|
|
||||||
UNIQUE KEY unique_name (name)
|
|
||||||
) ENGINE = InnoDB
|
|
||||||
DEFAULT CHARSET = utf8mb4;
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.2. Model: FilterModel
|
|
||||||
|
|
||||||
**Путь:** `module/oc_telegram_shop/upload/oc_telegram_shop/src/Models/FilterModel.php`
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
class FilterModel
|
|
||||||
{
|
|
||||||
public function create(array $data): int
|
|
||||||
{
|
|
||||||
// Создание нового фильтра
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(int $filterId, array $data): bool
|
|
||||||
{
|
|
||||||
// Обновление фильтра
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(int $filterId): bool
|
|
||||||
{
|
|
||||||
// Удаление фильтра
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getById(int $filterId): ?array
|
|
||||||
{
|
|
||||||
// Получение фильтра по ID
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getByName(string $name): ?array
|
|
||||||
{
|
|
||||||
// Получение фильтра по имени
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAll(): array
|
|
||||||
{
|
|
||||||
// Получение списка всех фильтров
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.3. Handler: FilterHandler
|
|
||||||
|
|
||||||
**Путь:** `module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/FilterHandler.php`
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Handlers;
|
|
||||||
|
|
||||||
use App\Models\FilterModel;
|
|
||||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
|
||||||
use Openguru\OpenCartFramework\Http\Request;
|
|
||||||
|
|
||||||
class FilterHandler
|
|
||||||
{
|
|
||||||
public function list(): JsonResponse
|
|
||||||
{
|
|
||||||
// GET /api/filter/list
|
|
||||||
// Возвращает список всех сохраненных фильтров
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
// GET /api/filter/show?name={filter_name}
|
|
||||||
// Возвращает фильтр по имени
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
// POST /api/filter/store
|
|
||||||
// Создание нового фильтра
|
|
||||||
// Body: { name: string, filter_json: object }
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
// POST /api/filter/update
|
|
||||||
// Обновление существующего фильтра
|
|
||||||
// Body: { id: int, name: string, filter_json: object }
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
// POST /api/filter/delete
|
|
||||||
// Удаление фильтра
|
|
||||||
// Body: { id: int }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.1.4. Обновление: BannerHandler
|
|
||||||
|
|
||||||
**Путь:** `module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/BannerHandler.php`
|
|
||||||
|
|
||||||
Обновить метод `show()` для поддержки фильтров:
|
|
||||||
|
|
||||||
```php
|
|
||||||
public function show(): JsonResponse
|
|
||||||
{
|
|
||||||
$data = [];
|
|
||||||
$bannerId = $this->settings->get('home_banner_id');
|
|
||||||
|
|
||||||
if ($bannerId) {
|
|
||||||
$banner = $this->registry->model_design_banner->getBanner($bannerId);
|
|
||||||
foreach ($banner as $index => $result) {
|
|
||||||
if (is_file(DIR_IMAGE . $result['image'])) {
|
|
||||||
$link = $result['link'];
|
|
||||||
|
|
||||||
// Определяем тип ссылки
|
|
||||||
$linkType = 'external';
|
|
||||||
$filterName = null;
|
|
||||||
|
|
||||||
if (preg_match('/^#filter:(.+)$/', $link, $matches)) {
|
|
||||||
$linkType = 'filter';
|
|
||||||
$filterName = $matches[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
$data[] = [
|
|
||||||
'id' => $index,
|
|
||||||
'title' => $result['title'],
|
|
||||||
'link' => $link,
|
|
||||||
'link_type' => $linkType,
|
|
||||||
'filter_name' => $filterName,
|
|
||||||
'image' => $this->imageTool->resize($result['image'], null, 200),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'data' => $data,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2. Frontend: Компоненты SPA
|
|
||||||
|
|
||||||
#### 3.2.1. Обновление: Banner.vue
|
|
||||||
|
|
||||||
**Путь:** `spa/src/components/Banner.vue`
|
|
||||||
|
|
||||||
Добавить обработку клика и определение типа ссылки:
|
|
||||||
|
|
||||||
```vue
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="slides.length > 0" class="app-banner px-4">
|
|
||||||
<Swiper ...>
|
|
||||||
<SwiperSlide
|
|
||||||
v-for="slide in slides"
|
|
||||||
:key="slide.id"
|
|
||||||
@click="onSlideClick(slide)"
|
|
||||||
>
|
|
||||||
<img :src="slide.image" :alt="slide.title" class="cursor-pointer">
|
|
||||||
</SwiperSlide>
|
|
||||||
</Swiper>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {useRouter} from 'vue-router';
|
|
||||||
import {useYaMetrikaStore} from '@/stores/yaMetrikaStore';
|
|
||||||
import {YA_METRIKA_GOAL} from '@/constants/yaMetrikaGoals';
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const yaMetrika = useYaMetrikaStore();
|
|
||||||
|
|
||||||
function onSlideClick(slide) {
|
|
||||||
yaMetrika.reachGoal(YA_METRIKA_GOAL.BANNER_CLICK, {
|
|
||||||
banner_title: slide.title,
|
|
||||||
link_type: slide.link_type
|
|
||||||
});
|
|
||||||
|
|
||||||
if (slide.link_type === 'filter') {
|
|
||||||
// Обработка ссылок формата #filter:{filter_name}
|
|
||||||
// Обычные якорные ссылки не ломают навигацию в OpenCart
|
|
||||||
router.push({
|
|
||||||
name: 'products-filter',
|
|
||||||
params: {filter_name: slide.filter_name}
|
|
||||||
});
|
|
||||||
} else if (slide.link_type === 'external') {
|
|
||||||
// Обработка внешних ссылок (если нужно)
|
|
||||||
console.log('External link:', slide.link);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2.2. Новый компонент: ProductsFilter.vue
|
|
||||||
|
|
||||||
**Путь:** `spa/src/views/ProductsFilter.vue`
|
|
||||||
|
|
||||||
```vue
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="pb-20">
|
|
||||||
<ProductsList
|
|
||||||
:products="products"
|
|
||||||
:hasMore="hasMore"
|
|
||||||
:isLoading="isLoading"
|
|
||||||
:isLoadingMore="isLoadingMore"
|
|
||||||
@loadMore="onLoadMore"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import {ref, onMounted} from 'vue';
|
|
||||||
import {useRoute} from 'vue-router';
|
|
||||||
import {useYaMetrikaStore} from '@/stores/yaMetrikaStore';
|
|
||||||
import ftch from '@/utils/ftch';
|
|
||||||
import ProductsList from '@/components/ProductsList.vue';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const yaMetrika = useYaMetrikaStore();
|
|
||||||
const products = ref([]);
|
|
||||||
const hasMore = ref(false);
|
|
||||||
const isLoading = ref(false);
|
|
||||||
const isLoadingMore = ref(false);
|
|
||||||
const page = ref(1);
|
|
||||||
const perPage = 20;
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
yaMetrika.pushHit(route.path, {title: 'Отфильтрованные товары'});
|
|
||||||
|
|
||||||
await fetchProducts();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchProducts() {
|
|
||||||
try {
|
|
||||||
isLoading.value = true;
|
|
||||||
const filterName = route.params.filter_name;
|
|
||||||
|
|
||||||
// Получаем фильтр по имени
|
|
||||||
const filterResponse = await ftch('filterShow', {name: filterName});
|
|
||||||
const filters = filterResponse.data.filter_json;
|
|
||||||
|
|
||||||
// Загружаем товары с фильтром
|
|
||||||
const response = await ftch('products', null, {
|
|
||||||
page: page.value,
|
|
||||||
perPage,
|
|
||||||
filters: filters,
|
|
||||||
});
|
|
||||||
|
|
||||||
products.value = response.data;
|
|
||||||
hasMore.value = response.meta.hasMore;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onLoadMore() {
|
|
||||||
// Реализация загрузки следующих страниц
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2.3. Обновление: Router
|
|
||||||
|
|
||||||
**Путь:** `spa/src/router.js`
|
|
||||||
|
|
||||||
Добавить новый роут:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
path: '/products-filter/:filter_name',
|
|
||||||
name
|
|
||||||
:
|
|
||||||
'products-filter',
|
|
||||||
component
|
|
||||||
:
|
|
||||||
() => import('@/views/ProductsFilter.vue')
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2.4. Обновление: ftch.js
|
|
||||||
|
|
||||||
**Путь:** `spa/src/utils/ftch.js`
|
|
||||||
|
|
||||||
Добавить новые методы:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export async function fetchFilterByName(name) {
|
|
||||||
return await ftch('filterShow', {name});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchFilterList() {
|
|
||||||
return await ftch('filterList');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveFilter(data) {
|
|
||||||
return await apiFetch(`${BASE_URL}index.php?route=extension/tgshop/handle&api_action=filterStore`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.2.5. Обновление: yaMetrikaGoals.js
|
|
||||||
|
|
||||||
**Путь:** `spa/src/constants/yaMetrikaGoals.js`
|
|
||||||
|
|
||||||
Добавить новую цель:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export const YA_METRIKA_GOAL = {
|
|
||||||
// ... существующие цели
|
|
||||||
BANNER_CLICK: 'banner_click',
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3. Admin Panel: Управление фильтрами
|
|
||||||
|
|
||||||
#### 3.3.1. Новый контроллер: FilterController
|
|
||||||
|
|
||||||
**Путь:** `module/oc_telegram_shop/upload/admin/controller/extension/tgshop/filter.php`
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controllers;
|
|
||||||
|
|
||||||
class FilterController extends Controller
|
|
||||||
{
|
|
||||||
public function index(): void
|
|
||||||
{
|
|
||||||
// Список фильтров
|
|
||||||
}
|
|
||||||
|
|
||||||
public function create(): void
|
|
||||||
{
|
|
||||||
// Форма создания фильтра
|
|
||||||
}
|
|
||||||
|
|
||||||
public function edit(): void
|
|
||||||
{
|
|
||||||
// Форма редактирования фильтра
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(): void
|
|
||||||
{
|
|
||||||
// Сохранение фильтра
|
|
||||||
}
|
|
||||||
|
|
||||||
public function delete(): void
|
|
||||||
{
|
|
||||||
// Удаление фильтра
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3.2. Новая модель: FilterModel (Admin)
|
|
||||||
|
|
||||||
**Путь:** `module/oc_telegram_shop/upload/admin/model/extension/tgshop/filter.php`
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
class ModelExtensionTgshopFilter extends Model
|
|
||||||
{
|
|
||||||
public function addFilter(array $data): int
|
|
||||||
{
|
|
||||||
$this->db->query("INSERT INTO " . DB_PREFIX . "telecart_filter
|
|
||||||
SET name = '" . $this->db->escape($data['name']) . "',
|
|
||||||
filter_json = '" . $this->db->escape(json_encode($data['filter_json'])) . "'");
|
|
||||||
|
|
||||||
return $this->db->getLastId();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFilter(int $filterId): ?array
|
|
||||||
{
|
|
||||||
$query = $this->db->query("SELECT * FROM " . DB_PREFIX . "telecart_filter
|
|
||||||
WHERE filter_id = '" . (int)$filterId . "'");
|
|
||||||
|
|
||||||
return $query->row;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getFilters(): array
|
|
||||||
{
|
|
||||||
$query = $this->db->query("SELECT * FROM " . DB_PREFIX . "telecart_filter
|
|
||||||
ORDER BY name ASC");
|
|
||||||
|
|
||||||
return $query->rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function editFilter(int $filterId, array $data): void
|
|
||||||
{
|
|
||||||
$this->db->query("UPDATE " . DB_PREFIX . "telecart_filter
|
|
||||||
SET name = '" . $this->db->escape($data['name']) . "',
|
|
||||||
filter_json = '" . $this->db->escape(json_encode($data['filter_json'])) . "'
|
|
||||||
WHERE filter_id = '" . (int)$filterId . "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteFilter(int $filterId): void
|
|
||||||
{
|
|
||||||
$this->db->query("DELETE FROM " . DB_PREFIX . "telecart_filter
|
|
||||||
WHERE filter_id = '" . (int)$filterId . "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3.3.3. Новый view: Filter Builder
|
|
||||||
|
|
||||||
**Путь:** `module/oc_telegram_shop/upload/admin/view/template/extension/tgshop/filter.twig`
|
|
||||||
|
|
||||||
Создать интерфейс конструктора фильтров на основе существующего функционала из `Filters.vue`:
|
|
||||||
|
|
||||||
- Визуальный редактор для создания JSON фильтров
|
|
||||||
- Поддержка всех существующих типов фильтров:
|
|
||||||
- ProductPrice
|
|
||||||
- ProductCategory
|
|
||||||
- ProductManufacturer
|
|
||||||
- ProductModel
|
|
||||||
- ProductStatus
|
|
||||||
- ProductQuantity
|
|
||||||
- И другие
|
|
||||||
- Поле для ввода имени фильтра
|
|
||||||
- Кнопки: Сохранить, Удалить, Отменить
|
|
||||||
|
|
||||||
## 4. Схема работы
|
|
||||||
|
|
||||||
### 4.1. Создание баннера с фильтром (Администратор)
|
|
||||||
|
|
||||||
1. Администратор открывает конструктор фильтров в админ-панели
|
|
||||||
2. Создает фильтр (например: "Товары со скидкой в категории Электроника")
|
|
||||||
3. Сохраняет фильтр с именем `discount_electronics`
|
|
||||||
4. Переходит в настройки баннеров OpenCart
|
|
||||||
5. Создает новый слайд
|
|
||||||
6. Загружает изображение баннера
|
|
||||||
7. В поле "Ссылка" вводит: `#filter:discount_electronics`
|
|
||||||
8. Сохраняет баннер
|
|
||||||
|
|
||||||
### 4.2. Пользователь кликает на баннер (Frontend)
|
|
||||||
|
|
||||||
1. Пользователь видит баннер на главной странице
|
|
||||||
2. Кликает на баннер
|
|
||||||
3. YaMetrika фиксирует цель `BANNER_CLICK`
|
|
||||||
4. Frontend определяет тип ссылки: `filter`
|
|
||||||
5. Переход на страницу `/products-filter/discount_electronics`
|
|
||||||
6. Загрузка фильтра по имени `discount_electronics`
|
|
||||||
7. Применение фильтра к списку товаров
|
|
||||||
8. Отображение отфильтрованных товаров
|
|
||||||
|
|
||||||
### 4.3. Пример JSON фильтра
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"operand": "AND",
|
|
||||||
"rules": {
|
|
||||||
"RULE_PRODUCT_PRICE": {
|
|
||||||
"criteria": {
|
|
||||||
"product_price": {
|
|
||||||
"type": "number",
|
|
||||||
"params": {
|
|
||||||
"operator": "between",
|
|
||||||
"value": {
|
|
||||||
"from": 1000,
|
|
||||||
"to": 5000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"RULE_PRODUCT_CATEGORY": {
|
|
||||||
"criteria": {
|
|
||||||
"product_category_id": {
|
|
||||||
"type": "product_category",
|
|
||||||
"params": {
|
|
||||||
"operator": "contains",
|
|
||||||
"value": 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Технические детали
|
|
||||||
|
|
||||||
### 5.1. Маршруты API
|
|
||||||
|
|
||||||
**Backend (PHP):**
|
|
||||||
|
|
||||||
- `GET /index.php?route=extension/tgshop/handle&api_action=filterList` - список фильтров
|
|
||||||
- `GET /index.php?route=extension/tgshop/handle&api_action=filterShow&name={name}` - получить фильтр
|
|
||||||
- `POST /index.php?route=extension/tgshop/handle&api_action=filterStore` - создать фильтр
|
|
||||||
- `POST /index.php?route=extension/tgshop/handle&api_action=filterUpdate` - обновить фильтр
|
|
||||||
- `POST /index.php?route=extension/tgshop/handle&api_action=filterDelete&id={id}` - удалить фильтр
|
|
||||||
|
|
||||||
**Admin Panel:**
|
|
||||||
|
|
||||||
- `GET /admin/index.php?route=extension/tgshop/filter` - список фильтров (страница)
|
|
||||||
- `GET /admin/index.php?route=extension/tgshop/filter/create` - форма создания
|
|
||||||
- `GET /admin/index.php?route=extension/tgshop/filter/edit&id={id}` - форма редактирования
|
|
||||||
- `POST /admin/index.php?route=extension/tgshop/filter/save` - сохранить фильтр
|
|
||||||
- `POST /admin/index.php?route=extension/tgshop/filter/delete&id={id}` - удалить фильтр
|
|
||||||
|
|
||||||
### 5.2. Валидация
|
|
||||||
|
|
||||||
- Имя фильтра: обязательно, уникальное, максимально 255 символов
|
|
||||||
- JSON фильтра: обязательно, валидный JSON, соответствует существующей схеме фильтров
|
|
||||||
- Проверка существования всех критериев в системе
|
|
||||||
|
|
||||||
### 5.3. Безопасность
|
|
||||||
|
|
||||||
- Проверка прав доступа для админ-панели
|
|
||||||
- SQL Injection защита через prepared statements
|
|
||||||
- XSS защита при выводе данных
|
|
||||||
- CSRF защита для форм администратора
|
|
||||||
|
|
||||||
### 5.4. Обратная совместимость
|
|
||||||
|
|
||||||
Использование формата ссылки `#filter:{filter_name}` с префиксом `#` гарантирует:
|
|
||||||
|
|
||||||
- **Совместимость с OpenCart**: При установке баннеров на обычный сайт OpenCart, якорная ссылка не приведет к ошибке 404, так как браузер просто оставит пользователя на текущей странице
|
|
||||||
- **Работа в SPA**: В Telegram веб-приложении JavaScript перехватывает клики и обрабатывает их специальным образом
|
|
||||||
- **Отказоустойчивость**: Если JavaScript не загрузился, пользователь не увидит ошибку, просто ничего не произойдет при клике
|
|
||||||
|
|
||||||
## 6. Интеграция с Яндекс.Метрикой
|
|
||||||
|
|
||||||
### 6.1. Цели метрики
|
|
||||||
|
|
||||||
Добавить в `spa/src/constants/yaMetrikaGoals.js`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
BANNER_CLICK: 'banner_click',
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2. События
|
|
||||||
|
|
||||||
- `banner_click` - клик по баннеру
|
|
||||||
- Параметры: `banner_title`, `link_type`, `filter_name`
|
|
||||||
|
|
||||||
### 6.3. Ecommerce события
|
|
||||||
|
|
||||||
При загрузке отфильтрованных товаров отправлять событие `view_item_list`:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
yaMetrika.dataLayerPush({
|
|
||||||
ecommerce: {
|
|
||||||
items: products.map((product, index) => ({
|
|
||||||
item_id: product.id,
|
|
||||||
item_name: product.name,
|
|
||||||
price: product.final_price_numeric,
|
|
||||||
item_category: product.category_name,
|
|
||||||
quantity: 1,
|
|
||||||
discount: product.price_numeric - product.final_price_numeric,
|
|
||||||
})),
|
|
||||||
item_list_id: 'banner_filter',
|
|
||||||
item_list_name: 'Отфильтрованные товары',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. План реализации
|
|
||||||
|
|
||||||
### Этап 1: Backend (API для управления фильтрами)
|
|
||||||
|
|
||||||
- [ ] Создать таблицу `oc_telecart_filter`
|
|
||||||
- [ ] Создать модель `FilterModel`
|
|
||||||
- [ ] Создать хэндлер `FilterHandler` с методами CRUD
|
|
||||||
- [ ] Добавить маршруты в `routes.php`
|
|
||||||
- [ ] Обновить `BannerHandler` для поддержки фильтров
|
|
||||||
|
|
||||||
### Этап 2: Frontend (SPA компоненты)
|
|
||||||
|
|
||||||
- [ ] Создать компонент `ProductsFilter.vue`
|
|
||||||
- [ ] Обновить `Banner.vue` для обработки клика
|
|
||||||
- [ ] Добавить маршрут в `router.js`
|
|
||||||
- [ ] Добавить методы в `ftch.js`
|
|
||||||
- [ ] Добавить цель `BANNER_CLICK` в YaMetrika
|
|
||||||
|
|
||||||
### Этап 3: Admin Panel (Конструктор фильтров)
|
|
||||||
|
|
||||||
- [ ] Создать контроллер `FilterController` в админке
|
|
||||||
- [ ] Создать модель `ModelExtensionTgshopFilter`
|
|
||||||
- [ ] Создать шаблоны для списка и форм
|
|
||||||
- [ ] Реализовать визуальный конструктор фильтров
|
|
||||||
|
|
||||||
### Этап 4: Тестирование
|
|
||||||
|
|
||||||
- [ ] Unit тесты для модели фильтров
|
|
||||||
- [ ] Интеграционные тесты для API
|
|
||||||
- [ ] Функциональные тесты в SPA
|
|
||||||
- [ ] Тестирование интеграции с Яндекс.Метрикой
|
|
||||||
|
|
||||||
### Этап 5: Документация
|
|
||||||
|
|
||||||
- [ ] Документация для администраторов
|
|
||||||
- [ ] API документация
|
|
||||||
- [ ] Описание схемы JSON фильтров
|
|
||||||
|
|
||||||
## 8. Возможные расширения (будущее)
|
|
||||||
|
|
||||||
1. **Предпросмотр фильтров** - возможность увидеть количество товаров, соответствующих фильтру
|
|
||||||
2. **Предустановленные фильтры** - шаблоны популярных фильтров
|
|
||||||
3. **A/B тестирование баннеров** - ротация баннеров с отслеживанием конверсии
|
|
||||||
4. **Аналитика кликов** - статистика по каждому баннеру
|
|
||||||
5. **Многовариантные фильтры** - баннер может открывать несколько результатов
|
|
||||||
|
|
||||||
## 9. Примеры использования
|
|
||||||
|
|
||||||
### Пример 1: Баннер "Скидки до 50%"
|
|
||||||
|
|
||||||
**Фильтр:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"operand": "AND",
|
|
||||||
"rules": {
|
|
||||||
"RULE_PRODUCT_SPECIAL": {
|
|
||||||
"criteria": {
|
|
||||||
"has_special": {
|
|
||||||
"type": "boolean",
|
|
||||||
"params": {
|
|
||||||
"operator": "equals",
|
|
||||||
"value": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ссылка в баннере:** `#filter:specials_50_discount`
|
|
||||||
|
|
||||||
### Пример 2: Баннер "Новинки в категории"
|
|
||||||
|
|
||||||
**Фильтр:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"operand": "AND",
|
|
||||||
"rules": {
|
|
||||||
"RULE_PRODUCT_CATEGORY": {
|
|
||||||
"criteria": {
|
|
||||||
"product_category_id": {
|
|
||||||
"type": "product_category",
|
|
||||||
"params": {
|
|
||||||
"operator": "contains",
|
|
||||||
"value": 42
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"RULE_PRODUCT_STATUS": {
|
|
||||||
"criteria": {
|
|
||||||
"product_status": {
|
|
||||||
"type": "boolean",
|
|
||||||
"params": {
|
|
||||||
"operator": "equals",
|
|
||||||
"value": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ссылка в баннере:** `#filter:new_in_electronics`
|
|
||||||
|
|
||||||
### Пример 3: Баннер "Товары до 1000₽"
|
|
||||||
|
|
||||||
**Фильтр:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"operand": "AND",
|
|
||||||
"rules": {
|
|
||||||
"RULE_PRODUCT_PRICE": {
|
|
||||||
"criteria": {
|
|
||||||
"product_price": {
|
|
||||||
"type": "number",
|
|
||||||
"params": {
|
|
||||||
"operator": "between",
|
|
||||||
"value": {
|
|
||||||
"from": 0,
|
|
||||||
"to": 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ссылка в баннере:** `#filter:budget_under_1000`
|
|
||||||
|
|
||||||
## 10. Выводы
|
|
||||||
|
|
||||||
Данное техническое задание описывает полную систему для реализации интерактивных баннеров с динамической фильтрацией
|
|
||||||
товаров. Реализация не нарушает существующую архитектуру проекта и полностью использует уже имеющийся функционал
|
|
||||||
фильтрации.
|
|
||||||
|
|
||||||
Основные преимущества:
|
|
||||||
|
|
||||||
- Гибкость настроек для администраторов
|
|
||||||
- Масштабируемость (можно добавить новые типы фильтров)
|
|
||||||
- Интеграция с Яндекс.Метрикой для аналитики
|
|
||||||
- Простота использования для администраторов магазина
|
|
||||||
- Обратная совместимость с существующими баннерами
|
|
||||||
- **Гарантированная совместимость с OpenCart**: использование якорных ссылок `#filter:{filter_name}` позволяет устанавливать баннеры как на SPA, так и на обычный сайт без ошибок
|
|
||||||
|
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
🚀 Вышла новая версия Telecart v1.3.2!
|
🚀 Вышла новая версия Telecart v2.0.0!
|
||||||
|
|
||||||
🎁 **ГЛАВНЫЕ НОВОСТИ:**
|
🎁 **ГЛАВНЫЕ НОВОСТИ:**
|
||||||
|
|
||||||
|
**Переработанная страница настроек модуля внутри OpenCart**
|
||||||
|
|
||||||
|
* Вывод базовой статистики по магазину.
|
||||||
|
* Ещё больше настроек
|
||||||
|
* Повышение производительности.
|
||||||
|
* Изменение формата хранения настроек на более расширяемый. Большинство настроек будут перенесены с предыдущей версии,
|
||||||
|
но всё равно я рекомендую открыть страницу настроек, заново просмотреть и сохранить их.
|
||||||
|
|
||||||
💰 **Поддержка купонов и подарочных сертификатов**
|
💰 **Поддержка купонов и подарочных сертификатов**
|
||||||
Telecart теперь поддерживает встроенную систему купонов OpenCart! Используйте нативные купоны и подарочных сертификаты для скидок и акций.
|
Telecart теперь поддерживает встроенную систему купонов OpenCart! Используйте нативные купоны и подарочных сертификаты
|
||||||
|
для скидок и акций.
|
||||||
|
|
||||||
📊 **Улучшенная Яндекс.Метрика**
|
📊 **Улучшенная Яндекс.Метрика**
|
||||||
Теперь с поддержкой ecommerce, хитов и целей! Полная воронка продаж:
|
Теперь с поддержкой ecommerce, хитов и целей! Полная воронка продаж:
|
||||||
@@ -15,8 +24,8 @@ Telecart теперь поддерживает встроенную систем
|
|||||||
📖 Документация: https://telecart-labs.github.io/docs/analitycs/start/
|
📖 Документация: https://telecart-labs.github.io/docs/analitycs/start/
|
||||||
|
|
||||||
🖼️ **Кликабельные Баннеры**
|
🖼️ **Кликабельные Баннеры**
|
||||||
Новинка для вашего Telegram-магазина!
|
Новинка для вашего Telegram-магазина!
|
||||||
Теперь каждый баннер ведет клиента именно на те товары, которые вы хотите продвигать.
|
Теперь каждый баннер ведет клиента именно на те товары, которые вы хотите продвигать.
|
||||||
Привяжите баннер к категории, товару или укажите внешнюю ссылку. При клике по баннеру покупатель перейдёт туда.
|
Привяжите баннер к категории, товару или укажите внешнюю ссылку. При клике по баннеру покупатель перейдёт туда.
|
||||||
Как бонус - клик по баннеру это тоже отдельная цель в Яндекс.Метрике.
|
Как бонус - клик по баннеру это тоже отдельная цель в Яндекс.Метрике.
|
||||||
|
|
||||||
@@ -28,15 +37,18 @@ Telecart теперь поддерживает встроенную систем
|
|||||||
Список настраиваемых текстов будет пополняться по запросам покупателей.
|
Список настраиваемых текстов будет пополняться по запросам покупателей.
|
||||||
|
|
||||||
✨ **Обновления UI/UX**
|
✨ **Обновления UI/UX**
|
||||||
• Шапка магазина с вашим логотипом и названием (настраивается в админке).
|
|
||||||
• Плавающая панель навигации в современном стиле, напоминающая эффект liquid glass от Apple. Полупрозрачный фон с размытием создает эффект глубины и современный эстетичный вид интерфейса.
|
* Шапка магазина с вашим логотипом и названием (настраивается в админке).
|
||||||
• Доработки интерфейса корзины, оформления заказа и ускорение поиска товаров.
|
* Нижняя панель навигации для более удобного доступа к основным страницам магазина.
|
||||||
|
* Доработана интеграция цветовых тем с Telegram. Теперь цвета темы распространяется на Telegram, плюс решена проблема,
|
||||||
|
когда цвет часов и иконок статусов сливались с фоном на iPhone.
|
||||||
|
* Множество мелких изменений, улучшающих внешний вид магазина.
|
||||||
|
|
||||||
🔧 **Технические улучшения:**
|
🔧 **Технические улучшения:**
|
||||||
• Добавлены автоматические тесты, запускаемые при каждом билде, что значительно снижает количество багов в новых версиях.
|
|
||||||
• Исправлены проблемы с поиском и оформлением заказов, обнаруженные на некоторых магазинах.
|
* Исправлены проблемы с поиском и оформлением заказов, обнаруженные на некоторых магазинах.
|
||||||
• Повышена стабильность работы.
|
* Повышена стабильность работы.
|
||||||
• Уменьшен размер архива с модулем примерно в 2 раза.
|
* Уменьшен размер архива с модулем примерно в 2 раза.
|
||||||
|
|
||||||
Купить модуль: https://liveopencart.ru/opencart-moduli-shablony/moduli/telecart
|
Купить модуль: https://liveopencart.ru/opencart-moduli-shablony/moduli/telecart
|
||||||
Документация: https://telecart-labs.github.io/docs/
|
Документация: https://telecart-labs.github.io/docs/
|
||||||
|
|||||||
40
frontend/admin/src/components/Form/ResetCacheBtn.vue
Normal file
40
frontend/admin/src/components/Form/ResetCacheBtn.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
icon="fa fa-refresh"
|
||||||
|
severity="warn"
|
||||||
|
v-tooltip.top="'Сбросить кеш модуля'"
|
||||||
|
:loading="isLoading"
|
||||||
|
@click="resetCache"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {Button, useToast} from "primevue";
|
||||||
|
import {ref} from "vue";
|
||||||
|
import {apiPost} from "@/utils/http.js";
|
||||||
|
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
async function resetCache() {
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
const response = await apiPost('resetCache');
|
||||||
|
if (response.success) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Выполнено',
|
||||||
|
detail: 'Кеш модуля сброшен.',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Ошибка',
|
||||||
|
detail: 'Ошибка при сбросе кеша.',
|
||||||
|
life: 3000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -65,6 +65,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw:mt-6 tw:lg:mt-0 tw:flex tw:items-center tw:gap-4">
|
<div class="tw:mt-6 tw:lg:mt-0 tw:flex tw:items-center tw:gap-4">
|
||||||
|
<ButtonGroup>
|
||||||
|
<ResetCacheBtn/>
|
||||||
|
</ButtonGroup>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<a
|
<a
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@@ -89,12 +92,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import Button from "primevue/button";
|
|
||||||
import {useSettingsStore} from "@/stores/settings.js";
|
import {useSettingsStore} from "@/stores/settings.js";
|
||||||
import {useStatsStore} from "@/stores/stats.js";
|
import {useStatsStore} from "@/stores/stats.js";
|
||||||
import {onMounted, ref} from "vue";
|
import {onMounted, ref} from "vue";
|
||||||
import OcImagePicker from "@/components/OcImagePicker.vue";
|
import OcImagePicker from "@/components/OcImagePicker.vue";
|
||||||
import {apiGet} from "@/utils/http.js";
|
import {apiGet} from "@/utils/http.js";
|
||||||
|
import ResetCacheBtn from "@/components/Form/ResetCacheBtn.vue";
|
||||||
|
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const stats = useStatsStore();
|
const stats = useStatsStore();
|
||||||
|
|||||||
@@ -4,13 +4,11 @@
|
|||||||
|
|
||||||
<div class="drawer-content">
|
<div class="drawer-content">
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<header class="app-header bg-neutral text-neutral-content w-full" v-if="platform === 'ios'"></header>
|
<header class="app-header bg-base-100 w-full"></header>
|
||||||
|
|
||||||
<section class="telecart-main-section">
|
<section class="telecart-main-section">
|
||||||
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
|
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
|
||||||
|
|
||||||
<Navbar @drawer="toggleDrawer"/>
|
|
||||||
|
|
||||||
<AppDebugMessage v-if="settings.app_debug"/>
|
<AppDebugMessage v-if="settings.app_debug"/>
|
||||||
|
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView v-slot="{ Component, route }">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
:moreText="block.data.all_text"
|
:moreText="block.data.all_text"
|
||||||
>
|
>
|
||||||
<Swiper
|
<Swiper
|
||||||
class="select-none"
|
class="select-none block-products-carousel"
|
||||||
:slides-per-view="block.data?.carousel?.slides_per_view || 2.5"
|
:slides-per-view="block.data?.carousel?.slides_per_view || 2.5"
|
||||||
:space-between="block.data?.carousel?.space_between || 20"
|
:space-between="block.data?.carousel?.space_between || 20"
|
||||||
:autoplay="block.data?.carousel?.autoplay || false"
|
:autoplay="block.data?.carousel?.autoplay || false"
|
||||||
@@ -14,16 +14,24 @@
|
|||||||
:lazy="true"
|
:lazy="true"
|
||||||
@sliderMove="hapticScroll"
|
@sliderMove="hapticScroll"
|
||||||
>
|
>
|
||||||
<SwiperSlide v-for="product in block.data.products.data" :key="product.id">
|
<SwiperSlide
|
||||||
<RouterLink
|
v-for="product in block.data.products.data"
|
||||||
:to="{name: 'product.show', params: {id: product.id}}"
|
:key="product.id"
|
||||||
@click="slideClick(product)"
|
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<div class="will-change-transform active:scale-97 transition-transform">
|
||||||
<img :src="product.images[0].url" :alt="product.name" loading="lazy" class="product-image"/>
|
<RouterLink
|
||||||
<PriceTitle :product="product"/>
|
:to="{name: 'product.show', params: {id: product.id}}"
|
||||||
</div>
|
@click="slideClick(product)"
|
||||||
</RouterLink>
|
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<img :src="product.images[0].url" :alt="product.name" loading="lazy" class="product-image"/>
|
||||||
|
<PriceTitle :product="product"/>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
</Swiper>
|
</Swiper>
|
||||||
</BaseBlock>
|
</BaseBlock>
|
||||||
@@ -32,13 +40,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||||
import {Swiper, SwiperSlide} from "swiper/vue";
|
import {Swiper, SwiperSlide} from "swiper/vue";
|
||||||
import ProductTitle from "@/components/ProductItem/ProductTitle.vue";
|
|
||||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||||
import Price from "@/components/ProductItem/Price.vue";
|
|
||||||
import BaseBlock from "@/components/MainPage/Blocks/BaseBlock.vue";
|
import BaseBlock from "@/components/MainPage/Blocks/BaseBlock.vue";
|
||||||
import PriceTitle from "@/components/ProductItem/PriceTitle.vue";
|
import PriceTitle from "@/components/ProductItem/PriceTitle.vue";
|
||||||
|
|
||||||
const hapticScroll = useHapticScroll(20, 'selectionChanged');
|
const hapticScroll = useHapticScroll();
|
||||||
const yaMetrika = useYaMetrikaStore();
|
const yaMetrika = useYaMetrikaStore();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="navbar bg-neutral text-neutral-content">
|
<div class="navbar">
|
||||||
<div class="navbar-start">
|
<div class="navbar-start">
|
||||||
<div v-if="false" class="dropdown">
|
<div v-if="false" class="dropdown">
|
||||||
<button class="btn btn-ghost btn-circle" @click="toggleDrawer">
|
<button class="btn btn-ghost btn-circle" @click="toggleDrawer">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="special">
|
<div v-if="special">
|
||||||
<span class="old-price text-stone-500 line-through mr-1">{{ price }}</span>
|
<span class="old-price text-neutral-content/70 line-through mr-1">{{ price }}</span>
|
||||||
<span class="curr-price font-medium">{{ special }}</span>
|
<span class="curr-price font-medium">{{ special }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="font-medium">{{ price }}</div>
|
<div v-else class="font-medium">{{ price }}</div>
|
||||||
|
|||||||
@@ -10,10 +10,11 @@
|
|||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="(product, index) in products"
|
v-for="(product, index) in products"
|
||||||
:key="product.id"
|
:key="product.id"
|
||||||
class="product-grid-card group"
|
class="product-grid-card group will-change-transform active:scale-97 transition-transform"
|
||||||
:to="`/product/${product.id}`"
|
:to="`/product/${product.id}`"
|
||||||
@click="productClick(product, index)"
|
@click="productClick(product, index)"
|
||||||
>
|
>
|
||||||
|
|
||||||
<ProductImageSwiper :images="product.images"/>
|
<ProductImageSwiper :images="product.images"/>
|
||||||
<PriceTitle :product="product"/>
|
<PriceTitle :product="product"/>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div v-if="config.slides.length > 0" class="app-banner" :class="classList">
|
<div v-if="config.slides.length > 0" class="app-banner" :class="classList">
|
||||||
<Swiper
|
<Swiper
|
||||||
:effect="slideEffect"
|
:effect="slideEffect"
|
||||||
class="select-none"
|
class="mainpage-slider select-none"
|
||||||
:slides-per-view="1"
|
:slides-per-view="1"
|
||||||
:space-between="config.space_between"
|
:space-between="config.space_between"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
@@ -77,7 +77,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const hapticScroll = useHapticScroll(20, 'impactOccurred', 'soft');
|
const hapticScroll = useHapticScroll();
|
||||||
const yaMetrika = useYaMetrikaStore();
|
const yaMetrika = useYaMetrikaStore();
|
||||||
const modules = [
|
const modules = [
|
||||||
Autoplay,
|
Autoplay,
|
||||||
@@ -105,7 +105,7 @@ const classList = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSwiper = (swiper) => {
|
const onSwiper = (swiper) => {
|
||||||
console.log(swiper);
|
|
||||||
};
|
};
|
||||||
const onSlideChange = () => {
|
const onSlideChange = () => {
|
||||||
|
|
||||||
@@ -195,4 +195,15 @@ onMounted(() => {
|
|||||||
.app-banner .swiper-horizontal .swiper-slide img {
|
.app-banner .swiper-horizontal .swiper-slide img {
|
||||||
border-radius: var(--radius-box);
|
border-radius: var(--radius-box);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.app-banner .mainpage-slider .swiper-pagination-bullet {
|
||||||
|
background-color: var(--color-neutral-content); /* неактивные точки */
|
||||||
|
opacity: 0.6; /* чуть прозрачнее, чтобы не отвлекали */
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-banner .mainpage-slider .swiper-pagination-bullet-active {
|
||||||
|
background-color: var(--color-primary); /* активная точка */
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -9,7 +9,7 @@ import {ref} from 'vue';
|
|||||||
*/
|
*/
|
||||||
export function useHapticScroll(
|
export function useHapticScroll(
|
||||||
threshold = 20,
|
threshold = 20,
|
||||||
type = 'impactOccurred',
|
type = 'selectionChanged',
|
||||||
feedback = 'soft'
|
feedback = 'soft'
|
||||||
) {
|
) {
|
||||||
const lastTranslate = ref(0);
|
const lastTranslate = ref(0);
|
||||||
|
|||||||
@@ -28,3 +28,62 @@ export function formatPrice(raw) {
|
|||||||
|
|
||||||
return `${sign}${formatted}`;
|
return `${sign}${formatted}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить CSS-переменную DaisyUI OKLH/OKLCH и вернуть HEX для Telegram
|
||||||
|
* @param {string} cssVarName - например '--color-base-100'
|
||||||
|
* @returns {string} #RRGGBB
|
||||||
|
*/
|
||||||
|
export function getCssVarOklchRgb(cssVarName) {
|
||||||
|
// Получаем значение CSS-переменной
|
||||||
|
const cssVar = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue(cssVarName)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Проверяем, что это OKLCH
|
||||||
|
const match = cssVar.match(/^oklch\(\s*([\d.]+)%?\s+([\d.]+)\s+([\d.]+)\s*\)$/);
|
||||||
|
if (!match) {
|
||||||
|
console.warn(`CSS variable ${cssVarName} is not a valid OKLCH`);
|
||||||
|
return { r:0, g:0, b:0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим L, C, H
|
||||||
|
const L = parseFloat(match[1]) / 100; // L в daisyUI в процентах
|
||||||
|
const C = parseFloat(match[2]);
|
||||||
|
const H = parseFloat(match[3]);
|
||||||
|
|
||||||
|
// --- OKLCH -> OKLab ---
|
||||||
|
const hRad = (H * Math.PI) / 180;
|
||||||
|
const a = C * Math.cos(hRad);
|
||||||
|
const b = C * Math.sin(hRad);
|
||||||
|
const l = L;
|
||||||
|
|
||||||
|
// --- OKLab -> Linear RGB ---
|
||||||
|
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||||
|
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||||
|
const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
|
||||||
|
|
||||||
|
const lCubed = l_ ** 3;
|
||||||
|
const mCubed = m_ ** 3;
|
||||||
|
const sCubed = s_ ** 3;
|
||||||
|
|
||||||
|
let r = 4.0767416621 * lCubed - 3.3077115913 * mCubed + 0.2309699292 * sCubed;
|
||||||
|
let g = -1.2684380046 * lCubed + 2.6097574011 * mCubed - 0.3413193965 * sCubed;
|
||||||
|
let b_ = -0.0041960863 * lCubed - 0.7034186147 * mCubed + 1.7076147010 * sCubed;
|
||||||
|
|
||||||
|
// --- Линейный RGB -> sRGB ---
|
||||||
|
const gammaCorrect = c => {
|
||||||
|
c = Math.min(Math.max(c, 0), 1); // обрезаем 0..1
|
||||||
|
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1/2.4) - 0.055;
|
||||||
|
};
|
||||||
|
|
||||||
|
r = Math.round(gammaCorrect(r) * 255);
|
||||||
|
g = Math.round(gammaCorrect(g) * 255);
|
||||||
|
b_ = Math.round(gammaCorrect(b_) * 255);
|
||||||
|
|
||||||
|
// --- Преобразуем в HEX ---
|
||||||
|
const toHex = n => n.toString(16).padStart(2, '0');
|
||||||
|
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b_)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import {createApp} from 'vue'
|
import {createApp} from 'vue';
|
||||||
import App from './App.vue'
|
import App from './App.vue';
|
||||||
import './style.css'
|
import './style.css';
|
||||||
import {VueTelegramPlugin} from 'vue-tg';
|
import {VueTelegramPlugin} from 'vue-tg';
|
||||||
import {router} from './router';
|
import {router} from './router';
|
||||||
import {createPinia} from 'pinia';
|
import {createPinia} from 'pinia';
|
||||||
|
|
||||||
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
|
|
||||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||||
import ApplicationError from "@/ApplicationError.vue";
|
import ApplicationError from "@/ApplicationError.vue";
|
||||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||||
|
|
||||||
import { register } from 'swiper/element/bundle';
|
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";
|
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||||
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||||
|
import {getCssVarOklchRgb} from "@/helpers.js";
|
||||||
|
|
||||||
register();
|
register();
|
||||||
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
@@ -46,20 +46,29 @@ settings.load()
|
|||||||
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
|
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.debug('[Init] Set theme attributes');
|
console.debug('[Init] Set theme attributes');
|
||||||
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
|
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
|
||||||
if (settings.night_auto) {
|
if (settings.night_auto) {
|
||||||
window.Telegram.WebApp.onEvent('themeChanged', function () {
|
window.Telegram.WebApp.onEvent('themeChanged', function () {
|
||||||
document.documentElement.setAttribute('data-theme', settings.theme[this.colorScheme]);
|
document.documentElement.setAttribute('data-theme', settings.theme[this.colorScheme]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key in settings.theme.variables) {
|
for (const key in settings.theme.variables) {
|
||||||
document.documentElement.style.setProperty(key, settings.theme.variables[key]);
|
document.documentElement.style.setProperty(key, settings.theme.variables[key]);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const daisyUIBgColor = getCssVarOklchRgb('--color-base-100');
|
||||||
|
window.Telegram.WebApp.setHeaderColor(daisyUIBgColor);
|
||||||
|
window.Telegram.WebApp.setBackgroundColor(daisyUIBgColor);
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
.then(() => new AppMetaInitializer(settings).init())
|
.then(() => new AppMetaInitializer(settings).init())
|
||||||
.then(() => { appLoading.unmount(); app.mount('#app'); })
|
.then(() => {
|
||||||
|
appLoading.unmount();
|
||||||
|
app.mount('#app');
|
||||||
|
})
|
||||||
.then(() => window.Telegram.WebApp.ready())
|
.then(() => window.Telegram.WebApp.ready())
|
||||||
.then(() => settings.ya_metrika_enabled && injectYaMetrika())
|
.then(() => settings.ya_metrika_enabled && injectYaMetrika())
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
themes: all;
|
themes: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
--color-base-100 - DaisyUI background
|
||||||
|
*/
|
||||||
|
|
||||||
html, body, #app {
|
html, body, #app {
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
@@ -11,16 +15,11 @@ html, body, #app {
|
|||||||
--swiper-pagination-bullet-horizontal-gap: 1px;
|
--swiper-pagination-bullet-horizontal-gap: 1px;
|
||||||
--swiper-pagination-bullet-size: 6px;
|
--swiper-pagination-bullet-size: 6px;
|
||||||
--swiper-pagination-color: #777;
|
--swiper-pagination-color: #777;
|
||||||
--swiper-pagination-bottom: -5px;
|
--swiper-pagination-bottom: 0px;
|
||||||
--product_list_title_max_lines: 2;
|
--product_list_title_max_lines: 2;
|
||||||
--tc-navbar-min-height: 3rem;
|
--tc-navbar-min-height: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swiper-pagination-bullets {
|
|
||||||
border-radius: var(--radius-selector);
|
|
||||||
padding: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
position: relative;
|
position: relative;
|
||||||
/*padding-top: var(--tg-content-safe-area-inset-top);*/
|
/*padding-top: var(--tg-content-safe-area-inset-top);*/
|
||||||
@@ -46,15 +45,14 @@ html, body, #app {
|
|||||||
.app-header {
|
.app-header {
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
height: calc(var(--tg-content-safe-area-inset-top, 0px) + var(--tg-safe-area-inset-top, 0px));
|
||||||
min-height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
min-height: calc(var(--tg-content-safe-area-inset-top, 0px) + var(--tg-safe-area-inset-top, 0px));
|
||||||
max-height: calc(var(--tg-content-safe-area-inset-top) + var(--tg-safe-area-inset-top));
|
max-height: calc(var(--tg-content-safe-area-inset-top, 0px) + var(--tg-safe-area-inset-top, 0px));
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: white;
|
color: white;
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.telecart-main-section {
|
.telecart-main-section {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="px-4 mt-4">
|
<main class="px-4 mt-4">
|
||||||
<header v-if="title" class="font-bold uppercase mb-4">{{ title }}</header>
|
<header v-if="title" class="font-bold uppercase mb-4 text-center">{{ title }}</header>
|
||||||
<section>
|
<section>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="goodsRef" class="space-y-8 mt-4">
|
<div>
|
||||||
<MainPage/>
|
<Navbar/>
|
||||||
|
<div ref="goodsRef" class="space-y-8 mt-4">
|
||||||
|
<MainPage/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -13,6 +16,7 @@ import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
|||||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||||
import MainPage from "@/components/MainPage/MainPage.vue";
|
import MainPage from "@/components/MainPage/MainPage.vue";
|
||||||
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||||
|
import Navbar from "@/components/Navbar.vue";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'Home'
|
name: 'Home'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use Bastion\Exceptions\BotTokenConfiguratorException;
|
|||||||
use Bastion\Services\BotTokenConfigurator;
|
use Bastion\Services\BotTokenConfigurator;
|
||||||
use Bastion\Services\SettingsService;
|
use Bastion\Services\SettingsService;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||||
use Openguru\OpenCartFramework\Config\Settings;
|
use Openguru\OpenCartFramework\Config\Settings;
|
||||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||||
use Openguru\OpenCartFramework\Http\Request;
|
use Openguru\OpenCartFramework\Http\Request;
|
||||||
@@ -17,15 +18,18 @@ class SettingsHandler
|
|||||||
private BotTokenConfigurator $botTokenConfigurator;
|
private BotTokenConfigurator $botTokenConfigurator;
|
||||||
private Settings $settings;
|
private Settings $settings;
|
||||||
private SettingsService $settingsUpdateService;
|
private SettingsService $settingsUpdateService;
|
||||||
|
private CacheInterface $cache;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
BotTokenConfigurator $botTokenConfigurator,
|
BotTokenConfigurator $botTokenConfigurator,
|
||||||
Settings $settings,
|
Settings $settings,
|
||||||
SettingsService $settingsUpdateService
|
SettingsService $settingsUpdateService,
|
||||||
|
CacheInterface $cache
|
||||||
) {
|
) {
|
||||||
$this->botTokenConfigurator = $botTokenConfigurator;
|
$this->botTokenConfigurator = $botTokenConfigurator;
|
||||||
$this->settings = $settings;
|
$this->settings = $settings;
|
||||||
$this->settingsUpdateService = $settingsUpdateService;
|
$this->settingsUpdateService = $settingsUpdateService;
|
||||||
|
$this->cache = $cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureBotToken(Request $request): JsonResponse
|
public function configureBotToken(Request $request): JsonResponse
|
||||||
@@ -71,4 +75,11 @@ class SettingsHandler
|
|||||||
private function validate(array $input): void
|
private function validate(array $input): void
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resetCache(): JsonResponse
|
||||||
|
{
|
||||||
|
$this->cache->prune();
|
||||||
|
|
||||||
|
return new JsonResponse([], Response::HTTP_ACCEPTED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ return [
|
|||||||
|
|
||||||
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
|
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
|
||||||
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
|
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
|
||||||
|
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ interface CacheInterface
|
|||||||
public function delete(string $key): void;
|
public function delete(string $key): void;
|
||||||
|
|
||||||
public function clear(): void;
|
public function clear(): void;
|
||||||
|
|
||||||
|
public function prune(): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,9 @@ class SymfonyMySqlCache implements CacheInterface
|
|||||||
{
|
{
|
||||||
$this->cache->clear();
|
$this->cache->clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function prune(): void
|
||||||
|
{
|
||||||
|
$this->cache->prune();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user