25 Commits

Author SHA1 Message Date
24075dceb7 docs: add design spec for honest opt-out heartbeat
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:04:17 +03:00
d621baae45 docs: add docs/features scaffold for feature documentation
Introduce docs/features/ with an index (conventions + feature registry),
a per-feature template, and a filled cart example. Reference the structure
from the root CLAUDE.md so context can be restored per feature on demand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:33:37 +03:00
ed9d2c1b1f build(deps): patch security vulnerabilities in composer and npm deps
PHP (composer.lock):
- guzzlehttp/guzzle 7.10.0 -> 7.13.1 (CVE-2026-55767, CVE-2026-55568)
- guzzlehttp/psr7 2.9.0 -> 2.12.3 (CVE-2026-55766, CVE-2026-49214, CVE-2026-48998)
- symfony/cache 5.4.46 -> 5.4.53 (CVE-2026-45073 SQL injection)

frontend/admin: npm audit fix (axios, shell-quote, form-data, js-yaml,
esbuild, vite) -> 0 vulnerabilities

frontend/spa: npm audit fix + vitest/@vitest/ui -> 4.1.9 (flatted) ->
0 vulnerabilities

Verified: 299 PHPUnit tests pass, 53 SPA vitest pass, both frontends build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:18:15 +03:00
7f73343f3f docs: replace cursor rules with CLAUDE.md
Migrate project guidance from .cursor/ to a single CLAUDE.md covering
architecture, backend/frontend conventions, commands and PHP 7.4 constraint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:13:20 +03:00
0d4ffa7411 chore(release): 2.3.0 2026-05-25 10:29:40 +03:00
c24e30f14c fix(spa): visual fixes for SPA (#106)
* fix(spa): prevent more-link button from wrapping in block header

Long block titles squeezed the "Смотреть всё" link in BaseBlock,
causing its text to wrap onto two lines. Allow the title column to
shrink and keep the link in a single line.

* fix(spa): enable typography styles for product description

Add @tailwindcss/typography plugin so the `prose` classes used in the
product description actually apply: paragraph spacing, headings, hr,
links and tables. Also make wide tables inside `.prose` horizontally
scrollable instead of overflowing the viewport.

* fix(spa): open description links via Telegram WebApp API

Links inside the v-html product description used to navigate the
Mini App's WebView away from the app on Telegram mobile. Intercept
clicks on anchors in the description and route them through
Telegram.WebApp.openTelegramLink for t.me/tg:// targets and
Telegram.WebApp.openLink for everything else, falling back to
window.open when running outside Telegram.

* fix(spa): refine categories list layout

Tighten category item typography (text-base, leading-snug) and slightly
enlarge the icon for a better balance with long titles. Drop the
duplicate "Назад к ..." link and replace the parent-category CTA with
a daisyUI soft block button "Показать все товары категории" so the
primary action is visually distinct from the list.
2026-05-25 10:13:42 +03:00
d43564481d fix(spa): handle empty category and products in carousel block
Render the products carousel block gracefully when category_id is
missing or when the selected category has no products, instead of
throwing Vue Router's "Missing required param 'category_id'" error.
2026-05-25 09:27:08 +03:00
dependabot[bot]
41b17925df build(deps): bump the npm_and_yarn group across 2 directories with 6 updates
Bumps the npm_and_yarn group with 4 updates in the /frontend/admin directory: [axios](https://github.com/axios/axios), [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite), [follow-redirects](https://github.com/follow-redirects/follow-redirects) and [postcss](https://github.com/postcss/postcss).
Bumps the npm_and_yarn group with 4 updates in the /frontend/spa directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite), [postcss](https://github.com/postcss/postcss), [js-cookie](https://github.com/js-cookie/js-cookie) and [ws](https://github.com/websockets/ws).


Updates `axios` from 1.13.6 to 1.15.2
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.6...v1.15.2)

Updates `vite` from 7.3.1 to 7.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.3/packages/vite)

Updates `follow-redirects` from 1.15.11 to 1.16.0
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)

Updates `postcss` from 8.5.8 to 8.5.15
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.15)

Updates `vite` from 7.3.1 to 7.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.3/packages/vite)

Updates `postcss` from 8.5.8 to 8.5.15
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.15)

