feat: UI/UX, add reset cache to admin

This commit is contained in:
2025-11-16 20:34:03 +03:00
parent 6ac6a42e21
commit 09f1e514a9
21 changed files with 227 additions and 886 deletions

View File

@@ -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, так и на обычный сайт без ошибок

View File

@@ -1,9 +1,18 @@
🚀 Вышла новая версия Telecart v1.3.2!
🚀 Вышла новая версия Telecart v2.0.0!
🎁 **ГЛАВНЫЕ НОВОСТИ:**
**Переработанная страница настроек модуля внутри OpenCart**
* Вывод базовой статистики по магазину.
* Ещё больше настроек
* Повышение производительности.
* Изменение формата хранения настроек на более расширяемый. Большинство настроек будут перенесены с предыдущей версии,
но всё равно я рекомендую открыть страницу настроек, заново просмотреть и сохранить их.
💰 **Поддержка купонов и подарочных сертификатов**
Telecart теперь поддерживает встроенную систему купонов OpenCart! Используйте нативные купоны и подарочных сертификаты для скидок и акций.
Telecart теперь поддерживает встроенную систему купонов OpenCart! Используйте нативные купоны и подарочных сертификаты
для скидок и акций.
📊 **Улучшенная Яндекс.Метрика**
Теперь с поддержкой ecommerce, хитов и целей! Полная воронка продаж:
@@ -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/

View 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>

View File

@@ -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();

View File

@@ -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 }">

View File

@@ -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">
<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({

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -9,7 +9,7 @@ import {ref} from 'vue';
*/
export function useHapticScroll(
threshold = 20,
type = 'impactOccurred',
type = 'selectionChanged',
feedback = 'soft'
) {
const lastTranslate = ref(0);

View File

@@ -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_)}`;
}

View File

@@ -1,11 +1,9 @@
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";
@@ -17,6 +15,8 @@ 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();
@@ -56,10 +56,19 @@ settings.load()
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 => {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -1,7 +1,10 @@
<template>
<div>
<Navbar/>
<div ref="goodsRef" class="space-y-8 mt-4">
<MainPage/>
</div>
</div>
</template>
<script setup>
@@ -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'

View File

@@ -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);
}
}

View File

@@ -21,4 +21,5 @@ return [
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
'resetCache' => [SettingsHandler::class, 'resetCache'],
];

View File

@@ -11,4 +11,6 @@ interface CacheInterface
public function delete(string $key): void;
public function clear(): void;
public function prune(): void;
}

View File

@@ -41,4 +41,9 @@ class SymfonyMySqlCache implements CacheInterface
{
$this->cache->clear();
}
public function prune(): void
{
$this->cache->prune();
}
}