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, хитов и целей! Полная воронка продаж:
|
||||
@@ -15,8 +24,8 @@ Telecart теперь поддерживает встроенную систем
|
||||
📖 Документация: https://telecart-labs.github.io/docs/analitycs/start/
|
||||
|
||||
🖼️ **Кликабельные Баннеры**
|
||||
Новинка для вашего Telegram-магазина!
|
||||
Теперь каждый баннер ведет клиента именно на те товары, которые вы хотите продвигать.
|
||||
Новинка для вашего Telegram-магазина!
|
||||
Теперь каждый баннер ведет клиента именно на те товары, которые вы хотите продвигать.
|
||||
Привяжите баннер к категории, товару или укажите внешнюю ссылку. При клике по баннеру покупатель перейдёт туда.
|
||||
Как бонус - клик по баннеру это тоже отдельная цель в Яндекс.Метрике.
|
||||
|
||||
@@ -28,15 +37,18 @@ Telecart теперь поддерживает встроенную систем
|
||||
Список настраиваемых текстов будет пополняться по запросам покупателей.
|
||||
|
||||
✨ **Обновления UI/UX**
|
||||
• Шапка магазина с вашим логотипом и названием (настраивается в админке).
|
||||
• Плавающая панель навигации в современном стиле, напоминающая эффект liquid glass от Apple. Полупрозрачный фон с размытием создает эффект глубины и современный эстетичный вид интерфейса.
|
||||
• Доработки интерфейса корзины, оформления заказа и ускорение поиска товаров.
|
||||
|
||||
* Шапка магазина с вашим логотипом и названием (настраивается в админке).
|
||||
* Нижняя панель навигации для более удобного доступа к основным страницам магазина.
|
||||
* Доработана интеграция цветовых тем с Telegram. Теперь цвета темы распространяется на Telegram, плюс решена проблема,
|
||||
когда цвет часов и иконок статусов сливались с фоном на iPhone.
|
||||
* Множество мелких изменений, улучшающих внешний вид магазина.
|
||||
|
||||
🔧 **Технические улучшения:**
|
||||
• Добавлены автоматические тесты, запускаемые при каждом билде, что значительно снижает количество багов в новых версиях.
|
||||
• Исправлены проблемы с поиском и оформлением заказов, обнаруженные на некоторых магазинах.
|
||||
• Повышена стабильность работы.
|
||||
• Уменьшен размер архива с модулем примерно в 2 раза.
|
||||
|
||||
* Исправлены проблемы с поиском и оформлением заказов, обнаруженные на некоторых магазинах.
|
||||
* Повышена стабильность работы.
|
||||
* Уменьшен размер архива с модулем примерно в 2 раза.
|
||||
|
||||
Купить модуль: https://liveopencart.ru/opencart-moduli-shablony/moduli/telecart
|
||||
Документация: 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 class="tw:mt-6 tw:lg:mt-0 tw:flex tw:items-center tw:gap-4">
|
||||
<ButtonGroup>
|
||||
<ResetCacheBtn/>
|
||||
</ButtonGroup>
|
||||
<div class="btn-group">
|
||||
<a
|
||||
class="btn btn-primary"
|
||||
@@ -89,12 +92,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Button from "primevue/button";
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import {useStatsStore} from "@/stores/stats.js";
|
||||
import {onMounted, ref} from "vue";
|
||||
import OcImagePicker from "@/components/OcImagePicker.vue";
|
||||
import {apiGet} from "@/utils/http.js";
|
||||
import ResetCacheBtn from "@/components/Form/ResetCacheBtn.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const stats = useStatsStore();
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
|
||||
<div class="drawer-content">
|
||||
<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">
|
||||
<FullscreenViewport v-if="platform === 'ios' || platform === 'android'"/>
|
||||
|
||||
<Navbar @drawer="toggleDrawer"/>
|
||||
|
||||
<AppDebugMessage v-if="settings.app_debug"/>
|
||||
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:moreText="block.data.all_text"
|
||||
>
|
||||
<Swiper
|
||||
class="select-none"
|
||||
class="select-none block-products-carousel"
|
||||
:slides-per-view="block.data?.carousel?.slides_per_view || 2.5"
|
||||
:space-between="block.data?.carousel?.space_between || 20"
|
||||
:autoplay="block.data?.carousel?.autoplay || false"
|
||||
@@ -14,16 +14,24 @@
|
||||
:lazy="true"
|
||||
@sliderMove="hapticScroll"
|
||||
>
|
||||
<SwiperSlide v-for="product in block.data.products.data" :key="product.id">
|
||||
<RouterLink
|
||||
:to="{name: 'product.show', params: {id: product.id}}"
|
||||
@click="slideClick(product)"
|
||||
>
|
||||
<div class="text-center">
|
||||
<img :src="product.images[0].url" :alt="product.name" loading="lazy" class="product-image"/>
|
||||
<PriceTitle :product="product"/>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<SwiperSlide
|
||||
v-for="product in block.data.products.data"
|
||||
:key="product.id"
|
||||
|
||||
>
|
||||
<div class="will-change-transform active:scale-97 transition-transform">
|
||||
<RouterLink
|
||||
:to="{name: 'product.show', params: {id: product.id}}"
|
||||
@click="slideClick(product)"
|
||||
|
||||
>
|
||||
<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>
|
||||
</Swiper>
|
||||
</BaseBlock>
|
||||
@@ -32,13 +40,11 @@
|
||||
<script setup>
|
||||
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
|
||||
import {Swiper, SwiperSlide} from "swiper/vue";
|
||||
import ProductTitle from "@/components/ProductItem/ProductTitle.vue";
|
||||
import {useHapticScroll} from "@/composables/useHapticScroll.js";
|
||||
import Price from "@/components/ProductItem/Price.vue";
|
||||
import BaseBlock from "@/components/MainPage/Blocks/BaseBlock.vue";
|
||||
import PriceTitle from "@/components/ProductItem/PriceTitle.vue";
|
||||
|
||||
const hapticScroll = useHapticScroll(20, 'selectionChanged');
|
||||
const hapticScroll = useHapticScroll();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="navbar bg-neutral text-neutral-content">
|
||||
<div class="navbar">
|
||||
<div class="navbar-start">
|
||||
<div v-if="false" class="dropdown">
|
||||
<button class="btn btn-ghost btn-circle" @click="toggleDrawer">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
<div v-else class="font-medium">{{ price }}</div>
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
<RouterLink
|
||||
v-for="(product, index) in products"
|
||||
: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}`"
|
||||
@click="productClick(product, index)"
|
||||
>
|
||||
|
||||
<ProductImageSwiper :images="product.images"/>
|
||||
<PriceTitle :product="product"/>
|
||||
</RouterLink>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div v-if="config.slides.length > 0" class="app-banner" :class="classList">
|
||||
<Swiper
|
||||
:effect="slideEffect"
|
||||
class="select-none"
|
||||
class="mainpage-slider select-none"
|
||||
:slides-per-view="1"
|
||||
:space-between="config.space_between"
|
||||
:pagination="pagination"
|
||||
@@ -77,7 +77,7 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const hapticScroll = useHapticScroll(20, 'impactOccurred', 'soft');
|
||||
const hapticScroll = useHapticScroll();
|
||||
const yaMetrika = useYaMetrikaStore();
|
||||
const modules = [
|
||||
Autoplay,
|
||||
@@ -105,7 +105,7 @@ const classList = computed(() => {
|
||||
});
|
||||
|
||||
const onSwiper = (swiper) => {
|
||||
console.log(swiper);
|
||||
|
||||
};
|
||||
const onSlideChange = () => {
|
||||
|
||||
@@ -195,4 +195,15 @@ onMounted(() => {
|
||||
.app-banner .swiper-horizontal .swiper-slide img {
|
||||
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>
|
||||
@@ -9,7 +9,7 @@ import {ref} from 'vue';
|
||||
*/
|
||||
export function useHapticScroll(
|
||||
threshold = 20,
|
||||
type = 'impactOccurred',
|
||||
type = 'selectionChanged',
|
||||
feedback = 'soft'
|
||||
) {
|
||||
const lastTranslate = ref(0);
|
||||
|
||||
@@ -28,3 +28,62 @@ export function formatPrice(raw) {
|
||||
|
||||
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 App from './App.vue'
|
||||
import './style.css'
|
||||
import {createApp} from 'vue';
|
||||
import App from './App.vue';
|
||||
import './style.css';
|
||||
import {VueTelegramPlugin} from 'vue-tg';
|
||||
import {router} from './router';
|
||||
import {createPinia} from 'pinia';
|
||||
|
||||
import {useCategoriesStore} from "@/stores/CategoriesStore.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import ApplicationError from "@/ApplicationError.vue";
|
||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||
|
||||
import { register } from 'swiper/element/bundle';
|
||||
import {register} from 'swiper/element/bundle';
|
||||
import 'swiper/element/bundle';
|
||||
import 'swiper/css/bundle';
|
||||
import AppLoading from "@/AppLoading.vue";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||
import {getCssVarOklchRgb} from "@/helpers.js";
|
||||
|
||||
register();
|
||||
|
||||
const pinia = createPinia();
|
||||
@@ -46,20 +46,29 @@ settings.load()
|
||||
filtersStore.applied = await filtersStore.fetchFiltersForMainPage();
|
||||
})
|
||||
.then(() => {
|
||||
console.debug('[Init] Set theme attributes');
|
||||
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
|
||||
if (settings.night_auto) {
|
||||
window.Telegram.WebApp.onEvent('themeChanged', function () {
|
||||
document.documentElement.setAttribute('data-theme', settings.theme[this.colorScheme]);
|
||||
});
|
||||
}
|
||||
console.debug('[Init] Set theme attributes');
|
||||
document.documentElement.setAttribute('data-theme', settings.theme[window.Telegram.WebApp.colorScheme]);
|
||||
if (settings.night_auto) {
|
||||
window.Telegram.WebApp.onEvent('themeChanged', function () {
|
||||
document.documentElement.setAttribute('data-theme', settings.theme[this.colorScheme]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const key in settings.theme.variables) {
|
||||
document.documentElement.style.setProperty(key, settings.theme.variables[key]);
|
||||
for (const key in settings.theme.variables) {
|
||||
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(() => { appLoading.unmount(); app.mount('#app'); })
|
||||
.then(() => {
|
||||
appLoading.unmount();
|
||||
app.mount('#app');
|
||||
})
|
||||
.then(() => window.Telegram.WebApp.ready())
|
||||
.then(() => settings.ya_metrika_enabled && injectYaMetrika())
|
||||
.catch(error => {
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
themes: all;
|
||||
}
|
||||
|
||||
/**
|
||||
--color-base-100 - DaisyUI background
|
||||
*/
|
||||
|
||||
html, body, #app {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@@ -11,16 +15,11 @@ html, body, #app {
|
||||
--swiper-pagination-bullet-horizontal-gap: 1px;
|
||||
--swiper-pagination-bullet-size: 6px;
|
||||
--swiper-pagination-color: #777;
|
||||
--swiper-pagination-bottom: -5px;
|
||||
--swiper-pagination-bottom: 0px;
|
||||
--product_list_title_max_lines: 2;
|
||||
--tc-navbar-min-height: 3rem;
|
||||
}
|
||||
|
||||
.swiper-pagination-bullets {
|
||||
border-radius: var(--radius-selector);
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
position: relative;
|
||||
/*padding-top: var(--tg-content-safe-area-inset-top);*/
|
||||
@@ -46,15 +45,14 @@ html, body, #app {
|
||||
.app-header {
|
||||
z-index: 60;
|
||||
position: fixed;
|
||||
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) + var(--tg-safe-area-inset-top));
|
||||
max-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, 0px) + var(--tg-safe-area-inset-top, 0px));
|
||||
max-height: calc(var(--tg-content-safe-area-inset-top, 0px) + var(--tg-safe-area-inset-top, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
color: white;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.telecart-main-section {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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>
|
||||
<slot></slot>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<div ref="goodsRef" class="space-y-8 mt-4">
|
||||
<MainPage/>
|
||||
<div>
|
||||
<Navbar/>
|
||||
<div ref="goodsRef" class="space-y-8 mt-4">
|
||||
<MainPage/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,6 +16,7 @@ import {YA_METRIKA_GOAL} from "@/constants/yaMetrikaGoals.js";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import MainPage from "@/components/MainPage/MainPage.vue";
|
||||
import {useBlocksStore} from "@/stores/BlocksStore.js";
|
||||
import Navbar from "@/components/Navbar.vue";
|
||||
|
||||
defineOptions({
|
||||
name: 'Home'
|
||||
|
||||
@@ -6,6 +6,7 @@ use Bastion\Exceptions\BotTokenConfiguratorException;
|
||||
use Bastion\Services\BotTokenConfigurator;
|
||||
use Bastion\Services\SettingsService;
|
||||
use Exception;
|
||||
use Openguru\OpenCartFramework\Cache\CacheInterface;
|
||||
use Openguru\OpenCartFramework\Config\Settings;
|
||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
@@ -17,15 +18,18 @@ class SettingsHandler
|
||||
private BotTokenConfigurator $botTokenConfigurator;
|
||||
private Settings $settings;
|
||||
private SettingsService $settingsUpdateService;
|
||||
private CacheInterface $cache;
|
||||
|
||||
public function __construct(
|
||||
BotTokenConfigurator $botTokenConfigurator,
|
||||
Settings $settings,
|
||||
SettingsService $settingsUpdateService
|
||||
SettingsService $settingsUpdateService,
|
||||
CacheInterface $cache
|
||||
) {
|
||||
$this->botTokenConfigurator = $botTokenConfigurator;
|
||||
$this->settings = $settings;
|
||||
$this->settingsUpdateService = $settingsUpdateService;
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
public function configureBotToken(Request $request): JsonResponse
|
||||
@@ -71,4 +75,11 @@ class SettingsHandler
|
||||
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'],
|
||||
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
|
||||
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
||||
];
|
||||
|
||||
@@ -11,4 +11,6 @@ interface CacheInterface
|
||||
public function delete(string $key): void;
|
||||
|
||||
public function clear(): void;
|
||||
|
||||
public function prune(): void;
|
||||
}
|
||||
|
||||
@@ -41,4 +41,9 @@ class SymfonyMySqlCache implements CacheInterface
|
||||
{
|
||||
$this->cache->clear();
|
||||
}
|
||||
|
||||
public function prune(): void
|
||||
{
|
||||
$this->cache->prune();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user