Updates `js-cookie` from 3.0.5 to 3.0.7
- [Release notes](https://github.com/js-cookie/js-cookie/releases)
- [Commits](https://github.com/js-cookie/js-cookie/compare/v3.0.5...v3.0.7)

Updates `ws` from 8.19.0 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.19.0...8.21.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.15.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 7.3.3
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: follow-redirects
  dependency-version: 1.16.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.15
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: vite
  dependency-version: 7.3.3
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: postcss
  dependency-version: 8.5.15
  dependency-type: direct:development
  dependency-group: npm_and_yarn
- dependency-name: js-cookie
  dependency-version: 3.0.7
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 08:01:29 +03:00
dependabot[bot]
caf862cfed build(deps): bump the npm_and_yarn group across 2 directories with 1 update
Bumps the npm_and_yarn group with 1 update in the /frontend/admin directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).
Bumps the npm_and_yarn group with 1 update in the /frontend/spa directory: [brace-expansion](https://github.com/juliangruber/brace-expansion).


Updates `brace-expansion` from 1.1.12 to 1.1.13
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

Updates `brace-expansion` from 2.0.2 to 2.0.3
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: brace-expansion
  dependency-version: 2.0.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 07:57:50 +03:00
dependabot[bot]
eed36fe00e build(deps): bump the npm_and_yarn group across 2 directories with 2 updates
Bumps the npm_and_yarn group with 2 updates in the /frontend/admin directory: [flatted](https://github.com/WebReflection/flatted) and [picomatch](https://github.com/micromatch/picomatch).
Bumps the npm_and_yarn group with 1 update in the /frontend/spa directory: [picomatch](https://github.com/micromatch/picomatch).


Updates `flatted` from 3.4.1 to 3.4.2
- [Commits](https://github.com/WebReflection/flatted/compare/v3.4.1...v3.4.2)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

Updates `picomatch` from 4.0.3 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-28 01:57:04 +03:00
28c8ce3b68 feat(ocmod): add TeleCart admin sidebar menu and local dev setup (#57)
- Add telecart.ocmod.xml: inject TeleCart menu item (with Settings child) after Marketing
- Add scripts/apply_ocmod.sh to symlink ocmod file in Docker container
- Add make ocmod target
- In CI: set version in telecart.ocmod.xml from version_meta; use docs.telecart.pro link

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-03-15 21:58:11 +03:00
80c2467b4a feat(admin): telegram settings improvements (#63) 2026-03-15 21:28:36 +03:00
a3f54a87dc feat(spa): Increase max page limit to product lists (#88) 2026-03-15 21:25:47 +03:00
554822720b build: ci/cd ignore issues branches (#92) 2026-03-15 21:24:48 +03:00
67f2fe6af6 chore(release): 2.2.2 2026-03-15 14:56:03 +03:00
0b786c832f build: update telegram api and composer deps (#82)
* build: update composer dependencies (#82)
* feat(spa): upgrade telegram web app version (#82)
2026-03-15 14:42:39 +03:00
f894d33c46 fix: normalize order response to valid UTF-8 before JSON encoding (#83)
* fix: normalize cart json utf8 and add tests

Ensure cart and checkout responses are cleaned to valid UTF-8 before JSON encoding and cover Utf8Cleaner with unit tests.

Made-with: Cursor

* fix: normalize order response to valid UTF-8 before JSON encoding

- Add Utf8Cleaner::clean() for successful order creation response (data)
- Add Utf8Cleaner::clean() for validation error response (ErrorBag)
- Prevents Malformed UTF-8 characters error when creating order with
  non-UTF-8 data from OpenCart/DB (same approach as cart endpoints)

Made-with: Cursor
2026-03-14 15:36:09 +03:00
3e6fed813d build: update npm dependencies (#77) 2026-03-14 00:27:27 +03:00
dependabot[bot]
16c60e0e12 build(deps): bump the npm_and_yarn group across 2 directories with 2 updates
Bumps the npm_and_yarn group with 2 updates in the /frontend/admin directory: [minimatch](https://github.com/isaacs/minimatch) and [rollup](https://github.com/rollup/rollup).
Bumps the npm_and_yarn group with 1 update in the /frontend/spa directory: [rollup](https://github.com/rollup/rollup).


Updates `minimatch` from 3.1.3 to 3.1.5
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.3...v3.1.5)

Updates `rollup` from 4.53.2 to 4.59.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.53.2...v4.59.0)

Updates `rollup` from 4.53.2 to 4.59.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.53.2...v4.59.0)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-14 00:22:59 +03:00
172c56de7d chore(release): 2.2.1 2026-02-22 13:42:32 +03:00
e6f570e1c3 build(deps): bump composer updates 2026-02-22 13:38:20 +03:00
dependabot[bot]
b5a46c9123 build(deps-dev): bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the /frontend/admin directory: [ajv](https://github.com/ajv-validator/ajv) and [minimatch](https://github.com/isaacs/minimatch).


Updates `ajv` from 6.12.6 to 6.14.0
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0)

Updates `minimatch` from 3.1.2 to 3.1.3
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.3)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 6.14.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: minimatch
  dependency-version: 3.1.3
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-22 13:34:35 +03:00
dependabot[bot]
66790768d9 build(deps): bump the npm_and_yarn group across 2 directories with 2 updates
Bumps the npm_and_yarn group with 1 update in the /frontend/admin directory: [axios](https://github.com/axios/axios).
Bumps the npm_and_yarn group with 1 update in the /frontend/spa directory: [swiper](https://github.com/nolimits4web/Swiper).


Updates `axios` from 1.13.2 to 1.13.5
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.13.5)

Updates `swiper` from 12.0.3 to 12.1.2
- [Release notes](https://github.com/nolimits4web/Swiper/releases)
- [Changelog](https://github.com/nolimits4web/swiper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nolimits4web/Swiper/compare/v12.0.3...v12.1.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.13.5
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: swiper
  dependency-version: 12.1.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-22 13:34:35 +03:00
dependabot[bot]
37fe0cabda build(deps): bump swiper
Bumps the npm_and_yarn group with 1 update in the /frontend/spa directory: [swiper](https://github.com/nolimits4web/Swiper).


Updates `swiper` from 12.0.3 to 12.1.2
- [Release notes](https://github.com/nolimits4web/Swiper/releases)
- [Changelog](https://github.com/nolimits4web/swiper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nolimits4web/Swiper/compare/v12.0.3...v12.1.2)

---
updated-dependencies:
- dependency-name: swiper
  dependency-version: 12.1.2
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-22 13:34:35 +03:00
dependabot[bot]
06c1bf2abb build(deps-dev): bump phpunit/phpunit
Bumps the composer group with 1 update in the /module/oc_telegram_shop/upload/oc_telegram_shop directory: [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit).


Updates `phpunit/phpunit` from 9.6.31 to 9.6.33
- [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
- [Changelog](https://github.com/sebastianbergmann/phpunit/blob/9.6.33/ChangeLog-9.6.md)
- [Commits](https://github.com/sebastianbergmann/phpunit/compare/9.6.31...9.6.33)

---
updated-dependencies:
- dependency-name: phpunit/phpunit
  dependency-version: 9.6.33
  dependency-type: direct:development
  dependency-group: composer
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-22 13:06:19 +03:00
43 changed files with 3092 additions and 5535 deletions

View File

@@ -1,64 +0,0 @@
# Cursor AI Agents Configuration
## Роли и правила поведения ИИ
### Основная роль: Senior Full-Stack Developer
Вы - опытный full-stack разработчик, специализирующийся на:
- OpenCart модульной разработке
- Кастомных фреймворках (OpenCart Framework)
- PHP 7.4+ с современными практиками
- Vue.js 3 (Composition API)
- Telegram Mini App разработке
### Правила работы с кодом
1. **Всегда используй существующие паттерны проекта**
2. **Не создавай дубликаты - используй существующие утилиты**
3. **Следуй соглашениям именования проекта**
4. **Тестируй изменения перед коммитом**
5. **Документируй публичные API**
6. **Комментарии только на английском языке и только если они действительно оправданы**
### Правила коммитов
1. **Следование Conventional Commits**
- Используй префиксы: `feat:`, `fix:`, `chore:`, `refactor:`, `style:`, `test:`, `docs:`
- Формат: `<type>: <subject>` (первая строка до 72 символов)
- После пустой строки - подробное описание изменений
2. **Язык коммитов**
- Все коммиты на **английском языке**
- Подробное описание изменений в теле коммита
- Перечисляй все измененные файлы и ключевые изменения
3. **Примеры правильных коммитов**
```
feat: add setting to control category products button visibility
- Add show_category_products_button field to StoreDTO
- Update SettingsSerializerService to support new field
- Add setting in admin panel on 'Store' tab with toggle
- Pass setting to SPA through SettingsHandler
- Button displays only for categories with child categories
- Add default value true to configuration
```
### Запрещено
- Хардкод значений (используй конфиги/настройки)
- Игнорирование обработки ошибок
- Создание циклических зависимостей
Для разработки FrontEnd используй:
- Vue.js 3 (Composition API)
- Старайся избегать функций watch там, где это возможно и где можно сделать более красиво.
- Для frontend/admin используй Tailwind 4 с префиксом `tw:`.
- Для frontend/spa используй Tailwind 4 без префикса.
- Для frontend/admin используй иконки от FontAwesome 4, потому что это уже встроено в OpenCart 3.
- Для frontend/admin используй компоненты VuePrime 4.
- Для frontend/spa используй Daisy UI.
- Чтобы получить название стандартной таблицы OpenCart, используй хелпер `db_table`, либо добавляй константу DB_PREFIX перед названием таблицы. Так ты получишь название таблицы с префиксом.
- Все таблицы моего модуля TeleCart начинаются с префикса `telecart_`. Примеры миграций лежат в `module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations`

View File

@@ -1,44 +0,0 @@
{
"rules": {
"preferCompositionAPI": true,
"strictTypes": true,
"noHardcodedValues": true,
"useDependencyInjection": true
},
"paths": {
"telecart_module": "module/oc_telegram_shop/upload/oc_telegram_shop",
"frontendAdmin": "frontend/admin",
"telegramShopSpa": "frontend/spa",
"migrations": "module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations",
"telecartHandlers": "module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers",
"adminHandlers": "module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers",
"models": "module/oc_telegram_shop/upload/oc_telegram_shop/src/Models",
"framework": "module/oc_telegram_shop/upload/oc_telegram_shop/framework"
},
"naming": {
"classes": "PascalCase",
"methods": "camelCase",
"variables": "camelCase",
"constants": "UPPER_SNAKE_CASE",
"files": "PascalCase for classes, kebab-case for others",
"tables": "snake_case with telecart_ prefix"
},
"php": {
"version": "7.4+",
"preferVersion": "7.4+",
"psr12": true
},
"javascript": {
"version": "ES2020+",
"framework": "Vue 3 Composition API",
"stateManagement": "Pinia",
"uiLibrary": "PrimeVue (admin), Tailwind (spa)"
},
"database": {
"queryBuilder": true,
"migrations": true,
"tablePrefix": "telecart_",
"noForeignKeys": true
}
}

View File

@@ -1,38 +0,0 @@
## TeleCart Pulse Heartbeat Telemetry
### Цель
Раз в час отправлять телеметрию (heartbeat) на TeleCart Pulse, чтобы фиксировать состояние магазина и версии окружения без участия пользователя.
### Backend (`module/oc_telegram_shop/upload/oc_telegram_shop`)
- `framework/TeleCartPulse/TeleCartPulseService.php`
- Новый метод `handleHeartbeat()` собирает данные: домен (через `Utils::getCurrentDomain()`), username бота (через `TelegramService::getMe()`), версии PHP, модуля (из `composer.json`), OpenCart (`VERSION` и `VERSION_CORE`), текущий UTC timestamp.
- Последний успешный пинг кешируется (ключ `telecart_pulse_heartbeat`, TTL 1 час) через существующий `CacheInterface`.
- Подпись heartbeat выполняется через отдельный `PayloadSigner`, который использует секрет `pulse.heartbeat_secret`/`PULSE_HEARTBEAT_SECRET`. Логируются предупреждения при ошибках кеша/бота/подписи.
- Отправка идет на эндпоинт `heartbeat` с таймаутом 2 секунды и заголовком `X-TELECART-VERSION`, взятым из `composer.json`.
- `framework/TeleCartPulse/TeleCartPulseServiceProvider.php`
- Регистрирует основной `PayloadSigner` (по `pulse.api_key`) и отдельный heartbeat signer (по `pulse.heartbeat_secret` или `PULSE_HEARTBEAT_SECRET`), инжектит `LoggerInterface`.
- `src/Handlers/TelemetryHandler.php` + `src/routes.php`
- Добавлен маршрут `heartbeat`, который вызывает `handleHeartbeat()` и возвращает `{ status: "ok" }`. Логгер пишет warning при проблемах.
### Frontend (`frontend/spa`)
- `src/utils/ftch.js`: новая функция `heartbeat()` вызывает `api_action=heartbeat`.
- `src/stores/Pulse.js`: добавлен action `heartbeat`, использующий новую API-функцию и логирующий результат.
- `src/main.js`: после `pulse.ingest(...)` вызывается `pulse.heartbeat()` без блокировки цепочки.
### Конфигурация / ENV
- `PULSE_API_HOST` — базовый URL TeleCart Pulse (используется и для events, и для heartbeat).
- `PULSE_TIMEOUT` — общий таймаут HTTP (для heartbeat принудительно 2 секунды).
- `PULSE_HEARTBEAT_SECRET` (или `pulse.heartbeat_secret` в настройках) — общий секрет для подписания heartbeat. Обязателен, иначе heartbeat не будет отправляться.
- `pulse.api_key` — прежний API ключ, используется только для event-инджеста.
### Поведение
1. Frontend (SPA) вызывает `heartbeat` при инициализации приложения (fire-and-forget).
2. Backend проверяет кеш. Если часа еще не прошло, `handleHeartbeat()` возвращает без запросов.
3. При необходимости собираются данные, подписываются через heartbeat signer и отправляются POST-запросом на `/heartbeat`.
4. Любые сбои (bot info, подпись, HTTP) логируются как warning, чтобы не тревожить пользователей.
### TODO / Возможные улучшения
- При необходимости вынести heartbeat запуск в крон/CLI, чтобы не зависеть от фронтенда.
- Добавить метрики успешности heartbeat в админку.

View File

@@ -1,128 +0,0 @@
# Промпты для генерации API
## Создание нового API endpoint
```
Создай новый API endpoint [ENDPOINT_NAME] для [DESCRIPTION]:
1. Handler в [HANDLER_PATH]:
- Метод handle() принимает Request
- Валидация входных данных
- Использование Service для бизнес-логики
- Возврат JsonResponse с правильной структурой
- Обработка ошибок с логированием
2. Service в [SERVICE_PATH]:
- Бизнес-логика
- Работа с Model
- Валидация данных
- Обработка исключений
3. Model в [MODEL_PATH] (если нужен):
- Методы для работы с БД
- Использование Query Builder
- Типизация методов
4. Route в routes.php:
- Добавь маршрут с правильным именем
5. Миграция (если нужна новая таблица):
- Создай миграцию в database/migrations/
- Используй фиксированный префикс telecart_
- Добавь индексы где необходимо
Следуй архитектуре MVC-L проекта и используй существующие паттерны.
```
## Создание CRUD API
```
Создай полный CRUD API для сущности [ENTITY_NAME]:
1. Handler с методами:
- list() - список с пагинацией и фильтрацией
- get() - получение одной записи
- create() - создание
- update() - обновление
- delete() - удаление
2. Service с бизнес-логикой для всех операций
3. Model с методами:
- findAll() - список
- findById() - по ID
- create() - создание
- update() - обновление
- delete() - удаление
4. DTO для валидации данных
5. Миграция для таблицы [TABLE_NAME]
6. Routes для всех endpoints
Используй серверную пагинацию, фильтрацию и сортировку для list().
```
## Создание Admin API endpoint
```
Создай Admin API endpoint [ENDPOINT_NAME] в bastion/Handlers/:
1. Handler в bastion/Handlers/[HANDLER_NAME].php:
- Используй Request для получения параметров
- Валидация данных
- Работа через Service
- Возврат JsonResponse с структурой { data: { data: [...], totalRecords: ... } }
- Обработка ошибок
2. Service в bastion/Services/ (если нужен):
- Бизнес-логика для админки
- Работа с Models
3. Route в bastion/routes.php
4. Frontend компонент (если нужен UI):
- Vue компонент в frontend/admin/src/views/
- Используй PrimeVue компоненты
- Серверная пагинация/фильтрация
- Обработка ошибок с toast уведомлениями
Следуй существующим паттернам проекта.
```
## Создание Frontend API клиента
```
Создай функцию для работы с API endpoint [ENDPOINT_NAME]:
1. В frontend/[admin|spa]/src/utils/http.js:
- Функция api[Method] для вызова endpoint
- Правильная обработка ошибок
- Возврат структурированного ответа
2. Использование:
- В компонентах через import
- Обработка loading states
- Toast уведомления для ошибок
Следуй существующим паттернам в http.js.
```
## Создание миграции
```
Создай миграцию для таблицы [TABLE_NAME]:
1. Файл: database/migrations/[TIMESTAMP]_[DESCRIPTION].php
2. Используй фиксированный префикс telecart_ для таблицы
3. Добавь все необходимые поля с правильными типами
4. Добавь индексы для часто используемых полей
5. Используй utf8mb4_unicode_ci collation
6. Используй InnoDB engine
7. Добавь created_at и updated_at timestamps
8. Не создавай foreign keys (используй только индексы)
Следуй структуре существующих миграций.
```

View File

@@ -1,106 +0,0 @@
# Правила оформления Changelog для документации
## Общие требования
- **Формат**: Markdown
- **Структура**: Одна версия = одна страница
- **Стиль**: Профессиональный, лаконичный, без маркетинговых лозунгов
- **Язык**: Русский
- **Целевая аудитория**: Разработчики и владельцы магазинов
- **Содержимое**: Только ключевые изменения, без лишних технических деталей
## Структура страницы
### Вводный абзац
- Краткое описание релиза (1-2 предложения)
- Дополнить 1-2 предложениями о ключевых изменениях
- Упоминать основные фичи и улучшения
### Разделы (строго в этом порядке)
1. **🚀 Добавлено** - новые функции и возможности
2. **✨ Улучшено** - улучшения существующих функций
3. **🐞 Исправлено** - исправленные ошибки
4. **⚠️ Несовместимые изменения** - изменения без обратной совместимости
5. **🔧 Технические изменения** - технические изменения (отдельный раздел)
### Правила для разделов
- Раздел **НЕ** добавлять, если в нём нет пунктов
- Использовать маркдаун-списки, без нумерации
- Не добавлять ссылок, если они явно не указаны
- Не добавлять лишних пояснений, выводов и заключений
- Выводить только содержимое Markdown-файла, без комментариев
## Разделение изменений
### Бизнес-логика и процессы
Разделы "Добавлено", "Улучшено", "Исправлено", "Несовместимые изменения" содержат только изменения, связанные с:
- Бизнес-процессами магазина
- Пользовательским опытом
- Функциональностью для владельцев магазинов
- Продающими фичами
### Технические изменения
Все технические изменения выносятся в отдельный раздел **🔧 Технические изменения**:
- Без подразделов, всё в одном списке
- Только самые ключевые технические изменения
- Не расписывать детали, которые не интересны пользователю
## Стиль написания
### Терминология
- Английские термины писать по-английски (например: "navbar", а не "навбар")
- Избегать технических терминов в бизнес-разделах (например: "customer_id" → "автоматическое связывание покупателей")
### Описания
**Для ключевых продающих фич:**
- Давать подробное описание с деталями
- Указывать возможности и преимущества
- Подчеркивать ценность для пользователя
**Для технических изменений:**
- Кратко, только ключевое
- Без лишних деталей
**Для обычных функций:**
- Лаконично, но информативно
- Фокус на пользе для пользователя
## Порядок размещения
### В разделе "Добавлено"
Ключевые продающие фичи размещать **первыми**:
1. Система конфигурации главной страницы через блоки
2. Конструктор форм на базе FormKit
3. Интеграция с Яндекс.Метрикой
4. Политика конфиденциальности
5. Поддержка купонов и подарочных сертификатов
6. Остальные функции
## Примеры правильных формулировок
### ✅ Хорошо
- "Автоматическое связывание покупателей Telegram с покупателями OpenCart: система автоматически находит и связывает покупателей из Telegram-магазина с существующими покупателями в OpenCart по email или номеру телефона, создавая единую базу клиентов"
### ❌ Плохо
- "Сохранение customer_id вместе с заказом для связи с клиентами OpenCart: автоматическая связь заказов из Telegram с покупателями в OpenCart, единая база клиентов"
### ✅ Хорошо
- "Navbar с логотипом и названием приложения"
### ❌ Плохо
- "Навбар с логотипом и названием приложения"
## Что не включать
- Внутренние детали разработки (тесты, статический анализ, обфускация)
- Технические детали, не интересные пользователям
- Избыточные технические описания
- Маркетинговые лозунги и призывы

View File

@@ -1,62 +0,0 @@
# Промпты для документирования
## Документирование класса
```
Добавь PHPDoc документацию для класса [CLASS_NAME]:
1. Описание класса и его назначения
2. @package тег
3. @author тег
4. Документация для всех публичных методов
5. Документация для публичных свойств
6. Примеры использования где уместно
```
## Документирование метода
```
Добавь PHPDoc для метода [METHOD_NAME]:
1. Описание метода
2. @param для всех параметров с типами
3. @return с типом возвращаемого значения
4. @throws для всех исключений
5. Примеры использования если сложная логика
```
## Документирование API endpoint
```
Создай документацию для API endpoint [ENDPOINT_NAME]:
1. Описание назначения
2. HTTP метод и путь
3. Параметры запроса (query/body)
4. Формат ответа (JSON структура)
5. Коды ошибок
6. Примеры запросов/ответов
7. Требования к авторизации
```
## Документирование Vue компонента
```
Добавь документацию для Vue компонента [COMPONENT_NAME]:
1. Описание компонента
2. Props с типами и описаниями
3. Emits с описаниями
4. Slots если есть
5. Примеры использования
6. Зависимости от других компонентов
```
## Создание README
```
Создай README.md для [MODULE/COMPONENT]:
1. Описание назначения
2. Установка/настройка
3. Использование с примерами
4. API документация
5. Конфигурация
6. Troubleshooting
```

View File

@@ -1,88 +0,0 @@
# Промпты для рефакторинга
## Общий рефакторинг
```
Проанализируй код в файле [FILE_PATH] и выполни рефакторинг:
1. Убери дублирование кода
2. Улучши читаемость
3. Примени принципы SOLID
4. Добавь обработку ошибок где необходимо
5. Улучши типизацию
6. Добавь документацию для публичных методов
7. Убедись что код следует архитектуре MVC-L проекта
8. Используй существующие утилиты и сервисы проекта вместо создания новых
```
## Рефакторинг Handler
```
Рефакторинг Handler [HANDLER_NAME]:
1. Вынеси бизнес-логику в отдельный Service
2. Добавь валидацию входных данных
3. Улучши обработку ошибок с логированием
4. Используй DTO для передачи данных
5. Добавь PHPDoc комментарии
6. Убедись что используется Dependency Injection
7. Оптимизируй запросы к БД если необходимо
```
## Рефакторинг Model
```
Рефакторинг Model [MODEL_NAME]:
1. Убедись что все запросы используют Query Builder
2. Добавь методы для частых операций (findBy, findAll, create, update)
3. Добавь валидацию данных перед сохранением
4. Улучши типизацию методов
5. Добавь PHPDoc комментарии
6. Используй транзакции для сложных операций
```
## Рефакторинг Vue компонента
```
Рефакторинг Vue компонента [COMPONENT_NAME]:
1. Вынеси логику в composable функции
2. Улучши типизацию props и emits
3. Оптимизируй computed properties
4. Добавь обработку ошибок
5. Улучши структуру template
6. Добавь loading states
7. Используй существующие утилиты проекта
```
## Удаление дублирования
```
Найди и устрани дублирование кода в:
- [FILE_PATH_1]
- [FILE_PATH_2]
- [FILE_PATH_3]
Создай общие утилиты/сервисы где необходимо, следуя архитектуре проекта.
```
## Улучшение производительности
```
Проанализируй производительность кода в [FILE_PATH]:
1. Оптимизируй запросы к БД (используй индексы, избегай N+1)
2. Добавь кэширование где уместно
3. Оптимизируй алгоритмы
4. Уменьши количество запросов к API
5. Используй ленивую загрузку на фронтенде
```
## Улучшение безопасности
```
Улучши безопасность кода в [FILE_PATH]:
1. Добавь валидацию всех входных данных
2. Используй prepared statements (Query Builder)
3. Добавь CSRF защиту где необходимо
4. Валидируй права доступа
5. Санитизируй выходные данные
6. Добавь rate limiting где необходимо
```

View File

@@ -1,53 +0,0 @@
# Промпты для тестирования
## Создание unit теста
```
Создай unit тест для [CLASS_NAME] в tests/Unit/:
1. Используй PHPUnit
2. Покрой все публичные методы
3. Тестируй успешные сценарии
4. Тестируй обработку ошибок
5. Используй моки для зависимостей
6. Следуй структуре существующих тестов
7. Используй TestCase базовый класс проекта
```
## Создание integration теста
```
Создай integration тест для [FEATURE_NAME] в tests/Integration/:
1. Тестируй полный flow от запроса до ответа
2. Используй тестовую БД
3. Очищай данные после тестов
4. Тестируй реальные сценарии использования
5. Проверяй валидацию данных
6. Проверяй обработку ошибок
```
## Создание Vue компонент теста
```
Создай тест для Vue компонента [COMPONENT_NAME] в frontend/[admin|spa]/tests/:
1. Используй Vitest
2. Тестируй рендеринг компонента
3. Тестируй props
4. Тестируй события (emits)
5. Тестируй пользовательские взаимодействия
6. Используй моки для API вызовов
7. Следуй структуре существующих тестов
```
## Покрытие тестами
```
Проанализируй покрытие тестами для [FILE_PATH]:
1. Определи какие методы не покрыты тестами
2. Создай тесты для критичных методов
3. Убедись что тестируются граничные случаи
4. Добавь тесты для обработки ошибок
```

View File

@@ -1,201 +0,0 @@
# Архитектурные правила
## OpenCart Framework Architecture
### MVC-L Pattern
Проект использует модифицированный паттерн MVC-L (Model-View-Controller-Language):
- **Model**: Классы в `src/Models/` - работа с данными, доступ к БД
- **View**: Vue компоненты на фронтенде, JSON ответы на бэкенде
- **Controller**: Handlers в `src/Handlers/` и `bastion/Handlers/`
- **Language**: Переводчик в `framework/Translator/`
### Dependency Injection
Все зависимости внедряются через Container:
```php
// ✅ Правильно
public function __construct(
private Builder $builder,
private TelegramCustomer $telegramCustomerModel
) {}
// ❌ Неправильно
public function __construct() {
$this->builder = new Builder(...);
}
```
### Service Providers
Регистрация сервисов через Service Providers:
```php
class MyServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(MyService::class, function ($app) {
return new MyService($app->get(Dependency::class));
});
}
}
```
### Routes
Маршруты определяются в `routes.php`:
```php
return [
'actionName' => [HandlerClass::class, 'methodName'],
];
```
### Handlers (Controllers)
Handlers обрабатывают HTTP запросы:
```php
class MyHandler
{
public function handle(Request $request): JsonResponse
{
// Валидация
// Бизнес-логика через Services
// Возврат JsonResponse
}
}
```
### Models
Models работают с данными:
```php
class MyModel
{
public function __construct(
private ConnectionInterface $database,
private Builder $builder
) {}
public function findById(int $id): ?array
{
return $this->builder->newQuery()
->from($this->tableName)
->where('id', '=', $id)
->firstOrNull();
}
}
```
### Services
Services содержат бизнес-логику:
```php
class MyService
{
public function __construct(
private MyModel $model
) {}
public function doSomething(array $data): array
{
// Бизнес-логика
return $this->model->create($data);
}
}
```
### Migrations
Миграции в `database/migrations/`:
```php
return new class extends Migration {
public function up(): void
{
$this->database->statement('CREATE TABLE ...');
}
};
```
### Query Builder
Всегда используй Query Builder вместо прямых SQL:
```php
// ✅ Правильно
$query = $this->builder->newQuery()
->select(['id', 'name'])
->from('table_name')
->where('status', '=', 'active')
->get();
// ❌ Неправильно
$result = $this->database->query("SELECT * FROM table_name WHERE status = 'active'");
```
### Frontend Architecture
#### Admin Panel (Vue 3)
- Composition API
- Pinia для state management
- PrimeVue для UI компонентов
- Axios для HTTP запросов
- Vue Router для навигации
#### SPA (Telegram Mini App)
- Composition API
- Pinia stores
- Tailwind CSS для стилей
- Telegram WebApp API
- Vue Router
### Naming Conventions
- **Classes**: PascalCase (`TelegramCustomerService`)
- **Methods**: camelCase (`getCustomers`)
- **Variables**: camelCase (`$customerData`)
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
- **Files**: PascalCase для классов, kebab-case для остального
- **Tables**: snake_case с префиксом `telecart_`
### Error Handling
Всегда обрабатывай ошибки:
```php
try {
$result = $this->service->doSomething();
} catch (SpecificException $e) {
$this->logger->error('Error message', ['exception' => $e]);
throw new UserFriendlyException('User message');
}
```
### Configuration
Используй конфигурационные файлы в `configs/`:
```php
$config = $this->app->getConfigValue('app.setting_name');
```
### Caching
Используй Cache Service для кэширования:
```php
$cache = $this->app->get(CacheInterface::class);
$value = $cache->get('key', function() {
return expensiveOperation();
});
```

View File

@@ -1,70 +0,0 @@
# FormBuilder System Context
## Architectural Overview
The FormBuilder ecosystem is a strictly typed Vue 3 application module designed to generate standard FormKit Schema JSON. It eschews internal DTOs in favor of direct schema manipulation.
### Core Components
1. **FormBuilderView (`views/FormBuilderView.vue`)**:
* **Role**: Smart container / Data fetcher.
* **Responsibility**: Fetches form data from API (`GET /api/admin/forms/{alias}`), handles loading states, and passes data to `FormBuilder`.
* **Contract**: Expects API response `{ data: { schema: Array, is_custom: Boolean, ... } }`.
2. **FormBuilder (`components/FormBuilder/FormBuilder.vue`)**:
* **Role**: Main Orchestrator / State Owner.
* **Responsibility**: Manages `v-model` (schema), mode switching (Visual/Code/Preview), and provides state to children.
* **State Management**: Uses `defineModel` for `formFields` (schema) and `isCustom` (mode flag). Uses `provide('formFields')` and `provide('selectedFieldId')` for deep dependency injection.
* **Modes**:
* **Visual**: Drag-and-drop interface using `vuedraggable`.
* **Code**: Direct JSON editing of the FormKit schema. Sets `isCustom = true`.
* **Preview**: Renders the current schema using `FormKit`.
3. **FormCanvas (`components/FormBuilder/FormCanvas.vue`)**:
* **Role**: Visual Editor Surface.
* **Responsibility**: Renders the draggable list of fields.
* **Implementation**: Uses `vuedraggable` bound to `formFields`.
* **UX**: Implements "Ghost" and "Drag" classes for visual feedback. Handles selection logic.
4. **FieldsPanel (`components/FormBuilder/FieldsPanel.vue`)**:
* **Role**: Component Palette.
* **Responsibility**: Source of truth for available field types.
* **Implementation**: Uses `vuedraggable` with `pull: 'clone', put: false` to spawn new fields.
5. **FieldSettings (`components/FormBuilder/FieldSettings.vue`)**:
* **Role**: Property Editor.
* **Responsibility**: Edits properties of the `selectedFieldId`.
* **Constraint**: Must use PrimeVue components for all inputs.
## Data Flow & Invariants
1. **Schema Authority**: The FormKit Schema JSON is the single source of truth. There is no "internal model" separate from the schema.
2. **Reactivity**:
* `formFields` is an Array of Objects.
* Mutations must preserve reactivity. When using `v-model` or `provide/inject`, ensure array methods (splice, push, filter) are used correctly or replace the entire array reference if needed to trigger watchers.
* **Immutability**: `useFormFields` composable uses immutable patterns (returning new array references) to ensure `defineModel` in parent detects changes.
3. **Mode Logic**:
* Switching to **Code** mode sets `isCustom = true`.
* Switching to **Visual** mode sets `isCustom = false`.
* **Safety**: Switching modes triggers JSON validation. Invalid JSON prevents mode switch.
4. **Drag and Drop**:
* Powered by `vuedraggable` (Sortable.js).
* **Clone Logic**: `FieldsPanel` clones from `availableFields`. `FormCanvas` receives the clone.
* **ID Generation**: Unique IDs are generated upon cloning/addition to ensure key stability.
## Naming & Conventions
* **Tailwind**: Use `tw:` prefix for all utility classes (e.g., `tw:flex`, `tw:p-4`).
* **Components**: PrimeVue components are the standard UI kit (Button, Panel, InputText, etc.).
* **Icons**: FontAwesome (`fa fa-*`).
* **Files**: PascalCase for components (`FormBuilder.vue`), camelCase for logic (`useFormFields.js`).
## Integration Rules
* **Backend**: The backend stores the JSON blob directly. `FormBuilder` does not transform data before save; it emits the raw schema.
* **API**: `useFormsStore` handles API communication.
## Pitfalls & Warnings
* **vuedraggable vs @formkit/drag-and-drop**: We strictly use `vuedraggable`. Do not re-introduce `@formkit/drag-and-drop`.
* **Watchers**: Avoid `watch` where `computed` or event handlers suffice, to prevent infinite loops in bidirectional data flow.
* **Tailwind Config**: Do not use `@apply` with `tw:` prefixed classes in `<style>` blocks; standard CSS properties should be used if custom classes are needed.
## Future Modifications
* **Adding Fields**: Update `constants/availableFields.js` and ensure `utils/fieldHelpers.js` supports the new type.
* **Validation**: FormKit validation rules string (e.g., "required|email") is edited as a raw string in `FieldSettings`. Complex validation builders would require a new UI component.

View File

@@ -1,332 +0,0 @@
# JavaScript/TypeScript Code Style Rules
## JavaScript Version
- ES2020+ features
- Modern async/await
- Optional chaining (`?.`)
- Nullish coalescing (`??`)
- Template literals
## Code Style
### Variable Declarations
```javascript
// ✅ Используй const по умолчанию
const customers = [];
const totalRecords = 0;
// ✅ let только когда нужно переназначение
let currentPage = 1;
currentPage = 2;
// ❌ Не используй var
var oldVariable = 'bad';
```
### Arrow Functions
```javascript
// ✅ Предпочтительно для коротких функций
const filtered = items.filter(item => item.isActive);
// ✅ Для методов объектов
const api = {
get: async (url) => {
return await fetch(url);
}
};
// ✅ Для сложной логики - обычные функции
function complexCalculation(data) {
// много строк кода
return result;
}
```
### Template Literals
```javascript
// ✅ Предпочтительно
const message = `User ${userId} not found`;
const url = `${baseUrl}/api/${endpoint}`;
// ❌ Не используй конкатенацию
const message = 'User ' + userId + ' not found';
```
### Optional Chaining
```javascript
// ✅ Используй optional chaining
const name = user?.profile?.name;
const count = data?.items?.length ?? 0;
// ❌ Избегай длинных проверок
const name = user && user.profile && user.profile.name;
```
### Nullish Coalescing
```javascript
// ✅ Используй ?? для значений по умолчанию
const page = params.page ?? 1;
const name = user.name ?? 'Unknown';
// ❌ Не используй || для чисел/булевых
const page = params.page || 1; // 0 будет заменено на 1
```
### Destructuring
```javascript
// ✅ Используй деструктуризацию
const { data, totalRecords } = response.data;
const [first, second] = items;
// ✅ В параметрах функций
function processUser({ id, name, email }) {
// ...
}
// ✅ С значениями по умолчанию
const { page = 1, limit = 20 } = params;
```
### Async/Await
```javascript
// ✅ Предпочтительно
async function loadCustomers() {
try {
const response = await apiGet('getCustomers', params);
return response.data;
} catch (error) {
console.error('Error:', error);
throw error;
}
}
// ❌ Избегай .then() цепочек
function loadCustomers() {
return apiGet('getCustomers', params)
.then(response => response.data)
.catch(error => console.error(error));
}
```
## Vue.js 3 Composition API
### Script Setup
```vue
<script setup>
// ✅ Используй <script setup>
import { ref, computed, onMounted } from 'vue';
import { apiGet } from '@/utils/http.js';
const customers = ref([]);
const loading = ref(false);
const totalRecords = computed(() => customers.value.length);
async function loadCustomers() {
loading.value = true;
try {
const result = await apiGet('getCustomers');
customers.value = result.data || [];
} finally {
loading.value = false;
}
}
onMounted(() => {
loadCustomers();
});
</script>
```
### Reactive State
```javascript
// ✅ Используй ref для примитивов
const count = ref(0);
const name = ref('');
// ✅ Используй reactive для объектов
import { reactive } from 'vue';
const state = reactive({
customers: [],
loading: false
});
// ✅ Или ref для объектов (предпочтительно)
const state = ref({
customers: [],
loading: false
});
```
### Computed Properties
```javascript
// ✅ Используй computed для производных значений
const filteredCustomers = computed(() => {
return customers.value.filter(c => c.isActive);
});
// ❌ Не используй методы для вычислений
function filteredCustomers() {
return customers.value.filter(c => c.isActive);
}
```
### Props
```vue
<script setup>
// ✅ Определяй props с типами
const props = defineProps({
customerId: {
type: Number,
required: true
},
showDetails: {
type: Boolean,
default: false
}
});
</script>
```
### Emits
```vue
<script setup>
// ✅ Определяй emits
const emit = defineEmits(['update', 'delete']);
function handleUpdate() {
emit('update', data);
}
</script>
```
## Pinia Stores
```javascript
// ✅ Используй setup stores
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCustomersStore = defineStore('customers', () => {
const customers = ref([]);
const loading = ref(false);
const totalRecords = computed(() => customers.value.length);
async function loadCustomers() {
loading.value = true;
try {
const result = await apiGet('getCustomers');
customers.value = result.data || [];
} finally {
loading.value = false;
}
}
return {
customers,
loading,
totalRecords,
loadCustomers
};
});
```
## Error Handling
```javascript
// ✅ Всегда обрабатывай ошибки
async function loadData() {
try {
const result = await apiGet('endpoint');
if (result.success) {
return result.data;
} else {
throw new Error(result.error);
}
} catch (error) {
console.error('Failed to load data:', error);
toast.error('Не удалось загрузить данные');
throw error;
}
}
```
## Naming Conventions
### Variables and Functions
```javascript
// ✅ camelCase
const customerData = {};
const totalRecords = 0;
function loadCustomers() {}
// ✅ Константы UPPER_SNAKE_CASE
const MAX_RETRIES = 3;
const API_BASE_URL = '/api';
```
### Components
```vue
<!-- PascalCase для компонентов -->
<CustomerCard />
<ProductsList />
```
### Files
```javascript
// ✅ kebab-case для файлов
// customers-view.vue
// http-utils.js
// customer-service.js
```
## Imports
```javascript
// ✅ Группируй импорты
// 1. Vue core
import { ref, computed, onMounted } from 'vue';
// 2. Third-party
import { apiGet } from '@/utils/http.js';
import { useToast } from 'primevue';
// 3. Local components
import CustomerCard from '@/components/CustomerCard.vue';
// 4. Types (если TypeScript)
import type { Customer } from '@/types';
```
## TypeScript (где используется)
```typescript
// ✅ Используй типы
interface Customer {
id: number;
name: string;
email?: string;
}
function getCustomer(id: number): Promise<Customer> {
return apiGet(`customers/${id}`);
}
```

View File

@@ -1,243 +0,0 @@
# PHP Code Style Rules
## PHP Version
Проект поддерживает PHP 7.4+
## PSR Standards
- **PSR-1**: Basic Coding Standard
- **PSR-4**: Autoloading Standard
- **PSR-12**: Extended Coding Style
## Code Style
### Type Declarations
```php
// ✅ Правильно - строгая типизация
public function getCustomers(Request $request): JsonResponse
{
$id = (int) $request->get('id');
return new JsonResponse(['data' => $customers]);
}
// ❌ Неправильно - без типов
public function getCustomers($request)
{
return ['data' => $customers];
}
```
### Nullable Types
```php
// ✅ Правильно
public function findById(?int $id): ?array
{
if ($id === null) {
return null;
}
return $this->query->where('id', '=', $id)->firstOrNull();
}
```
### Strict Types
Всегда используй `declare(strict_types=1);`:
```php
<?php
declare(strict_types=1);
namespace App\Services;
```
### Array Syntax
```php
// ✅ Предпочтительно - короткий синтаксис
$array = ['key' => 'value'];
// ❌ Не использовать
$array = array('key' => 'value');
```
### String Interpolation
```php
// ✅ Предпочтительно
$message = "User {$userId} not found";
// ✅ Альтернатива
$message = sprintf('User %d not found', $userId);
```
### Arrow Functions (PHP 7.4+)
```php
// ✅ Для простых операций
$filtered = array_filter($items, fn($item) => $item->isActive());
// ❌ Для сложной логики - используй обычные функции
```
### Nullsafe Operator (PHP 8.0+)
```php
// ✅ Для PHP 7.4
$name = $user && $user->profile ? $user->profile->name : null;
```
## Naming Conventions
### Classes
```php
// ✅ PascalCase
class TelegramCustomerService {}
class UserRepository {}
```
### Methods
```php
// ✅ camelCase
public function getCustomers(): array {}
public function saveOrUpdate(array $data): array {}
```
### Variables
```php
// ✅ camelCase
$customerData = [];
$totalRecords = 0;
```
### Constants
```php
// ✅ UPPER_SNAKE_CASE
private const MAX_RETRIES = 3;
public const DEFAULT_PAGE_SIZE = 20;
```
### Private Properties
```php
// ✅ camelCase с модификатором доступа
private string $tableName;
private Builder $builder;
```
## Documentation
### PHPDoc
```php
/**
* @throws ValidationException Если параметры невалидны
*/
public function getCustomers(Request $request): JsonResponse
{
// ...
}
```
### Inline Comments
```php
// ✅ Полезные комментарии
// Применяем фильтры для подсчета общего количества записей
$countQuery = $this->buildCountQuery($filters);
// ❌ Очевидные комментарии
// Получаем данные
$data = $this->getData();
```
## Error Handling
### Exceptions
```php
// ✅ Специфичные исключения
if (!$userId) {
throw new InvalidArgumentException('User ID is required');
}
// ✅ Логирование
try {
$result = $this->service->process();
} catch (Exception $e) {
$this->logger->error('Processing failed', [
'exception' => $e,
'context' => $context,
]);
throw new ProcessingException('Failed to process', 0, $e);
}
```
## Query Builder Usage
### Always Use Query Builder
```php
// ✅ Правильно
$customers = $this->builder->newQuery()
->select(['id', 'name', 'email'])
->from('telecart_customers')
->where('status', '=', 'active')
->orderBy('created_at', 'DESC')
->get();
// В крайних случаях можно использовать прямые SQL
$result = $this->database->query("SELECT * FROM telecart_customers");
```
### Parameter Binding
```php
// ✅ Query Builder автоматически биндит параметры
$query->where('name', 'LIKE', "%{$search}%");
// ❌ Никогда не конкатенируй значения в SQL, избегай SQL Injection.
```
## Array Access
### Safe Array Access
```php
// ✅ Используй Arr::get()
use Openguru\OpenCartFramework\Support\Arr;
$value = Arr::get($data, 'key', 'default');
// ❌ Небезопасно
$value = $data['key']; // может вызвать ошибку
```
## Return Types
```php
// ✅ Всегда указывай return type
public function getData(): array {}
public function findById(int $id): ?array {}
public function process(): void {}
// ❌ Без типа
public function getData() {}
```
## Visibility Modifiers
```php
// ✅ Всегда указывай модификатор доступа
private string $tableName;
protected Builder $builder;
public function getData(): array {}
```

View File

@@ -1,370 +0,0 @@
# Vue.js 3 Rules
## Component Structure
### Template
```vue
<template>
<!-- Логическая структура -->
<div class="container">
<header>
<h2>{{ title }}</h2>
</header>
<main>
<DataTable :value="items" />
</main>
<footer>
<Button @click="handleSave">Save</Button>
</footer>
</div>
</template>
```
### Script Setup
```vue
<script setup>
// ✅ Всегда используй <script setup>
import { ref, computed, onMounted } from 'vue';
import DataTable from 'primevue/datatable';
import { apiGet } from '@/utils/http.js';
// Props
const props = defineProps({
title: {
type: String,
required: true
}
});
// Emits
const emit = defineEmits(['update', 'delete']);
// State
const items = ref([]);
const loading = ref(false);
// Computed
const totalItems = computed(() => items.value.length);
// Methods
async function loadItems() {
loading.value = true;
try {
const result = await apiGet('getItems');
items.value = result.data || [];
} finally {
loading.value = false;
}
}
// Lifecycle
onMounted(() => {
loadItems();
});
</script>
```
### Styles
```vue
<style scoped>
/* ✅ Используй scoped стили */
.container {
padding: 1rem;
}
/* ✅ Используй :deep() для доступа к дочерним компонентам */
:deep(.p-datatable) {
border: 1px solid #ccc;
}
</style>
```
## Component Naming
```vue
<!-- PascalCase для компонентов -->
<CustomerCard />
<ProductsList />
<OrderDetails />
<!-- kebab-case в шаблоне тоже работает -->
<customer-card />
```
## Props
```vue
<script setup>
// ✅ Всегда определяй типы и валидацию
const props = defineProps({
customerId: {
type: Number,
required: true,
validator: (value) => value > 0
},
showDetails: {
type: Boolean,
default: false
},
items: {
type: Array,
default: () => []
}
});
</script>
```
## Emits
```vue
<script setup>
// ✅ Определяй emits с типами
const emit = defineEmits<{
update: [id: number, data: object];
delete: [id: number];
cancel: [];
}>();
// ✅ Или с валидацией
const emit = defineEmits({
update: (id: number, data: object) => {
if (id > 0 && typeof data === 'object') {
return true;
}
console.warn('Invalid emit arguments');
return false;
}
});
</script>
```
## Reactive State
```vue
<script setup>
// ✅ ref для примитивов
const count = ref(0);
const name = ref('');
// ✅ ref для объектов (предпочтительно)
const customer = ref({
id: null,
name: '',
email: ''
});
// ✅ reactive только если нужно
import { reactive } from 'vue';
const state = reactive({
items: [],
loading: false
});
</script>
```
## Computed Properties
```vue
<script setup>
// ✅ Используй computed для производных значений
const filteredItems = computed(() => {
return items.value.filter(item => item.isActive);
});
// ✅ Computed с getter/setter
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (value) => {
const parts = value.split(' ');
firstName.value = parts[0];
lastName.value = parts[1];
}
});
</script>
```
## Event Handlers
```vue
<template>
<!-- Используй kebab-case для событий -->
<Button @click="handleClick" />
<Input @input="handleInput" />
<Form @submit.prevent="handleSubmit" />
</template>
<script setup>
// ✅ Именуй обработчики с префиксом handle
function handleClick() {
// ...
}
function handleInput(event) {
// ...
}
function handleSubmit() {
// ...
}
</script>
```
## Conditional Rendering
```vue
<template>
<!-- Используй v-if для условного рендеринга -->
<div v-if="loading">
<LoadingSpinner />
</div>
<!-- v-show для частых переключений -->
<div v-show="hasItems">
<ItemsList :items="items" />
</div>
<!-- v-else для альтернатив -->
<div v-else>
<EmptyState />
</div>
</template>
```
## Lists
```vue
<template>
<!-- Всегда используй :key -->
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
<!-- Для индексов -->
<div v-for="(item, index) in items" :key="`item-${index}`">
{{ item.name }}
</div>
</template>
```
## Form Handling
```vue
<template>
<form @submit.prevent="handleSubmit">
<!-- Используй v-model -->
<InputText v-model="form.name" />
<Textarea v-model="form.description" />
<!-- Для кастомных компонентов -->
<CustomInput v-model="form.email" />
</form>
</template>
<script setup>
const form = ref({
name: '',
description: '',
email: ''
});
function handleSubmit() {
// Валидация и отправка
}
</script>
```
## PrimeVue Components
```vue
<template>
<!-- Используй PrimeVue компоненты в админке -->
<DataTable
:value="customers"
:loading="loading"
paginator
:rows="20"
@page="onPage"
>
<Column field="name" header="Name" sortable />
</DataTable>
</template>
```
## Styling
```vue
<style scoped>
/* ✅ Используй scoped -->
.container {
padding: 1rem;
}
/* ✅ :deep() для дочерних компонентов */
:deep(.p-datatable) {
border: 1px solid #ccc;
}
/* ✅ :slotted() для слотов */
:slotted(.header) {
font-weight: bold;
}
</style>
```
## Composition Functions
```vue
<script setup>
// ✅ Выноси сложную логику в composables
import { useCustomers } from '@/composables/useCustomers.js';
const {
customers,
loading,
loadCustomers,
totalRecords
} = useCustomers();
onMounted(() => {
loadCustomers();
});
</script>
```
## Error Handling
```vue
<script setup>
import { useToast } from 'primevue';
const toast = useToast();
async function loadData() {
try {
const result = await apiGet('endpoint');
if (result.success) {
data.value = result.data;
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: result.error
});
}
} catch (error) {
console.error('Error:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось загрузить данные'
});
}
}
</script>
```

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- master
- 'issue/**'
- develop
pull_request:
types:
@@ -135,6 +134,11 @@ jobs:
MODULE_ROOT="module/oc_telegram_shop/upload/oc_telegram_shop"
echo "${{ needs.version_meta.outputs.tag }}" > "${MODULE_ROOT}/version.txt"
- name: Set version in install.xml
run: |
TAG="${{ needs.version_meta.outputs.tag }}"
sed -i "s#<version>.*</version>#<version>${TAG}</version>#" module/oc_telegram_shop/install.xml
- name: Build module
run: |
bash scripts/ci/build.sh "${GITHUB_WORKSPACE}"

View File

@@ -4,6 +4,48 @@
All notable changes to this project will be documented in this file.
<!--- END HEADER -->
## [2.3.0](https://github.com/telecart-labs/telecart/compare/v2.2.2...v2.3.0) (2026-05-25)
### Features
##### Admin
* Telegram settings improvements (#63) ([80c246](https://github.com/telecart-labs/telecart/commit/80c2467b4a7c2cb1c4101c483fe3ac31a5ad0cf1))
##### Ocmod
* Add TeleCart admin sidebar menu and local dev setup (#57) ([28c8ce](https://github.com/telecart-labs/telecart/commit/28c8ce3b6841ca1d4cc98c8e7bb275e1ad3adcf3))
##### Spa
* Increase max page limit to product lists (#88) ([a3f54a](https://github.com/telecart-labs/telecart/commit/a3f54a87dc8799b3355b294fc155c3cf9d22c3eb))
### Bug Fixes
##### Spa
* Handle empty category and products in carousel block ([d43564](https://github.com/telecart-labs/telecart/commit/d43564481d1e35ea8bb92310d20bff2989ca6ea7))
* Visual fixes for SPA (#106) ([c24e30](https://github.com/telecart-labs/telecart/commit/c24e30f14cef8a4b6956aaa2ada5dc9b12fbb9cb))
---
## [2.2.2](https://github.com/telecart-labs/telecart/compare/v2.2.1...v2.2.2) (2026-03-15)
### Bug Fixes
* Normalize order response to valid UTF-8 before JSON encoding (#83) ([f894d3](https://github.com/telecart-labs/telecart/commit/f894d33c464e28a15cbdcd5c72dfd90bd5379e92))
---
## [2.2.1](https://github.com/telecart-labs/telecart/compare/v2.2.0...v2.2.1) (2026-02-22)
---
## [2.2.0](https://github.com/telecart-labs/telecart/compare/v2.1.0...v2.2.0) (2026-01-09)
### Features

147
CLAUDE.md Normal file
View File

@@ -0,0 +1,147 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
TeleCart — коммерческий модуль для OpenCart, превращающий магазин в Telegram Mini App. Это монорепозиторий из трёх
частей:
- **PHP-модуль OpenCart** — `module/oc_telegram_shop/upload/` (вся бизнес-логика и собственный фреймворк).
- **SPA** — `frontend/spa/` (Telegram Mini App, витрина для покупателей).
- **Admin** — `frontend/admin/` (панель управления настройками модуля).
`src/` в корне — это распакованная установка OpenCart (генерируется скриптами, **не редактировать**; код модуля
«линкуется» в неё через `make link`).
## Документация по фичам (`docs/features/`)
Контекстная документация хранится в `docs/features/` — по одной поддиректории на фичу, внутри индексный
`CLAUDE.md`. Вложенные `CLAUDE.md` Claude Code подхватывает автоматически при чтении файлов из их директории,
поэтому индекс фичи читается «по требованию», когда работа касается этой фичи.
Перед тем как трогать незнакомую часть системы — загляни в её папку в `docs/features/`. Конвенции, шаблон новой
фичи и **реестр всех фич с прямыми путями** — в [`docs/features/CLAUDE.md`](docs/features/CLAUDE.md). Шаблон для
новой фичи — `docs/features/_TEMPLATE.md`. При изменении фичи поддерживай её доку в актуальном состоянии (код —
источник истины; при расхождении правь доку).
## Где живёт код модуля
Сердце проекта — `module/oc_telegram_shop/upload/oc_telegram_shop/` (далее «корень модуля»). Его структура:
| Каталог | Namespace | Назначение |
|------------------------|-------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
| `framework/` | `Openguru\OpenCartFramework\` | Собственный Laravel-подобный фреймворк: Container (DI), Router, QueryBuilder, Cache, Migrations, Telegram, Http, Translator |
| `src/` | `App\` | API витрины/SPA (catalog). Handlers, Services, Models, DTO, Filters, ServiceProviders |
| `bastion/` | `Bastion\` | API админ-панели. Handlers, Services, Tasks, ScheduledTasks |
| `console/` | `Console\` | CLI-команды (Symfony Console), точка входа `upload/cli.php` |
| `database/migrations/` | — | Миграции БД |
| `configs/` | — | `app.php`, `maintenance.php`, `schedule.php` |
| `tests/` | `Tests\` | `Unit/`, `Integration/`, `Telegram/` |
**Маршрутизация.** Запросы приходят через action-параметр и резолвятся по двум картам:
- `src/routes.php` — действия SPA (`products`, `getCart`, `checkout`, `webhook`, …)
- `bastion/routes.php` — действия админки (`getSettingsForm`, `getTelegramCustomers`, `getDashboardStats`, …)
Каждая запись — `'actionName' => [HandlerClass::class, 'method']`.
**Точки входа OpenCart** (тонкие обёртки, грузят autoload и поднимают `App\ApplicationFactory`):
- `module/.../upload/catalog/controller/extension/tgshop/handle.php` — SPA API
- `module/.../upload/admin/controller/extension/module/tgshop.php` — админка
В продакшене модуль собирается в `oc_telegram_shop.phar` и подключается из `system/library/oc_telegram_shop/`; в dev —
из исходников через `vendor/autoload.php`.
## Архитектурные правила (бэкенд)
Паттерн — MVC-L поверх собственного фреймворка. Соблюдай строго:
- **Совместимость с PHP 7.4.** Минимальные требования OpenCart и модуля — PHP 7.4 (`composer.json`: `^7.4`). Не используй синтаксис и функции PHP 8.0+ (named arguments, `match`, constructor property promotion, `enum`, `readonly`, nullsafe `?->`, union types и т.п.).
- **Dependency Injection через конструктор.** Никогда не создавай зависимости через `new` внутри классов — внедряй их.
Регистрация сервисов идёт в Service Providers (`src/ServiceProviders/`) через `$this->app->singleton(...)`.
- **Query Builder вместо сырого SQL.** Используй `$this->builder->newQuery()->from(...)->where(...)->get()`. Прямой
SQL — только в крайних случаях; никогда не конкатенируй значения в запрос (SQL injection).
- **Слои:** Handler (валидация + вызов сервиса + `JsonResponse`) → Service (бизнес-логика) → Model (доступ к данным). Не
смешивай.
- **Строгая типизация:** `declare(strict_types=1)`, типы аргументов и возвращаемых значений, nullable где нужно. PSR-12.
- **Безопасный доступ к массивам:** `Arr::get($data, 'key', $default)` вместо `$data['key']`.
- **Таблицы:** префикс OpenCart (`oc_`) + собственные таблицы модуля.
## Архитектура фронтенда
Оба приложения — Vue 3, **только `<script setup>` + Composition API**, Pinia (setup-stores), Vue Router.
- **SPA** (`frontend/spa/`): Tailwind CSS, FormKit, Swiper, `ofetch`. Сборка →
`module/.../upload/image/catalog/tgshopspa/`. Тесты — Vitest.
- **Admin** (`frontend/admin/`): PrimeVue (основной UI-кит), Tailwind/DaisyUI, axios, CodeMirror, `vuedraggable`.
Сборка → `module/.../upload/admin/view/javascript/telecart/`.
Конвенции: компоненты — PascalCase, файлы логики — camelCase; обработчики событий с префиксом `handle`; `computed` для
производных значений; `??` (не `||`) для дефолтов чисел/булевых; сложную логику выносить в composables. Ошибки API
ловить и показывать через toast (admin: `useToast` из PrimeVue).
**FormBuilder** (admin) — отдельная подсистема визуального конструктора форм. Единственный источник истины — FormKit
Schema JSON (без промежуточных DTO). Drag-and-drop строго на `vuedraggable` (не `@formkit/drag-and-drop`).
Tailwind-классы там идут с префиксом `tw:`.
## Команды
Все `make`-цели выполняются **внутри Docker-контейнера `web`** — нужен запущенный `docker compose`.
**Старт / окружение:**
```bash
make setup # первичная установка: скачать OpenCart, установить, поднять, link
make start # поднять контейнеры
make dev # link + параллельно vite dev для SPA и Admin
make dev-spa # только SPA (vite)
make dev-admin # только Admin (vite)
make link # «слинковать» код модуля в установку OpenCart (после правок структуры)
make ssh # шелл в контейнер в каталоге модуля
```
**Тесты (PHP, PHPUnit в контейнере):**
```bash
make test # все тесты (--testdox)
make test-unit # tests/Unit
make test-integration # tests/Integration
make test-telegram # tests/Telegram
make test-coverage # HTML-покрытие в coverage/
```
Запуск одного теста — через ssh в контейнер:
```bash
make ssh
./vendor/bin/phpunit --filter testMethodName tests/Unit/SomeTest.php
```
**Тесты фронтенда (SPA):** `cd frontend/spa && npm run test` (Vitest), `npm run test:run` для однократного прогона.
**Линтеры / статический анализ:**
```bash
make lint # PHPStan (level 1) по src, bastion, framework, console
make phpcs # PHP_CodeSniffer PSR-12 (проверка)
make phpcbf # PHP_CodeSniffer PSR-12 (автофикс)
```
Admin-фронтенд: `cd frontend/admin && npm run lint` (oxlint + eslint), `npm run format` (prettier).
**Сборка / релиз:**
```bash
make phar # собрать oc_telegram_shop.phar (через docker/build.dockerfile)
make ocmod # применить OCMOD-модификации
make cli ARGS="..." # запустить console-команду модуля
make changelog # сгенерировать CHANGELOG из conventional commits
make release # changelog + commit релиза
```
## Коммиты
Используются **Conventional Commits** (`feat:`, `fix:`, `chore:`, `build(deps):` …) — CHANGELOG и версии генерируются
автоматически. **Не делать `git commit`/`git push` без явного указания пользователя.**

View File

@@ -45,6 +45,9 @@ dev-admin:
rm -rf src/upload/admin/view/javascript/telecart && \
cd frontend/admin && npm run dev
ocmod:
./scripts/apply_ocmod.sh
lint:
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpstan analyse"

43
docs/features/CLAUDE.md Normal file
View File

@@ -0,0 +1,43 @@
# docs/features — документация по фичам
Эта директория хранит описания **фич** проекта TeleCart: по одной поддиректории на фичу.
Цель — дать агенту (и человеку) возможность быстро восстановить контекст по конкретной
части системы, не перечитывая весь код: что фича делает, через какие точки входит, какие
файлы за неё отвечают и как данные текут по слоям.
Вложенные `CLAUDE.md` подхватываются Claude Code автоматически при чтении файлов из их
директории — поэтому индекс фичи читается «по требованию», когда работа касается этой фичи.
## Структура
```
docs/features/
├── CLAUDE.md # этот файл: конвенции + реестр фич
├── _TEMPLATE.md # шаблон для новой фичи (копировать при описании)
└── <feature-slug>/
└── CLAUDE.md # индексный файл конкретной фичи
```
## Конвенции
- **Имя папки** — `kebab-case`, по сути фичи: `cart`, `telegram-webhook`, `form-builder`.
- **Один индекс на фичу** — `<feature-slug>/CLAUDE.md`, заполняется по `_TEMPLATE.md`.
- **Ссылки на код — обязательны.** Указывай реальные пути (`module/.../src/Handlers/CartHandler.php`)
и `action` из карт маршрутов. Код — источник истины; документация лишь навигирует к нему.
- **Не дублируй код.** Описывай назначение, поток и нюансы, а не построчный пересказ — он устареет.
- **Связывай фичи** через ссылки на их папки (например `[checkout](../checkout/CLAUDE.md)`).
- Если описанное в доке расходится с кодом — прав код; обнови доку.
## Как добавить фичу
1. `cp _TEMPLATE.md <feature-slug>/CLAUDE.md` (создав папку).
2. Заполни секции шаблона, опираясь на реальный код.
3. Добавь строку в реестр ниже.
## Реестр фич
| Фича | Путь | Краткое описание |
|------|----------------------------------|-------------------------------------------------------------|
| cart | [cart/CLAUDE.md](cart/CLAUDE.md) | Корзина: наполнение, просмотр, передача в оформление заказа |
<!-- Новые фичи добавляй строкой в таблицу выше, по алфавиту. -->

View File

@@ -0,0 +1,38 @@
# <Название фичи>
> Скопируй этот файл в `docs/features/<feature-slug>/CLAUDE.md` и заполни секции.
> Удали подсказки в кавычках после заполнения. Не оставляй пустых «TODO».
## Назначение
> 13 предложения: какую задачу решает фича и для кого (SPA-покупатель / админ / системное).
## Точки входа
> `action` из карт маршрутов и где они объявлены. Таблица:
| action | Метод | Где объявлен |
|-----------------|-------------------|-------------------------------------------|
| `exampleAction` | `Handler::method` | `src/routes.php` или `bastion/routes.php` |
## Ключевые файлы по слоям
> Путь от корня репозитория. Только то, что реально относится к фиче.
- **Handler:** `module/.../src/Handlers/XxxHandler.php` — валидация, вызов сервиса, `JsonResponse`.
- **Service:** `module/.../src/Services/XxxService.php` — бизнес-логика.
- **Model / данные:** `module/.../src/Models/Xxx.php` или таблицы БД.
- **Фронтенд:** `frontend/spa/...` или `frontend/admin/...` (store, view, компоненты).
- **Регистрация в DI:** `src/ServiceProviders/...` (если есть нестандартная привязка).
## Поток данных
> Кратко: запрос → слои → ответ. Можно списком шагов или стрелками.
## Связанные фичи
> Ссылки на соседние папки: `[checkout](../checkout/CLAUDE.md)`.
## Нюансы и подводные камни
> Неочевидное: ограничения PHP 7.4, особенности OpenCart-реестра, кэш, edge-cases, известные баги.

View File

@@ -0,0 +1,59 @@
# Cart — корзина
## Назначение
Корзина покупателя в SPA (Telegram Mini App): отдаёт текущее содержимое корзины с
посчитанными ценами/итогами, принимает набор позиций из SPA и наполняет ими корзину
OpenCart перед оформлением заказа. Создание самого заказа — за пределами фичи (см.
[order](../order/CLAUDE.md), если описан).
## Точки входа
| action | Метод | Где объявлен |
|------------|-------------------------|------------------|
| `getCart` | `CartHandler::index` | `src/routes.php` |
| `checkout` | `CartHandler::checkout` | `src/routes.php` |
> `storeOrder` (`OrderHandler::store`) относится к оформлению заказа, не к корзине.
## Ключевые файлы по слоям
- **Handler:** `module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/CartHandler.php`
`index()` возвращает содержимое корзины, `checkout()` принимает позиции из SPA
(`productId`, `quantity`, `options`) и кладёт их в корзину через `Cart\Cart::add()`.
- **Service:** `module/oc_telegram_shop/upload/oc_telegram_shop/src/Services/CartService.php`
`getCart()` собирает данные через OpenCart-реестр (`OcRegistryDecorator`): язык
`checkout/cart`, проверки стока, веса, цен, форматирование через `Cart\Currency`.
- **OpenCart-зависимости:** `Cart\Cart`, `Cart\Currency` — нативные классы OpenCart,
внедряются через DI.
- **Фронтенд (SPA):**
- `frontend/spa/src/stores/CartStore.js` — Pinia-стор корзины.
- `frontend/spa/src/ShoppingCart.js` — клиентская логика корзины.
- `frontend/spa/src/views/Cart.vue` — экран корзины.
- `frontend/spa/src/components/CartButton.vue` — кнопка/индикатор.
## Поток данных
**Просмотр:** SPA → `action=getCart``CartHandler::index``CartService::getCart()`
читает корзину из OpenCart-реестра → ответ оборачивается `Utf8Cleaner::clean()`
`JsonResponse { data }`.
**Передача в оформление:** SPA отправляет массив позиций → `action=checkout`
`CartHandler::checkout` маппит `options` (берёт `product_option_value_id`) и для каждой
позиции вызывает `$cart->add(productId, quantity, options)` → возвращает очищенные позиции.
## Связанные фичи
- Оформление заказа (`OrderHandler::store` / `OrderCreateService`).
- Настройки (`SettingsService`) влияют на отображение цен/стока в `getCart`.
## Нюансы и подводные камни
- **PHP 7.4.** Без синтаксиса 8.0+ (см. корневой `CLAUDE.md`).
- **Весь вывод чистится `App\Support\Utf8Cleaner::clean()`** перед отдачей — невалидный
UTF-8 из товаров OpenCart ломает `json_encode`.
- **`getCart` зависит от состояния OpenCart-сессии и реестра** (сток, валюта, логин
покупателя, язык `checkout/cart`) — это не чистая функция, результат меняется от конфига
магазина (`config_stock_checkout`, `config_customer_price`, `config_cart_weight` и т.д.).
- **`checkout` мутирует корзину OpenCart** (`Cart\Cart::add`), а не локальное состояние —
он переносит выбор из SPA в нативную корзину OpenCart.

View File

@@ -0,0 +1,168 @@
# Дизайн: честный и отключаемый heartbeat
Дата: 2026-06-30
Статус: согласовано (ожидает финального ревью)
## Проблема
Модуль скрытно отправляет на сервер разработчика (`pulse.telecart.pro`) сведения об
окружении, где он запущен (heartbeat). Покупатель об этом не уведомляется и не может
это отключить. Это нечестно по отношению к покупателю и создаёт риски (GDPR / 152-ФЗ,
претензии маркетплейсов).
## Цель
Сделать heartbeat **честным**: прозрачным и отключаемым, сохранив поведение по умолчанию.
- **Прозрачность:** в админ-панели покупатель видит, что именно собирается, куда
отправляется и зачем.
- **Контроль:** отдельный переключатель, которым покупатель может выключить heartbeat.
- **По умолчанию включено** (текущее поведение сохраняется, но становится видимым и
управляемым).
## Границы (scope)
- **Только heartbeat.** Остальной TeleCart Pulse (Telegram-рассылки, события кампаний)
**не трогаем** — он гейтится наличием `pulse.api_key` и, по позиции владельца, прав не
нарушает.
- Концептуально heartbeat считаем частью фичи **TeleCart Pulse** (одна папка доков), но
у него **отдельный собственный выключатель**, не завязанный на `pulse.api_key` и не
завязанный на секрет подписи. Pulse и heartbeat включаются/выключаются независимо.
## Терминология
- **heartbeat** — периодическая (раз в час по кэшу) отправка информации об окружении:
`domain`, `bot_name`, `php_version`, `module_version`, `opencart_version`,
`opencart_version_core`. Триггерится из SPA при открытии WebApp.
- **TeleCart Pulse** — фича рассылок и аналитики кампаний; heartbeat живёт внутри её
namespace в коде (`framework/TeleCartPulse/`).
## Текущее устройство (как есть)
- `TeleCartPulseService::handleHeartbeat()`
(`module/.../framework/TeleCartPulse/TeleCartPulseService.php:133`) — ставит кэш-флаг
`telecart_pulse_heartbeat` (ttl 3600), дёргает `getMe()`, формирует payload и шлёт POST
на `PULSE_API_HOST/heartbeat`.
- Отправка фактически происходит только если есть `heartbeatPayloadSigner` (строки 168170),
который создаётся в провайдере при наличии секрета (`pulse.heartbeat_secret` /
`PULSE_HEARTBEAT_SECRET`).
- `TeleCartPulseServiceProvider`
(`module/.../framework/TeleCartPulse/TeleCartPulseServiceProvider.php:22-35`) читает
значения из конфига и инжектит их в `TeleCartPulseService` через конструктор.
- **Пользовательской (per-install) настройки для heartbeat сейчас нет.**
## Решение
### Принцип
Использовать существующий паттерн: провайдер читает значение из конфига и **инжектит**
его в сервис через конструктор (как уже делается для `pulse.api_key` и signer). Не вводить
новых механизмов гейтинга.
### 1. Backend
**`module/.../configs/app.php`** — секция `pulse` (строки 8690): добавить флаг с дефолтом
`true`.
```php
'pulse' => [
'api_key' => '',
'heartbeat_enabled' => true, // новый флаг
'batch_size' => 50,
'max_attempts' => 3,
],
```
**`TeleCartPulseServiceProvider`** — прочитать `pulse.heartbeat_enabled` и передать булевый
флаг в конструктор `TeleCartPulseService` (рядом с `apiKey` и `heartbeatSigner`). Привести
к `bool` явно.
**`TeleCartPulseService`** — принять флаг в конструкторе (новое поле `private bool
$heartbeatEnabled`). В `handleHeartbeat()`**ранний `return` в самом начале метода**, до
проверки/установки кэша и до `getMe()`, если флаг выключен:
```php
public function handleHeartbeat(): void
{
if (! $this->heartbeatEnabled) {
return;
}
// ... существующая логика
}
```
Совместимость с PHP 7.4: без named arguments / промоушена свойств / union-типов. Параметр
конструктора — позиционный, со значением по умолчанию `true` для обратной совместимости
вызовов в тестах.
### 2. Frontend (админка) + прозрачность
**`frontend/admin/src/stores/settings.js`** — секция `pulse` (строки 8992): добавить
`heartbeat_enabled` в дефолтное состояние state.
**`frontend/admin/src/views/TeleCartPulseView.vue`** — добавить `ItemBool` с переключателем,
привязанным к `settings.items.pulse.heartbeat_enabled`. В слот-описание — **честное, но
нейтральное по тону раскрытие**:
- перечислить, что отправляется: домен, имя бота, версия PHP, версия модуля, версии
OpenCart;
- указать адрес назначения: `pulse.telecart.pro`;
- описать цель мягко: для улучшения продукта и техподдержки (без акцента на проверке
лицензии).
Точную формулировку владелец отредактирует; черновик в нейтральном тоне готовит реализация.
### 3. Поведение для существующих установок
Дефолт `true` в конфиге мержится с сохранёнными настройками
(`ApplicationFactory`: `Arr::mergeArraysRecursively`). У установок без сохранённого значения
heartbeat остаётся включённым — поведение не меняется, но теперь переключатель виден.
Когда покупатель выключает тумблер и сохраняет, значение пишется в `oc_setting`
(`module_telecart_settings`) и читается на backend.
### 4. Гейтинг — итоговая логика
heartbeat реально отправляется только если **одновременно**: `heartbeat_enabled = true`
**и** есть signer (секрет настроен). Переключатель — пользовательский контроль поверх
существующего серверного гейта; они независимы.
## Тестирование
Unit-тест на `TeleCartPulseService::handleHeartbeat()`:
- `heartbeat_enabled = false` → метод выходит сразу: HTTP-запрос не выполняется, кэш-флаг
`telecart_pulse_heartbeat` **не** ставится, `getMe()` не вызывается.
- `heartbeat_enabled = true` + signer присутствует → payload формируется и уходит (как
сейчас).
Запуск: `make test-unit` (или `./vendor/bin/phpunit --filter ...` через `make ssh`).
## Документация (в этом порядке)
1. **`docs/features/settings/CLAUDE.md`** — каркас системы настроек: путь поля «форма в
админке → `saveSettingsForm` → таблица `oc_setting` (`module_telecart_settings`) →
`Config\Settings` / DTO → backend», и **чек-лист «как добавить новый toggle»** по слоям
(config-дефолт → провайдер/DTO → store → view-компонент `ItemBool`). По шаблону
`docs/features/_TEMPLATE.md`. Добавить строку в реестр в `docs/features/CLAUDE.md`.
2. **`docs/features/telecart-pulse/CLAUDE.md`** — фича Pulse целиком: рассылки, события
кампаний (гейт `api_key`) и heartbeat с новым выключателем. Ссылка на `[[settings]]`.
Добавить строку в реестр.
## Затрагиваемые файлы (сводка)
- `module/.../configs/app.php` — флаг `pulse.heartbeat_enabled`.
- `module/.../framework/TeleCartPulse/TeleCartPulseServiceProvider.php` — чтение и инжект.
- `module/.../framework/TeleCartPulse/TeleCartPulseService.php` — поле + ранний return.
- `frontend/admin/src/stores/settings.js` — дефолт в state.
- `frontend/admin/src/views/TeleCartPulseView.vue``ItemBool` + текст раскрытия.
- `module/.../tests/Unit/...` — тест на гейтинг heartbeat.
- `docs/features/settings/CLAUDE.md`, `docs/features/telecart-pulse/CLAUDE.md`,
`docs/features/CLAUDE.md` (реестр).
## Явно вне scope (YAGNI)
- Не делаем opt-in (по умолчанию остаётся включено).
- Не делаем отдельный consent-баннер на первой установке (достаточно видимого
переключателя с раскрытием).
- Не трогаем события кампаний и рассылки Pulse.
- Не выносим heartbeat в отдельный namespace/фичу из `TeleCartPulse`.

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,7 @@
"@primeuix/themes": "^1.2.5",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^14.0.0",
"axios": "^1.13.1",
"axios": "^1.15.2",
"codemirror": "^6.0.2",
"daisyui": "^5.4.2",
"js-md5": "^0.8.3",
@@ -51,7 +51,7 @@
"npm-run-all2": "^8.0.4",
"oxlint": "~1.23.0",
"prettier": "3.6.2",
"vite": "^7.1.11",
"vite": "^7.3.3",
"vite-plugin-vue-devtools": "^8.0.3"
}
}

View File

@@ -1,6 +1,14 @@
<template>
<SettingsItem :label="label">
<template #default>
<InputGroup v-if="allowCopy && isSupported">
<Button
:key="copied ? 'copied' : 'copy'"
:icon="copied ? 'fa fa-check' : 'fa fa-copy'"
severity="secondary"
v-tooltip.top="{ value: copied ? 'Скопировано' : 'Скопировать' }"
@click="copyToClipboard"
/>
<InputText
:type="type"
v-model="model"
@@ -8,6 +16,16 @@
:placeholder="placeholder"
:readonly="readonly"
/>
</InputGroup>
<InputText
v-else
:type="type"
v-model="model"
class="form-control"
:placeholder="placeholder"
:readonly="readonly"
/>
</template>
<template #help>
<slot></slot>
@@ -18,6 +36,9 @@
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
import InputText from 'primevue/inputtext';
import InputGroup from 'primevue/inputgroup';
import Button from 'primevue/button';
import { useClipboard } from '@vueuse/core';
const props = defineProps({
label: {
@@ -36,10 +57,16 @@ const props = defineProps({
type: Boolean,
default: false,
},
allowCopy: {
type: Boolean,
default: false,
},
});
const model = defineModel();
const { copy, copied, isSupported } = useClipboard({ source: model })
function copyToClipboard() {
copy();
}
</script>
<style scoped>
</style>

View File

@@ -4,11 +4,11 @@
type="text"
:readonly="true"
:modelValue="model"
:allowCopy="true"
>
Токен, полученный при создании бота через @BotFather.
Он используется для взаимодействия модуля с Telegram API.
Подробная инструкция доступна в
<a href="https://nikitakiselev.github.io/telecart-docs/#telegram" target="_blank">
Ссылка на сайт с TeleCart витриной, которую нужно указывать в настройках MiniApp в @BotFather.<br>
Подробная инструкция по настройке в
<a href="https://docs.telecart.pro/telegram/telegram/" target="_blank">
документации <i class="fa fa-external-link"></i>
</a>.
</ItemInput>

View File

@@ -9,7 +9,7 @@
<body>
<div id="app"></div>
<div id="app-error"></div>
<script src="https://telegram.org/js/telegram-web-app.js?59"></script>
<script src="https://telegram.org/js/telegram-web-app.js?61"></script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -23,23 +23,24 @@
"js-md5": "^0.8.3",
"ofetch": "^1.4.1",
"pinia": "^3.0.3",
"swiper": "^12.0.3",
"swiper": "^12.1.2",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/vue": "^8.1.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/ui": "^4.0.8",
"@vitest/ui": "^4.1.9",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.21",
"daisyui": "^5.3.10",
"jsdom": "^27.1.0",
"postcss": "^8.5.6",
"postcss": "^8.5.15",
"tailwindcss": "^4.1.16",
"terser": "^5.44.0",
"vite": "^7.1.12",
"vitest": "^4.0.8"
"vite": "^7.3.3",
"vitest": "^4.1.9"
}
}

View File

@@ -2,16 +2,16 @@
<a
href="#"
:key="category.id"
class="flex items-center"
class="flex items-center gap-3"
@click.prevent="$emit('onSelect', category)"
>
<div class="avatar">
<div class="w-8 h-8 rounded">
<img :src="category.image" :alt="category.name" loading="lazy" width="30" height="30" class="bg-base-400"/>
<div class="avatar shrink-0">
<div class="w-10 h-10 rounded">
<img :src="category.image" :alt="category.name" loading="lazy" width="40" height="40" class="bg-base-400"/>
</div>
</div>
<h3 class="ml-4 text-lg line-clamp-2">{{ category.name }}</h3>
<h3 class="text-base font-medium leading-snug line-clamp-2">{{ category.name }}</h3>
</a>
</template>

View File

@@ -1,13 +1,13 @@
<template>
<section class="px-4">
<header class="flex justify-between items-end mb-4">
<div>
<header class="flex justify-between items-end mb-4 gap-2">
<div class="min-w-0">
<div v-if="title" class="font-bold uppercase">{{ title }}</div>
<div v-if="description" class="text-sm text-base-content/50">{{ description }}</div>
</div>
<div v-if="moreLink">
<RouterLink :to="moreLink" class="btn btn-soft btn-xs" @click="haptic.selectionChanged">
<div v-if="moreLink" class="shrink-0">
<RouterLink :to="moreLink" class="btn btn-soft btn-xs whitespace-nowrap" @click="haptic.selectionChanged">
{{ moreText || 'Смотреть всё' }}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />

View File

@@ -2,10 +2,23 @@
<BaseBlock
:title="block.title"
:description="block.description"
:moreLink="{name: 'product.categories.show', params: { category_id: block.data.category_id }}"
:moreLink="hasCategory ? {name: 'product.categories.show', params: { category_id: block.data.category_id }} : null"
:moreText="block.data.all_text"
>
<div v-if="!hasCategory" role="alert" class="alert alert-warning alert-soft">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Для блока не выбрана категория.</span>
</div>
<div v-else-if="!hasProducts" role="alert" class="alert alert-soft">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>В этой категории пока нет товаров.</span>
</div>
<Swiper
v-else
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"
@@ -36,6 +49,7 @@
</template>
<script setup>
import {computed} from "vue";
import {useYaMetrikaStore} from "@/stores/yaMetrikaStore.js";
import {Swiper, SwiperSlide} from "swiper/vue";
import {useHapticScroll} from "@/composables/useHapticScroll.js";
@@ -52,6 +66,9 @@ const props = defineProps({
}
});
const hasCategory = computed(() => Boolean(props.block.data?.category_id));
const hasProducts = computed(() => (props.block.data?.products?.data?.length ?? 0) > 0);
const freeModeSettings = {
enabled: props.block.data?.carousel?.freemode?.enabled || false,
};

View File

@@ -2,10 +2,19 @@
@custom-variant dark (&:where(.dark, .dark *));
@plugin "@tailwindcss/typography";
@plugin "daisyui" {
themes: all;
}
.prose :where(table):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto;
}
/**
--color-base-100 - DaisyUI background
*/

View File

@@ -9,31 +9,22 @@
</div>
<template v-else>
<button v-if="parentId && parentCategory" class="py-1 px-4 flex items-center mb-3 cursor-pointer" @click="goBack">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6 min-w-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
<span class="ml-2 line-clamp-2">Назад к "{{ parentCategory.name }}"</span>
</button>
<button
v-if="parentCategory && parentCategory.children?.length && settings.show_category_products_button"
class="py-2 px-4 flex items-center mb-3 cursor-pointer border-b w-full pb-2 border-base-200"
class="btn btn-soft btn-block btn-sm mb-3 justify-start"
@click.prevent="showProductsInParentCategory"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg>
<span class="ml-2">Показать товары из "{{ parentCategory.name }}"</span>
<span>Показать все товары категории</span>
</button>
<TransitionGroup
name="stagger"
tag="ul"
appear
class="space-y-4"
class="space-y-3"
>
<li
v-for="(category, i) in categories"
@@ -99,10 +90,6 @@ function onSelect(category) {
}
}
function goBack() {
router.back();
}
function showProductsInParentCategory() {
if (parentId.value != null) {
router.push({name: "product.categories.show", params: {category_id: parentId.value}});

View File

@@ -87,7 +87,11 @@
</svg>
Описание
</h3>
<div class="prose prose-sm max-w-none text-base-content/80" v-html="product.description"></div>
<div
class="prose prose-sm max-w-none text-base-content/80"
v-html="product.description"
@click="handleDescriptionClick"
></div>
</div>
<!-- Attributes -->
@@ -306,6 +310,29 @@ async function onCartBtnClick() {
}
}
function handleDescriptionClick(event) {
const link = event.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
event.preventDefault();
const telegramWebApp = window.Telegram?.WebApp;
if (!telegramWebApp) {
window.open(href, '_blank', 'noopener,noreferrer');
return;
}
if (/^https?:\/\/(t|telegram)\.me\//i.test(href) || href.startsWith('tg://')) {
telegramWebApp.openTelegramLink(href);
return;
}
telegramWebApp.openLink(href, {try_instant_view: false});
}
function openProductInMarketplace() {
if (!product.value.share) {
return;

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<modification>
<name>TeleCart</name>
<code>telecart</code>
<version>1.0</version>
<author>TeleCart</author>
<link>https://docs.telecart.pro/</link>
<file path="admin/controller/common/column_left.php">
<operation>
<search><![CDATA[// System]]></search>
<add position="before"><![CDATA[ // BEGIN TeleCart modification
$telecart = array();
$telecart[] = array(
'name' => 'Настройки',
'href' => $this->url->link('extension/module/tgshop', 'user_token=' . $this->session->data['user_token'], true),
'children' => array()
);
$data['menus'][] = array(
'id' => 'menu-telecart',
'icon' => 'fa-telegram',
'name' => 'TeleCart',
'href' => '',
'children' => $telecart
);
// END TeleCart modification
]]></add>
</operation>
</file>
</modification>

View File

@@ -1,6 +1,6 @@
{
"name": "nikitakiselev/oc_telegram_shop",
"version": "v2.2.0",
"version": "v2.2.1",
"autoload": {
"psr-4": {
"Openguru\\OpenCartFramework\\": "framework/",

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
namespace App\Handlers;
use App\Services\CartService;
use App\Support\Utf8Cleaner;
use Cart\Cart;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
@@ -23,7 +24,7 @@ class CartHandler
$items = $this->cartService->getCart();
return new JsonResponse([
'data' => $items,
'data' => Utf8Cleaner::clean($items),
]);
}
@@ -48,7 +49,7 @@ class CartHandler
}
return new JsonResponse([
'data' => $items,
'data' => Utf8Cleaner::clean($items),
]);
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Handlers;
use App\Exceptions\OrderValidationFailedException;
use App\Services\OrderCreateService;
use App\Support\Utf8Cleaner;
use Symfony\Component\HttpFoundation\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -26,11 +27,11 @@ class OrderHandler
]);
return new JsonResponse([
'data' => $order,
'data' => Utf8Cleaner::clean($order),
], Response::HTTP_CREATED);
} catch (OrderValidationFailedException $exception) {
return new JsonResponse([
'data' => $exception->getErrorBag()->firstOfAll(),
'data' => Utf8Cleaner::clean($exception->getErrorBag()->firstOfAll()),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}

View File

@@ -64,7 +64,7 @@ class ProductsService
$perPage = $params['perPage'];
$search = $params['search'] ?? false;
$categoryName = '';
$maxPages = $params['maxPages'] ?? 50;
$maxPages = 200;
$filters = $params['filters'] ?? [];
$aspectRatio = $this->settings->get('app.image_aspect_ratio', '1:1');

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Support;
class Utf8Cleaner
{
/**
* Рекурсивно проходит по массиву/объекту и приводит все строковые значения к валидному UTF-8.
*/
public static function clean($value)
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$value[$key] = self::clean($item);
}
return $value;
}
if (is_object($value)) {
foreach (get_object_vars($value) as $property => $item) {
$value->{$property} = self::clean($item);
}
return $value;
}
if (is_string($value)) {
// Попробовать нормализовать строку в UTF-8, подменяя невалидные последовательности.
$converted = @mb_convert_encoding($value, 'UTF-8', 'UTF-8');
if ($converted === false) {
// Фолбэк: заменить невалидные байты стандартным алгоритмом json_encode.
$encoded = json_encode($value, JSON_INVALID_UTF8_SUBSTITUTE);
if ($encoded === false) {
return '';
}
// Убираем кавычки, которые добавляет json_encode.
return json_decode($encoded, true) ?? '';
}
return $converted;
}
return $value;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Support;
use App\Support\Utf8Cleaner;
use PHPUnit\Framework\TestCase;
class Utf8CleanerTest extends TestCase
{
/** @test */
public function returns_non_string_values_as_is(): void
{
$this->assertSame(123, Utf8Cleaner::clean(123));
$this->assertSame(12.3, Utf8Cleaner::clean(12.3));
$this->assertSame(true, Utf8Cleaner::clean(true));
$this->assertSame(false, Utf8Cleaner::clean(false));
$this->assertNull(Utf8Cleaner::clean(null));
}
/** @test */
public function leaves_valid_utf8_strings_unchanged(): void
{
$value = 'Привет, мир! 😀';
$this->assertSame($value, Utf8Cleaner::clean($value));
}
/** @test */
public function cleans_strings_with_invalid_utf8_bytes(): void
{
// Строка с заведомо некорректной UTF-8 последовательностью.
$invalid = "\xF0\x28\x8C\x28";
$cleaned = Utf8Cleaner::clean($invalid);
$this->assertIsString($cleaned);
$this->assertNotSame($invalid, $cleaned);
$json = json_encode($cleaned);
$this->assertNotFalse($json, 'Resulting string must be valid UTF-8 for json_encode');
}
/** @test */
public function cleans_nested_arrays_recursively(): void
{
$invalid = "\xF0\x28\x8C\x28";
$data = [
'foo' => 'bar',
'nested' => [
'value' => $invalid,
],
];
$cleaned = Utf8Cleaner::clean($data);
$this->assertIsArray($cleaned);
$this->assertSame('bar', $cleaned['foo']);
$this->assertIsString($cleaned['nested']['value']);
$this->assertNotSame($invalid, $cleaned['nested']['value']);
$json = json_encode($cleaned);
$this->assertNotFalse($json);
}
/** @test */
public function cleans_objects_recursively(): void
{
$invalid = "\xF0\x28\x8C\x28";
$object = new class {
public string $name = 'test';
public array $meta = [];
};
$object->meta['invalid'] = $invalid;
$cleaned = Utf8Cleaner::clean($object);
$this->assertSame('test', $cleaned->name);
$this->assertIsString($cleaned->meta['invalid']);
$this->assertNotSame($invalid, $cleaned->meta['invalid']);
$json = json_encode($cleaned);
$this->assertNotFalse($json);
}
}

51
scripts/apply_ocmod.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Создаёт симлинк install.xml в system/ OpenCart внутри контейнера.
# OpenCart при «Обновить» (Расширения → Модификации) подхватывает файлы
# DIR_SYSTEM . '*.ocmod.xml' и применяет их своей логикой — так же, как на сервере.
#
# Использование:
# ./scripts/apply_ocmod.sh
#
# Переменные (по умолчанию из docker-compose.yaml):
# CONTAINER — сервис/контейнер (по умолчанию web)
# TARGET_DIR — каталог system в контейнере (по умолчанию /web/upload/system)
# SOURCE — путь к install.xml в контейнере (по умолчанию /module/oc_telegram_shop/ocmod/install.xml)
#
# После запуска: открой админку → Расширения → Модификации → нажми «Обновить».
set -euo pipefail
CONTAINER="${CONTAINER:-web}"
TARGET_DIR="${TARGET_DIR:-/web/upload/system}"
SOURCE="${SOURCE:-/module/oc_telegram_shop/install.xml}"
TARGET="$TARGET_DIR/telecart.ocmod.xml"
if ! docker compose exec "$CONTAINER" test -f "$SOURCE" 2>/dev/null; then
echo "Ошибка: в контейнере $CONTAINER не найден $SOURCE" >&2
echo "Проверь, что том ./module примонтирован в контейнер." >&2
exit 1
fi
if ! docker compose exec "$CONTAINER" test -d "$TARGET_DIR" 2>/dev/null; then
echo "Ошибка: в контейнере $CONTAINER не найден каталог $TARGET_DIR" >&2
exit 1
fi
docker compose exec "$CONTAINER" bash -c "
if [ -e '$TARGET' ]; then
if [ -L '$TARGET' ]; then
current=\$(readlink '$TARGET')
if [ \"\$current\" = '$SOURCE' ]; then
echo 'Симлинк уже указывает на модуль: $TARGET'
exit 0
fi
fi
rm -f '$TARGET'
fi
ln -sf '$SOURCE' '$TARGET'
echo 'Симлинк создан в контейнере: $TARGET$SOURCE'
"
echo ""
echo "Дальше: открой админку OpenCart → Расширения → Модификации → нажми «Обновить»."
echo "Модификация применится той же логикой, что и при установке на сервере."