Squashed commit message
Some checks are pending
Telegram Mini App Shop Builder / Compute version metadata (push) Waiting to run
Telegram Mini App Shop Builder / Run Frontend tests (push) Waiting to run
Telegram Mini App Shop Builder / Run Backend tests (push) Waiting to run
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Waiting to run
Telegram Mini App Shop Builder / Build module. (push) Blocked by required conditions
Telegram Mini App Shop Builder / release (push) Blocked by required conditions
Some checks are pending
Telegram Mini App Shop Builder / Compute version metadata (push) Waiting to run
Telegram Mini App Shop Builder / Run Frontend tests (push) Waiting to run
Telegram Mini App Shop Builder / Run Backend tests (push) Waiting to run
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Waiting to run
Telegram Mini App Shop Builder / Build module. (push) Blocked by required conditions
Telegram Mini App Shop Builder / release (push) Blocked by required conditions
This commit is contained in:
64
.cursor/agents.md
Normal file
64
.cursor/agents.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Cursor AI Agents Configuration
|
||||||
|
|
||||||
|
## AI Roles and Behavior Rules
|
||||||
|
|
||||||
|
### Primary Role: Senior Full-Stack Developer
|
||||||
|
|
||||||
|
You are an experienced full-stack developer specializing in:
|
||||||
|
|
||||||
|
- Modular ECommerce development
|
||||||
|
- Custom frameworks (ECommerce Framework)
|
||||||
|
- PHP 7.4+ with modern best practices
|
||||||
|
- Vue.js 3 (Composition API)
|
||||||
|
- Telegram Mini App development
|
||||||
|
|
||||||
|
### Coding Rules
|
||||||
|
|
||||||
|
1. **Always follow existing project patterns**
|
||||||
|
2. **Do not create duplicates – reuse existing utilities**
|
||||||
|
3. **Follow project naming conventions**
|
||||||
|
4. **Test changes before committing**
|
||||||
|
5. **Document public APIs**
|
||||||
|
6. **Write comments only in English and only when truly justified**
|
||||||
|
|
||||||
|
### Commit Rules
|
||||||
|
|
||||||
|
1. **Follow Conventional Commits**
|
||||||
|
- Use prefixes: `feat:`, `fix:`, `chore:`, `refactor:`, `style:`, `test:`, `docs:`
|
||||||
|
- Format: `<type>: <subject>` (first line up to 72 characters)
|
||||||
|
- After an empty line – detailed description of changes
|
||||||
|
|
||||||
|
2. **Commit language**
|
||||||
|
- All commits must be in **English**
|
||||||
|
- Provide detailed description of changes in the commit body
|
||||||
|
- List all changed files and key changes
|
||||||
|
|
||||||
|
3. **Examples of good commits**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Forbidden
|
||||||
|
|
||||||
|
- Hardcoding values (use configs/settings instead)
|
||||||
|
- Ignoring error handling
|
||||||
|
- Creating circular dependencies
|
||||||
|
|
||||||
|
For frontend development use:
|
||||||
|
|
||||||
|
- Vue.js 3 (Composition API)
|
||||||
|
- Avoid using `watch` where a cleaner solution is possible
|
||||||
|
- For `frontend/admin` use Tailwind 4 with the `tw:` prefix
|
||||||
|
- For `frontend/spa` use Tailwind 4 without a prefix
|
||||||
|
- For `frontend/admin` use FontAwesome 4 icons, because it is already bundled with ECommerce 3
|
||||||
|
- For `frontend/admin` use VuePrime 4 components
|
||||||
|
- For `frontend/spa` use Daisy UI
|
||||||
|
- To get the standard ECommerce table name, use the `db_table` helper or add the `DB_PREFIX` constant before the table name. This way you will get the table name with prefix.
|
||||||
|
- All tables of my `AcmeShop` module start with the `acmeshop_` prefix. Migration examples are located in `module/acmeshop/upload/acmeshop/database/migrations`
|
||||||
44
.cursor/config.json
Normal file
44
.cursor/config.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"preferCompositionAPI": true,
|
||||||
|
"strictTypes": true,
|
||||||
|
"noHardcodedValues": true,
|
||||||
|
"useDependencyInjection": true
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"acmeshop_module": "module/acmeshop/upload/acmeshop",
|
||||||
|
"frontendAdmin": "frontend/admin",
|
||||||
|
"telegramShopSpa": "frontend/spa",
|
||||||
|
"migrations": "module/acmeshop/upload/acmeshop/database/migrations",
|
||||||
|
"acmeshopHandlers": "module/acmeshop/upload/acmeshop/src/Handlers",
|
||||||
|
"adminHandlers": "module/acmeshop/upload/acmeshop/bastion/Handlers",
|
||||||
|
"models": "module/acmeshop/upload/acmeshop/src/Models",
|
||||||
|
"framework": "module/acmeshop/upload/acmeshop/framework"
|
||||||
|
},
|
||||||
|
"naming": {
|
||||||
|
"classes": "PascalCase",
|
||||||
|
"methods": "camelCase",
|
||||||
|
"variables": "camelCase",
|
||||||
|
"constants": "UPPER_SNAKE_CASE",
|
||||||
|
"files": "PascalCase for classes, kebab-case for others",
|
||||||
|
"tables": "snake_case with acmeshop_ 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": "acmeshop_",
|
||||||
|
"noForeignKeys": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
38
.cursor/features/acme-pulse-heartbeat.md
Normal file
38
.cursor/features/acme-pulse-heartbeat.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
## AcmeShop Pulse Heartbeat Telemetry
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
Send heartbeat telemetry to AcmeShop Pulse once per hour to capture store state and environment versions without any user interaction.
|
||||||
|
|
||||||
|
### Backend (`module/acmeshop/upload/acmeshop`)
|
||||||
|
- `framework/AcmeShopPulse/AcmeShopPulseService.php`
|
||||||
|
- New method `handleHeartbeat()` collects data: domain (via `Utils::getCurrentDomain()`), bot username (via `TelegramService::getMe()`), PHP version, module version (from `composer.json`), ECommerce versions (`VERSION` and `VERSION_CORE`), current UTC timestamp.
|
||||||
|
- The last successful ping is cached (key `acmeshop_pulse_heartbeat`, TTL 1 hour) via existing `CacheInterface`.
|
||||||
|
- Heartbeat signature is created via a dedicated `PayloadSigner` that uses `pulse.heartbeat_secret`/`PULSE_HEARTBEAT_SECRET`. Warnings are logged on cache/bot/signature failures.
|
||||||
|
- Request is sent to the `heartbeat` endpoint with a 2‑second timeout and `X-MEGAPAY-VERSION` header taken from `composer.json`.
|
||||||
|
- `framework/AcmeShopPulse/AcmeShopPulseServiceProvider.php`
|
||||||
|
- Registers main `PayloadSigner` (by `pulse.api_key`) and a separate heartbeat signer (by `pulse.heartbeat_secret` or `PULSE_HEARTBEAT_SECRET`), injects `LoggerInterface`.
|
||||||
|
- `src/Handlers/TelemetryHandler.php` + `src/routes.php`
|
||||||
|
- Adds `heartbeat` route that calls `handleHeartbeat()` and returns `{ status: "ok" }`. Logger writes warnings on problems.
|
||||||
|
|
||||||
|
### Frontend (`frontend/spa`)
|
||||||
|
- `src/utils/ftch.js`: new `heartbeat()` function calls `api_action=heartbeat`.
|
||||||
|
- `src/stores/Pulse.js`: new `heartbeat` action uses the API function and logs the result.
|
||||||
|
- `src/main.js`: after `pulse.ingest(...)` the code calls `pulse.heartbeat()` without blocking the chain.
|
||||||
|
|
||||||
|
### Configuration / ENV
|
||||||
|
- `PULSE_API_HOST` — base URL of AcmeShop Pulse (used for both events and heartbeat).
|
||||||
|
- `PULSE_TIMEOUT` — global HTTP timeout (for heartbeat forced to 2 seconds).
|
||||||
|
- `PULSE_HEARTBEAT_SECRET` (or `pulse.heartbeat_secret` in settings) — shared secret for signing heartbeat. Required; otherwise heartbeat will not be sent.
|
||||||
|
- `pulse.api_key` — legacy API key, used only for event ingest.
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
1. Frontend (SPA) calls `heartbeat` on app initialization (fire-and-forget).
|
||||||
|
2. Backend checks the cache. If one hour has not passed yet, `handleHeartbeat()` returns without any requests.
|
||||||
|
3. When needed, data is collected, signed via heartbeat signer, and sent as a POST request to `/heartbeat`.
|
||||||
|
4. Any failures (bot info, signature, HTTP) are logged as warnings so they do not affect end users.
|
||||||
|
|
||||||
|
### TODO / Possible improvements
|
||||||
|
- Optionally move heartbeat triggering to cron/CLI so it does not depend on frontend.
|
||||||
|
- Add heartbeat success metrics to the admin panel.
|
||||||
|
|
||||||
|
|
||||||
127
.cursor/prompts/api-generation.md
Normal file
127
.cursor/prompts/api-generation.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Prompts for API generation
|
||||||
|
|
||||||
|
## Creating a new API endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
Create a new API endpoint [ENDPOINT_NAME] for [DESCRIPTION]:
|
||||||
|
|
||||||
|
1. Handler in [HANDLER_PATH]:
|
||||||
|
- Method handle() accepts Request
|
||||||
|
- Validate input data
|
||||||
|
- Use a Service for business logic
|
||||||
|
- Return JsonResponse with correct structure
|
||||||
|
- Handle errors with logging
|
||||||
|
|
||||||
|
2. Service in [SERVICE_PATH]:
|
||||||
|
- Business logic
|
||||||
|
- Work with Model
|
||||||
|
- Data validation
|
||||||
|
- Exception handling
|
||||||
|
|
||||||
|
3. Model in [MODEL_PATH] (if needed):
|
||||||
|
- Methods for working with the database
|
||||||
|
- Use Query Builder
|
||||||
|
- Proper method typing
|
||||||
|
|
||||||
|
4. Route in routes.php:
|
||||||
|
- Add a route with the correct name
|
||||||
|
|
||||||
|
5. Migration (if a new table is needed):
|
||||||
|
- Create a migration in database/migrations/
|
||||||
|
- Use fixed acmeshop_ prefix for the table
|
||||||
|
- Add indexes where necessary
|
||||||
|
|
||||||
|
Follow the project MVC-L architecture and reuse existing patterns.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a CRUD API
|
||||||
|
|
||||||
|
```
|
||||||
|
Create a full CRUD API for entity [ENTITY_NAME]:
|
||||||
|
|
||||||
|
1. Handler with methods:
|
||||||
|
- list() – list with pagination and filtering
|
||||||
|
- get() – fetch a single record
|
||||||
|
- create() – create
|
||||||
|
- update() – update
|
||||||
|
- delete() – delete
|
||||||
|
|
||||||
|
2. Service with business logic for all operations
|
||||||
|
|
||||||
|
3. Model with methods:
|
||||||
|
- findAll() – list
|
||||||
|
- findById() – by ID
|
||||||
|
- create() – create
|
||||||
|
- update() – update
|
||||||
|
- delete() – delete
|
||||||
|
|
||||||
|
4. DTO for data validation
|
||||||
|
|
||||||
|
5. Migration for table [TABLE_NAME]
|
||||||
|
|
||||||
|
6. Routes for all endpoints
|
||||||
|
|
||||||
|
Use server-side pagination, filtering, and sorting for list().
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating an Admin API endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
Create an Admin API endpoint [ENDPOINT_NAME] in bastion/Handlers/:
|
||||||
|
|
||||||
|
1. Handler in bastion/Handlers/[HANDLER_NAME].php:
|
||||||
|
- Use Request to read parameters
|
||||||
|
- Validate data
|
||||||
|
- Call a Service for business logic
|
||||||
|
- Return JsonResponse with structure { data: { data: [...], totalRecords: ... } }
|
||||||
|
- Handle errors
|
||||||
|
|
||||||
|
2. Service in bastion/Services/ (if needed):
|
||||||
|
- Admin-specific business logic
|
||||||
|
- Work with Models
|
||||||
|
|
||||||
|
3. Route in bastion/routes.php
|
||||||
|
|
||||||
|
4. Frontend component (if UI is needed):
|
||||||
|
- Vue component in frontend/admin/src/views/
|
||||||
|
- Use PrimeVue components
|
||||||
|
- Server-side pagination/filtering
|
||||||
|
- Error handling with toast notifications
|
||||||
|
|
||||||
|
Follow existing project patterns.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a Frontend API client
|
||||||
|
|
||||||
|
```
|
||||||
|
Create a function for working with API endpoint [ENDPOINT_NAME]:
|
||||||
|
|
||||||
|
1. In frontend/[admin|spa]/src/utils/http.js:
|
||||||
|
- api[Method] function to call the endpoint
|
||||||
|
- Proper error handling
|
||||||
|
- Return a structured response
|
||||||
|
|
||||||
|
2. Usage:
|
||||||
|
- Import in components
|
||||||
|
- Handle loading states
|
||||||
|
- Show toast notifications on errors
|
||||||
|
|
||||||
|
Follow existing patterns in http.js.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a migration
|
||||||
|
|
||||||
|
```
|
||||||
|
Create a migration for table [TABLE_NAME]:
|
||||||
|
|
||||||
|
1. File: database/migrations/[TIMESTAMP]_[DESCRIPTION].php
|
||||||
|
2. Use fixed acmeshop_ prefix for the table
|
||||||
|
3. Add all required fields with correct types
|
||||||
|
4. Add indexes for frequently used fields
|
||||||
|
5. Use utf8mb4_unicode_ci collation
|
||||||
|
6. Use InnoDB engine
|
||||||
|
7. Add created_at and updated_at timestamps
|
||||||
|
8. Do not create foreign keys (use indexes only)
|
||||||
|
|
||||||
|
Follow the structure of existing migrations.
|
||||||
|
```
|
||||||
101
.cursor/prompts/changelog.md
Normal file
101
.cursor/prompts/changelog.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Changelog Documentation Rules
|
||||||
|
|
||||||
|
## General requirements
|
||||||
|
|
||||||
|
- **Format**: Markdown
|
||||||
|
- **Structure**: One version = one page
|
||||||
|
- **Style**: Professional, concise, without marketing slogans
|
||||||
|
- **Language**: English
|
||||||
|
- **Target audience**: Developers and store owners
|
||||||
|
- **Content**: Only key changes, without unnecessary technical details
|
||||||
|
|
||||||
|
## Page structure
|
||||||
|
|
||||||
|
### Intro paragraph
|
||||||
|
- Short release description (1–2 sentences)
|
||||||
|
- Add 1–2 sentences about key changes
|
||||||
|
- Mention the main features and improvements
|
||||||
|
|
||||||
|
### Sections (strictly in this order)
|
||||||
|
|
||||||
|
1. **🚀 Added** – new features and capabilities
|
||||||
|
2. **✨ Improved** – improvements to existing features
|
||||||
|
3. **🐞 Fixed** – bug fixes
|
||||||
|
4. **⚠️ Breaking changes** – non‑backward compatible changes
|
||||||
|
5. **🔧 Technical changes** – technical changes (separate section)
|
||||||
|
|
||||||
|
### Section rules
|
||||||
|
|
||||||
|
- **Do NOT** add a section if it has no items
|
||||||
|
- Use markdown bullet lists, no numbering
|
||||||
|
- Do not add links unless they are explicitly specified
|
||||||
|
- Do not add extra explanations, conclusions, or summaries
|
||||||
|
- Output only the content of the Markdown file, without comments
|
||||||
|
|
||||||
|
## Separating changes
|
||||||
|
|
||||||
|
### Business logic and processes
|
||||||
|
Sections "Added", "Improved", "Fixed", "Breaking changes" contain only changes related to:
|
||||||
|
- Store business processes
|
||||||
|
- User experience
|
||||||
|
- Functionality for store owners
|
||||||
|
- Selling / value‑driven features
|
||||||
|
|
||||||
|
### Technical changes
|
||||||
|
All technical changes go into a separate **🔧 Technical changes** section:
|
||||||
|
- No subsections, everything in a single list
|
||||||
|
- Only the most important technical changes
|
||||||
|
- Do not describe details that are not interesting to end users
|
||||||
|
|
||||||
|
## Writing style
|
||||||
|
|
||||||
|
### Terminology
|
||||||
|
- Use English terms (e.g. "navbar", not a localized variant)
|
||||||
|
- Avoid low‑level technical terms in business sections (e.g. "customer_id" → "automatic customer linking")
|
||||||
|
|
||||||
|
### Descriptions
|
||||||
|
|
||||||
|
**For key selling features:**
|
||||||
|
- Provide detailed descriptions
|
||||||
|
- Highlight capabilities and advantages
|
||||||
|
- Emphasize value for the user
|
||||||
|
|
||||||
|
**For technical changes:**
|
||||||
|
- Be brief and to the point
|
||||||
|
- Avoid unnecessary details
|
||||||
|
|
||||||
|
**For regular features:**
|
||||||
|
- Be concise but informative
|
||||||
|
- Focus on user benefit
|
||||||
|
|
||||||
|
## Ordering
|
||||||
|
|
||||||
|
### In the "Added" section
|
||||||
|
Place key selling features **first**:
|
||||||
|
1. Main page configuration system based on blocks
|
||||||
|
2. Form builder based on FormKit
|
||||||
|
3. Yandex.Metrica integration
|
||||||
|
4. Privacy policy
|
||||||
|
5. Support for coupons and gift certificates
|
||||||
|
6. Other features
|
||||||
|
|
||||||
|
## Examples of good wording
|
||||||
|
|
||||||
|
### ✅ Good
|
||||||
|
- "Automatic linking of Telegram customers with ECommerce customers: the system automatically finds and links customers from the Telegram shop with existing customers in ECommerce by email or phone number, creating a unified customer base"
|
||||||
|
|
||||||
|
### ❌ Bad
|
||||||
|
- "Saving customer_id with the order to link with ECommerce customers: automatic linking of orders from Telegram with customers in ECommerce, unified customer base"
|
||||||
|
|
||||||
|
### ✅ Good
|
||||||
|
- "Navbar with logo and application name"
|
||||||
|
|
||||||
|
### ❌ Bad
|
||||||
|
- "Navigation bar with logo and application name" (too verbose, no added value)
|
||||||
|
|
||||||
|
## What not to include
|
||||||
|
|
||||||
|
- Internal development details (tests, static analysis, obfuscation)
|
||||||
|
- Technical details not interesting to users
|
||||||
|
- Excessively detailed technical descriptions
|
||||||
|
- Marketing slogans and calls to action
|
||||||
61
.cursor/prompts/documentation.md
Normal file
61
.cursor/prompts/documentation.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Prompts for documentation
|
||||||
|
|
||||||
|
## Documenting a class
|
||||||
|
|
||||||
|
```
|
||||||
|
Add PHPDoc documentation for class [CLASS_NAME]:
|
||||||
|
1. Description of the class and its purpose
|
||||||
|
2. @package tag
|
||||||
|
3. @author tag
|
||||||
|
4. Documentation for all public methods
|
||||||
|
5. Documentation for public properties
|
||||||
|
6. Usage examples where appropriate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documenting a method
|
||||||
|
|
||||||
|
```
|
||||||
|
Add PHPDoc for method [METHOD_NAME]:
|
||||||
|
1. Method description
|
||||||
|
2. @param for all parameters with types
|
||||||
|
3. @return with the return type
|
||||||
|
4. @throws for all exceptions
|
||||||
|
5. Usage examples if the logic is complex
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documenting an API endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
Create documentation for API endpoint [ENDPOINT_NAME]:
|
||||||
|
1. Description of purpose
|
||||||
|
2. HTTP method and path
|
||||||
|
3. Request parameters (query/body)
|
||||||
|
4. Response format (JSON structure)
|
||||||
|
5. Error codes
|
||||||
|
6. Request/response examples
|
||||||
|
7. Authorization requirements
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documenting a Vue component
|
||||||
|
|
||||||
|
```
|
||||||
|
Add documentation for Vue component [COMPONENT_NAME]:
|
||||||
|
1. Component description
|
||||||
|
2. Props with types and descriptions
|
||||||
|
3. Emits with descriptions
|
||||||
|
4. Slots if present
|
||||||
|
5. Usage examples
|
||||||
|
6. Dependencies on other components
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a README
|
||||||
|
|
||||||
|
```
|
||||||
|
Create README.md for [MODULE/COMPONENT]:
|
||||||
|
1. Purpose description
|
||||||
|
2. Installation/configuration
|
||||||
|
3. Usage with examples
|
||||||
|
4. API documentation
|
||||||
|
5. Configuration options
|
||||||
|
6. Troubleshooting
|
||||||
|
```
|
||||||
87
.cursor/prompts/refactoring.md
Normal file
87
.cursor/prompts/refactoring.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Prompts for refactoring
|
||||||
|
|
||||||
|
## General refactoring
|
||||||
|
|
||||||
|
```
|
||||||
|
Analyze the code in file [FILE_PATH] and perform refactoring:
|
||||||
|
1. Remove duplicated code
|
||||||
|
2. Improve readability
|
||||||
|
3. Apply SOLID principles
|
||||||
|
4. Add error handling where necessary
|
||||||
|
5. Improve typing
|
||||||
|
6. Add documentation for public methods
|
||||||
|
7. Ensure the code follows the project's MVC-L architecture
|
||||||
|
8. Use existing utilities and services instead of creating new ones
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refactoring a Handler
|
||||||
|
|
||||||
|
```
|
||||||
|
Refactor Handler [HANDLER_NAME]:
|
||||||
|
1. Extract business logic into a separate Service
|
||||||
|
2. Add validation for input data
|
||||||
|
3. Improve error handling with logging
|
||||||
|
4. Use DTOs for data transfer
|
||||||
|
5. Add PHPDoc comments
|
||||||
|
6. Ensure Dependency Injection is used
|
||||||
|
7. Optimize database queries if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refactoring a Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Refactor Model [MODEL_NAME]:
|
||||||
|
1. Ensure all queries use the Query Builder
|
||||||
|
2. Add methods for common operations (findBy, findAll, create, update)
|
||||||
|
3. Add data validation before saving
|
||||||
|
4. Improve method typing
|
||||||
|
5. Add PHPDoc comments
|
||||||
|
6. Use transactions for complex operations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Refactoring a Vue component
|
||||||
|
|
||||||
|
```
|
||||||
|
Refactor Vue component [COMPONENT_NAME]:
|
||||||
|
1. Extract logic into composable functions
|
||||||
|
2. Improve typing for props and emits
|
||||||
|
3. Optimize computed properties
|
||||||
|
4. Add error handling
|
||||||
|
5. Improve template structure
|
||||||
|
6. Add loading states
|
||||||
|
7. Use existing project utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## Removing duplication
|
||||||
|
|
||||||
|
```
|
||||||
|
Find and remove code duplication in:
|
||||||
|
- [FILE_PATH_1]
|
||||||
|
- [FILE_PATH_2]
|
||||||
|
- [FILE_PATH_3]
|
||||||
|
|
||||||
|
Create shared utilities/services where appropriate, following the project architecture.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Improving performance
|
||||||
|
|
||||||
|
```
|
||||||
|
Analyze performance of the code in [FILE_PATH]:
|
||||||
|
1. Optimize database queries (use indexes, avoid N+1)
|
||||||
|
2. Add caching where appropriate
|
||||||
|
3. Optimize algorithms
|
||||||
|
4. Reduce the number of API calls
|
||||||
|
5. Use lazy loading on the frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Improving security
|
||||||
|
|
||||||
|
```
|
||||||
|
Improve security of the code in [FILE_PATH]:
|
||||||
|
1. Add validation for all input data
|
||||||
|
2. Use prepared statements (Query Builder)
|
||||||
|
3. Add CSRF protection where necessary
|
||||||
|
4. Validate access rights
|
||||||
|
5. Sanitize output data
|
||||||
|
6. Add rate limiting where necessary
|
||||||
|
```
|
||||||
52
.cursor/prompts/testing.md
Normal file
52
.cursor/prompts/testing.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Prompts for testing
|
||||||
|
|
||||||
|
## Creating a unit test
|
||||||
|
|
||||||
|
```
|
||||||
|
Create a unit test for [CLASS_NAME] in tests/Unit/:
|
||||||
|
|
||||||
|
1. Use PHPUnit
|
||||||
|
2. Cover all public methods
|
||||||
|
3. Test successful scenarios
|
||||||
|
4. Test error handling
|
||||||
|
5. Use mocks for dependencies
|
||||||
|
6. Follow the structure of existing tests
|
||||||
|
7. Use the project's base TestCase class
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating an integration test
|
||||||
|
|
||||||
|
```
|
||||||
|
Create an integration test for [FEATURE_NAME] in tests/Integration/:
|
||||||
|
|
||||||
|
1. Test the full flow from request to response
|
||||||
|
2. Use a test database
|
||||||
|
3. Clean up data after tests
|
||||||
|
4. Test real-life usage scenarios
|
||||||
|
5. Verify data validation
|
||||||
|
6. Verify error handling
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a Vue component test
|
||||||
|
|
||||||
|
```
|
||||||
|
Create a test for Vue component [COMPONENT_NAME] in frontend/[admin|spa]/tests/:
|
||||||
|
|
||||||
|
1. Use Vitest
|
||||||
|
2. Test component rendering
|
||||||
|
3. Test props
|
||||||
|
4. Test events (emits)
|
||||||
|
5. Test user interactions
|
||||||
|
6. Use mocks for API calls
|
||||||
|
7. Follow the structure of existing tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test coverage
|
||||||
|
|
||||||
|
```
|
||||||
|
Analyze test coverage for [FILE_PATH]:
|
||||||
|
1. Determine which methods are not covered by tests
|
||||||
|
2. Create tests for critical methods
|
||||||
|
3. Ensure edge cases are tested
|
||||||
|
4. Add tests for error handling
|
||||||
|
```
|
||||||
201
.cursor/rules/architecture.md
Normal file
201
.cursor/rules/architecture.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# Architectural Rules
|
||||||
|
|
||||||
|
## ECommerce Framework Architecture
|
||||||
|
|
||||||
|
### MVC-L Pattern
|
||||||
|
|
||||||
|
The project uses a modified MVC-L pattern (Model-View-Controller-Language):
|
||||||
|
|
||||||
|
- **Model**: Classes in `src/Models/` – data access and database operations
|
||||||
|
- **View**: Vue components on the frontend, JSON responses on the backend
|
||||||
|
- **Controller**: Handlers in `src/Handlers/` and `bastion/Handlers/`
|
||||||
|
- **Language**: Translator in `framework/Translator/`
|
||||||
|
|
||||||
|
### Dependency Injection
|
||||||
|
|
||||||
|
All dependencies are injected via the Container:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Correct
|
||||||
|
public function __construct(
|
||||||
|
private Builder $builder,
|
||||||
|
private TelegramCustomer $telegramCustomerModel
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ❌ Incorrect
|
||||||
|
public function __construct() {
|
||||||
|
$this->builder = new Builder(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Providers
|
||||||
|
|
||||||
|
Services are registered via 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 are defined in `routes.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
return [
|
||||||
|
'actionName' => [HandlerClass::class, 'methodName'],
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Handlers (Controllers)
|
||||||
|
|
||||||
|
Handlers process HTTP requests:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class MyHandler
|
||||||
|
{
|
||||||
|
public function handle(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
// Validation
|
||||||
|
// Business logic via Services
|
||||||
|
// Return JsonResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
Models work with data:
|
||||||
|
|
||||||
|
```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 contain business logic:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class MyService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private MyModel $model
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function doSomething(array $data): array
|
||||||
|
{
|
||||||
|
// Business logic
|
||||||
|
return $this->model->create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
Migrations live in `database/migrations/`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$this->database->statement('CREATE TABLE ...');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Builder
|
||||||
|
|
||||||
|
Always use Query Builder instead of raw SQL:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Correct
|
||||||
|
$query = $this->builder->newQuery()
|
||||||
|
->select(['id', 'name'])
|
||||||
|
->from('table_name')
|
||||||
|
->where('status', '=', 'active')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// ❌ Incorrect
|
||||||
|
$result = $this->database->query("SELECT * FROM table_name WHERE status = 'active'");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Architecture
|
||||||
|
|
||||||
|
#### Admin Panel (Vue 3)
|
||||||
|
|
||||||
|
- Composition API
|
||||||
|
- Pinia for state management
|
||||||
|
- PrimeVue for UI components
|
||||||
|
- Axios for HTTP requests
|
||||||
|
- Vue Router for navigation
|
||||||
|
|
||||||
|
#### SPA (Telegram Mini App)
|
||||||
|
|
||||||
|
- Composition API
|
||||||
|
- Pinia stores
|
||||||
|
- Tailwind CSS for styles
|
||||||
|
- Telegram WebApp API
|
||||||
|
- Vue Router
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Classes**: PascalCase (`TelegramCustomerService`)
|
||||||
|
- **Methods**: camelCase (`getCustomers`)
|
||||||
|
- **Variables**: camelCase (`$customerData`)
|
||||||
|
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
|
||||||
|
- **Files**: PascalCase for classes, kebab-case for everything else
|
||||||
|
- **Tables**: snake_case with `acmeshop_` prefix
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Always handle errors:
|
||||||
|
|
||||||
|
```php
|
||||||
|
try {
|
||||||
|
$result = $this->service->doSomething();
|
||||||
|
} catch (SpecificException $e) {
|
||||||
|
$this->logger->error('Error message', ['exception' => $e]);
|
||||||
|
throw new UserFriendlyException('User message');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Use configuration files in `configs/`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$config = $this->app->getConfigValue('app.setting_name');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Caching
|
||||||
|
|
||||||
|
Use Cache Service for caching:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$cache = $this->app->get(CacheInterface::class);
|
||||||
|
$value = $cache->get('key', function() {
|
||||||
|
return expensiveOperation();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
70
.cursor/rules/form-builder.md
Normal file
70
.cursor/rules/form-builder.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
332
.cursor/rules/javascript.md
Normal file
332
.cursor/rules/javascript.md
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
# JavaScript/TypeScript Code Style Rules
|
||||||
|
|
||||||
|
## JavaScript Version
|
||||||
|
|
||||||
|
- ES2020+ features
|
||||||
|
- Modern async/await
|
||||||
|
- Optional chaining (`?.`)
|
||||||
|
- Nullish coalescing (`??`)
|
||||||
|
- Template literals
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
### Variable Declarations
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Use const by default
|
||||||
|
const customers = [];
|
||||||
|
const totalRecords = 0;
|
||||||
|
|
||||||
|
// ✅ Use let only when reassignment is needed
|
||||||
|
let currentPage = 1;
|
||||||
|
currentPage = 2;
|
||||||
|
|
||||||
|
// ❌ Do not use var
|
||||||
|
var oldVariable = 'bad';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arrow Functions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Preferred for short functions
|
||||||
|
const filtered = items.filter(item => item.isActive);
|
||||||
|
|
||||||
|
// ✅ For object methods
|
||||||
|
const api = {
|
||||||
|
get: async (url) => {
|
||||||
|
return await fetch(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ For complex logic – use regular functions
|
||||||
|
function complexCalculation(data) {
|
||||||
|
// many lines of code
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Literals
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Preferred
|
||||||
|
const message = `User ${userId} not found`;
|
||||||
|
const url = `${baseUrl}/api/${endpoint}`;
|
||||||
|
|
||||||
|
// ❌ Do not use concatenation
|
||||||
|
const message = 'User ' + userId + ' not found';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional Chaining
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Use optional chaining
|
||||||
|
const name = user?.profile?.name;
|
||||||
|
const count = data?.items?.length ?? 0;
|
||||||
|
|
||||||
|
// ❌ Avoid long nested checks
|
||||||
|
const name = user && user.profile && user.profile.name;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nullish Coalescing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Use ?? for default values
|
||||||
|
const page = params.page ?? 1;
|
||||||
|
const name = user.name ?? 'Unknown';
|
||||||
|
|
||||||
|
// ❌ Do not use || for numbers/booleans
|
||||||
|
const page = params.page || 1; // 0 will be replaced with 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Destructuring
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Use destructuring
|
||||||
|
const { data, totalRecords } = response.data;
|
||||||
|
const [first, second] = items;
|
||||||
|
|
||||||
|
// ✅ In function parameters
|
||||||
|
function processUser({ id, name, email }) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ With default values
|
||||||
|
const { page = 1, limit = 20 } = params;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async/Await
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Preferred
|
||||||
|
async function loadCustomers() {
|
||||||
|
try {
|
||||||
|
const response = await apiGet('getCustomers', params);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Avoid .then() chains
|
||||||
|
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>
|
||||||
|
// ✅ Use <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
|
||||||
|
// ✅ Use ref for primitives
|
||||||
|
const count = ref(0);
|
||||||
|
const name = ref('');
|
||||||
|
|
||||||
|
// ✅ Use reactive for objects
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
const state = reactive({
|
||||||
|
customers: [],
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Or ref for objects (preferred)
|
||||||
|
const state = ref({
|
||||||
|
customers: [],
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Computed Properties
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Use computed for derived values
|
||||||
|
const filteredCustomers = computed(() => {
|
||||||
|
return customers.value.filter(c => c.isActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ❌ Do not use methods for derived values
|
||||||
|
function filteredCustomers() {
|
||||||
|
return customers.value.filter(c => c.isActive);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
// ✅ Define props with types
|
||||||
|
const props = defineProps({
|
||||||
|
customerId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
showDetails: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Emits
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
// ✅ Define emits
|
||||||
|
const emit = defineEmits(['update', 'delete']);
|
||||||
|
|
||||||
|
function handleUpdate() {
|
||||||
|
emit('update', data);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pinia Stores
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Use 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
|
||||||
|
// ✅ Always handle errors
|
||||||
|
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('Failed to load data');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
### Variables and Functions
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ camelCase
|
||||||
|
const customerData = {};
|
||||||
|
const totalRecords = 0;
|
||||||
|
function loadCustomers() {}
|
||||||
|
|
||||||
|
// ✅ Constants in UPPER_SNAKE_CASE
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const API_BASE_URL = '/api';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ✅ PascalCase for components -->
|
||||||
|
<CustomerCard />
|
||||||
|
<ProductsList />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Use kebab-case for files
|
||||||
|
// customers-view.vue
|
||||||
|
// http-utils.js
|
||||||
|
// customer-service.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Imports
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ✅ Group imports
|
||||||
|
// 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 (if using TypeScript)
|
||||||
|
import type { Customer } from '@/types';
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript (where used)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Use types
|
||||||
|
interface Customer {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomer(id: number): Promise<Customer> {
|
||||||
|
return apiGet(`customers/${id}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
243
.cursor/rules/php.md
Normal file
243
.cursor/rules/php.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# PHP Code Style Rules
|
||||||
|
|
||||||
|
## PHP Version
|
||||||
|
|
||||||
|
The project supports PHP 7.4+
|
||||||
|
|
||||||
|
## PSR Standards
|
||||||
|
|
||||||
|
- **PSR-1**: Basic Coding Standard
|
||||||
|
- **PSR-4**: Autoloading Standard
|
||||||
|
- **PSR-12**: Extended Coding Style
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
### Type Declarations
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Correct – strict typing
|
||||||
|
public function getCustomers(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$id = (int) $request->get('id');
|
||||||
|
return new JsonResponse(['data' => $customers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ❌ Incorrect – no types
|
||||||
|
public function getCustomers($request)
|
||||||
|
{
|
||||||
|
return ['data' => $customers];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nullable Types
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Correct
|
||||||
|
public function findById(?int $id): ?array
|
||||||
|
{
|
||||||
|
if ($id === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $this->query->where('id', '=', $id)->firstOrNull();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Strict Types
|
||||||
|
|
||||||
|
Always use `declare(strict_types=1);`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Array Syntax
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Preferred – short syntax
|
||||||
|
$array = ['key' => 'value'];
|
||||||
|
|
||||||
|
// ❌ Do not use
|
||||||
|
$array = array('key' => 'value');
|
||||||
|
```
|
||||||
|
|
||||||
|
### String Interpolation
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Preferred
|
||||||
|
$message = "User {$userId} not found";
|
||||||
|
|
||||||
|
// ✅ Alternative
|
||||||
|
$message = sprintf('User %d not found', $userId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arrow Functions (PHP 7.4+)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ For simple operations
|
||||||
|
$filtered = array_filter($items, fn($item) => $item->isActive());
|
||||||
|
|
||||||
|
// ❌ For complex logic – use regular functions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nullsafe Operator (PHP 8.0+)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ For 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 with visibility modifier
|
||||||
|
private string $tableName;
|
||||||
|
private Builder $builder;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
### PHPDoc
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @throws ValidationException If parameters are invalid
|
||||||
|
*/
|
||||||
|
public function getCustomers(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline Comments
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Useful comments
|
||||||
|
// Apply filters to calculate total number of records
|
||||||
|
$countQuery = $this->buildCountQuery($filters);
|
||||||
|
|
||||||
|
// ❌ Obvious comments
|
||||||
|
// Get data
|
||||||
|
$data = $this->getData();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Exceptions
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Specific exceptions
|
||||||
|
if (!$userId) {
|
||||||
|
throw new InvalidArgumentException('User ID is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Logging
|
||||||
|
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
|
||||||
|
// ✅ Correct
|
||||||
|
$customers = $this->builder->newQuery()
|
||||||
|
->select(['id', 'name', 'email'])
|
||||||
|
->from('acmeshop_customers')
|
||||||
|
->where('status', '=', 'active')
|
||||||
|
->orderBy('created_at', 'DESC')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// In edge cases raw SQL may be used
|
||||||
|
$result = $this->database->query("SELECT * FROM acmeshop_customers");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameter Binding
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Query Builder automatically binds parameters
|
||||||
|
$query->where('name', 'LIKE', "%{$search}%");
|
||||||
|
|
||||||
|
// ❌ Never concatenate values into SQL, avoid SQL Injection.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Array Access
|
||||||
|
|
||||||
|
### Safe Array Access
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Use Arr::get()
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
|
||||||
|
$value = Arr::get($data, 'key', 'default');
|
||||||
|
|
||||||
|
// ❌ Unsafe
|
||||||
|
$value = $data['key']; // may trigger an error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Return Types
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Always specify return type
|
||||||
|
public function getData(): array {}
|
||||||
|
public function findById(int $id): ?array {}
|
||||||
|
public function process(): void {}
|
||||||
|
|
||||||
|
// ❌ Without type
|
||||||
|
public function getData() {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Visibility Modifiers
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ✅ Always specify visibility modifier
|
||||||
|
private string $tableName;
|
||||||
|
protected Builder $builder;
|
||||||
|
public function getData(): array {}
|
||||||
|
```
|
||||||
|
|
||||||
370
.cursor/rules/vue.md
Normal file
370
.cursor/rules/vue.md
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# Vue.js 3 Rules
|
||||||
|
|
||||||
|
## Component Structure
|
||||||
|
|
||||||
|
### Template
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- ✅ Logical structure -->
|
||||||
|
<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>
|
||||||
|
// ✅ Always use <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>
|
||||||
|
/* ✅ Use scoped styles */
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ Use :deep() to style nested components */
|
||||||
|
:deep(.p-datatable) {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Naming
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- ✅ PascalCase for components -->
|
||||||
|
<CustomerCard />
|
||||||
|
<ProductsList />
|
||||||
|
<OrderDetails />
|
||||||
|
|
||||||
|
<!-- ✅ kebab-case in templates also works -->
|
||||||
|
<customer-card />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
// ✅ Always define types and validation
|
||||||
|
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>
|
||||||
|
// ✅ Define emits with types
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [id: number, data: object];
|
||||||
|
delete: [id: number];
|
||||||
|
cancel: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ✅ Or with validation
|
||||||
|
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 for primitives
|
||||||
|
const count = ref(0);
|
||||||
|
const name = ref('');
|
||||||
|
|
||||||
|
// ✅ ref for objects (preferred)
|
||||||
|
const customer = ref({
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
email: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Use reactive only when necessary
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
const state = reactive({
|
||||||
|
items: [],
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Computed Properties
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
// ✅ Use computed for derived values
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
return items.value.filter(item => item.isActive);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Computed with 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>
|
||||||
|
<!-- ✅ Use kebab-case for events -->
|
||||||
|
<Button @click="handleClick" />
|
||||||
|
<Input @input="handleInput" />
|
||||||
|
<Form @submit.prevent="handleSubmit" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// ✅ Name handlers with handle* prefix
|
||||||
|
function handleClick() {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(event) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Rendering
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- ✅ Use v-if for conditional rendering -->
|
||||||
|
<div v-if="loading">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ✅ v-show for frequent toggling -->
|
||||||
|
<div v-show="hasItems">
|
||||||
|
<ItemsList :items="items" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ✅ v-else for alternatives -->
|
||||||
|
<div v-else>
|
||||||
|
<EmptyState />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lists
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- ✅ Always use :key -->
|
||||||
|
<div v-for="item in items" :key="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ✅ For index-based lists -->
|
||||||
|
<div v-for="(item, index) in items" :key="`item-${index}`">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Form Handling
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<!-- ✅ Use v-model -->
|
||||||
|
<InputText v-model="form.name" />
|
||||||
|
<Textarea v-model="form.description" />
|
||||||
|
|
||||||
|
<!-- ✅ For custom components -->
|
||||||
|
<CustomInput v-model="form.email" />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
email: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
// Validation and submit
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## PrimeVue Components
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- ✅ Use PrimeVue components in the admin panel -->
|
||||||
|
<DataTable
|
||||||
|
:value="customers"
|
||||||
|
:loading="loading"
|
||||||
|
paginator
|
||||||
|
:rows="20"
|
||||||
|
@page="onPage"
|
||||||
|
>
|
||||||
|
<Column field="name" header="Name" sortable />
|
||||||
|
</DataTable>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<style scoped>
|
||||||
|
/* ✅ Use scoped -->
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ :deep() for nested components */
|
||||||
|
:deep(.p-datatable) {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ :slotted() for slots */
|
||||||
|
:slotted(.header) {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composition Functions
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
// ✅ Extract complex logic into 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: 'Error',
|
||||||
|
detail: result.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Error',
|
||||||
|
detail: 'Failed to load data'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
98
.cursorignore
Normal file
98
.cursorignore
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Cursor ignore patterns
|
||||||
|
|
||||||
|
frontend/spa/node_modules
|
||||||
|
frontend/admin/node_modules
|
||||||
|
module/acmeshop/upload/acmeshop/vendor
|
||||||
|
module/acmeshop/upload/image
|
||||||
|
module/acmeshop/upload/acmeshop/.phpunit.cache
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
composer.lock
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.phar
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
storage/logs/
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
storage/cache/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sql
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ECommerce specific
|
||||||
|
src/upload/system/
|
||||||
|
src/upload/image/cache/
|
||||||
|
src/storage/
|
||||||
|
|
||||||
|
# Test fixtures (large files)
|
||||||
|
tests/fixtures/*.sql
|
||||||
|
tests/fixtures/*.json
|
||||||
|
|
||||||
|
# Documentation builds
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
221
.github/workflows/main.yaml
vendored
Normal file
221
.github/workflows/main.yaml
vendored
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
name: Telegram Mini App Shop Builder
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- 'issue/**'
|
||||||
|
- develop
|
||||||
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version_meta:
|
||||||
|
name: Compute version metadata
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
tag: ${{ steps.meta.outputs.tag }}
|
||||||
|
filename: ${{ steps.meta.outputs.filename }}
|
||||||
|
is_release: ${{ steps.meta.outputs.is_release }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Extract tag and set filename
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RELEASE_TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)
|
||||||
|
|
||||||
|
if [ -n "$RELEASE_TAG" ]; then
|
||||||
|
echo "Это полноценный релиз"
|
||||||
|
TAG="$RELEASE_TAG"
|
||||||
|
FILENAME="acmeshop_${TAG}.ocmod.zip"
|
||||||
|
IS_RELEASE=true
|
||||||
|
else
|
||||||
|
echo "Это dev-сборка"
|
||||||
|
LAST_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)
|
||||||
|
[ -z "$LAST_TAG" ] && LAST_TAG="v0.0.0"
|
||||||
|
SHORT_SHA=$(git rev-parse --short=7 HEAD)
|
||||||
|
DATE=$(date +%Y%m%d%H%M)
|
||||||
|
TAG="${LAST_TAG}-dev.${DATE}+${SHORT_SHA}"
|
||||||
|
FILENAME="acmeshop_${TAG}.ocmod.zip"
|
||||||
|
IS_RELEASE=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "is_release=$IS_RELEASE" >> $GITHUB_OUTPUT
|
||||||
|
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "filename=$FILENAME" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
test_frontend:
|
||||||
|
name: Run Frontend tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
working-directory: frontend/spa
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: frontend/spa
|
||||||
|
env:
|
||||||
|
APP_ENV: testing
|
||||||
|
run: npm run test
|
||||||
|
|
||||||
|
test_backend:
|
||||||
|
name: Run Backend tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP 7.4
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '7.4'
|
||||||
|
tools: composer
|
||||||
|
extensions: mbstring
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
working-directory: module/acmeshop/upload/acmeshop
|
||||||
|
run: composer install --no-progress --no-interaction
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
working-directory: module/acmeshop/upload/acmeshop
|
||||||
|
env:
|
||||||
|
APP_ENV: testing
|
||||||
|
run: ./vendor/bin/phpunit --testdox tests/Unit tests/Telegram
|
||||||
|
|
||||||
|
- name: Static Analyzer
|
||||||
|
working-directory: module/acmeshop/upload/acmeshop
|
||||||
|
run: ./vendor/bin/phpstan analyse
|
||||||
|
|
||||||
|
phpcs:
|
||||||
|
name: Run PHP_CodeSniffer
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP 7.4
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '7.4'
|
||||||
|
tools: phpcs
|
||||||
|
|
||||||
|
- name: Run PHP_CodeSniffer
|
||||||
|
working-directory: module/acmeshop/upload/acmeshop
|
||||||
|
run: phpcs --standard=PSR12 bastion framework src
|
||||||
|
|
||||||
|
module-build:
|
||||||
|
name: Build module.
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: version_meta
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
|
||||||
|
- name: Setup PHP 7.4
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '7.4'
|
||||||
|
tools: composer
|
||||||
|
|
||||||
|
- name: Write version.txt
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MODULE_ROOT="module/acmeshop/upload/acmeshop"
|
||||||
|
echo "${{ needs.version_meta.outputs.tag }}" > "${MODULE_ROOT}/version.txt"
|
||||||
|
|
||||||
|
- name: Build module
|
||||||
|
run: |
|
||||||
|
bash scripts/ci/build.sh "${GITHUB_WORKSPACE}"
|
||||||
|
|
||||||
|
- name: Upload build artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: acmeshop.ocmod.zip
|
||||||
|
path: ./build/acmeshop.ocmod.zip
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [ version_meta, test_frontend, test_backend, module-build ]
|
||||||
|
if: github.ref == 'refs/heads/master' || github.event_name == 'pull_request'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download build artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: acmeshop.ocmod.zip
|
||||||
|
path: ./build
|
||||||
|
|
||||||
|
- name: Rename artifact file
|
||||||
|
run: mv ./build/acmeshop.ocmod.zip ./build/${{ needs.version_meta.outputs.filename }}
|
||||||
|
|
||||||
|
- name: Delete existing GitHub release and tag
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG=${{ needs.version_meta.outputs.tag }}
|
||||||
|
echo "⛔ Deleting existing release and tag (if any): $TAG"
|
||||||
|
gh release delete "$TAG" --cleanup-tag --yes || true
|
||||||
|
git push origin ":refs/tags/$TAG" || true
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
draft: ${{ needs.version_meta.outputs.is_release == 'false' }}
|
||||||
|
tag_name: ${{ needs.version_meta.outputs.tag }}
|
||||||
|
files: ./build/${{ needs.version_meta.outputs.filename }}
|
||||||
|
generate_release_notes: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Delete draft releases older than 7 days
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const daysToKeep = 7;
|
||||||
|
const cutoffDate = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const releases = await github.rest.repos.listReleases({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
per_page: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const release of releases.data) {
|
||||||
|
if (release.draft) {
|
||||||
|
const created = new Date(release.created_at);
|
||||||
|
if (created < cutoffDate) {
|
||||||
|
console.log(`Deleting draft release: ${release.name || release.tag_name} (${release.id})`);
|
||||||
|
await github.rest.repos.deleteRelease({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
release_id: release.id
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await github.rest.git.deleteRef({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
ref: `tags/${release.tag_name}`
|
||||||
|
});
|
||||||
|
console.log(`Deleted tag: ${release.tag_name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Tag ${release.tag_name} not found or already deleted.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
src/*
|
||||||
|
frontend/spa/node_modules
|
||||||
|
module/acmeshop/upload/acmeshop/vendor
|
||||||
|
module/acmeshop/upload/image
|
||||||
|
module/acmeshop/upload/acmeshop/.phpunit.cache
|
||||||
|
module/acmeshop/upload/acmeshop/.env
|
||||||
543
CHANGELOG.md
Normal file
543
CHANGELOG.md
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
<!--- BEGIN HEADER -->
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
<!--- END HEADER -->
|
||||||
|
|
||||||
|
## [2.2.1](https://github.com/acme-inc/shop-module/compare/v2.2.0...v2.2.1) (2026-02-22)
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.0](https://github.com/acme-inc/shop-module/compare/v2.1.0...v2.2.0) (2026-01-09)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add BETA label and UI improvements for AcmeShop Pulse tab ([551c4a](https://github.com/acme-inc/shop-module/commit/551c4a3506ddedb1b11851e3d3cbcb4f3ed34e03))
|
||||||
|
* Add cache:clear CLI command for module cache clearing (#46) ([3d0a75](https://github.com/acme-inc/shop-module/commit/3d0a7536a64bc88dbb349a9640260757b46009c4))
|
||||||
|
* Add changelog ([bf99bf](https://github.com/acme-inc/shop-module/commit/bf99bfe8a442c8eaad64f348792b7ddcbfb4486c))
|
||||||
|
* Add config redis cache, categories cache (#44) ([0798f5](https://github.com/acme-inc/shop-module/commit/0798f5c3e98721efbb45e2988350364b606622cd))
|
||||||
|
* Add customer account page with profile information and actions ([ad94af](https://github.com/acme-inc/shop-module/commit/ad94afda6826dd1d120599353121bad000b675a7))
|
||||||
|
* Add customizable text for manager contact button ([0a7877](https://github.com/acme-inc/shop-module/commit/0a7877ddbe7908d6a17089e1005308a945d3d21f))
|
||||||
|
* Add haptic feedback toggle setting ([afade8](https://github.com/acme-inc/shop-module/commit/afade85d004872d10929119db3ac95ee3acd0251))
|
||||||
|
* Add product interaction mode selector with three scenarios ([ecf4df](https://github.com/acme-inc/shop-module/commit/ecf4df363d49bf0d8bdc1b9ca3a241f99f26cfb8))
|
||||||
|
* Add store_id conditions (#43) ([846418](https://github.com/acme-inc/shop-module/commit/84641868e98786264f517865d48619ac4bc1ef7a))
|
||||||
|
* Add system information drawer (#44) ([9da605](https://github.com/acme-inc/shop-module/commit/9da605b9eac82045b46c3edbb998d28a63c22188))
|
||||||
|
* Increase dock icons size and add click animation ([ce2ea9](https://github.com/acme-inc/shop-module/commit/ce2ea9dea1fcd24d70ea66345d761a739d25f9d1))
|
||||||
|
|
||||||
|
##### Admin
|
||||||
|
|
||||||
|
* Improve navigation UI and move logs to drawer ([6a635e](https://github.com/acme-inc/shop-module/commit/6a635e189614c50c8d4bdbeb2486eb5b32ba7da0))
|
||||||
|
|
||||||
|
##### Search
|
||||||
|
|
||||||
|
* Improvement search cache (#44) ([8a9bac](https://github.com/acme-inc/shop-module/commit/8a9bac8221146b5db4960cc10de3e29dcd75c9bf))
|
||||||
|
|
||||||
|
##### Spa
|
||||||
|
|
||||||
|
* Add UTM markers for product view on ECommerce (#47) ([647e20](https://github.com/acme-inc/shop-module/commit/647e20c6b093f3de5e6aafcad474cf1a99189d2e))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Correct external .env loading ([089b68](https://github.com/acme-inc/shop-module/commit/089b68672262286f4568a6a40627a0c2e0c51b14))
|
||||||
|
* Correctly work with acmeshop customers without usernames ([0312b8](https://github.com/acme-inc/shop-module/commit/0312b882e1ad5596e823943924d1b284d5592b14))
|
||||||
|
* Missing store_id for carousel products ([3a1f8d](https://github.com/acme-inc/shop-module/commit/3a1f8dbf948c65c6f2d94392c277317f1ce5da75))
|
||||||
|
|
||||||
|
##### Admin
|
||||||
|
|
||||||
|
* Correct logs sorting by datetime with milliseconds ([115c13](https://github.com/acme-inc/shop-module/commit/115c13393f045a8f2eb7992be11ce20e94b23b96))
|
||||||
|
|
||||||
|
##### Spa
|
||||||
|
|
||||||
|
* Correct line breaks for long attribute names and values in Product.vue ([ff7263](https://github.com/acme-inc/shop-module/commit/ff7263649c208449f0b0b65df3e6088115ec78f6))
|
||||||
|
* Correct privacy policy message margin ([79f234](https://github.com/acme-inc/shop-module/commit/79f23400d20ba3cb43160cf898ea589f30c7aa83))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.1.0](https://github.com/acme-inc/shop-module/compare/v2.0.0...v2.1.0) (2025-12-24)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add setting to control category products button visibility ([c3994b](https://github.com/acme-inc/shop-module/commit/c3994b2291790f21cd219d1c5e820c274cb6e085))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.0.0](https://github.com/acme-inc/shop-module/compare/v1.3.2...v2.0.0) (2025-12-23)
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* None ([9a93cc](https://github.com/acme-inc/shop-module/commit/9a93cc73421c9c85e3cfbe403cd2c8fb41ba3406))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add aspect ratio selector for products_carousel ([615e8c](https://github.com/acme-inc/shop-module/commit/615e8c54a60d076a65bc04e60d26c5cbb21c264f))
|
||||||
|
* Add cron service to run acmeshop schedule tasks ([16a258](https://github.com/acme-inc/shop-module/commit/16a258ab682947f9856459797bc99b0adbf0d335))
|
||||||
|
* Add debug mode for developers. Logs improvements ([fbccd5](https://github.com/acme-inc/shop-module/commit/fbccd506752e8cdada461e92a85b7603335a8f23))
|
||||||
|
* Add default configs ([2bc751](https://github.com/acme-inc/shop-module/commit/2bc751119cb5c55c7d29a90d28a24f015ba76692))
|
||||||
|
* Added new products_carousel bock type ([f0837e](https://github.com/acme-inc/shop-module/commit/f0837e5c94ef3327f0d249e1994dc73a6da1c42b))
|
||||||
|
* Add FormKit framework support and update dependencies ([6a59dc](https://github.com/acme-inc/shop-module/commit/6a59dcc0c9b4f8e6ee003c7e168b632d8199981e))
|
||||||
|
* Add hide keyboard button on search page ([17ff88](https://github.com/acme-inc/shop-module/commit/17ff888c053983a7ae334ba695338ccd8b2db3ab))
|
||||||
|
* Add html editor for telegram messages ([97df5b](https://github.com/acme-inc/shop-module/commit/97df5b4c0aa1d5fbf19c2132436045af0846b5f1))
|
||||||
|
* Add italy dump ([13f63e](https://github.com/acme-inc/shop-module/commit/13f63e09fcc3c33cb4de2e809a981a0bf532bb63))
|
||||||
|
* Add migrations, mantenance tasks, database cache, blocks cache ([c0a6cb](https://github.com/acme-inc/shop-module/commit/c0a6cb17b3fa5a75185ad2e42e8979b1c848c285))
|
||||||
|
* Add old browser checks ([76c32c](https://github.com/acme-inc/shop-module/commit/76c32c53200f33a5de8fee3587b6aa597ce6d04a))
|
||||||
|
* Add options to select aspect ratio and cron algo for product images ([e9c6ed](https://github.com/acme-inc/shop-module/commit/e9c6ed8ddf801d3cfbb91c08733ab118fec3de21))
|
||||||
|
* Add reactivity to formkit ([fdcfce](https://github.com/acme-inc/shop-module/commit/fdcfce0a79af94f5f7ff05e19b4edec0fad4d452))
|
||||||
|
* Add redis cache driver ([2b0f04](https://github.com/acme-inc/shop-module/commit/2b0f04eb9455e2f1abb5b9374f3348072ffd1d6a))
|
||||||
|
* Add scheduler module ([65973d](https://github.com/acme-inc/shop-module/commit/65973d2d79a8c6bfbfc367b56a2b83e465fa2e32))
|
||||||
|
* Add AcmeShop Pulse heartbeat telemetry ([b60c77](https://github.com/acme-inc/shop-module/commit/b60c77e4539aab9d2cdb1e9916b7e60c9848d686))
|
||||||
|
* Add AcmeShopPulse telemetry system and ETL endpoints ([e8d0f8](https://github.com/acme-inc/shop-module/commit/e8d0f8a8190c2877ac5aa1e0cc7a5a1663598fe5))
|
||||||
|
* Add Telegram customers management system with admin panel ([9a93cc](https://github.com/acme-inc/shop-module/commit/9a93cc73421c9c85e3cfbe403cd2c8fb41ba3406))
|
||||||
|
* Add texts configuration ([34dfe9](https://github.com/acme-inc/shop-module/commit/34dfe9028693ad488d40f2015af482d789f012c6))
|
||||||
|
* Add UI for CRON Scheduler ([7372b9](https://github.com/acme-inc/shop-module/commit/7372b9c330ba4ba83458ca8d722cc71f57316180))
|
||||||
|
* Add warmup images command ([ecd372](https://github.com/acme-inc/shop-module/commit/ecd372dad30e05c5913fa489e561475584b89079))
|
||||||
|
* Better algorythm for image resize ([13e5bc](https://github.com/acme-inc/shop-module/commit/13e5bce8a548439da3dcd892b0c5600ffc995be6))
|
||||||
|
* Button to show all products from category ([b2d29f](https://github.com/acme-inc/shop-module/commit/b2d29fd3e288991f77ba6c0bee4bc7c5092b6594))
|
||||||
|
* Change image crop algorythm for product view page ([262f52](https://github.com/acme-inc/shop-module/commit/262f52929063802404af6f0592741ca836c91bcd))
|
||||||
|
* Clear cache after settings update ([6f9855](https://github.com/acme-inc/shop-module/commit/6f9855995dd3603b622a9e601162ac0b6da9a694))
|
||||||
|
* Correct stats for acmeshop dashboard ([05af49](https://github.com/acme-inc/shop-module/commit/05af4949bfcf2a42ece30f1d77816a3c3018eae2))
|
||||||
|
* Design update, show avatar in navbar ([6ac6a4](https://github.com/acme-inc/shop-module/commit/6ac6a42e2105bb6f234c108e3a5d21096b87660f))
|
||||||
|
* Disable source maps for frontend production builds ([770ec8](https://github.com/acme-inc/shop-module/commit/770ec81fdcd1456ad7787c9d1e31d92383849f8f))
|
||||||
|
* Dont migrate tg messages from v1 ([b87797](https://github.com/acme-inc/shop-module/commit/b87797ee6728523d8e17eeab13b85b014c157d95))
|
||||||
|
* Expose module version ([f1a39e](https://github.com/acme-inc/shop-module/commit/f1a39eeb0023d9fdf99cfd95b21288d950730b23))
|
||||||
|
* Fixed width and preloader for product view page ([5d775e](https://github.com/acme-inc/shop-module/commit/5d775e8eb6710cc1ca3501be1fdfc957582e8663))
|
||||||
|
* Fix opecart module status, remove .vite ([e72948](https://github.com/acme-inc/shop-module/commit/e729484fd7a698fcadacdfc99a30beb9d4acbb09))
|
||||||
|
* Hide greeting image from frontend ([2ec683](https://github.com/acme-inc/shop-module/commit/2ec683f0163804e7562de14d098b8a0c0f0f28da))
|
||||||
|
* Image processing improve ([38668f](https://github.com/acme-inc/shop-module/commit/38668fb4a7f2a3f94a85e06e20ecdff98f5d160d))
|
||||||
|
* Images and products loading optimization ([bf6744](https://github.com/acme-inc/shop-module/commit/bf674473e97111aa7a2acad9c835fcea37c3b2ec))
|
||||||
|
* Improve mainpage ui/ux ([f5d9d4](https://github.com/acme-inc/shop-module/commit/f5d9d417b3b86c7b710da5751a1d50af10a42b6e))
|
||||||
|
* Increase default per_page products ([6ed2fd](https://github.com/acme-inc/shop-module/commit/6ed2fd2062295bc4296d9ef5c4852541e0e4138f))
|
||||||
|
* Integrate yandex metrika ecommerce ([2f74ab](https://github.com/acme-inc/shop-module/commit/2f74aba35f548d632beed65d81693483942289d5))
|
||||||
|
* Maintenance tasks, logs ([ae9771](https://github.com/acme-inc/shop-module/commit/ae9771dec436bd3ff619b26c9c6ce811b1e876dd))
|
||||||
|
* More fluent vuejs app error handler ([955747](https://github.com/acme-inc/shop-module/commit/955747334d7a7f4863e145f52bcc2864beb8818e))
|
||||||
|
* Move getImage response to admin ([f539bb](https://github.com/acme-inc/shop-module/commit/f539bbfbbf023995f88b684406ac5eb8f16fff66))
|
||||||
|
* New settings and mainpage blocks ([6176c7](https://github.com/acme-inc/shop-module/commit/6176c720b1f4c0ce9f06a3cc4ff50b72a52ab0fb))
|
||||||
|
* Provide current ecommerce timezone to App ([51f462](https://github.com/acme-inc/shop-module/commit/51f462922ec49c8cc5e1b0c7909a69180cbe8e72))
|
||||||
|
* Remove unused js libs ([08f0e2](https://github.com/acme-inc/shop-module/commit/08f0e24859c4e201e85075f2186ed741e3180b38))
|
||||||
|
* Send xdebug trigger from frontend ([2743b8](https://github.com/acme-inc/shop-module/commit/2743b83a2c624191d2b65a1b13f5b3645e69b71a))
|
||||||
|
* Separated coupon and voucher errors ([dd12cb](https://github.com/acme-inc/shop-module/commit/dd12cb8c3434cd3d6f3b8eed4e469db8cd02e3f5))
|
||||||
|
* Set environment variables ([3716e8](https://github.com/acme-inc/shop-module/commit/3716e89811f2a4135d644cb5a6bae0bb57c367ee))
|
||||||
|
* Show module version in admin ([116821](https://github.com/acme-inc/shop-module/commit/116821a20946bf3f341e1589af2b24ace1e904da))
|
||||||
|
* Store customer_id in with order ([8260d2](https://github.com/acme-inc/shop-module/commit/8260d2bc96bfb256e73673e13740b242756eede2))
|
||||||
|
* Tg bot start message customization ([152e6d](https://github.com/acme-inc/shop-module/commit/152e6d715bfff1cfd05bdab72c4d4b54f7878e4a))
|
||||||
|
* Track and push AcmeShop Pulse events ([ef7856](https://github.com/acme-inc/shop-module/commit/ef785654b969e7abc955ed452d8367d6cf3aa55e))
|
||||||
|
* UI/UX, add reset cache to admin ([09f1e5](https://github.com/acme-inc/shop-module/commit/09f1e514a975fea5c4fcd3b8cc587f906ab30bd3))
|
||||||
|
* Update admin page ([cd818d](https://github.com/acme-inc/shop-module/commit/cd818d3356d5738a9fb534e056d2e1055b2016ce))
|
||||||
|
* Update design for product and product cards ([8a777c](https://github.com/acme-inc/shop-module/commit/8a777cd4d280b7049b60fdd0d3fa0586561e0a65))
|
||||||
|
* Update product page design ([c64170](https://github.com/acme-inc/shop-module/commit/c64170f2d8058d99ae60323d907579b59566c119))
|
||||||
|
* Update readme ([5fb450](https://github.com/acme-inc/shop-module/commit/5fb45000ac77de0019a256150089256d5c423d68), [540595](https://github.com/acme-inc/shop-module/commit/540595c9f0661a2ca4c16d8876be54f9258bc0a3), [1361fe](https://github.com/acme-inc/shop-module/commit/1361fea993bcc37b0495b8fff7c20a45ccbd8ca2))
|
||||||
|
* Update styles for swipe to back ([e6a9e6](https://github.com/acme-inc/shop-module/commit/e6a9e6797f518d27caba507ac79d07ac8c113b06))
|
||||||
|
* Use yaMetrika number in settings ([cedc49](https://github.com/acme-inc/shop-module/commit/cedc49f0d5c3107791c1e6ff87a2f024a8baf828))
|
||||||
|
* Visualize swipe back ([50bdb8](https://github.com/acme-inc/shop-module/commit/50bdb8601c04799a4ecdb1b854ee1151a02f00f1))
|
||||||
|
* WIP add yandex metrika goals ([4e59c4](https://github.com/acme-inc/shop-module/commit/4e59c4e7888925a87ce63eb53587d5e21fec4561))
|
||||||
|
* добавлена функциональность политики конфиденциальности и согласия на обработку ПД ([7a5eeb](https://github.com/acme-inc/shop-module/commit/7a5eebec91ee73a2d38509cfa4f9bbb87cb75225))
|
||||||
|
* добавлен жест swipe back для навигации назад ([179729](https://github.com/acme-inc/shop-module/commit/17972993ca815072ad5ded2bbc7a29e97f1abc6f))
|
||||||
|
|
||||||
|
##### Admin
|
||||||
|
|
||||||
|
* Add more details for admin errors ([17865d](https://github.com/acme-inc/shop-module/commit/17865d8af4ed4b7f1f02a5b065847281fa5ede5f))
|
||||||
|
* Refactor logs viewer with table display and detailed dialog ([b39a34](https://github.com/acme-inc/shop-module/commit/b39a344a7dac32225d6fe939ea81fcc67f4b5750))
|
||||||
|
* Remove legacy setting keys that not defined in defaults ([107741](https://github.com/acme-inc/shop-module/commit/1077417d717cbd601bdff82ab3dfbb61402c3640))
|
||||||
|
|
||||||
|
##### Banner
|
||||||
|
|
||||||
|
* Add banner feature ([05e7ca](https://github.com/acme-inc/shop-module/commit/05e7cafd0f36b204e0dea51a0f46f1a2c795dceb))
|
||||||
|
|
||||||
|
##### Customers
|
||||||
|
|
||||||
|
* Track order meta and OC sync ([952d8e](https://github.com/acme-inc/shop-module/commit/952d8e58da2972ff834d7f6609749b6dbd15a938))
|
||||||
|
|
||||||
|
##### Products-feed
|
||||||
|
|
||||||
|
* Replace fixed image dimensions with aspect ratio selection ([cd0606](https://github.com/acme-inc/shop-module/commit/cd060610fe991c7c6d0db81a24bfa2b062192d20))
|
||||||
|
|
||||||
|
##### Pulse
|
||||||
|
|
||||||
|
* Implement reliable event tracking and delivery system ([4a3dcc](https://github.com/acme-inc/shop-module/commit/4a3dcc11d161420c58494d744909f48982bd2582))
|
||||||
|
|
||||||
|
##### Search
|
||||||
|
|
||||||
|
* Add keyboard hide button and auto-hide Dock ([db8d13](https://github.com/acme-inc/shop-module/commit/db8d1360fc9d8702fa7f2607337ac447ea646c5d))
|
||||||
|
* Improve search UI with sticky bar and keyboard handling ([64ead2](https://github.com/acme-inc/shop-module/commit/64ead29583086dc55ae59e5d2b775dae31f36944))
|
||||||
|
|
||||||
|
##### Slider
|
||||||
|
|
||||||
|
* Add slider feature ([3049bd](https://github.com/acme-inc/shop-module/commit/3049bd3101a44259f2883b351244c6eb5564cf89))
|
||||||
|
|
||||||
|
##### Spa
|
||||||
|
|
||||||
|
* Add custom dock ([4936e6](https://github.com/acme-inc/shop-module/commit/4936e6f16c0cd44299d086911a347cd3626fa2af))
|
||||||
|
* Add dock ([2e699e](https://github.com/acme-inc/shop-module/commit/2e699eb0d6aca08d3f87030ea822c1fc79d3d477))
|
||||||
|
* Correct radius for floating panel, small ui fixes ([72ab84](https://github.com/acme-inc/shop-module/commit/72ab842a95f090b886787f551bee274fc2f6932c))
|
||||||
|
* Show navbar with app logo and app name ([c3c0d6](https://github.com/acme-inc/shop-module/commit/c3c0d6d2c179c83a1700d773c496ff7a44cce99c))
|
||||||
|
* UI changes ([ed8592](https://github.com/acme-inc/shop-module/commit/ed8592c19dabf4f26d6ed45e55a3c6f7398d667e))
|
||||||
|
|
||||||
|
##### Megapay
|
||||||
|
|
||||||
|
* Add vouchers and coupons (#9) ([ac24f0](https://github.com/acme-inc/shop-module/commit/ac24f0376bee13cc14db49a2904867ef173dcf95))
|
||||||
|
|
||||||
|
##### Texts
|
||||||
|
|
||||||
|
* Add options to redefine text for zero product prices ([1fbbb7](https://github.com/acme-inc/shop-module/commit/1fbbb7b6db13a9dac745c32d11f2e71ed79e854e))
|
||||||
|
|
||||||
|
##### Ya metrika
|
||||||
|
|
||||||
|
* WIP yandex metrika ([d7666f](https://github.com/acme-inc/shop-module/commit/d7666f94ba22fc1a808299e9a91ead14e6b58b25))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Admin mainpage builder drawer doesnot show ([ad54b1](https://github.com/acme-inc/shop-module/commit/ad54b14c6804fae8960a5e15dbceb0549d91c732))
|
||||||
|
* Base header color ([28d80d](https://github.com/acme-inc/shop-module/commit/28d80d0f19ee31fea5011c2b466edea1590ab71e))
|
||||||
|
* Browser check ([4cd49b](https://github.com/acme-inc/shop-module/commit/4cd49b17a6df65863dc9fd32efdd0bba4b4e44ff))
|
||||||
|
* Center image on product view ([dc198c](https://github.com/acme-inc/shop-module/commit/dc198c63b7c4f66b92eeb55958983d8eaed0260f))
|
||||||
|
* Correct cli.php path for phar ([57c840](https://github.com/acme-inc/shop-module/commit/57c8400904b74569c843cd898fe6c39552f91e6b))
|
||||||
|
* Correct counter id for yandex metrika test ([9870f2](https://github.com/acme-inc/shop-module/commit/9870f2f36364ec7d968b3aec14091aefae774199))
|
||||||
|
* Correct crontab line ([613ce5](https://github.com/acme-inc/shop-module/commit/613ce520ee53be47ee06e101daf54c8f5136184b))
|
||||||
|
* Correct path for cron ([185f30](https://github.com/acme-inc/shop-module/commit/185f3096e1e17507f4191104794991448d4d44bb))
|
||||||
|
* Correct url for hit ([515b82](https://github.com/acme-inc/shop-module/commit/515b82302ba603f61324ace576e23adbf82560fd))
|
||||||
|
* Disable fullscreen for desktop ([bf32d9](https://github.com/acme-inc/shop-module/commit/bf32d9081169206cef62d92f27338321d1cc1e69))
|
||||||
|
* Fix dock layout ([bdbdfc](https://github.com/acme-inc/shop-module/commit/bdbdfc3650ff24e77f7f35059ac72e87cd02ddf2))
|
||||||
|
* Fix errors and small improvements ([3b2e2c](https://github.com/acme-inc/shop-module/commit/3b2e2cb656bb8db6feebdbb23612202f96cdde3f))
|
||||||
|
* Fix search issues ([2f9a55](https://github.com/acme-inc/shop-module/commit/2f9a553ae356fe4fb7ee3a481010d14be7d94ad7))
|
||||||
|
* Fix type error ([836161](https://github.com/acme-inc/shop-module/commit/8361616dd647397777849fd87267134e0bc1fb9b))
|
||||||
|
* Glob not work with phar ([24db69](https://github.com/acme-inc/shop-module/commit/24db69fbbad6758d11dabdac54f075611cde9593))
|
||||||
|
* Grant +x permissions for cli.php ([0ee3b7](https://github.com/acme-inc/shop-module/commit/0ee3b7d091da970d19a755207c73c28689bfd2a4))
|
||||||
|
* Handle missing tags in workflow ([bc50cf](https://github.com/acme-inc/shop-module/commit/bc50cf064854ad0597f1d7a39b0eb32d88d2598a))
|
||||||
|
* Image picker component name type ([30b010](https://github.com/acme-inc/shop-module/commit/30b0108fe78b2a594db0c749f563577921c189d0))
|
||||||
|
* Many products in search ([a5e91d](https://github.com/acme-inc/shop-module/commit/a5e91dd488b1f13abf739797e75400ddf36ba7e1))
|
||||||
|
* Order creation ([82ab81](https://github.com/acme-inc/shop-module/commit/82ab8134e19f2cc4066de5241e7ff29905d79b17))
|
||||||
|
* Pulse ingest ([95dd54](https://github.com/acme-inc/shop-module/commit/95dd545dc5718046cd421d70ad2d4ea137919852))
|
||||||
|
* Scroll behaviour ([359395](https://github.com/acme-inc/shop-module/commit/359395b7e880d72dd34da504f6d9fe001d6f0aff))
|
||||||
|
* Search ([e5792a](https://github.com/acme-inc/shop-module/commit/e5792a059a0986b6d6c86df9dbcbda212bc0f548))
|
||||||
|
* Settings numeric error ([44d2af](https://github.com/acme-inc/shop-module/commit/44d2af3b30a7133b550e56385c3096c0e8848df5))
|
||||||
|
* Store error ([ab5c2f](https://github.com/acme-inc/shop-module/commit/ab5c2f42b907d19f0c52c631cea02b981a199c39))
|
||||||
|
* Switch between code and visual for custom forms ([0ab09a](https://github.com/acme-inc/shop-module/commit/0ab09aad10eb724cf0378bcc2f46001b5108fade))
|
||||||
|
* Test ([c4b192](https://github.com/acme-inc/shop-module/commit/c4b19286f36ad166a1092dda86e65d48e3390723))
|
||||||
|
* Use html for tg bot ([7e6502](https://github.com/acme-inc/shop-module/commit/7e6502b07e74e27e27326c9593f25c2c9c03418b))
|
||||||
|
|
||||||
|
##### Admin
|
||||||
|
|
||||||
|
* Fix error when chat_id is string ([8f6af0](https://github.com/acme-inc/shop-module/commit/8f6af04e732f853eb79504676dc5ae83ba151c93))
|
||||||
|
|
||||||
|
##### Spa
|
||||||
|
|
||||||
|
* Remove html in price for some ecommerce custom themes ([3423dd](https://github.com/acme-inc/shop-module/commit/3423dd172748845ce5177ea6bf5894a6da977c37), [d6a436](https://github.com/acme-inc/shop-module/commit/d6a43605acaff1cf335dc044e2b297132d6eb2ce))
|
||||||
|
|
||||||
|
##### Megapay
|
||||||
|
|
||||||
|
* Fix products search ([98ee6d](https://github.com/acme-inc/shop-module/commit/98ee6d9ecac4349cad847bdba1b10cf8660c251f))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.2](https://github.com/acme-inc/shop-module/compare/v1.3.1...v1.3.2) (2025-10-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
|
||||||
|
##### Products
|
||||||
|
|
||||||
|
* Encode html for title on products page ([78ca4f](https://github.com/acme-inc/shop-module/commit/78ca4fd309e2254771a01ade75197d46e149c5f3))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.1](https://github.com/acme-inc/shop-module/compare/v1.3.0...v1.3.1) (2025-10-19)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
|
||||||
|
##### App
|
||||||
|
|
||||||
|
* Fix unhandled exceptions ([aa4264](https://github.com/acme-inc/shop-module/commit/aa42643c34c1a7cb11aae2d3191ac63c0af3236a))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.0](https://github.com/acme-inc/shop-module/compare/v1.2.0...v1.3.0) (2025-10-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add filters to mainpage ([1e2a9b](https://github.com/acme-inc/shop-module/commit/1e2a9bc7051e14c65eb44b392dba11f766b95d33))
|
||||||
|
* Handle start command for acmeshop bot ([c936d7](https://github.com/acme-inc/shop-module/commit/c936d727b495b06f63d7f15949d540f2c9a2b9c0))
|
||||||
|
|
||||||
|
##### Admin
|
||||||
|
|
||||||
|
* Do not log assets cleanup message if nothing deleted ([00165b](https://github.com/acme-inc/shop-module/commit/00165b3b61841303a6eff0447ee134d860f4a8b9))
|
||||||
|
* Remove old assets ([01368b](https://github.com/acme-inc/shop-module/commit/01368bbfce831cd8949500ffdf5f5e4614316459))
|
||||||
|
* Remove old maps ([31a990](https://github.com/acme-inc/shop-module/commit/31a9909cc37c953113c743d248c9dd4065f89acb))
|
||||||
|
|
||||||
|
##### Bot
|
||||||
|
|
||||||
|
* Add bot commands ([023ace](https://github.com/acme-inc/shop-module/commit/023acee68fb8f247a5e84f62aade44c77cfc0ed5))
|
||||||
|
|
||||||
|
##### Filters
|
||||||
|
|
||||||
|
* Add filters for the main page ([e7e045](https://github.com/acme-inc/shop-module/commit/e7e045b695d227d2d895242b4c0f19883d07f69e))
|
||||||
|
|
||||||
|
##### Spa
|
||||||
|
|
||||||
|
* Hide floating cart btn for filters page ([259154](https://github.com/acme-inc/shop-module/commit/259154e4f1ca2ac0e2e6a357e8a53be78a00441a))
|
||||||
|
* Lock vertical orientation ([646721](https://github.com/acme-inc/shop-module/commit/6467216775c44a7f4cc924d4551cc88ca246b757))
|
||||||
|
* Update Telegram Mini App to 59 version ([3ecb51](https://github.com/acme-inc/shop-module/commit/3ecb51b5cd1751f4e2ace73171225ee3a33e46c4))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Escape character for start message command ([a051ff](https://github.com/acme-inc/shop-module/commit/a051ff545e920760e3a0e6c34ef3cc94a0c1bfdb))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0](https://github.com/acme-inc/shop-module/compare/v1.1.0...v1.2.0) (2025-09-27)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
|
||||||
|
##### Product
|
||||||
|
|
||||||
|
* Add option to disable store feature ([d7dd05](https://github.com/acme-inc/shop-module/commit/d7dd055e245a5bb0772b382ca8542394e92fecd5))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Correct update ecommerce config after defaults diff update ([e24e7c](https://github.com/acme-inc/shop-module/commit/e24e7c6d106597c627451abf8014723f42fdda34))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0](https://github.com/acme-inc/shop-module/compare/v1.0.7...v1.1.0) (2025-09-26)
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.7](https://github.com/acme-inc/shop-module/compare/v1.0.6...v1.0.7) (2025-09-26)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
|
||||||
|
##### Categories
|
||||||
|
|
||||||
|
* Added animations for categories list ([b7b255](https://github.com/acme-inc/shop-module/commit/b7b255887db2d04b8ba70a966382f44c92475df0))
|
||||||
|
* Add skeleton for categories loading ([294e0c](https://github.com/acme-inc/shop-module/commit/294e0cd17e2038f3088b504331ad9b009129e8ed))
|
||||||
|
* Hide button from categories ([f06606](https://github.com/acme-inc/shop-module/commit/f066069a1b6cf186046e272bc7af61ab46f79c0e))
|
||||||
|
|
||||||
|
##### Design
|
||||||
|
|
||||||
|
* Add safe top padding for product page ([a3e5b8](https://github.com/acme-inc/shop-module/commit/a3e5b8b07a28813115662b566284f8622f0b3722))
|
||||||
|
* Product link in cart ([39a350](https://github.com/acme-inc/shop-module/commit/39a350d517d5d762720236f0e9b682299fd2b746))
|
||||||
|
|
||||||
|
##### Products
|
||||||
|
|
||||||
|
* Show correct product prices ([35dd0d](https://github.com/acme-inc/shop-module/commit/35dd0de261a4497c01cd6eb54ed0d7032cea5f8b))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
|
||||||
|
##### Product
|
||||||
|
|
||||||
|
* Decode html entities for product and category names ([acbfae](https://github.com/acme-inc/shop-module/commit/acbfaebcf415f42c6fb16c6a39d5e10f0776da90))
|
||||||
|
* Fix error when image not found ([a381b3](https://github.com/acme-inc/shop-module/commit/a381b3a6ee6972775815db382269ec8ab3d31a4f))
|
||||||
|
* Fix select product option UI ([22a783](https://github.com/acme-inc/shop-module/commit/22a783f0ef833f5797e798222dce65493d71b34b))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.6](https://github.com/acme-inc/shop-module/compare/v1.0.5...v1.0.6) (2025-09-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Fix possible foreign error message on acmeshop page ([016eeb](https://github.com/acme-inc/shop-module/commit/016eeb445db7ce692825d323bed7c1dd815e30af))
|
||||||
|
|
||||||
|
##### Categories
|
||||||
|
|
||||||
|
* Fix nested lvl > 2 categories rendering ([0f04cb](https://github.com/acme-inc/shop-module/commit/0f04cbf105252b88358095ae5be33fedca6f1e63))
|
||||||
|
* Increase max categories count to display up to 100 ([9f6416](https://github.com/acme-inc/shop-module/commit/9f6416a1b7b7f065b558ecd3089c42ef397bd817))
|
||||||
|
|
||||||
|
##### Database
|
||||||
|
|
||||||
|
* Fix db connection error when not standard mysql port ([ec5cdf](https://github.com/acme-inc/shop-module/commit/ec5cdfcaa9321cb824c858df91ce4464d6158a2c))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.5](https://github.com/acme-inc/shop-module/compare/v1.0.4...v1.0.5) (2025-09-24)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
|
||||||
|
##### Categories
|
||||||
|
|
||||||
|
* Add options to select what categories to show on front page ([9e4022](https://github.com/acme-inc/shop-module/commit/9e4022f64856082fffa7a0264949373319cdf9ff))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.4](https://github.com/acme-inc/shop-module/compare/v1.0.3...v1.0.4) (2025-09-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Error when category doesnt have image ([490cbf](https://github.com/acme-inc/shop-module/commit/490cbfacf72095001dccaf374034292ea247e21b))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.3](https://github.com/acme-inc/shop-module/compare/v1.0.2...v1.0.3) (2025-09-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Init exception for some ecommerce versions ([0cf0c4](https://github.com/acme-inc/shop-module/commit/0cf0c438433f8c1895bef5f490bc0f9af86b0c04))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.2](https://github.com/acme-inc/shop-module/compare/v1.0.1...v1.0.2) (2025-08-16)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* UI fixes ([854dfd](https://github.com/acme-inc/shop-module/commit/854dfdf7f2dba7bc78b53c19f345c1909298c474))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.1](https://github.com/acme-inc/shop-module/compare/v1.0.0...v1.0.1) (2025-08-16)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Check code phrase when configure chat_id ([a0abc1](https://github.com/acme-inc/shop-module/commit/a0abc14c6db91fb6cec14f8aa64297d671e88a7e))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0](https://github.com/acme-inc/shop-module/compare/v0.0.2...v1.0.0) (2025-08-16)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add bot_token validation ([d7df5a](https://github.com/acme-inc/shop-module/commit/d7df5a4b5c8abdf5117c07a9bb7fc7744c23eb1d))
|
||||||
|
* Add carousel for images ([a40089](https://github.com/acme-inc/shop-module/commit/a40089ef553eaf30d813a9e2b2495fe3aa7dd0d4))
|
||||||
|
* Add Categories ([6a8ea0](https://github.com/acme-inc/shop-module/commit/6a8ea048ea52e6bd3c146b4ec311e9633fce269a))
|
||||||
|
* Add custom BottomButton instead of TG ([b0cc02](https://github.com/acme-inc/shop-module/commit/b0cc0237af12ea5560835092bb808e4bc742c380))
|
||||||
|
* Add fullscreen viewer ([4ae8d5](https://github.com/acme-inc/shop-module/commit/4ae8d593280774527fbeda3e52d924bd23a12813))
|
||||||
|
* Add fulscreen mode, dark mode ([252854](https://github.com/acme-inc/shop-module/commit/252854e67ea93716c271e2e20d25b0d73e24e380))
|
||||||
|
* Add haptictouch to bottom buttons ([51ce6e](https://github.com/acme-inc/shop-module/commit/51ce6ed959e9b673a0cfd9fac614f743b24d582f))
|
||||||
|
* Add hero block ([3c819e](https://github.com/acme-inc/shop-module/commit/3c819e6c6cf9d25088c2a8024da13e8e0180bde7))
|
||||||
|
* Add manufacturer to product view ([b25f6d](https://github.com/acme-inc/shop-module/commit/b25f6d3c7335c42487702aa7fff2c5003fd63046))
|
||||||
|
* Add new mainpage products options, hide attributes ([d9fd26](https://github.com/acme-inc/shop-module/commit/d9fd26d3541e02d4656d32af547a3e338bbbc4ff))
|
||||||
|
* Add preloader for product page ([b66a02](https://github.com/acme-inc/shop-module/commit/b66a02fd57a2f0233b37bb76b30a360e71333256))
|
||||||
|
* Add product view page ([f13e12](https://github.com/acme-inc/shop-module/commit/f13e128d03831598ecd058217a0e8874f0831f75))
|
||||||
|
* Add telegram api ([b958fe](https://github.com/acme-inc/shop-module/commit/b958feaec751b2e3a4134f925a74c75d5d2d1b42))
|
||||||
|
* Add telegram safe content area ([1715c0](https://github.com/acme-inc/shop-module/commit/1715c01b1d1b99d4e99a8fe6f40107a384250326))
|
||||||
|
* Add validation and use ecommerce logger ([9f35ac](https://github.com/acme-inc/shop-module/commit/9f35acf39935416bfbb35735c3749baf0af20995))
|
||||||
|
* Allow only vertical orientation ([fe4188](https://github.com/acme-inc/shop-module/commit/fe4188eb8b3d58cb5fa25c267e0e0ba46effbbac))
|
||||||
|
* Cache frontpage products and categories ([5f785e](https://github.com/acme-inc/shop-module/commit/5f785e82e6689283526dd5a218d76908078e7942))
|
||||||
|
* Create new order ([c057f4](https://github.com/acme-inc/shop-module/commit/c057f4be76544466af62556237f7031c874f5f51))
|
||||||
|
* Deny direct access to the spa ([41e74b](https://github.com/acme-inc/shop-module/commit/41e74bad121d76b9a4be2a2f02822d8323e739cc))
|
||||||
|
* Diplicate webhook info request ([6249b2](https://github.com/acme-inc/shop-module/commit/6249b218a137e105e64fbfb0b6c8829e2ca01349))
|
||||||
|
* Display product options ([f47bb4](https://github.com/acme-inc/shop-module/commit/f47bb46751fea79e43a96e2b63afde4cb7ef801b))
|
||||||
|
* Do not check signature if bot token not set ([1d892f](https://github.com/acme-inc/shop-module/commit/1d892f7d090a1ff91f724871e688b18a40df768e))
|
||||||
|
* Encode images to webp for telegram mini app ([c282b6](https://github.com/acme-inc/shop-module/commit/c282b6ea3b5c04ae92708eb1984ad14d2ea46cfa))
|
||||||
|
* Expand mini app on mounted ([1e454b](https://github.com/acme-inc/shop-module/commit/1e454b8f2387d9a4e2e4316253d7f8bddadccc1c))
|
||||||
|
* Fix module name in admin ([9770a0](https://github.com/acme-inc/shop-module/commit/9770a09fc0abe57d7b97137c9fef4bfaf5687278))
|
||||||
|
* Infinity scroll, load more, resore scroll ([bb2ee3](https://github.com/acme-inc/shop-module/commit/bb2ee38118e8626f8d85070047e256ad8305c1e5))
|
||||||
|
* Make two columns grid for product list ([34bd64](https://github.com/acme-inc/shop-module/commit/34bd64e9025fbd61cd3c64c1e9a9bebb4bf98e5d))
|
||||||
|
* Product options, speedup home page, themes ([e3cc0d](https://github.com/acme-inc/shop-module/commit/e3cc0d4b10edf3a7c655a8e6d9a39ca587d6ecbc))
|
||||||
|
* Remove cache, refactor ([7404ec](https://github.com/acme-inc/shop-module/commit/7404ecb33e1289439a3b4b9b5926175fe5d3872d))
|
||||||
|
* Remove prefilled fields in checkout ([33b350](https://github.com/acme-inc/shop-module/commit/33b3500aa470438963af90ee2edccdff9a27233d))
|
||||||
|
* Safe-top and search ([a8bb5e](https://github.com/acme-inc/shop-module/commit/a8bb5eb493ab329bebca8c7903d4facf4a22d76a))
|
||||||
|
* Search component and loading splashscreen ([2fb841](https://github.com/acme-inc/shop-module/commit/2fb841ef08027eeabdade90d9a4725ea602b3f48))
|
||||||
|
* Show tg app link ([b1ea16](https://github.com/acme-inc/shop-module/commit/b1ea169e2f83cd3d3108d9d11d2b9bb8ee234211))
|
||||||
|
* UI changes ([d522cb](https://github.com/acme-inc/shop-module/commit/d522cbef8389adb05cc6e70ed6665db37915233c))
|
||||||
|
* Ui improvements, show only active products, limit max page for infinity scroll ([d499d7](https://github.com/acme-inc/shop-module/commit/d499d7d846d55cc158306160c51d4b871f5b6376))
|
||||||
|
* Update styles ([ca3a59](https://github.com/acme-inc/shop-module/commit/ca3a59f43ae19f9c8417993e45c63f29696f46c8))
|
||||||
|
|
||||||
|
##### Admin
|
||||||
|
|
||||||
|
* Correct getting chat_id ([1e80fd](https://github.com/acme-inc/shop-module/commit/1e80fdb2ebaf47e39a6cbd45438860428146aac6))
|
||||||
|
* Correct merge new default settings after initializing app ([469077](https://github.com/acme-inc/shop-module/commit/469077d0c9006f3bcfffcecf4454f2e5e4492fac))
|
||||||
|
* Update disclaimer text ([133bad](https://github.com/acme-inc/shop-module/commit/133badf45b9727fbf2bee7c9b9f74ff274fa3cc8))
|
||||||
|
|
||||||
|
##### App
|
||||||
|
|
||||||
|
* Add maintenance mode ([2752ec](https://github.com/acme-inc/shop-module/commit/2752ec3dd18261af9894c8a28a6775bdb22301c3))
|
||||||
|
* Telegram init data signature validator ([350ec4](https://github.com/acme-inc/shop-module/commit/350ec4f64bf6534e57cf613e6b38d39a052fd646))
|
||||||
|
|
||||||
|
##### Order
|
||||||
|
|
||||||
|
* Add success haptic for order created event ([858be6](https://github.com/acme-inc/shop-module/commit/858be67c89130ab291b34d8bd7fb4340b6fff422))
|
||||||
|
* Order default status and customer group ([14d42c](https://github.com/acme-inc/shop-module/commit/14d42c6ecb1967cc626c57ae7ccb60f66b361aec))
|
||||||
|
* Order process enchancements ([85101b](https://github.com/acme-inc/shop-module/commit/85101b988140c1d0114d3176115aab0864011b16))
|
||||||
|
* WIP: telegram notifications ([454bd3](https://github.com/acme-inc/shop-module/commit/454bd39f1f12a6fa004f80c3b13ebc17032a35f9))
|
||||||
|
|
||||||
|
##### Orders
|
||||||
|
|
||||||
|
* Tg notifications, ya metrika, meta tags ([86d0fa](https://github.com/acme-inc/shop-module/commit/86d0fa95941fd2b1d491de8280817d0e80b461f2))
|
||||||
|
|
||||||
|
##### Product
|
||||||
|
|
||||||
|
* Change router history driver, change add to cart behaviour ([ebc352](https://github.com/acme-inc/shop-module/commit/ebc352dcdfcf08694d2590ee94c9e799e795a2fc))
|
||||||
|
* Display attributes ([63adf9](https://github.com/acme-inc/shop-module/commit/63adf96908137ab0c173415f77278ee7483a2fb8))
|
||||||
|
|
||||||
|
##### Shop
|
||||||
|
|
||||||
|
* Change grid image resize algorythm ([c3c256](https://github.com/acme-inc/shop-module/commit/c3c25619326e292575236979e389f8ddb68b6958))
|
||||||
|
|
||||||
|
##### Style
|
||||||
|
|
||||||
|
* Change pagination swiper styles ([50bf90](https://github.com/acme-inc/shop-module/commit/50bf9061be778b37f7f6869f4c39a4833af31b1d))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Add CORS headers, make ci builds as preleases ([551535](https://github.com/acme-inc/shop-module/commit/55153531fb4899d0f3e699b70231d32290800ee2))
|
||||||
|
* Add route names ([47bb2c](https://github.com/acme-inc/shop-module/commit/47bb2cae85e9a16b0076898cd6265512c3adfc3c))
|
||||||
|
* Change hardcoded axios url ([4bb983](https://github.com/acme-inc/shop-module/commit/4bb983e4af53baf2a7a5aa39f15b5389906a4c71))
|
||||||
|
* Correct back button work ([08af20](https://github.com/acme-inc/shop-module/commit/08af204d7403572dbc45f3a74e13cf5d3d560a42))
|
||||||
|
* Correct controller class ([5af66d](https://github.com/acme-inc/shop-module/commit/5af66d228a3defbc6f0b4fd15a9e2a3c192bf41d))
|
||||||
|
* Corrent telegram mini app url in settings ([ea2a60](https://github.com/acme-inc/shop-module/commit/ea2a60b59b20d2bede9d6884349b09e55e345774))
|
||||||
|
* Exception if no images ([9bcf32](https://github.com/acme-inc/shop-module/commit/9bcf32841ebd4663b5c6bd5e855b18e8cd486e45))
|
||||||
|
* Fullscreen slide index ([4114c3](https://github.com/acme-inc/shop-module/commit/4114c3366e4090e41e29bf6e48fe5f54d0dd4a9c))
|
||||||
|
* Glitch ([db24be](https://github.com/acme-inc/shop-module/commit/db24be6f92bbe485985892ea017f4e4ef457cd52))
|
||||||
|
* Icon error ([19911c](https://github.com/acme-inc/shop-module/commit/19911c8f871e456c51836c3d07add3f066744ace))
|
||||||
|
* Infinity scroll, init data in base64 ([f2f161](https://github.com/acme-inc/shop-module/commit/f2f1618e0ee591bc58a830a333b1f759b0a860d6))
|
||||||
|
* Night theme ([06a6dc](https://github.com/acme-inc/shop-module/commit/06a6dca656871a920092dc6767990ab70b9fc6c2))
|
||||||
|
* Router in ecommerce ([ad92db](https://github.com/acme-inc/shop-module/commit/ad92dbfad48f993e2393c0e235083614581ae0c6))
|
||||||
|
* Router scroll scrollBehavior ([08d245](https://github.com/acme-inc/shop-module/commit/08d2453df92ffc89c5e6c4e264370d8b9c32a432))
|
||||||
|
* Totals ([eb1f1d](https://github.com/acme-inc/shop-module/commit/eb1f1dc9c1de7c4733d0117257f7902f145614b2))
|
||||||
|
* Watch router ([1ffb1c](https://github.com/acme-inc/shop-module/commit/1ffb1cef12df1bde4330a7c9531b6574a07d2fe6))
|
||||||
|
|
||||||
|
##### Admin
|
||||||
|
|
||||||
|
* Fix shop url ([c61dfd](https://github.com/acme-inc/shop-module/commit/c61dfd824a532512703c207c464954b51dbcce5a))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.0.2](https://github.com/acme-inc/shop-module/compare/v0.0.1+a26c8ba...v0.0.2) (2025-07-10)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Add CORS headers, make ci builds as preleases ([551535](https://github.com/acme-inc/shop-module/commit/55153531fb4899d0f3e699b70231d32290800ee2))
|
||||||
|
* Correct controller class ([5af66d](https://github.com/acme-inc/shop-module/commit/5af66d228a3defbc6f0b4fd15a9e2a3c192bf41d))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.0.1+a26c8ba](https://github.com/acme-inc/shop-module/compare/v0.0.1...v0.0.1+a26c8ba) (2025-07-10)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Add CORS headers, make ci builds as preleases ([551535](https://github.com/acme-inc/shop-module/commit/55153531fb4899d0f3e699b70231d32290800ee2))
|
||||||
|
* Correct controller class ([5af66d](https://github.com/acme-inc/shop-module/commit/5af66d228a3defbc6f0b4fd15a9e2a3c192bf41d))
|
||||||
|
* Move files to the correct folder ([9735d4](https://github.com/acme-inc/shop-module/commit/9735d48957b7d9947be5a1be18edba8aebc45531))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.0.1](https://github.com/acme-inc/shop-module/compare/c3664025ba6b608920a0182799102a207980d7be...v0.0.1) (2025-07-10)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* WIP ([846fa6](https://github.com/acme-inc/shop-module/commit/846fa64fb4db9760c4264179098c43e7f53b557c))
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
83
Makefile
Normal file
83
Makefile
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
.PHONY: build
|
||||||
|
|
||||||
|
fresh:
|
||||||
|
$(MAKE) stop && \
|
||||||
|
rm -rf ./src && \
|
||||||
|
./scripts/download_oc_store.sh && \
|
||||||
|
./scripts/install_acmeshop.sh && \
|
||||||
|
$(MAKE) start
|
||||||
|
|
||||||
|
setup:
|
||||||
|
$(MAKE) stop && \
|
||||||
|
rm -rf ./src && \
|
||||||
|
./scripts/download_oc_store.sh && \
|
||||||
|
./scripts/install_acmeshop.sh && \
|
||||||
|
$(MAKE) start && \
|
||||||
|
$(MAKE) link
|
||||||
|
|
||||||
|
stop:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
start:
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
restart:
|
||||||
|
docker compose down && docker compose up -d
|
||||||
|
|
||||||
|
ssh:
|
||||||
|
docker compose exec -w /module/acmeshop/upload/acmeshop web bash
|
||||||
|
|
||||||
|
link:
|
||||||
|
docker compose exec web bash -c "php ./scripts/link.php"
|
||||||
|
|
||||||
|
dev:
|
||||||
|
$(MAKE) link
|
||||||
|
@echo "Starting SPA + Admin..."
|
||||||
|
@make -j2 dev-spa dev-admin
|
||||||
|
|
||||||
|
dev-spa:
|
||||||
|
rm -rf module/acmeshop/upload/system/library/acmeshop && \
|
||||||
|
cd frontend/spa && npm run dev
|
||||||
|
|
||||||
|
dev-admin:
|
||||||
|
rm -rf module/acmeshop/upload/admin/view/javascript && \
|
||||||
|
rm -rf module/acmeshop/upload/system/library/acmeshop && \
|
||||||
|
rm -rf src/upload/admin/view/javascript/acmeshop && \
|
||||||
|
cd frontend/admin && npm run dev
|
||||||
|
|
||||||
|
lint:
|
||||||
|
docker compose exec -w /module/acmeshop/upload/acmeshop web bash -c "./vendor/bin/phpstan analyse"
|
||||||
|
|
||||||
|
phpcs:
|
||||||
|
docker compose exec -w /module/acmeshop/upload/acmeshop web bash -c "./vendor/bin/phpcs --standard=PSR12 bastion framework src"
|
||||||
|
|
||||||
|
phpcbf:
|
||||||
|
docker compose exec -w /module/acmeshop/upload/acmeshop web bash -c "./vendor/bin/phpcbf --standard=PSR12 bastion framework src"
|
||||||
|
|
||||||
|
test:
|
||||||
|
docker compose exec -w /module/acmeshop/upload/acmeshop web bash -c "./vendor/bin/phpunit --testdox tests/"
|
||||||
|
|
||||||
|
test-integration:
|
||||||
|
docker compose exec -w /module/acmeshop/upload/acmeshop web bash -c "./vendor/bin/phpunit --testdox tests/Integration"
|
||||||
|
|
||||||
|
test-unit:
|
||||||
|
docker compose exec -w /module/acmeshop/upload/acmeshop web bash -c "./vendor/bin/phpunit --testdox tests/Unit"
|
||||||
|
|
||||||
|
test-telegram:
|
||||||
|
docker compose exec -w /module/acmeshop/upload/acmeshop web bash -c "./vendor/bin/phpunit --testdox tests/Telegram"
|
||||||
|
|
||||||
|
test-coverage:
|
||||||
|
docker compose exec -w /module/acmeshop/upload/acmeshop web bash -c "./vendor/bin/phpunit --coverage-html coverage tests/"
|
||||||
|
|
||||||
|
phar:
|
||||||
|
docker build -t acmeshop_local_build -f ./deployment/build.dockerfile . && \
|
||||||
|
docker run -v "./src/upload/system/library/acmeshop:/build" acmeshop_local_build sh -c 'sh /scripts/build_phar.sh'
|
||||||
|
|
||||||
|
cli:
|
||||||
|
docker compose exec -w /module/acmeshop/upload web bash -c "/usr/local/bin/php cli.php $(ARGS)"
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
php ./module/acmeshop/upload/acmeshop/vendor/bin/conventional-changelog
|
||||||
|
|
||||||
|
release:
|
||||||
|
php ./module/acmeshop/upload/acmeshop/vendor/bin/conventional-changelog --commit
|
||||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Demo code for interviewing
|
||||||
|
|
||||||
|
This repository contains a simplified version of one of my personal commercial pet project prepared for technical interviews.
|
||||||
|
Because of that:
|
||||||
|
|
||||||
|
1. All sensitive data has been renamed and/or deleted. In some places this may break autoloading or other integrations, but the code structure remains intact and suitable for review.
|
||||||
|
2. The Git history has been squashed into a single commit for the same reason.
|
||||||
|
3. The maximum supported PHP version is 7.4. This requirement comes from the external CMS that this project integrates with. Therefore, some modern PHP 8+ features are not used here, although I have experience working with the latest PHP versions.
|
||||||
|
|
||||||
|
## What the application does
|
||||||
|
|
||||||
|
The application integrates with an e-commerce CMS and provides a Telegram Mini App that exposes the product catalog to the end users.
|
||||||
|
The application uses the external e-commerce database (MySQL) and adds some tables (see `backend/src/database/migrations`).
|
||||||
|
|
||||||
|
# Directory structure
|
||||||
|
|
||||||
|
The project has monorepo includes frontend (vuejs) and backend (php).
|
||||||
|
|
||||||
|
## Backend structure main points:
|
||||||
|
```
|
||||||
|
backend - native php code with my framework.
|
||||||
|
└── src
|
||||||
|
├── app - the main application code
|
||||||
|
├── bastion - the admin panel application code
|
||||||
|
├── cli.php - cli entrypoint
|
||||||
|
├── composer.json
|
||||||
|
├── composer.lock
|
||||||
|
├── configs
|
||||||
|
├── console - CLI application
|
||||||
|
├── database
|
||||||
|
├── framework - self-written php framework
|
||||||
|
├── phpstan.neon
|
||||||
|
├── phpunit.xml
|
||||||
|
├── stubs
|
||||||
|
└── tests
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
12
backend/src/.env.example
Executable file
12
backend/src/.env.example
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
APP_DEBUG=true
|
||||||
|
PULSE_API_HOST=https://pulse.example.com/api/
|
||||||
|
PULSE_HEARTBEAT_SECRET=example-heartbeat-secret
|
||||||
|
|
||||||
|
MEGAPAY_CACHE_DRIVER=redis
|
||||||
|
#MEGAPAY_REDIS_HOST=redis
|
||||||
|
#MEGAPAY_REDIS_PORT=6379
|
||||||
|
#MEGAPAY_REDIS_DATABASE=0
|
||||||
|
|
||||||
|
SENTRY_ENABLED=false
|
||||||
|
SENTRY_DSN=
|
||||||
|
SENTRY_ENABLE_LOGS=false
|
||||||
7
backend/src/app/Adapters/OcCartAdapter.php
Executable file
7
backend/src/app/Adapters/OcCartAdapter.php
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Adapters;
|
||||||
|
|
||||||
|
class OcCartAdapter
|
||||||
|
{
|
||||||
|
}
|
||||||
31
backend/src/app/Adapters/OcModelCatalogProductAdapter.php
Executable file
31
backend/src/app/Adapters/OcModelCatalogProductAdapter.php
Executable file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Adapters;
|
||||||
|
|
||||||
|
use ModelCatalogProduct;
|
||||||
|
use Proxy;
|
||||||
|
|
||||||
|
class OcModelCatalogProductAdapter
|
||||||
|
{
|
||||||
|
/** @var Proxy|ModelCatalogProduct */
|
||||||
|
private Proxy $model;
|
||||||
|
|
||||||
|
public function __construct($model)
|
||||||
|
{
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProductOptions(int $productId): array
|
||||||
|
{
|
||||||
|
return $this->model->getProductOptions($productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $productId
|
||||||
|
* @return array|false
|
||||||
|
*/
|
||||||
|
public function getProduct(int $productId)
|
||||||
|
{
|
||||||
|
return $this->model->getProduct($productId);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/src/app/ApplicationFactory.php
Executable file
44
backend/src/app/ApplicationFactory.php
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use App\ServiceProviders\AppServiceProvider;
|
||||||
|
use App\ServiceProviders\SettingsServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Application;
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageToolServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\QueryBuilderServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Router\RouteServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Scheduler\SchedulerServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramValidateInitDataMiddleware;
|
||||||
|
use Acme\ECommerceFramework\Validator\ValidatorServiceProvider;
|
||||||
|
|
||||||
|
class ApplicationFactory
|
||||||
|
{
|
||||||
|
public static function create(array $config): Application
|
||||||
|
{
|
||||||
|
$defaultConfig = require __DIR__ . '/../configs/app.php';
|
||||||
|
$routes = require __DIR__ . '/routes.php';
|
||||||
|
|
||||||
|
return (new Application(Arr::mergeArraysRecursively($defaultConfig, $config)))
|
||||||
|
->withRoutes(fn() => $routes)
|
||||||
|
->withServiceProviders([
|
||||||
|
SettingsServiceProvider::class,
|
||||||
|
QueryBuilderServiceProvider::class,
|
||||||
|
CacheServiceProvider::class,
|
||||||
|
RouteServiceProvider::class,
|
||||||
|
AppServiceProvider::class,
|
||||||
|
TelegramServiceProvider::class,
|
||||||
|
SchedulerServiceProvider::class,
|
||||||
|
ValidatorServiceProvider::class,
|
||||||
|
AcmeShopPulseServiceProvider::class,
|
||||||
|
ImageToolServiceProvider::class,
|
||||||
|
])
|
||||||
|
->withMiddlewares([
|
||||||
|
TelegramValidateInitDataMiddleware::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/src/app/DTO/Settings/AppDTO.php
Executable file
98
backend/src/app/DTO/Settings/AppDTO.php
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTO\Settings;
|
||||||
|
|
||||||
|
final class AppDTO
|
||||||
|
{
|
||||||
|
private bool $appEnabled;
|
||||||
|
private string $appName;
|
||||||
|
private ?string $appIcon;
|
||||||
|
private string $themeLight;
|
||||||
|
private string $themeDark;
|
||||||
|
private bool $appDebug;
|
||||||
|
private int $languageId;
|
||||||
|
private string $shopBaseUrl;
|
||||||
|
private bool $hapticEnabled;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
bool $appEnabled,
|
||||||
|
string $appName,
|
||||||
|
?string $appIcon,
|
||||||
|
string $themeLight,
|
||||||
|
string $themeDark,
|
||||||
|
bool $appDebug,
|
||||||
|
int $languageId,
|
||||||
|
string $shopBaseUrl,
|
||||||
|
bool $hapticEnabled = true
|
||||||
|
) {
|
||||||
|
$this->appEnabled = $appEnabled;
|
||||||
|
$this->appName = $appName;
|
||||||
|
$this->appIcon = $appIcon;
|
||||||
|
$this->themeLight = $themeLight;
|
||||||
|
$this->themeDark = $themeDark;
|
||||||
|
$this->appDebug = $appDebug;
|
||||||
|
$this->languageId = $languageId;
|
||||||
|
$this->shopBaseUrl = $shopBaseUrl;
|
||||||
|
$this->hapticEnabled = $hapticEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAppEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->appEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAppName(): string
|
||||||
|
{
|
||||||
|
return $this->appName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAppIcon(): ?string
|
||||||
|
{
|
||||||
|
return $this->appIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getThemeLight(): string
|
||||||
|
{
|
||||||
|
return $this->themeLight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getThemeDark(): string
|
||||||
|
{
|
||||||
|
return $this->themeDark;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAppDebug(): bool
|
||||||
|
{
|
||||||
|
return $this->appDebug;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLanguageId(): int
|
||||||
|
{
|
||||||
|
return $this->languageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getShopBaseUrl(): string
|
||||||
|
{
|
||||||
|
return $this->shopBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isHapticEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->hapticEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'app_enabled' => $this->isAppEnabled(),
|
||||||
|
'app_name' => $this->getAppName(),
|
||||||
|
'app_icon' => $this->getAppIcon(),
|
||||||
|
'theme_light' => $this->getThemeLight(),
|
||||||
|
'theme_dark' => $this->getThemeDark(),
|
||||||
|
'app_debug' => $this->isAppDebug(),
|
||||||
|
'language_id' => $this->getLanguageId(),
|
||||||
|
'shop_base_url' => $this->getShopBaseUrl(),
|
||||||
|
'haptic_enabled' => $this->isHapticEnabled(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
89
backend/src/app/DTO/Settings/ConfigDTO.php
Executable file
89
backend/src/app/DTO/Settings/ConfigDTO.php
Executable file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTO\Settings;
|
||||||
|
|
||||||
|
final class ConfigDTO
|
||||||
|
{
|
||||||
|
private AppDTO $app;
|
||||||
|
private TelegramDTO $telegram;
|
||||||
|
private MetricsDTO $metrics;
|
||||||
|
private StoreDTO $store;
|
||||||
|
private OrdersDTO $orders;
|
||||||
|
private TextsDTO $texts;
|
||||||
|
private DatabaseDTO $database;
|
||||||
|
private LogsDTO $logs;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
AppDTO $app,
|
||||||
|
TelegramDTO $telegram,
|
||||||
|
MetricsDTO $metrics,
|
||||||
|
StoreDTO $store,
|
||||||
|
OrdersDTO $orders,
|
||||||
|
TextsDTO $texts,
|
||||||
|
DatabaseDTO $database,
|
||||||
|
LogsDTO $logs
|
||||||
|
) {
|
||||||
|
$this->app = $app;
|
||||||
|
$this->telegram = $telegram;
|
||||||
|
$this->metrics = $metrics;
|
||||||
|
$this->store = $store;
|
||||||
|
$this->orders = $orders;
|
||||||
|
$this->texts = $texts;
|
||||||
|
$this->database = $database;
|
||||||
|
$this->logs = $logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getApp(): AppDTO
|
||||||
|
{
|
||||||
|
return $this->app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTelegram(): TelegramDTO
|
||||||
|
{
|
||||||
|
return $this->telegram;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMetrics(): MetricsDTO
|
||||||
|
{
|
||||||
|
return $this->metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStore(): StoreDTO
|
||||||
|
{
|
||||||
|
return $this->store;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrders(): OrdersDTO
|
||||||
|
{
|
||||||
|
return $this->orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTexts(): TextsDTO
|
||||||
|
{
|
||||||
|
return $this->texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDatabase(): DatabaseDTO
|
||||||
|
{
|
||||||
|
return $this->database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLogs(): LogsDTO
|
||||||
|
{
|
||||||
|
return $this->logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'app' => $this->app->toArray(),
|
||||||
|
'database' => $this->database->toArray(),
|
||||||
|
'logs' => $this->logs->toArray(),
|
||||||
|
'metrics' => $this->metrics->toArray(),
|
||||||
|
'orders' => $this->orders->toArray(),
|
||||||
|
'store' => $this->store->toArray(),
|
||||||
|
'telegram' => $this->telegram->toArray(),
|
||||||
|
'texts' => $this->texts->toArray(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
71
backend/src/app/DTO/Settings/DatabaseDTO.php
Executable file
71
backend/src/app/DTO/Settings/DatabaseDTO.php
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTO\Settings;
|
||||||
|
|
||||||
|
final class DatabaseDTO
|
||||||
|
{
|
||||||
|
private string $host;
|
||||||
|
private string $database;
|
||||||
|
private string $username;
|
||||||
|
private string $password;
|
||||||
|
private string $prefix;
|
||||||
|
private int $port;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $host,
|
||||||
|
string $database,
|
||||||
|
string $username,
|
||||||
|
string $password,
|
||||||
|
string $prefix,
|
||||||
|
int $port
|
||||||
|
) {
|
||||||
|
$this->host = $host;
|
||||||
|
$this->database = $database;
|
||||||
|
$this->username = $username;
|
||||||
|
$this->password = $password;
|
||||||
|
$this->prefix = $prefix;
|
||||||
|
$this->port = $port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHost(): string
|
||||||
|
{
|
||||||
|
return $this->host;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDatabase(): string
|
||||||
|
{
|
||||||
|
return $this->database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUsername(): string
|
||||||
|
{
|
||||||
|
return $this->username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPassword(): string
|
||||||
|
{
|
||||||
|
return $this->password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPrefix(): string
|
||||||
|
{
|
||||||
|
return $this->prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPort(): int
|
||||||
|
{
|
||||||
|
return $this->port;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'host' => $this->host,
|
||||||
|
'database' => $this->database,
|
||||||
|
'username' => $this->username,
|
||||||
|
'password' => $this->password,
|
||||||
|
'prefix' => $this->prefix,
|
||||||
|
'port' => $this->port,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/app/DTO/Settings/LogsDTO.php
Executable file
25
backend/src/app/DTO/Settings/LogsDTO.php
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTO\Settings;
|
||||||
|
|
||||||
|
final class LogsDTO
|
||||||
|
{
|
||||||
|
private string $path;
|
||||||
|
|
||||||
|
public function __construct(string $path)
|
||||||
|
{
|
||||||
|
$this->path = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPath(): string
|
||||||
|
{
|
||||||
|
return $this->path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'path' => $this->path,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
backend/src/app/DTO/Settings/MetricsDTO.php
Executable file
35
backend/src/app/DTO/Settings/MetricsDTO.php
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTO\Settings;
|
||||||
|
|
||||||
|
final class MetricsDTO
|
||||||
|
{
|
||||||
|
private bool $yandexMetrikaEnabled;
|
||||||
|
private string $yandexMetrikaCounter;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
bool $yandexMetrikaEnabled,
|
||||||
|
string $yandexMetrikaCounter
|
||||||
|
) {
|
||||||
|
$this->yandexMetrikaEnabled = $yandexMetrikaEnabled;
|
||||||
|
$this->yandexMetrikaCounter = $yandexMetrikaCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isYandexMetrikaEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->yandexMetrikaEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getYandexMetrikaCounter(): string
|
||||||
|
{
|
||||||
|
return $this->yandexMetrikaCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'yandex_metrika_enabled' => $this->yandexMetrikaEnabled,
|
||||||
|
'yandex_metrika_counter' => $this->yandexMetrikaCounter,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/src/app/DTO/Settings/OrdersDTO.php
Executable file
33
backend/src/app/DTO/Settings/OrdersDTO.php
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTO\Settings;
|
||||||
|
|
||||||
|
final class OrdersDTO
|
||||||
|
{
|
||||||
|
private int $orderDefaultStatusId;
|
||||||
|
private int $ocCustomerGroupId;
|
||||||
|
|
||||||
|
public function __construct(int $orderDefaultStatusId, int $ocCustomerGroupId)
|
||||||
|
{
|
||||||
|
$this->orderDefaultStatusId = $orderDefaultStatusId;
|
||||||
|
$this->ocCustomerGroupId = $ocCustomerGroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOrderDefaultStatusId(): int
|
||||||
|
{
|
||||||
|
return $this->orderDefaultStatusId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOcCustomerGroupId(): int
|
||||||
|
{
|
||||||
|
return $this->ocCustomerGroupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'order_default_status_id' => $this->orderDefaultStatusId,
|
||||||
|
'oc_customer_group_id' => $this->ocCustomerGroupId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
90
backend/src/app/DTO/Settings/StoreDTO.php
Executable file
90
backend/src/app/DTO/Settings/StoreDTO.php
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTO\Settings;
|
||||||
|
|
||||||
|
final class StoreDTO
|
||||||
|
{
|
||||||
|
private bool $featureCoupons;
|
||||||
|
private bool $featureVouchers;
|
||||||
|
private bool $showCategoryProductsButton;
|
||||||
|
private string $productInteractionMode;
|
||||||
|
private ?string $managerUsername;
|
||||||
|
private string $ocDefaultCurrency;
|
||||||
|
private bool $ocConfigTax;
|
||||||
|
private int $acmeShopId;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
bool $featureCoupons,
|
||||||
|
bool $featureVouchers,
|
||||||
|
bool $showCategoryProductsButton,
|
||||||
|
string $productInteractionMode,
|
||||||
|
?string $managerUsername,
|
||||||
|
string $ocDefaultCurrency,
|
||||||
|
bool $ocConfigTax,
|
||||||
|
int $acmeShopId
|
||||||
|
) {
|
||||||
|
$this->featureCoupons = $featureCoupons;
|
||||||
|
$this->featureVouchers = $featureVouchers;
|
||||||
|
$this->showCategoryProductsButton = $showCategoryProductsButton;
|
||||||
|
$this->productInteractionMode = $productInteractionMode;
|
||||||
|
$this->managerUsername = $managerUsername;
|
||||||
|
$this->ocDefaultCurrency = $ocDefaultCurrency;
|
||||||
|
$this->ocConfigTax = $ocConfigTax;
|
||||||
|
$this->acmeShopId = $acmeShopId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFeatureCoupons(): bool
|
||||||
|
{
|
||||||
|
return $this->featureCoupons;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isFeatureVouchers(): bool
|
||||||
|
{
|
||||||
|
return $this->featureVouchers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isShowCategoryProductsButton(): bool
|
||||||
|
{
|
||||||
|
return $this->showCategoryProductsButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProductInteractionMode(): string
|
||||||
|
{
|
||||||
|
return $this->productInteractionMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getManagerUsername(): ?string
|
||||||
|
{
|
||||||
|
return $this->managerUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOcDefaultCurrency(): string
|
||||||
|
{
|
||||||
|
return $this->ocDefaultCurrency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isOcConfigTax(): bool
|
||||||
|
{
|
||||||
|
return $this->ocConfigTax;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAcmeShopId(): int
|
||||||
|
{
|
||||||
|
return $this->acmeShopId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// enable_store больше не сериализуется, так как заменен на product_interaction_mode
|
||||||
|
'feature_coupons' => $this->featureCoupons,
|
||||||
|
'feature_vouchers' => $this->featureVouchers,
|
||||||
|
'show_category_products_button' => $this->showCategoryProductsButton,
|
||||||
|
'product_interaction_mode' => $this->productInteractionMode,
|
||||||
|
'manager_username' => $this->managerUsername,
|
||||||
|
'oc_default_currency' => $this->ocDefaultCurrency,
|
||||||
|
'oc_config_tax' => $this->ocConfigTax,
|
||||||
|
'oc_store_id' => $this->acmeShopId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
backend/src/app/DTO/Settings/TelegramDTO.php
Executable file
62
backend/src/app/DTO/Settings/TelegramDTO.php
Executable file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTO\Settings;
|
||||||
|
|
||||||
|
final class TelegramDTO
|
||||||
|
{
|
||||||
|
private string $botToken;
|
||||||
|
private ?int $chatId;
|
||||||
|
private string $ownerNotificationTemplate;
|
||||||
|
private string $customerNotificationTemplate;
|
||||||
|
private string $miniAppUrl;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $botToken,
|
||||||
|
?int $chatId,
|
||||||
|
string $ownerNotificationTemplate,
|
||||||
|
string $customerNotificationTemplate,
|
||||||
|
string $miniAppUrl
|
||||||
|
) {
|
||||||
|
$this->botToken = $botToken;
|
||||||
|
$this->chatId = $chatId;
|
||||||
|
$this->ownerNotificationTemplate = $ownerNotificationTemplate;
|
||||||
|
$this->customerNotificationTemplate = $customerNotificationTemplate;
|
||||||
|
$this->miniAppUrl = $miniAppUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBotToken(): string
|
||||||
|
{
|
||||||
|
return $this->botToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChatId(): ?int
|
||||||
|
{
|
||||||
|
return $this->chatId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOwnerNotificationTemplate(): string
|
||||||
|
{
|
||||||
|
return $this->ownerNotificationTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCustomerNotificationTemplate(): string
|
||||||
|
{
|
||||||
|
return $this->customerNotificationTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMiniAppUrl(): string
|
||||||
|
{
|
||||||
|
return $this->miniAppUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'bot_token' => $this->botToken,
|
||||||
|
'chat_id' => $this->chatId,
|
||||||
|
'owner_notification_template' => $this->ownerNotificationTemplate,
|
||||||
|
'customer_notification_template' => $this->customerNotificationTemplate,
|
||||||
|
'mini_app_url' => $this->miniAppUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
53
backend/src/app/DTO/Settings/TextsDTO.php
Executable file
53
backend/src/app/DTO/Settings/TextsDTO.php
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\DTO\Settings;
|
||||||
|
|
||||||
|
final class TextsDTO
|
||||||
|
{
|
||||||
|
private string $textNoMoreProducts;
|
||||||
|
private string $textEmptyCart;
|
||||||
|
private string $textOrderCreatedSuccess;
|
||||||
|
private string $textManagerButton;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $textNoMoreProducts,
|
||||||
|
string $textEmptyCart,
|
||||||
|
string $textOrderCreatedSuccess,
|
||||||
|
string $textManagerButton
|
||||||
|
) {
|
||||||
|
$this->textNoMoreProducts = $textNoMoreProducts;
|
||||||
|
$this->textEmptyCart = $textEmptyCart;
|
||||||
|
$this->textOrderCreatedSuccess = $textOrderCreatedSuccess;
|
||||||
|
$this->textManagerButton = $textManagerButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTextNoMoreProducts(): string
|
||||||
|
{
|
||||||
|
return $this->textNoMoreProducts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTextEmptyCart(): string
|
||||||
|
{
|
||||||
|
return $this->textEmptyCart;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTextOrderCreatedSuccess(): string
|
||||||
|
{
|
||||||
|
return $this->textOrderCreatedSuccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTextManagerButton(): string
|
||||||
|
{
|
||||||
|
return $this->textManagerButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'text_no_more_products' => $this->textNoMoreProducts,
|
||||||
|
'text_empty_cart' => $this->textEmptyCart,
|
||||||
|
'text_order_created_success' => $this->textOrderCreatedSuccess,
|
||||||
|
'text_manager_button' => $this->textManagerButton,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/app/Exceptions/CustomExceptionHandler.php
Executable file
25
backend/src/app/Exceptions/CustomExceptionHandler.php
Executable file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Contracts\ExceptionHandlerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Exceptions\TelegramInvalidSignatureException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class CustomExceptionHandler implements ExceptionHandlerInterface
|
||||||
|
{
|
||||||
|
public function respond(Throwable $exception): ?JsonResponse
|
||||||
|
{
|
||||||
|
if ($exception instanceof TelegramInvalidSignatureException) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'error' => 'Invalid Signature',
|
||||||
|
'code' => 'NO_INIT_DATA',
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/src/app/Exceptions/OrderValidationFailedException.php
Executable file
28
backend/src/app/Exceptions/OrderValidationFailedException.php
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Validator\ErrorBag;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class OrderValidationFailedException extends RuntimeException
|
||||||
|
{
|
||||||
|
private ErrorBag $errorBag;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
ErrorBag $errorBag,
|
||||||
|
string $message = 'Validation failed',
|
||||||
|
int $code = 422,
|
||||||
|
Throwable $previous = null
|
||||||
|
) {
|
||||||
|
$this->errorBag = $errorBag;
|
||||||
|
|
||||||
|
parent::__construct($message, $code, $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getErrorBag(): ErrorBag
|
||||||
|
{
|
||||||
|
return $this->errorBag;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/app/Exceptions/TelegramCustomerNotFoundException.php
Executable file
24
backend/src/app/Exceptions/TelegramCustomerNotFoundException.php
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исключение, выбрасываемое когда Telegram-кастомер не найден
|
||||||
|
*
|
||||||
|
* @package App\Exceptions
|
||||||
|
*/
|
||||||
|
class TelegramCustomerNotFoundException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(int $customerId, ?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct(
|
||||||
|
"Telegram customer with record ID {$customerId} not found",
|
||||||
|
404,
|
||||||
|
$previous
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/app/Exceptions/TelegramCustomerWriteNotAllowedException.php
Executable file
24
backend/src/app/Exceptions/TelegramCustomerWriteNotAllowedException.php
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Исключение, выбрасываемое когда пользователь не разрешил писать ему в PM
|
||||||
|
*
|
||||||
|
* @package App\Exceptions
|
||||||
|
*/
|
||||||
|
class TelegramCustomerWriteNotAllowedException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(int $telegramUserId, ?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct(
|
||||||
|
"User {$telegramUserId} has not allowed writing to PM",
|
||||||
|
400,
|
||||||
|
$previous
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/src/app/Filters/ProductAttribute.php
Executable file
70
backend/src/app/Filters/ProductAttribute.php
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
|
||||||
|
class ProductAttribute extends BaseRule
|
||||||
|
{
|
||||||
|
public const NAME = 'RULE_PRODUCT_ATTRIBUTE';
|
||||||
|
|
||||||
|
public static function initWithDefaults(): BaseRule
|
||||||
|
{
|
||||||
|
return new self(static::NAME, [
|
||||||
|
'product_attribute' => new Criterion(static::CRITERIA_OPTION_PRODUCT_ATTRIBUTE, [
|
||||||
|
'attribute_id' => null,
|
||||||
|
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
|
||||||
|
'keyword' => '',
|
||||||
|
'language_id' => config('language_id'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(Builder $builder, $operand): void
|
||||||
|
{
|
||||||
|
foreach ($this->criteria as $criterion) {
|
||||||
|
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_ATTRIBUTE) {
|
||||||
|
$facetHash = md5(serialize($criterion));
|
||||||
|
$joinAlias = 'product_attributes_facet_' . $facetHash;
|
||||||
|
if ($builder->hasJoinAlias($joinAlias)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operator = static::$stringCompareOperators[$criterion->params['operator']];
|
||||||
|
|
||||||
|
$languageId = $criterion->params['language_id'] ?? null;
|
||||||
|
if (! $languageId) {
|
||||||
|
throw new InvalidArgumentException('language_id is required for the product attribute filter');
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->leftJoin(
|
||||||
|
db_table('product_attribute') . " AS $joinAlias",
|
||||||
|
function (JoinClause $join) use ($criterion, $joinAlias, $operator, $languageId) {
|
||||||
|
$join->on('products.product_id', '=', "$joinAlias.product_id")
|
||||||
|
->where("$joinAlias.attribute_id", '=', $criterion->params['attribute_id'])
|
||||||
|
->where("$joinAlias.language_id", '=', $languageId);
|
||||||
|
|
||||||
|
if ($operator !== 'is_empty' && $operator !== 'is_not_empty') {
|
||||||
|
$this->criterionStringCondition(
|
||||||
|
$join,
|
||||||
|
$criterion,
|
||||||
|
"$joinAlias.text",
|
||||||
|
'and'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($operator === 'is_empty') {
|
||||||
|
$builder->whereNull("$joinAlias.product_id", $operand);
|
||||||
|
} else {
|
||||||
|
$builder->whereNotNull("$joinAlias.product_id", $operand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
backend/src/app/Filters/ProductCategories.php
Executable file
61
backend/src/app/Filters/ProductCategories.php
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
|
||||||
|
class ProductCategories extends BaseRule
|
||||||
|
{
|
||||||
|
public const NAME = 'RULE_PRODUCT_CATEGORIES';
|
||||||
|
|
||||||
|
public static function initWithDefaults(): BaseRule
|
||||||
|
{
|
||||||
|
return new self(static::NAME, [
|
||||||
|
'product_category_ids' => new Criterion(static::CRITERIA_OPTION_PRODUCT_CATEGORIES, [
|
||||||
|
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
|
||||||
|
'value' => [],
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(Builder $builder, $operand): void
|
||||||
|
{
|
||||||
|
/** @var Criterion $criterion */
|
||||||
|
foreach ($this->criteria as $criterion) {
|
||||||
|
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_CATEGORIES) {
|
||||||
|
$uniqHash = md5(serialize($criterion));
|
||||||
|
$joinAlias = 'product_category_' . $uniqHash;
|
||||||
|
if ($builder->hasJoinAlias($joinAlias)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operator = $criterion->params['operator'];
|
||||||
|
$categoryIds = $criterion->params['value'];
|
||||||
|
|
||||||
|
$builder->join(
|
||||||
|
db_table('product_to_category') . " AS $joinAlias",
|
||||||
|
function (JoinClause $join) use ($joinAlias, $categoryIds) {
|
||||||
|
$join->on('products.product_id', '=', "$joinAlias.product_id");
|
||||||
|
if ($categoryIds) {
|
||||||
|
$join->whereIn("$joinAlias.category_id", $categoryIds);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'left'
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($operator === 'contains' && ! $categoryIds) {
|
||||||
|
$builder->whereNull("$joinAlias.product_id", $operand);
|
||||||
|
} elseif ($operator === 'not_contains' && ! $categoryIds) {
|
||||||
|
$builder->whereNotNull("$joinAlias.product_id", $operand);
|
||||||
|
} elseif ($operator === 'contains') {
|
||||||
|
$builder->whereNotNull("$joinAlias.product_id", $operand);
|
||||||
|
} else {
|
||||||
|
$builder->whereNull("$joinAlias.product_id", $operand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/src/app/Filters/ProductCategory.php
Executable file
63
backend/src/app/Filters/ProductCategory.php
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
|
||||||
|
class ProductCategory extends BaseRule
|
||||||
|
{
|
||||||
|
public const NAME = 'RULE_PRODUCT_CATEGORY';
|
||||||
|
|
||||||
|
public static function initWithDefaults(): BaseRule
|
||||||
|
{
|
||||||
|
return new self(static::NAME, [
|
||||||
|
'product_category_id' => new Criterion(static::CRITERIA_OPTION_PRODUCT_CATEGORIES, [
|
||||||
|
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
|
||||||
|
'value' => null,
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(Builder $builder, $operand): void
|
||||||
|
{
|
||||||
|
/** @var Criterion $criterion */
|
||||||
|
foreach ($this->criteria as $criterion) {
|
||||||
|
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_CATEGORY) {
|
||||||
|
$operator = $criterion->params['operator'];
|
||||||
|
$categoryId = $criterion->params['value'];
|
||||||
|
|
||||||
|
if (! $categoryId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uniqHash = md5(serialize($criterion));
|
||||||
|
$joinAlias = 'product_category_' . $uniqHash;
|
||||||
|
if ($builder->hasJoinAlias($joinAlias)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->join(
|
||||||
|
db_table('product_to_category') . " AS $joinAlias",
|
||||||
|
function (JoinClause $join) use ($joinAlias, $categoryId) {
|
||||||
|
$join
|
||||||
|
->on('products.product_id', '=', "$joinAlias.product_id")
|
||||||
|
->where("$joinAlias.category_id", '=', $categoryId);
|
||||||
|
},
|
||||||
|
'left'
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($operator === 'contains') {
|
||||||
|
$builder->whereNotNull("$joinAlias.product_id", $operand);
|
||||||
|
} elseif ($operator === 'not_contains') {
|
||||||
|
$builder->whereNull("$joinAlias.product_id", $operand);
|
||||||
|
} else {
|
||||||
|
throw new InvalidArgumentException('Invalid operator: ' . $operator);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/src/app/Filters/ProductManufacturer.php
Executable file
48
backend/src/app/Filters/ProductManufacturer.php
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
|
||||||
|
class ProductManufacturer extends BaseRule
|
||||||
|
{
|
||||||
|
public const NAME = 'RULE_PRODUCT_MANUFACTURER';
|
||||||
|
|
||||||
|
public static function initWithDefaults(): BaseRule
|
||||||
|
{
|
||||||
|
return new self(static::NAME, [
|
||||||
|
'product_manufacturer_ids' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MANUFACTURER, [
|
||||||
|
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
|
||||||
|
'value' => [],
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(Builder $builder, $operand): void
|
||||||
|
{
|
||||||
|
/** @var Criterion $criterion */
|
||||||
|
foreach ($this->criteria as $criterion) {
|
||||||
|
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_MANUFACTURER) {
|
||||||
|
$operator = $criterion->params['operator'];
|
||||||
|
$ids = $criterion->params['value'];
|
||||||
|
|
||||||
|
if ($ids) {
|
||||||
|
$builder->whereIn(
|
||||||
|
'products.manufacturer_id',
|
||||||
|
$ids,
|
||||||
|
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$builder->where(
|
||||||
|
'products.manufacturer_id',
|
||||||
|
'=',
|
||||||
|
0,
|
||||||
|
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
backend/src/app/Filters/ProductModel.php
Executable file
45
backend/src/app/Filters/ProductModel.php
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
|
||||||
|
class ProductModel extends BaseRule
|
||||||
|
{
|
||||||
|
public const NAME = 'RULE_PRODUCT_MODEL';
|
||||||
|
|
||||||
|
public static function initWithDefaults(): BaseRule
|
||||||
|
{
|
||||||
|
return new self(static::NAME, [
|
||||||
|
'product_model' => new Criterion(static::CRITERIA_OPTION_PRODUCT_MODEL, [
|
||||||
|
'operator' => static::CRITERIA_OPERATOR_CONTAINS,
|
||||||
|
'value' => [],
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(Builder $builder, $operand): void
|
||||||
|
{
|
||||||
|
/** @var Criterion $criterion */
|
||||||
|
foreach ($this->criteria as $criterion) {
|
||||||
|
if ($criterion->type === static::CRITERIA_OPTION_PRODUCT_MODEL) {
|
||||||
|
$operator = $criterion->params['operator'];
|
||||||
|
$models = $criterion->params['value'] ?? [];
|
||||||
|
|
||||||
|
if ($models) {
|
||||||
|
$builder->whereIn(
|
||||||
|
'products.model',
|
||||||
|
$models,
|
||||||
|
$operator === static::CRITERIA_OPERATOR_NOT_CONTAINS
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$builder->whereRaw('TRUE = FALSE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
161
backend/src/app/Filters/ProductPrice.php
Executable file
161
backend/src/app/Filters/ProductPrice.php
Executable file
@@ -0,0 +1,161 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Table;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ProductPrice extends BaseRule
|
||||||
|
{
|
||||||
|
public const NAME = 'RULE_PRODUCT_PRICE';
|
||||||
|
|
||||||
|
public static function initWithDefaults(): BaseRule
|
||||||
|
{
|
||||||
|
return new self(static::NAME, [
|
||||||
|
'product_price' => new Criterion(static::CRITERIA_OPTION_NUMBER, [
|
||||||
|
'operator' => static::CRITERIA_OPERATOR_GREATER_OR_EQUAL,
|
||||||
|
'value' => [
|
||||||
|
'from' => 0,
|
||||||
|
'to' => null,
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'include_discounts' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
|
||||||
|
'value' => true,
|
||||||
|
]),
|
||||||
|
'include_specials' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
|
||||||
|
'value' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function apply(Builder $builder, $operand)
|
||||||
|
{
|
||||||
|
$includeSpecials = $this->criteria['include_specials']->params['value'] ?? true;
|
||||||
|
|
||||||
|
/** @var Criterion|null $productPriceCriterion */
|
||||||
|
$productPriceCriterion = $this->criteria['product_price'] ?? null;
|
||||||
|
|
||||||
|
if (! $productPriceCriterion) {
|
||||||
|
throw new RuntimeException('Invalid product price rule format. Criterion is not found. Check filter JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset(static::$numberCompareOperators[$productPriceCriterion->params['operator']])) {
|
||||||
|
throw new InvalidArgumentException('Invalid operator: ' . $productPriceCriterion->params['operator']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$column = 'products.price';
|
||||||
|
|
||||||
|
if ($includeSpecials) {
|
||||||
|
$specialsFacetHash = md5(serialize($productPriceCriterion) . 'specials');
|
||||||
|
$joinAlias = 'product_specials_' . $specialsFacetHash;
|
||||||
|
if ($builder->hasJoinAlias($joinAlias)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customerGroupId = config('oc_customer_group_id', 1);
|
||||||
|
|
||||||
|
$sub2 = $builder->newQuery()
|
||||||
|
->select([
|
||||||
|
'product_id',
|
||||||
|
new RawExpression("MIN(CONCAT(LPAD(priority, 5, '0'), LPAD(price, 10, '0'))) AS sort_key"),
|
||||||
|
])
|
||||||
|
->from(db_table('product_special'), 'ps')
|
||||||
|
->where("ps.customer_group_id", '=', $customerGroupId)
|
||||||
|
->whereRaw(
|
||||||
|
"
|
||||||
|
(ps.date_start = '0000-00-00' OR ps.date_start < NOW())
|
||||||
|
AND (ps.date_end = '0000-00-00' OR ps.date_end > NOW())
|
||||||
|
"
|
||||||
|
)
|
||||||
|
->groupBy(['product_id']);
|
||||||
|
|
||||||
|
$sub = $builder->newQuery()
|
||||||
|
->select([
|
||||||
|
'ps1.product_id',
|
||||||
|
'ps1.price',
|
||||||
|
])
|
||||||
|
->from(db_table('product_special'), 'ps1')
|
||||||
|
->join(new Table($sub2, 'ps2'), function (JoinClause $join) {
|
||||||
|
$join->on('ps1.product_id', '=', 'ps2.product_id')
|
||||||
|
->whereRaw("CONCAT(LPAD(ps1.priority, 5, '0'), LPAD(ps1.price, 10, '0')) = ps2.sort_key");
|
||||||
|
});
|
||||||
|
|
||||||
|
$builder->join(new Table($sub, $joinAlias), function (JoinClause $join) use ($joinAlias) {
|
||||||
|
$join->on('products.product_id', '=', "$joinAlias.product_id");
|
||||||
|
}, Builder::JOIN_TYPE_LEFT);
|
||||||
|
|
||||||
|
$column = new RawExpression("COALESCE($joinAlias.price, products.price)");
|
||||||
|
}
|
||||||
|
|
||||||
|
$numberOperator = static::$numberCompareOperators[$productPriceCriterion->params['operator']];
|
||||||
|
$value = $this->prepareValue(
|
||||||
|
$numberOperator,
|
||||||
|
$productPriceCriterion->params['value']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($numberOperator === 'BETWEEN') {
|
||||||
|
[$min, $max] = $value; // $min = левая, $max = правая граница
|
||||||
|
|
||||||
|
// если обе границы не указаны — фильтр игнорируем
|
||||||
|
if ($min === null && $max === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// если только правая граница — "меньше или равно"
|
||||||
|
if ($min === null && $max !== null) {
|
||||||
|
$builder->where($column, '<=', $max, $operand);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// если только левая граница — "больше или равно"
|
||||||
|
if ($min !== null && $max === null) {
|
||||||
|
$builder->where($column, '>=', $min, $operand);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// левая и правая граница равны
|
||||||
|
if ($min !== null && $max !== null && $min === $max) {
|
||||||
|
$builder->where($column, '=', $min, $operand);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// если обе границы есть — классический between (min ≤ x ≤ max)
|
||||||
|
if ($min !== null && $max !== null) {
|
||||||
|
$builder->whereBetween($column, [$min, $max], $operand);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$builder->where($column, $numberOperator, $value[0], $operand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prepareValue($numberOperator, array $value): array
|
||||||
|
{
|
||||||
|
$from = null;
|
||||||
|
$to = null;
|
||||||
|
|
||||||
|
if (is_numeric($value['from'])) {
|
||||||
|
$from = (int) $value['from'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value['to'])) {
|
||||||
|
$to = (int) $value['to'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$from, $to];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
backend/src/app/Filters/ProductQuantity.php
Executable file
68
backend/src/app/Filters/ProductQuantity.php
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Exceptions\CriteriaBuilderException;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class ProductQuantity extends BaseRule
|
||||||
|
{
|
||||||
|
public const NAME = 'RULE_PRODUCT_QUANTITY';
|
||||||
|
|
||||||
|
public static function initWithDefaults(): BaseRule
|
||||||
|
{
|
||||||
|
return new self(static::NAME, [
|
||||||
|
'product_quantity' => new Criterion(static::CRITERIA_OPTION_NUMBER, [
|
||||||
|
'operator' => static::CRITERIA_OPERATOR_GREATER_OR_EQUAL,
|
||||||
|
'value' => [0, null],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(Builder $builder, $operand): void
|
||||||
|
{
|
||||||
|
/** @var Criterion|null $productQuantityCriterion */
|
||||||
|
$productQuantityCriterion = $this->criteria['product_quantity'] ?? null;
|
||||||
|
|
||||||
|
if (! $productQuantityCriterion) {
|
||||||
|
throw new RuntimeException('Product Quantity rule criterion is not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$column = 'products.quantity';
|
||||||
|
|
||||||
|
if (! isset(static::$numberCompareOperators[$productQuantityCriterion->params['operator']])) {
|
||||||
|
throw new InvalidArgumentException('Invalid operator: ' . $productQuantityCriterion->params['operator']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$numberOperator = static::$numberCompareOperators[$productQuantityCriterion->params['operator']];
|
||||||
|
|
||||||
|
$value = $this->prepareValue(
|
||||||
|
$numberOperator,
|
||||||
|
$productQuantityCriterion->params['value'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($numberOperator === 'BETWEEN') {
|
||||||
|
$builder->whereBetween($column, $value, $operand);
|
||||||
|
} else {
|
||||||
|
$builder->where($column, $numberOperator, $value[0], $operand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prepareValue($numberOperator, array $value): array
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
(isset($value[0]) && ! is_numeric($value[0]))
|
||||||
|
|| ($numberOperator === 'BETWEEN' && ! $value[1])
|
||||||
|
) {
|
||||||
|
throw new CriteriaBuilderException('Value is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map('intval', $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
backend/src/app/Filters/ProductStatus.php
Executable file
35
backend/src/app/Filters/ProductStatus.php
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filters;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Criterion;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\Rules\BaseRule;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
|
||||||
|
class ProductStatus extends BaseRule
|
||||||
|
{
|
||||||
|
public const NAME = 'RULE_PRODUCT_STATUS';
|
||||||
|
|
||||||
|
public static function initWithDefaults(): BaseRule
|
||||||
|
{
|
||||||
|
return new self(static::NAME, [
|
||||||
|
'product_status' => new Criterion(static::CRITERIA_OPTION_BOOLEAN, [
|
||||||
|
'operator' => static::CRITERIA_OPERATOR_EQUALS,
|
||||||
|
'value' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply(Builder $builder, $operand): void
|
||||||
|
{
|
||||||
|
/** @var Criterion $criterion */
|
||||||
|
foreach ($this->criteria as $criterion) {
|
||||||
|
if ($criterion->type === static::CRITERIA_OPTION_BOOLEAN) {
|
||||||
|
$value = $criterion->params['value'];
|
||||||
|
$builder->where('products.status', '=', $value, $operand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/app/Handlers/BlocksHandler.php
Executable file
26
backend/src/app/Handlers/BlocksHandler.php
Executable file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Services\BlocksService;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
|
||||||
|
class BlocksHandler
|
||||||
|
{
|
||||||
|
private BlocksService $blocksService;
|
||||||
|
|
||||||
|
public function __construct(BlocksService $blocksService)
|
||||||
|
{
|
||||||
|
$this->blocksService = $blocksService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processBlock(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$block = $request->json();
|
||||||
|
|
||||||
|
$data = $this->blocksService->process($block);
|
||||||
|
|
||||||
|
return new JsonResponse(compact('data'));
|
||||||
|
}
|
||||||
|
}
|
||||||
54
backend/src/app/Handlers/CartHandler.php
Executable file
54
backend/src/app/Handlers/CartHandler.php
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Services\CartService;
|
||||||
|
use Cart\Cart;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
|
||||||
|
class CartHandler
|
||||||
|
{
|
||||||
|
private Cart $cart;
|
||||||
|
private CartService $cartService;
|
||||||
|
|
||||||
|
public function __construct(Cart $cart, CartService $cartService)
|
||||||
|
{
|
||||||
|
$this->cart = $cart;
|
||||||
|
$this->cartService = $cartService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$items = $this->cartService->getCart();
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$items = $request->json();
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$options = [];
|
||||||
|
|
||||||
|
foreach ($item['options'] as $option) {
|
||||||
|
if (! empty($option['value']) && ! empty($option['value']['product_option_value_id'])) {
|
||||||
|
$options[$option['product_option_id']] = $option['value']['product_option_value_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cart->add(
|
||||||
|
$item['productId'],
|
||||||
|
$item['quantity'],
|
||||||
|
$options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
backend/src/app/Handlers/CategoriesHandler.php
Executable file
127
backend/src/app/Handlers/CategoriesHandler.php
Executable file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Services\SettingsService;
|
||||||
|
use App\Support\Utils;
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheInterface;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageFactory;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Table;
|
||||||
|
use Acme\ECommerceFramework\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
|
||||||
|
class CategoriesHandler
|
||||||
|
{
|
||||||
|
private const THUMB_SIZE = 150;
|
||||||
|
|
||||||
|
private Builder $queryBuilder;
|
||||||
|
private ImageFactory $image;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private CacheInterface $cache;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
Builder $queryBuilder,
|
||||||
|
ImageFactory $ocImageTool,
|
||||||
|
SettingsService $settings,
|
||||||
|
CacheInterface $cache
|
||||||
|
) {
|
||||||
|
$this->queryBuilder = $queryBuilder;
|
||||||
|
$this->image = $ocImageTool;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->cache = $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$cacheKey = 'categories.index';
|
||||||
|
|
||||||
|
$categories = $this->cache->get($cacheKey);
|
||||||
|
|
||||||
|
if ($categories === null) {
|
||||||
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
$storeId = $this->settings->get('store.oc_store_id', 0);
|
||||||
|
|
||||||
|
$perPage = $request->get('perPage', 100);
|
||||||
|
|
||||||
|
$categoriesFlat = $this->queryBuilder->newQuery()
|
||||||
|
->select([
|
||||||
|
'categories.category_id' => 'id',
|
||||||
|
'categories.parent_id' => 'parent_id',
|
||||||
|
'categories.image' => 'image',
|
||||||
|
'descriptions.name' => 'name',
|
||||||
|
'descriptions.description' => 'description',
|
||||||
|
])
|
||||||
|
->from(db_table('category'), 'categories')
|
||||||
|
->join(
|
||||||
|
db_table('category_description') . ' AS descriptions',
|
||||||
|
function (JoinClause $join) use ($languageId) {
|
||||||
|
$join->on('categories.category_id', '=', 'descriptions.category_id')
|
||||||
|
->where('descriptions.language_id', '=', $languageId);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->join(
|
||||||
|
new Table(db_table('category_to_store'), 'category_to_store'),
|
||||||
|
function (JoinClause $join) use ($storeId) {
|
||||||
|
$join->on('category_to_store.category_id', '=', 'categories.category_id')
|
||||||
|
->where('category_to_store.store_id', '=', $storeId);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->where('categories.status', '=', 1)
|
||||||
|
->orderBy('parent_id')
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$categories = $this->buildCategoryTree($categoriesFlat);
|
||||||
|
|
||||||
|
$categories = array_slice($categories, 0, $perPage);
|
||||||
|
|
||||||
|
$this->cache->set($cacheKey, $categories, 60 * 60 * 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => array_map(static function ($category) {
|
||||||
|
return [
|
||||||
|
'id' => (int) $category['id'],
|
||||||
|
'image' => $category['image'] ?? '',
|
||||||
|
'name' => Str::htmlEntityEncode($category['name']),
|
||||||
|
'description' => $category['description'],
|
||||||
|
'children' => $category['children'],
|
||||||
|
];
|
||||||
|
}, $categories),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildCategoryTree(array $flat, $parentId = 0): array
|
||||||
|
{
|
||||||
|
$branch = [];
|
||||||
|
|
||||||
|
foreach ($flat as $category) {
|
||||||
|
if ((int) $category['parent_id'] === (int) $parentId) {
|
||||||
|
$children = $this->buildCategoryTree($flat, $category['id']);
|
||||||
|
if ($children) {
|
||||||
|
$category['children'] = $children;
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = $this->image
|
||||||
|
->make($category['image'])
|
||||||
|
->resize(self::THUMB_SIZE, self::THUMB_SIZE)
|
||||||
|
->url();
|
||||||
|
|
||||||
|
$branch[] = [
|
||||||
|
'id' => (int) $category['id'],
|
||||||
|
'image' => $image,
|
||||||
|
'name' => Utils::htmlEntityEncode($category['name']),
|
||||||
|
'description' => $category['description'],
|
||||||
|
'children' => $category['children'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $branch;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
backend/src/app/Handlers/CronHandler.php
Normal file
58
backend/src/app/Handlers/CronHandler.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Config\Settings;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\Scheduler\SchedulerService;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class CronHandler
|
||||||
|
{
|
||||||
|
private Settings $settings;
|
||||||
|
private SchedulerService $schedulerService;
|
||||||
|
|
||||||
|
public function __construct(Settings $settings, SchedulerService $schedulerService)
|
||||||
|
{
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->schedulerService = $schedulerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запуск планировщика по HTTP (для cron-job.org и аналогов).
|
||||||
|
* Требует api_key в query, совпадающий с настройкой cron.api_key.
|
||||||
|
*/
|
||||||
|
public function runSchedule(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$mode = $this->settings->get('cron.mode', 'disabled');
|
||||||
|
if ($mode !== 'cron_job_org') {
|
||||||
|
return new JsonResponse(['error' => 'Scheduler is not in cron-job.org mode'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey = $request->get('api_key', '');
|
||||||
|
$expectedKey = $this->settings->get('cron.api_key', '');
|
||||||
|
if ($expectedKey === '' || $apiKey === '' || !hash_equals($expectedKey, $apiKey)) {
|
||||||
|
return new JsonResponse(['error' => 'Invalid or missing API key'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Увеличиваем лимит времени выполнения при запуске по HTTP, чтобы снизить риск timeout
|
||||||
|
$limit = 300; // 5 минут
|
||||||
|
if (function_exists('set_time_limit')) {
|
||||||
|
@set_time_limit($limit);
|
||||||
|
}
|
||||||
|
if (function_exists('ini_set')) {
|
||||||
|
@ini_set('max_execution_time', (string) $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->schedulerService->run(true);
|
||||||
|
$data = [
|
||||||
|
'success' => true,
|
||||||
|
'executed' => count($result->executed),
|
||||||
|
'failed' => count($result->failed),
|
||||||
|
'skipped' => count($result->skipped),
|
||||||
|
];
|
||||||
|
|
||||||
|
return new JsonResponse($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
backend/src/app/Handlers/ETLHandler.php
Executable file
187
backend/src/app/Handlers/ETLHandler.php
Executable file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Acme\ECommerceFramework\Config\Settings;
|
||||||
|
use Acme\ECommerceFramework\Exceptions\InvalidApiTokenException;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
|
||||||
|
use Acme\ECommerceFramework\Support\DateUtils;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class ETLHandler
|
||||||
|
{
|
||||||
|
private Builder $builder;
|
||||||
|
private Settings $settings;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
|
public function __construct(Builder $builder, Settings $settings, LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
$this->builder = $builder;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLastUpdatedAtSql(): string
|
||||||
|
{
|
||||||
|
return '
|
||||||
|
GREATEST(
|
||||||
|
COALESCE((
|
||||||
|
SELECT MAX(date_modified)
|
||||||
|
FROM oc_order as o
|
||||||
|
where o.customer_id = acmeshop_customers.oc_customer_id
|
||||||
|
), 0),
|
||||||
|
acmeshop_customers.updated_at
|
||||||
|
)
|
||||||
|
';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCustomerQuery(?Carbon $updatedAt = null): Builder
|
||||||
|
{
|
||||||
|
$lastUpdatedAtSql = $this->getLastUpdatedAtSql();
|
||||||
|
|
||||||
|
return $this->builder->newQuery()
|
||||||
|
->from('acmeshop_customers')
|
||||||
|
->where('allows_write_to_pm', '=', 1)
|
||||||
|
->when($updatedAt !== null, function (Builder $builder) use ($lastUpdatedAtSql, $updatedAt) {
|
||||||
|
$builder->where(new RawExpression($lastUpdatedAtSql), '>=', $updatedAt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws InvalidApiTokenException
|
||||||
|
*/
|
||||||
|
public function getCustomersMeta(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->validateApiKey($request);
|
||||||
|
|
||||||
|
$updatedAt = $request->get('updated_at');
|
||||||
|
if ($updatedAt) {
|
||||||
|
$updatedAt = DateUtils::toSystemTimezone($updatedAt);
|
||||||
|
}
|
||||||
|
$query = $this->getCustomerQuery($updatedAt);
|
||||||
|
$total = $query->count();
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'total' => $total,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws InvalidApiTokenException
|
||||||
|
*/
|
||||||
|
public function customers(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->validateApiKey($request);
|
||||||
|
|
||||||
|
$this->logger->debug('Get customers for ETL');
|
||||||
|
|
||||||
|
$page = (int)$request->get('page', 1);
|
||||||
|
$perPage = (int)$request->get('perPage', 10000);
|
||||||
|
$successOrderStatusIds = '5,3';
|
||||||
|
$updatedAt = $request->get('updated_at');
|
||||||
|
if ($updatedAt) {
|
||||||
|
$updatedAt = DateUtils::toSystemTimezone($updatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastUpdatedAtSql = $this->getLastUpdatedAtSql();
|
||||||
|
$query = $this->getCustomerQuery($updatedAt);
|
||||||
|
|
||||||
|
$query->orderBy('telegram_user_id');
|
||||||
|
$query->forPage($page, $perPage);
|
||||||
|
|
||||||
|
$query
|
||||||
|
->select([
|
||||||
|
'tracking_id',
|
||||||
|
'username',
|
||||||
|
'photo_url',
|
||||||
|
'telegram_user_id' => 'tg_user_id',
|
||||||
|
'acmeshop_customers.oc_customer_id',
|
||||||
|
'is_premium',
|
||||||
|
'last_seen_at',
|
||||||
|
'orders_count' => 'orders_count_total',
|
||||||
|
'created_at' => 'registered_at',
|
||||||
|
new RawExpression(
|
||||||
|
'(
|
||||||
|
SELECT MIN(date_added)
|
||||||
|
FROM oc_order
|
||||||
|
WHERE oc_order.customer_id = acmeshop_customers.oc_customer_id
|
||||||
|
) AS first_order_date'
|
||||||
|
),
|
||||||
|
new RawExpression(
|
||||||
|
'(
|
||||||
|
SELECT MAX(date_added)
|
||||||
|
FROM oc_order
|
||||||
|
WHERE oc_order.customer_id = acmeshop_customers.oc_customer_id
|
||||||
|
) AS last_order_date'
|
||||||
|
),
|
||||||
|
new RawExpression(
|
||||||
|
"COALESCE((
|
||||||
|
SELECT
|
||||||
|
SUM(total)
|
||||||
|
FROM
|
||||||
|
oc_order
|
||||||
|
WHERE
|
||||||
|
oc_order.customer_id = acmeshop_customers.oc_customer_id
|
||||||
|
AND oc_order.order_status_id IN ($successOrderStatusIds)
|
||||||
|
), 0) AS total_spent"
|
||||||
|
),
|
||||||
|
new RawExpression(
|
||||||
|
"COALESCE((
|
||||||
|
SELECT
|
||||||
|
COUNT(*)
|
||||||
|
FROM
|
||||||
|
oc_order
|
||||||
|
WHERE
|
||||||
|
oc_order.customer_id = acmeshop_customers.oc_customer_id
|
||||||
|
AND oc_order.order_status_id IN ($successOrderStatusIds)
|
||||||
|
), 0) AS orders_count_success"
|
||||||
|
),
|
||||||
|
new RawExpression("$lastUpdatedAtSql AS updated_at"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = $query->get();
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => array_map(static function ($item) {
|
||||||
|
return [
|
||||||
|
'tracking_id' => $item['tracking_id'],
|
||||||
|
'username' => $item['username'],
|
||||||
|
'photo_url' => $item['photo_url'],
|
||||||
|
'tg_user_id' => filter_var($item['tg_user_id'], FILTER_VALIDATE_INT),
|
||||||
|
'oc_customer_id' => filter_var($item['oc_customer_id'], FILTER_VALIDATE_INT),
|
||||||
|
'is_premium' => filter_var($item['is_premium'], FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'last_seen_at' => DateUtils::toUTC($item['last_seen_at']),
|
||||||
|
'orders_count_total' => filter_var($item['orders_count_total'], FILTER_VALIDATE_INT),
|
||||||
|
'registered_at' => DateUtils::toUTC($item['registered_at']),
|
||||||
|
'first_order_date' => DateUtils::toUTC($item['first_order_date']),
|
||||||
|
'last_order_date' => DateUtils::toUTC($item['last_order_date']),
|
||||||
|
'total_spent' => (float)$item['total_spent'],
|
||||||
|
'orders_count_success' => filter_var($item['orders_count_success'], FILTER_VALIDATE_INT),
|
||||||
|
'updated_at' => DateUtils::toUTC($item['updated_at']),
|
||||||
|
];
|
||||||
|
}, $items),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws InvalidApiTokenException
|
||||||
|
*/
|
||||||
|
private function validateApiKey(Request $request): void
|
||||||
|
{
|
||||||
|
$token = $request->getApiKey();
|
||||||
|
|
||||||
|
if (empty($token)) {
|
||||||
|
throw new InvalidApiTokenException('Invalid API Key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strcmp($token, $this->settings->get('pulse.api_key')) !== 0) {
|
||||||
|
throw new InvalidApiTokenException('Invalid API Key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
backend/src/app/Handlers/FiltersHandler.php
Executable file
49
backend/src/app/Handlers/FiltersHandler.php
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Filters\ProductCategory;
|
||||||
|
use App\Filters\ProductPrice;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
|
||||||
|
class FiltersHandler
|
||||||
|
{
|
||||||
|
public function getFiltersForMainPage(): JsonResponse
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'operand' => 'AND',
|
||||||
|
'rules' => [
|
||||||
|
ProductPrice::NAME => [
|
||||||
|
'criteria' => [
|
||||||
|
'product_price' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'params' => [
|
||||||
|
'operator' => 'between',
|
||||||
|
'value' => [
|
||||||
|
'from' => 0,
|
||||||
|
'to' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
ProductCategory::NAME => [
|
||||||
|
'criteria' => [
|
||||||
|
'product_category_id' => [
|
||||||
|
'type' => 'product_category',
|
||||||
|
'params' => [
|
||||||
|
'operator' => 'contains',
|
||||||
|
'value' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $filters,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
backend/src/app/Handlers/FormsHandler.php
Executable file
51
backend/src/app/Handlers/FormsHandler.php
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use JsonException;
|
||||||
|
use Acme\ECommerceFramework\Exceptions\EntityNotFoundException;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
|
||||||
|
class FormsHandler
|
||||||
|
{
|
||||||
|
private Builder $builder;
|
||||||
|
|
||||||
|
public function __construct(Builder $builder)
|
||||||
|
{
|
||||||
|
$this->builder = $builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws EntityNotFoundException
|
||||||
|
* @throws JsonException
|
||||||
|
*/
|
||||||
|
public function getForm(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$alias = $request->json('alias');
|
||||||
|
if (! $alias) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'error' => 'Form alias is required',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->builder->newQuery()
|
||||||
|
->from('acmeshop_forms')
|
||||||
|
->where('alias', '=', $alias)
|
||||||
|
->firstOrNull();
|
||||||
|
|
||||||
|
if (! $form) {
|
||||||
|
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'schema' => $schema,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/app/Handlers/HealthCheckHandler.php
Executable file
16
backend/src/app/Handlers/HealthCheckHandler.php
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class HealthCheckHandler
|
||||||
|
{
|
||||||
|
public function handle(): JsonResponse
|
||||||
|
{
|
||||||
|
return new JsonResponse([
|
||||||
|
'status' => 'ok',
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/src/app/Handlers/OrderHandler.php
Executable file
37
backend/src/app/Handlers/OrderHandler.php
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Exceptions\OrderValidationFailedException;
|
||||||
|
use App\Services\OrderCreateService;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class OrderHandler
|
||||||
|
{
|
||||||
|
private OrderCreateService $orderCreateService;
|
||||||
|
|
||||||
|
public function __construct(OrderCreateService $orderCreateService)
|
||||||
|
{
|
||||||
|
$this->orderCreateService = $orderCreateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$order = $this->orderCreateService->create($request->json(), [
|
||||||
|
'ip' => $request->getClientIp(),
|
||||||
|
'user_agent' => $request->getUserAgent(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $order,
|
||||||
|
], Response::HTTP_CREATED);
|
||||||
|
} catch (OrderValidationFailedException $exception) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $exception->getErrorBag()->firstOfAll(),
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/src/app/Handlers/PrivacyPolicyHandler.php
Executable file
73
backend/src/app/Handlers/PrivacyPolicyHandler.php
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Models\TelegramCustomer;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Enums\TelegramHeader;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class PrivacyPolicyHandler
|
||||||
|
{
|
||||||
|
private TelegramService $telegramService;
|
||||||
|
private TelegramCustomer $telegramCustomer;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
TelegramService $telegramService,
|
||||||
|
TelegramCustomer $telegramCustomer,
|
||||||
|
LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
$this->telegramService = $telegramService;
|
||||||
|
$this->telegramCustomer = $telegramCustomer;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkIsUserPrivacyConsented(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$isPrivacyConsented = false;
|
||||||
|
$telegramUserId = $this->telegramService->userId($request->header(TelegramHeader::INIT_DATA));
|
||||||
|
|
||||||
|
if (! $telegramUserId) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'is_privacy_consented' => false,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
|
||||||
|
|
||||||
|
if ($customer) {
|
||||||
|
$isPrivacyConsented = Arr::get($customer, 'privacy_consented_at') !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'is_privacy_consented' => $isPrivacyConsented,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userPrivacyConsent(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$telegramUserId = $this->telegramService->userId($request->header(TelegramHeader::INIT_DATA));
|
||||||
|
|
||||||
|
if ($telegramUserId) {
|
||||||
|
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, [
|
||||||
|
'privacy_consented_at' => Carbon::now()->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$this->logger->warning(
|
||||||
|
'Could not find customer with telegram user_id: ' . $telegramUserId . ' to give privacy consent.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
backend/src/app/Handlers/ProductsHandler.php
Executable file
125
backend/src/app/Handlers/ProductsHandler.php
Executable file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Services\ProductsService;
|
||||||
|
use App\Services\SettingsService;
|
||||||
|
use Exception;
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheInterface;
|
||||||
|
use Acme\ECommerceFramework\Exceptions\EntityNotFoundException;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ProductsHandler
|
||||||
|
{
|
||||||
|
private SettingsService $settings;
|
||||||
|
private ProductsService $productsService;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
private CacheInterface $cache;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
SettingsService $settings,
|
||||||
|
ProductsService $productsService,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
CacheInterface $cache
|
||||||
|
) {
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->productsService = $productsService;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->cache = $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = (int) $request->json('page', 1);
|
||||||
|
$perPage = min((int) $request->json('perPage', 20), 20);
|
||||||
|
$maxPages = (int) $request->json('maxPages', 10);
|
||||||
|
$search = trim($request->json('search', ''));
|
||||||
|
$filters = $request->json('filters');
|
||||||
|
$storeId = $this->settings->get('store.oc_store_id', 0);
|
||||||
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
|
||||||
|
$response = $this->productsService->getProductsResponse(
|
||||||
|
compact('page', 'perPage', 'search', 'filters', 'maxPages'),
|
||||||
|
$languageId,
|
||||||
|
$storeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$productId = (int) $request->get('id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$product = $this->productsService->getProductById($productId);
|
||||||
|
} catch (EntityNotFoundException $exception) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => 'Product with id ' . $productId . ' not found',
|
||||||
|
], Response::HTTP_NOT_FOUND);
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||||
|
throw new RuntimeException('Error get product with id ' . $productId, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $product,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProductImages(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$productId = (int) $request->get('id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$images = $this->productsService->getProductImages($productId);
|
||||||
|
} catch (EntityNotFoundException $exception) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => 'Product with id ' . $productId . ' not found',
|
||||||
|
], Response::HTTP_NOT_FOUND);
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$this->logger->error('Could not load images for product ' . $productId, ['exception' => $exception]);
|
||||||
|
$images = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $images,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSearchPlaceholder(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$storeId = $this->settings->get('store.oc_store_id', 0);
|
||||||
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
$cacheKey = "products.search_placeholder.{$storeId}.{$languageId}";
|
||||||
|
|
||||||
|
$cached = $this->cache->get($cacheKey);
|
||||||
|
|
||||||
|
if ($cached !== null) {
|
||||||
|
return new JsonResponse($cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->productsService->getProductsResponse(
|
||||||
|
[
|
||||||
|
'page' => 1,
|
||||||
|
'perPage' => 3,
|
||||||
|
'search' => '',
|
||||||
|
'filters' => [],
|
||||||
|
'maxPages' => 1,
|
||||||
|
],
|
||||||
|
$languageId,
|
||||||
|
$storeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Кешируем на 24 часа
|
||||||
|
$this->cache->set($cacheKey, $response, 60 * 60 * 24);
|
||||||
|
|
||||||
|
return new JsonResponse($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
backend/src/app/Handlers/SettingsHandler.php
Executable file
115
backend/src/app/Handlers/SettingsHandler.php
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Services\SettingsService;
|
||||||
|
use Exception;
|
||||||
|
use GuzzleHttp\Exception\ClientException;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageFactory;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramService;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
|
||||||
|
class SettingsHandler
|
||||||
|
{
|
||||||
|
private SettingsService $settings;
|
||||||
|
private ImageFactory $image;
|
||||||
|
private TelegramService $telegramService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
SettingsService $settings,
|
||||||
|
ImageFactory $image,
|
||||||
|
TelegramService $telegramService
|
||||||
|
) {
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->image = $image;
|
||||||
|
$this->telegramService = $telegramService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$appConfig = $this->settings->config()->getApp();
|
||||||
|
|
||||||
|
$appIcon = $appConfig->getAppIcon();
|
||||||
|
$hash = $this->settings->getHash();
|
||||||
|
|
||||||
|
if ($appIcon) {
|
||||||
|
$appIcon = $this->image->make($appIcon)->resize(null, 64)->url('png') . '?_v=' . $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'app_name' => $appConfig->getAppName(),
|
||||||
|
'app_debug' => $appConfig->isAppDebug(),
|
||||||
|
'app_icon' => $appIcon,
|
||||||
|
'theme_light' => $appConfig->getThemeLight(),
|
||||||
|
'theme_dark' => $appConfig->getThemeDark(),
|
||||||
|
'ya_metrika_enabled' => $this->settings->config()->getMetrics()->isYandexMetrikaEnabled(),
|
||||||
|
'app_enabled' => $appConfig->isAppEnabled(),
|
||||||
|
'product_interaction_mode' => $this->settings->config()->getStore()->getProductInteractionMode(),
|
||||||
|
'manager_username' => $this->settings->config()->getStore()->getManagerUsername(),
|
||||||
|
'feature_coupons' => $this->settings->config()->getStore()->isFeatureCoupons(),
|
||||||
|
'feature_vouchers' => $this->settings->config()->getStore()->isFeatureVouchers(),
|
||||||
|
'show_category_products_button' => $this->settings->config()->getStore()->isShowCategoryProductsButton(),
|
||||||
|
'currency_code' => $this->settings->config()->getStore()->getOcDefaultCurrency(),
|
||||||
|
'texts' => $this->settings->config()->getTexts()->toArray(),
|
||||||
|
'mainpage_blocks' => $this->settings->get('mainpage_blocks', []),
|
||||||
|
'privacy_policy_link' => $this->settings->get('app.privacy_policy_link'),
|
||||||
|
'image_aspect_ratio' => $this->settings->get('app.image_aspect_ratio', '1:1'),
|
||||||
|
'haptic_enabled' => $appConfig->isHapticEnabled(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTgMessage(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$template = $request->json('template', 'Нет шаблона');
|
||||||
|
$token = $request->json('token');
|
||||||
|
$chatId = $request->json('chat_id');
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => 'Не задан Telegram BotToken',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $chatId) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => 'Не задан ChatID.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variables = [
|
||||||
|
'{store_name}' => $this->settings->config()->getApp()->getAppName(),
|
||||||
|
'{order_id}' => 777,
|
||||||
|
'{customer}' => 'Иван Васильевич',
|
||||||
|
'{email}' => 'telegram@ecommerce.com',
|
||||||
|
'{phone}' => '+79999999999',
|
||||||
|
'{comment}' => 'Это тестовый заказ',
|
||||||
|
'{address}' => 'г. Москва',
|
||||||
|
'{total}' => 100000,
|
||||||
|
'{ip}' => '127.0.0.1',
|
||||||
|
'{created_at}' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$message = $this->telegramService->prepareMessage($template, $variables);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->telegramService
|
||||||
|
->setBotToken($token)
|
||||||
|
->sendMessage($chatId, $message);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => 'Сообщение отправлено. Проверьте Telegram.',
|
||||||
|
]);
|
||||||
|
} catch (ClientException $exception) {
|
||||||
|
$json = json_decode($exception->getResponse()->getBody(), true);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => $json['description'],
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
backend/src/app/Handlers/TelegramCustomerHandler.php
Executable file
134
backend/src/app/Handlers/TelegramCustomerHandler.php
Executable file
@@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use App\Services\MegapayCustomerService;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\TrackingIdGenerator;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Enums\TelegramHeader;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramInitDataDecoder;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class TelegramCustomerHandler
|
||||||
|
{
|
||||||
|
private MegapayCustomerService $telegramCustomerService;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
private TelegramInitDataDecoder $initDataDecoder;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
MegapayCustomerService $telegramCustomerService,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
TelegramInitDataDecoder $initDataDecoder
|
||||||
|
) {
|
||||||
|
$this->telegramCustomerService = $telegramCustomerService;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->initDataDecoder = $initDataDecoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить или обновить Telegram-пользователя
|
||||||
|
*
|
||||||
|
* @param Request $request HTTP запрос с данными пользователя
|
||||||
|
* @return JsonResponse JSON ответ с результатом операции
|
||||||
|
*/
|
||||||
|
public function saveOrUpdate(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$customer = $this->telegramCustomerService->saveOrUpdate(
|
||||||
|
$this->extractTelegramUserData($request)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'tracking_id' => Arr::get($customer, 'tracking_id'),
|
||||||
|
'created_at' => Arr::get($customer, 'created_at'),
|
||||||
|
],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('Could not save telegram customer data', ['exception' => $e]);
|
||||||
|
|
||||||
|
return new JsonResponse([], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить данные текущего пользователя
|
||||||
|
*
|
||||||
|
* @param Request $request HTTP запрос
|
||||||
|
* @return JsonResponse JSON ответ с данными пользователя
|
||||||
|
*/
|
||||||
|
public function getCurrent(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$telegramUserData = $this->extractUserDataFromInitData($request);
|
||||||
|
$telegramUserId = (int)Arr::get($telegramUserData, 'id');
|
||||||
|
|
||||||
|
if ($telegramUserId <= 0) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => null,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = $this->telegramCustomerService->getByTelegramUserId($telegramUserId);
|
||||||
|
|
||||||
|
if (!$customer) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => null,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'created_at' => Arr::get($customer, 'created_at'),
|
||||||
|
],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error('Could not get current telegram customer data', ['exception' => $e]);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => null,
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлечь данные Telegram пользователя из запроса
|
||||||
|
*
|
||||||
|
* @param Request $request HTTP запрос
|
||||||
|
* @return array Данные пользователя
|
||||||
|
* @throws RuntimeException|DecodeTelegramInitDataException невозможно извлечь данные пользователя из Request
|
||||||
|
*/
|
||||||
|
private function extractTelegramUserData(Request $request): array
|
||||||
|
{
|
||||||
|
$telegramUserData = $request->json('user');
|
||||||
|
|
||||||
|
if (! $telegramUserData) {
|
||||||
|
$telegramUserData = $this->extractUserDataFromInitData($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $telegramUserData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws DecodeTelegramInitDataException
|
||||||
|
*/
|
||||||
|
private function extractUserDataFromInitData(Request $request): array
|
||||||
|
{
|
||||||
|
$raw = $request->header(TelegramHeader::INIT_DATA);
|
||||||
|
if (! $raw) {
|
||||||
|
throw new RuntimeException('No init data found in http request header');
|
||||||
|
}
|
||||||
|
|
||||||
|
$initData = $this->initDataDecoder->decode($raw);
|
||||||
|
|
||||||
|
return Arr::get($initData, 'user');
|
||||||
|
}
|
||||||
|
}
|
||||||
99
backend/src/app/Handlers/TelegramHandler.php
Executable file
99
backend/src/app/Handlers/TelegramHandler.php
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use Mockery\Exception;
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheInterface;
|
||||||
|
use Acme\ECommerceFramework\Container\Container;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Contracts\TelegramCommandInterface;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Exceptions\TelegramCommandNotFoundException;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramBotStateManager;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramCommandsRegistry;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramService;
|
||||||
|
|
||||||
|
class TelegramHandler
|
||||||
|
{
|
||||||
|
private CacheInterface $cache;
|
||||||
|
private TelegramCommandsRegistry $telegramCommandsRegistry;
|
||||||
|
private Container $container;
|
||||||
|
private TelegramBotStateManager $telegramBotStateManager;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
private TelegramService $telegramService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
CacheInterface $cache,
|
||||||
|
TelegramCommandsRegistry $telegramCommandsRegistry,
|
||||||
|
Container $container,
|
||||||
|
TelegramBotStateManager $telegramBotStateManager,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
TelegramService $telegramService
|
||||||
|
) {
|
||||||
|
$this->cache = $cache;
|
||||||
|
$this->telegramCommandsRegistry = $telegramCommandsRegistry;
|
||||||
|
$this->container = $container;
|
||||||
|
$this->telegramBotStateManager = $telegramBotStateManager;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->telegramService = $telegramService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws GuzzleException
|
||||||
|
* @throws \JsonException
|
||||||
|
*/
|
||||||
|
public function webhook(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->logger->debug('Webhook received');
|
||||||
|
|
||||||
|
$update = $request->json();
|
||||||
|
$message = $update['message'] ?? null;
|
||||||
|
if (! $message) {
|
||||||
|
return new JsonResponse([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $update['message']['from']['id'];
|
||||||
|
$chatId = $update['message']['chat']['id'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$message = Arr::get($update, 'message', []);
|
||||||
|
|
||||||
|
$this->cache->set('tg_latest_msg', $message, 60);
|
||||||
|
|
||||||
|
$text = Arr::get($message, 'text', '');
|
||||||
|
|
||||||
|
// command starts from "/"
|
||||||
|
if (strpos($text, '/') === 0) {
|
||||||
|
$this->telegramBotStateManager->clearState($userId, $chatId);
|
||||||
|
$command = substr($text, 1);
|
||||||
|
$handler = $this->telegramCommandsRegistry->resolve($command);
|
||||||
|
|
||||||
|
/** @var TelegramCommandInterface $concrete */
|
||||||
|
$concrete = $this->container->get($handler);
|
||||||
|
$concrete->handle($update);
|
||||||
|
|
||||||
|
return new JsonResponse([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue state
|
||||||
|
$hasState = $this->telegramBotStateManager->hasState($userId, $chatId);
|
||||||
|
if ($hasState) {
|
||||||
|
$handler = $this->telegramBotStateManager->getCurrentStateCommandHandler($userId, $chatId);
|
||||||
|
/** @var TelegramCommandInterface $concrete */
|
||||||
|
$concrete = $this->container->get($handler);
|
||||||
|
$concrete->handle($update);
|
||||||
|
|
||||||
|
return new JsonResponse([]);
|
||||||
|
}
|
||||||
|
} catch (TelegramCommandNotFoundException $exception) {
|
||||||
|
$this->telegramService->sendMessage($chatId, 'Неверная команда');
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/src/app/Handlers/TelemetryHandler.php
Executable file
48
backend/src/app/Handlers/TelemetryHandler.php
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Handlers;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\PulseIngestException;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class TelemetryHandler
|
||||||
|
{
|
||||||
|
private AcmeShopPulseService $megaPayPulseService;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
AcmeShopPulseService $megaPayPulseService,
|
||||||
|
LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
$this->megaPayPulseService = $megaPayPulseService;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws PulseIngestException
|
||||||
|
*/
|
||||||
|
public function ingest(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->megaPayPulseService->handleIngest($request->json());
|
||||||
|
|
||||||
|
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function heartbeat(): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->megaPayPulseService->handleHeartbeat();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->warning('AcmeShop Pulse Heartbeat failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(['status' => 'ok']);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
backend/src/app/Models/TelegramCustomer.php
Executable file
135
backend/src/app/Models/TelegramCustomer.php
Executable file
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\TrackingIdGenerator;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class TelegramCustomer
|
||||||
|
{
|
||||||
|
private const TABLE_NAME = 'acmeshop_customers';
|
||||||
|
|
||||||
|
private ConnectionInterface $database;
|
||||||
|
private Builder $builder;
|
||||||
|
|
||||||
|
public function __construct(ConnectionInterface $database, Builder $builder)
|
||||||
|
{
|
||||||
|
$this->database = $database;
|
||||||
|
$this->builder = $builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти запись по ID
|
||||||
|
*
|
||||||
|
* @param int $id ID записи
|
||||||
|
* @return array|null Данные пользователя или null если не найдено
|
||||||
|
*/
|
||||||
|
public function findById(int $id): ?array
|
||||||
|
{
|
||||||
|
return $this->builder
|
||||||
|
->newQuery()
|
||||||
|
->select(['*'])
|
||||||
|
->from(self::TABLE_NAME)
|
||||||
|
->where('id', '=', $id)
|
||||||
|
->firstOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти запись по Telegram user ID
|
||||||
|
*
|
||||||
|
* @param int $telegramUserId Telegram user ID
|
||||||
|
* @return array|null Данные пользователя или null если не найдено
|
||||||
|
*/
|
||||||
|
public function findByTelegramUserId(int $telegramUserId): ?array
|
||||||
|
{
|
||||||
|
return $this->builder
|
||||||
|
->newQuery()
|
||||||
|
->select(['*'])
|
||||||
|
->from(self::TABLE_NAME)
|
||||||
|
->where('telegram_user_id', '=', $telegramUserId)
|
||||||
|
->firstOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти запись по oc_customer_id
|
||||||
|
*
|
||||||
|
* @param int $customerId ID покупателя в ECommerce
|
||||||
|
* @return array|null Данные пользователя или null если не найдено
|
||||||
|
*/
|
||||||
|
public function findByCustomerId(int $customerId): ?array
|
||||||
|
{
|
||||||
|
return $this->builder
|
||||||
|
->newQuery()
|
||||||
|
->select(['*'])
|
||||||
|
->from(self::TABLE_NAME)
|
||||||
|
->where('oc_customer_id', '=', $customerId)
|
||||||
|
->firstOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать новую запись
|
||||||
|
*
|
||||||
|
* @param array $data Данные для создания записи
|
||||||
|
* @return int ID созданной записи
|
||||||
|
* @throws RuntimeException Если не удалось создать запись
|
||||||
|
*/
|
||||||
|
public function create(array $data): int
|
||||||
|
{
|
||||||
|
$data['created_at'] = Carbon::now()->toDateTimeString();
|
||||||
|
$data['updated_at'] = Carbon::now()->toDateTimeString();
|
||||||
|
$data['tracking_id'] = TrackingIdGenerator::generate();
|
||||||
|
|
||||||
|
$success = $this->database->insert(self::TABLE_NAME, $data);
|
||||||
|
|
||||||
|
if (! $success) {
|
||||||
|
$error = $this->database->getLastError();
|
||||||
|
$errorMessage = $error ? $error[1] : 'Unknown error';
|
||||||
|
throw new RuntimeException("Failed to insert telegram customer. Error: {$errorMessage}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->database->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить запись по Telegram user ID
|
||||||
|
*
|
||||||
|
* @param int $telegramUserId Telegram user ID
|
||||||
|
* @param array $data Данные для обновления
|
||||||
|
* @return bool true если обновление успешно
|
||||||
|
*/
|
||||||
|
public function updateByTelegramUserId(int $telegramUserId, array $data): bool
|
||||||
|
{
|
||||||
|
$data['updated_at'] = Carbon::now()->toDateTimeString();
|
||||||
|
|
||||||
|
return $this->builder->newQuery()
|
||||||
|
->where('telegram_user_id', '=', $telegramUserId)
|
||||||
|
->update(self::TABLE_NAME, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить last_seen_at для пользователя
|
||||||
|
*
|
||||||
|
* @param int $telegramUserId Telegram user ID
|
||||||
|
* @return bool true если обновление успешно
|
||||||
|
*/
|
||||||
|
public function updateLastSeen(int $telegramUserId): bool
|
||||||
|
{
|
||||||
|
return $this->updateByTelegramUserId($telegramUserId, [
|
||||||
|
'last_seen_at' => Carbon::now()->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function increase(int $id, string $field): bool
|
||||||
|
{
|
||||||
|
$now = Carbon::now()->toDateTimeString();
|
||||||
|
$table = self::TABLE_NAME;
|
||||||
|
$sql = "UPDATE `$table` SET `$field` = `$field` + 1, updated_at = '$now' WHERE id = ?";
|
||||||
|
|
||||||
|
return $this->database->statement($sql, [$id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
backend/src/app/ServiceProviders/AppServiceProvider.php
Executable file
69
backend/src/app/ServiceProviders/AppServiceProvider.php
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\ServiceProviders;
|
||||||
|
|
||||||
|
use App\Exceptions\CustomExceptionHandler;
|
||||||
|
use App\Filters\ProductAttribute;
|
||||||
|
use App\Filters\ProductCategories;
|
||||||
|
use App\Filters\ProductCategory;
|
||||||
|
use App\Filters\ProductManufacturer;
|
||||||
|
use App\Filters\ProductModel;
|
||||||
|
use App\Filters\ProductPrice;
|
||||||
|
use App\Filters\ProductQuantity;
|
||||||
|
use App\Filters\ProductStatus;
|
||||||
|
use App\Telegram\LinkCommand;
|
||||||
|
use Acme\ECommerceFramework\Container\ServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Contracts\ExceptionHandlerInterface;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\RulesRegistry;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Commands\ChatIdCommand;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Commands\StartCommand;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramCommandsRegistry;
|
||||||
|
|
||||||
|
class AppServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->container->singleton(ExceptionHandlerInterface::class, function () {
|
||||||
|
return new CustomExceptionHandler();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->registerTelegramCommands();
|
||||||
|
$this->registerFacetFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerTelegramCommands(): void
|
||||||
|
{
|
||||||
|
$this->container->singleton(TelegramCommandsRegistry::class, function () {
|
||||||
|
return new TelegramCommandsRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
$registry = $this->container->get(TelegramCommandsRegistry::class);
|
||||||
|
|
||||||
|
$registry->addCommand('id', ChatIdCommand::class, 'Возвращает ChatID текущего чата.');
|
||||||
|
$registry->addCommand('link', LinkCommand::class, 'Генератор Telegram сообщений с кнопкой');
|
||||||
|
$registry->addCommand(
|
||||||
|
'start',
|
||||||
|
StartCommand::class,
|
||||||
|
'Базовая команда Telegram бота. Присылает ссылку на открытие Megapay магазина.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function registerFacetFilters(): void
|
||||||
|
{
|
||||||
|
$this->container->singleton(RulesRegistry::class, function () {
|
||||||
|
return new RulesRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
$registry = $this->container->get(RulesRegistry::class);
|
||||||
|
$registry->register([
|
||||||
|
ProductAttribute::NAME => ProductAttribute::class,
|
||||||
|
ProductCategories::NAME => ProductCategories::class,
|
||||||
|
ProductManufacturer::NAME => ProductManufacturer::class,
|
||||||
|
ProductModel::NAME => ProductModel::class,
|
||||||
|
ProductPrice::NAME => ProductPrice::class,
|
||||||
|
ProductQuantity::NAME => ProductQuantity::class,
|
||||||
|
ProductStatus::NAME => ProductStatus::class,
|
||||||
|
ProductCategory::NAME => ProductCategory::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/src/app/ServiceProviders/SettingsServiceProvider.php
Executable file
21
backend/src/app/ServiceProviders/SettingsServiceProvider.php
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\ServiceProviders;
|
||||||
|
|
||||||
|
use App\Services\SettingsSerializerService;
|
||||||
|
use App\Services\SettingsService;
|
||||||
|
use Acme\ECommerceFramework\Container\Container;
|
||||||
|
use Acme\ECommerceFramework\Container\ServiceProvider;
|
||||||
|
|
||||||
|
class SettingsServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->container->singleton(SettingsService::class, function (Container $container) {
|
||||||
|
return new SettingsService(
|
||||||
|
$container->getConfigValue(),
|
||||||
|
$container->get(SettingsSerializerService::class)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
151
backend/src/app/Services/BlocksService.php
Executable file
151
backend/src/app/Services/BlocksService.php
Executable file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheInterface;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageFactory;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class BlocksService
|
||||||
|
{
|
||||||
|
private static array $processors = [
|
||||||
|
'slider' => [self::class, 'processSlider'],
|
||||||
|
'categories_top' => [self::class, 'processCategoriesTop'],
|
||||||
|
'products_feed' => [self::class, 'processProductsFeed'],
|
||||||
|
'products_carousel' => [self::class, 'processProductsCarousel'],
|
||||||
|
];
|
||||||
|
|
||||||
|
private ImageFactory $image;
|
||||||
|
private CacheInterface $cache;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private Builder $queryBuilder;
|
||||||
|
private ProductsService $productsService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
ImageFactory $image,
|
||||||
|
CacheInterface $cache,
|
||||||
|
SettingsService $settings,
|
||||||
|
Builder $queryBuilder,
|
||||||
|
ProductsService $productsService
|
||||||
|
) {
|
||||||
|
$this->image = $image;
|
||||||
|
$this->cache = $cache;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->queryBuilder = $queryBuilder;
|
||||||
|
$this->productsService = $productsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function process(array $block): array
|
||||||
|
{
|
||||||
|
$blockType = $block['type'];
|
||||||
|
$cacheKey = "block_{$blockType}_" . md5(serialize($block['data']));
|
||||||
|
$cacheTtlSeconds = 3600;
|
||||||
|
|
||||||
|
$data = $this->cache->get($cacheKey);
|
||||||
|
if (! $data) {
|
||||||
|
$method = self::$processors[$block['type']] ?? null;
|
||||||
|
|
||||||
|
if (! $method) {
|
||||||
|
throw new RuntimeException('Processor for block type ' . $block['type'] . ' does not exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = call_user_func_array($method, [$block]);
|
||||||
|
|
||||||
|
$this->cache->set($cacheKey, $data, $cacheTtlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processSlider(array $block): array
|
||||||
|
{
|
||||||
|
$slides = $block['data']['slides'];
|
||||||
|
foreach ($slides as $slideIndex => $slide) {
|
||||||
|
if (is_file(DIR_IMAGE . $slide['image'])) {
|
||||||
|
$image = $this->image->make($slide['image']);
|
||||||
|
$block['data']['slides'][$slideIndex]['image'] = $image->cover(1110, 600)->url();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processCategoriesTop(array $block): array
|
||||||
|
{
|
||||||
|
$count = $block['data']['count'];
|
||||||
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
$categories = [];
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
$categories = $this->queryBuilder->newQuery()
|
||||||
|
->select([
|
||||||
|
'categories.category_id' => 'id',
|
||||||
|
'descriptions.name' => 'name',
|
||||||
|
])
|
||||||
|
->from(db_table('category'), 'categories')
|
||||||
|
->join(
|
||||||
|
db_table('category_description') . ' AS descriptions',
|
||||||
|
function (JoinClause $join) use ($languageId) {
|
||||||
|
$join->on('categories.category_id', '=', 'descriptions.category_id')
|
||||||
|
->where('descriptions.language_id', '=', $languageId);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->where('categories.status', '=', 1)
|
||||||
|
->where('categories.parent_id', '=', 0)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderBy('descriptions.name')
|
||||||
|
->limit($count)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$categories = array_map(static function ($category) {
|
||||||
|
$category['id'] = (int) $category['id'];
|
||||||
|
|
||||||
|
return $category;
|
||||||
|
}, $categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
$block['data']['categories'] = $categories;
|
||||||
|
|
||||||
|
return $block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processProductsFeed(array $block): array
|
||||||
|
{
|
||||||
|
return $block;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processProductsCarousel(array $block): array
|
||||||
|
{
|
||||||
|
$categoryId = $block['data']['category_id'];
|
||||||
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
$params = [
|
||||||
|
'page' => 1,
|
||||||
|
'perPage' => 10,
|
||||||
|
'filters' => [
|
||||||
|
"operand" => "AND",
|
||||||
|
"rules" => [
|
||||||
|
"RULE_PRODUCT_CATEGORIES" => [
|
||||||
|
"criteria" => [
|
||||||
|
"product_category_ids" => [
|
||||||
|
"type" => "product_categories",
|
||||||
|
"params" => [
|
||||||
|
"operator" => "contains",
|
||||||
|
"value" => [$categoryId],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$storeId = $this->settings->get('store.store_id', 0);
|
||||||
|
|
||||||
|
$response = $this->productsService->getProductsResponse($params, $languageId, $storeId);
|
||||||
|
|
||||||
|
$block['data']['products'] = $response;
|
||||||
|
|
||||||
|
return $block;
|
||||||
|
}
|
||||||
|
}
|
||||||
314
backend/src/app/Services/CartService.php
Executable file
314
backend/src/app/Services/CartService.php
Executable file
@@ -0,0 +1,314 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Cart\Cart;
|
||||||
|
use Cart\Currency;
|
||||||
|
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
|
||||||
|
|
||||||
|
class CartService
|
||||||
|
{
|
||||||
|
private OcRegistryDecorator $oc;
|
||||||
|
private Cart $cart;
|
||||||
|
private Currency $currency;
|
||||||
|
|
||||||
|
public function __construct(OcRegistryDecorator $registry, Cart $cart, Currency $currency)
|
||||||
|
{
|
||||||
|
$this->oc = $registry;
|
||||||
|
$this->cart = $cart;
|
||||||
|
$this->currency = $currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCart(): array
|
||||||
|
{
|
||||||
|
$this->oc->load->language('checkout/cart');
|
||||||
|
|
||||||
|
if ($this->oc->cart->hasProducts()) {
|
||||||
|
if (
|
||||||
|
! $this->oc->cart->hasStock()
|
||||||
|
&& (
|
||||||
|
! $this->oc->config->get('config_stock_checkout')
|
||||||
|
|| $this->oc->config->get('config_stock_warning')
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
$data['error_warning'] = $this->oc->language->get('error_stock');
|
||||||
|
} elseif (isset($this->oc->session->data['error'])) {
|
||||||
|
$data['error_warning'] = $this->oc->session->data['error'];
|
||||||
|
|
||||||
|
unset($this->oc->session->data['error']);
|
||||||
|
} else {
|
||||||
|
$data['error_warning'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->oc->config->get('config_customer_price') && ! $this->oc->customer->isLogged()) {
|
||||||
|
$data['attention'] = sprintf(
|
||||||
|
$this->oc->language->get('text_login'),
|
||||||
|
$this->oc->url->link('account/login'),
|
||||||
|
$this->oc->url->link('account/register')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$data['attention'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($this->oc->session->data['success'])) {
|
||||||
|
$data['success'] = $this->oc->session->data['success'];
|
||||||
|
|
||||||
|
unset($this->oc->session->data['success']);
|
||||||
|
} else {
|
||||||
|
$data['success'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->oc->config->get('config_cart_weight')) {
|
||||||
|
$data['weight'] = $this->oc->weight->format(
|
||||||
|
$this->oc->cart->getWeight(),
|
||||||
|
$this->oc->config->get('config_weight_class_id'),
|
||||||
|
$this->oc->language->get('decimal_point'),
|
||||||
|
$this->oc->language->get('thousand_point')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$data['weight'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->oc->load->model('tool/image');
|
||||||
|
$this->oc->load->model('tool/upload');
|
||||||
|
|
||||||
|
$data['products'] = array();
|
||||||
|
|
||||||
|
$products = $this->oc->cart->getProducts();
|
||||||
|
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$product_total = 0;
|
||||||
|
|
||||||
|
foreach ($products as $product_2) {
|
||||||
|
if ($product_2['product_id'] == $product['product_id']) {
|
||||||
|
$product_total += $product_2['quantity'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($product['minimum'] > $product_total) {
|
||||||
|
$data['error_warning'] = sprintf(
|
||||||
|
$this->oc->language->get('error_minimum'),
|
||||||
|
$product['name'],
|
||||||
|
$product['minimum']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($product['image']) {
|
||||||
|
$image = $this->oc->model_tool_image->resize(
|
||||||
|
$product['image'],
|
||||||
|
$this->oc->config->get('theme_' . $this->oc->config->get('config_theme') . '_image_cart_width'),
|
||||||
|
$this->oc->config->get('theme_' . $this->oc->config->get('config_theme') . '_image_cart_height')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$image = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$option_data = array();
|
||||||
|
|
||||||
|
foreach ($product['option'] as $option) {
|
||||||
|
if ($option['type'] != 'file') {
|
||||||
|
$value = $option['value'];
|
||||||
|
} else {
|
||||||
|
$upload_info = $this->oc->model_tool_upload->getUploadByCode($option['value']);
|
||||||
|
|
||||||
|
if ($upload_info) {
|
||||||
|
$value = $upload_info['name'];
|
||||||
|
} else {
|
||||||
|
$value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$option_data[] = [
|
||||||
|
'product_option_id' => (int) $option['product_option_id'],
|
||||||
|
'product_option_value_id' => (int) $option['product_option_value_id'],
|
||||||
|
'name' => $option['name'],
|
||||||
|
'value' => (strlen($value) > 20 ? substr($value, 0, 20) . '..' : $value),
|
||||||
|
'type' => $option['type'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceNumeric = 0;
|
||||||
|
$totalNumeric = 0;
|
||||||
|
|
||||||
|
// Display prices
|
||||||
|
if ($this->oc->customer->isLogged() || ! $this->oc->config->get('config_customer_price')) {
|
||||||
|
$unit_price = $this->oc->tax->calculate(
|
||||||
|
$product['price'],
|
||||||
|
$product['tax_class_id'],
|
||||||
|
$this->oc->config->get('config_tax')
|
||||||
|
);
|
||||||
|
|
||||||
|
$priceNumeric = $unit_price;
|
||||||
|
$totalNumeric = $unit_price * $product['quantity'];
|
||||||
|
|
||||||
|
$price = $this->currency->format($unit_price, $this->oc->session->data['currency']);
|
||||||
|
$total = $this->currency->format($totalNumeric, $this->oc->session->data['currency']);
|
||||||
|
} else {
|
||||||
|
$price = false;
|
||||||
|
$total = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recurring = '';
|
||||||
|
|
||||||
|
if ($product['recurring']) {
|
||||||
|
$frequencies = array(
|
||||||
|
'day' => $this->oc->language->get('text_day'),
|
||||||
|
'week' => $this->oc->language->get('text_week'),
|
||||||
|
'semi_month' => $this->oc->language->get('text_semi_month'),
|
||||||
|
'month' => $this->oc->language->get('text_month'),
|
||||||
|
'year' => $this->oc->language->get('text_year')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($product['recurring']['trial']) {
|
||||||
|
$recurring = sprintf(
|
||||||
|
$this->oc->language->get('text_trial_description'),
|
||||||
|
$this->currency->format(
|
||||||
|
$this->oc->tax->calculate(
|
||||||
|
$product['recurring']['trial_price'] * $product['quantity'],
|
||||||
|
$product['tax_class_id'],
|
||||||
|
$this->oc->config->get('config_tax')
|
||||||
|
),
|
||||||
|
$this->oc->session->data['currency']
|
||||||
|
),
|
||||||
|
$product['recurring']['trial_cycle'],
|
||||||
|
$frequencies[$product['recurring']['trial_frequency']],
|
||||||
|
$product['recurring']['trial_duration']
|
||||||
|
) . ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($product['recurring']['duration']) {
|
||||||
|
$recurring .= sprintf(
|
||||||
|
$this->oc->language->get('text_payment_description'),
|
||||||
|
$this->currency->format(
|
||||||
|
$this->oc->tax->calculate(
|
||||||
|
$product['recurring']['price'] * $product['quantity'],
|
||||||
|
$product['tax_class_id'],
|
||||||
|
$this->oc->config->get('config_tax')
|
||||||
|
),
|
||||||
|
$this->oc->session->data['currency']
|
||||||
|
),
|
||||||
|
$product['recurring']['cycle'],
|
||||||
|
$frequencies[$product['recurring']['frequency']],
|
||||||
|
$product['recurring']['duration']
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$recurring .= sprintf(
|
||||||
|
$this->oc->language->get('text_payment_cancel'),
|
||||||
|
$this->currency->format(
|
||||||
|
$this->oc->tax->calculate(
|
||||||
|
$product['recurring']['price'] * $product['quantity'],
|
||||||
|
$product['tax_class_id'],
|
||||||
|
$this->oc->config->get('config_tax')
|
||||||
|
),
|
||||||
|
$this->oc->session->data['currency']
|
||||||
|
),
|
||||||
|
$product['recurring']['cycle'],
|
||||||
|
$frequencies[$product['recurring']['frequency']],
|
||||||
|
$product['recurring']['duration']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['products'][] = array(
|
||||||
|
'product_id' => (int) $product['product_id'],
|
||||||
|
'cart_id' => (int) $product['cart_id'],
|
||||||
|
'thumb' => $image,
|
||||||
|
'name' => $product['name'],
|
||||||
|
'model' => $product['model'],
|
||||||
|
'option' => $option_data,
|
||||||
|
'recurring' => $recurring,
|
||||||
|
'quantity' => (int) $product['quantity'],
|
||||||
|
'stock' => $product['stock'] ? true : ! (! $this->oc->config->get(
|
||||||
|
'config_stock_checkout'
|
||||||
|
) || $this->oc->config->get('config_stock_warning')),
|
||||||
|
'reward' => ($product['reward'] ? sprintf(
|
||||||
|
$this->oc->language->get('text_points'),
|
||||||
|
$product['reward']
|
||||||
|
) : ''),
|
||||||
|
'price' => $price,
|
||||||
|
'total' => $total,
|
||||||
|
'href' => $this->oc->url->link('product/product', 'product_id=' . $product['product_id']),
|
||||||
|
|
||||||
|
'price_numeric' => $priceNumeric,
|
||||||
|
'total_numeric' => $totalNumeric,
|
||||||
|
'reward_numeric' => $product['reward'] ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals
|
||||||
|
$this->oc->load->model('setting/extension');
|
||||||
|
|
||||||
|
$totals = array();
|
||||||
|
$taxes = $this->oc->cart->getTaxes();
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
// Because __call can not keep var references so we put them into an array.
|
||||||
|
$total_data = array(
|
||||||
|
'totals' => &$totals,
|
||||||
|
'taxes' => &$taxes,
|
||||||
|
'total' => &$total
|
||||||
|
);
|
||||||
|
|
||||||
|
$sort_order = array();
|
||||||
|
|
||||||
|
$results = $this->oc->model_setting_extension->getExtensions('total');
|
||||||
|
|
||||||
|
foreach ($results as $key => $value) {
|
||||||
|
$sort_order[$key] = $this->oc->config->get('total_' . $value['code'] . '_sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
array_multisort($sort_order, SORT_ASC, $results);
|
||||||
|
|
||||||
|
foreach ($results as $result) {
|
||||||
|
if ($this->oc->config->get('total_' . $result['code'] . '_status')) {
|
||||||
|
$this->oc->load->model('extension/total/' . $result['code']);
|
||||||
|
|
||||||
|
// We have to put the totals in an array so that they pass by reference.
|
||||||
|
$this->oc->{'model_extension_total_' . $result['code']}->getTotal($total_data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sort_order = array();
|
||||||
|
|
||||||
|
foreach ($totals as $key => $value) {
|
||||||
|
$sort_order[$key] = $value['sort_order'];
|
||||||
|
}
|
||||||
|
|
||||||
|
array_multisort($sort_order, SORT_ASC, $totals);
|
||||||
|
|
||||||
|
$data['totals'] = array();
|
||||||
|
|
||||||
|
foreach ($totals as $total) {
|
||||||
|
$data['totals'][] = [
|
||||||
|
'code' => $total['code'],
|
||||||
|
'title' => $total['title'],
|
||||||
|
'value' => $total['value'],
|
||||||
|
'sort_order' => $total['sort_order'],
|
||||||
|
'text' => $this->currency->format($total['value'], $this->oc->session->data['currency']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastTotal = $totals[count($totals) - 1] ?? false;
|
||||||
|
$data['total'] = $lastTotal ? $lastTotal['value'] : 0;
|
||||||
|
$data['total_text'] = $lastTotal
|
||||||
|
? $this->currency->format($lastTotal['value'], $this->oc->session->data['currency'])
|
||||||
|
: 0;
|
||||||
|
$data['total_products_count'] = $this->oc->cart->countProducts();
|
||||||
|
} else {
|
||||||
|
$data['text_error'] = $this->oc->language->get('text_empty');
|
||||||
|
$data['totals'] = [];
|
||||||
|
$data['total'] = 0;
|
||||||
|
$data['total_text'] = '';
|
||||||
|
$data['products'] = [];
|
||||||
|
$data['total_products_count'] = 0;
|
||||||
|
unset($this->oc->session->data['success']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flush(): void
|
||||||
|
{
|
||||||
|
$this->cart->clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
129
backend/src/app/Services/MegapayCustomerService.php
Executable file
129
backend/src/app/Services/MegapayCustomerService.php
Executable file
@@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\TelegramCustomer;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Acme\ECommerceFramework\Config\Settings;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\Support\Utils;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class MegapayCustomerService
|
||||||
|
{
|
||||||
|
private TelegramCustomer $telegramCustomer;
|
||||||
|
private Settings $settings;
|
||||||
|
|
||||||
|
public function __construct(TelegramCustomer $telegramCustomer, Settings $settings)
|
||||||
|
{
|
||||||
|
$this->telegramCustomer = $telegramCustomer;
|
||||||
|
$this->settings = $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить или обновить Telegram-пользователя
|
||||||
|
*
|
||||||
|
* @param array $telegramUserData Данные пользователя из Telegram.WebApp.initDataUnsafe
|
||||||
|
* @return array
|
||||||
|
* @throws RuntimeException Если данные невалидны или не удалось сохранить
|
||||||
|
*/
|
||||||
|
public function saveOrUpdate(array $telegramUserData): array
|
||||||
|
{
|
||||||
|
$telegramUserId = $this->extractTelegramUserId($telegramUserData);
|
||||||
|
$telegramCustomerData = $this->prepareCustomerData($telegramUserData, $telegramUserId);
|
||||||
|
|
||||||
|
$existingRecord = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
|
||||||
|
|
||||||
|
if ($existingRecord) {
|
||||||
|
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, $telegramCustomerData);
|
||||||
|
} else {
|
||||||
|
$this->telegramCustomer->create($telegramCustomerData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->telegramCustomer->findByTelegramUserId($telegramUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлечь Telegram user ID из данных
|
||||||
|
*
|
||||||
|
* @param array $telegramUserData Данные пользователя
|
||||||
|
* @return int Telegram user ID
|
||||||
|
* @throws RuntimeException Если ID отсутствует или невалиден
|
||||||
|
*/
|
||||||
|
private function extractTelegramUserId(array $telegramUserData): int
|
||||||
|
{
|
||||||
|
$telegramUserId = (int)Arr::get($telegramUserData, 'id');
|
||||||
|
|
||||||
|
if ($telegramUserId <= 0) {
|
||||||
|
throw new RuntimeException('Telegram user ID is required and must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $telegramUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подготовить данные для сохранения в БД
|
||||||
|
*
|
||||||
|
* @param array $telegramUserData Исходные данные пользователя
|
||||||
|
* @param int $telegramUserId Telegram user ID
|
||||||
|
* @return array Подготовленные данные
|
||||||
|
*/
|
||||||
|
private function prepareCustomerData(array $telegramUserData, int $telegramUserId): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'telegram_user_id' => $telegramUserId,
|
||||||
|
'username' => Arr::get($telegramUserData, 'username', $telegramUserId),
|
||||||
|
'first_name' => Arr::get($telegramUserData, 'first_name'),
|
||||||
|
'last_name' => Arr::get($telegramUserData, 'last_name'),
|
||||||
|
'language_code' => Arr::get($telegramUserData, 'language_code'),
|
||||||
|
'is_premium' => Utils::boolToInt(Arr::get($telegramUserData, 'is_premium', false)),
|
||||||
|
'allows_write_to_pm' => Utils::boolToInt(Arr::get($telegramUserData, 'allows_write_to_pm', false)),
|
||||||
|
'photo_url' => Arr::get($telegramUserData, 'photo_url'),
|
||||||
|
'last_seen_at' => date('Y-m-d H:i:s'),
|
||||||
|
'store_id' => $this->settings->get('store.oc_store_id', 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign ECommerce Customer to Telegram User ID and return Megapay Customer ID if it exists.
|
||||||
|
*
|
||||||
|
* @param $telegramUserId
|
||||||
|
* @param int $ocCustomerId
|
||||||
|
* @return int|null
|
||||||
|
*/
|
||||||
|
public function assignOcCustomer($telegramUserId, int $ocCustomerId): ?int
|
||||||
|
{
|
||||||
|
$customer = $this->telegramCustomer->findByTelegramUserId($telegramUserId);
|
||||||
|
|
||||||
|
if (! $customer) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customer['oc_customer_id'] === null) {
|
||||||
|
$this->telegramCustomer->updateByTelegramUserId($telegramUserId, [
|
||||||
|
'oc_customer_id' => $ocCustomerId,
|
||||||
|
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int)$customer['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function increaseOrdersCount(int $acmeshopCustomerId): void
|
||||||
|
{
|
||||||
|
$this->telegramCustomer->increase($acmeshopCustomerId, 'orders_count');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить данные пользователя по Telegram user ID
|
||||||
|
*
|
||||||
|
* @param int $telegramUserId Telegram user ID
|
||||||
|
* @return array|null Данные пользователя или null если не найдено
|
||||||
|
*/
|
||||||
|
public function getByTelegramUserId(int $telegramUserId): ?array
|
||||||
|
{
|
||||||
|
return $this->telegramCustomer->findByTelegramUserId($telegramUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
backend/src/app/Services/OcCustomerService.php
Executable file
88
backend/src/app/Services/OcCustomerService.php
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
|
||||||
|
class OcCustomerService
|
||||||
|
{
|
||||||
|
private Builder $builder;
|
||||||
|
private ConnectionInterface $database;
|
||||||
|
|
||||||
|
public function __construct(Builder $builder, ConnectionInterface $database)
|
||||||
|
{
|
||||||
|
$this->builder = $builder;
|
||||||
|
$this->database = $database;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $orderData, ?int $acmeshopCustomerId): ?int
|
||||||
|
{
|
||||||
|
$customerData = [
|
||||||
|
'customer_group_id' => $orderData['customer_group_id'],
|
||||||
|
'store_id' => $orderData['store_id'],
|
||||||
|
'language_id' => $orderData['language_id'],
|
||||||
|
'firstname' => $orderData['firstname'] ?? '',
|
||||||
|
'lastname' => $orderData['lastname'] ?? '',
|
||||||
|
'email' => $orderData['email'] ?? '',
|
||||||
|
'telephone' => $orderData['telephone'] ?? '',
|
||||||
|
'fax' => $orderData['fax'] ?? '',
|
||||||
|
'password' => bin2hex(random_bytes(16)),
|
||||||
|
'salt' => bin2hex(random_bytes(9)),
|
||||||
|
'ip' => $orderData['ip'] ?? '',
|
||||||
|
'status' => 1,
|
||||||
|
'safe' => 0,
|
||||||
|
'token' => bin2hex(random_bytes(32)),
|
||||||
|
'code' => '',
|
||||||
|
'date_added' => $orderData['date_added'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->database->insert(db_table('customer'), $customerData);
|
||||||
|
$lastInsertId = $this->database->lastInsertId();
|
||||||
|
|
||||||
|
if ($acmeshopCustomerId) {
|
||||||
|
$this->builder
|
||||||
|
->where('id', '=', $acmeshopCustomerId)
|
||||||
|
->update('acmeshop_customers', [
|
||||||
|
'oc_customer_id' => $lastInsertId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lastInsertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByMegapayCustomerId(int $telegramCustomerId): ?array
|
||||||
|
{
|
||||||
|
return $this->builder->newQuery()
|
||||||
|
->select(['oc_customers.*'])
|
||||||
|
->from(db_table('customer'), 'oc_customers')
|
||||||
|
->join('acmeshop_customers', function (JoinClause $join) {
|
||||||
|
$join->on('acmeshop_customers.oc_customer_id', '=', 'oc_customers.customer_id');
|
||||||
|
})
|
||||||
|
->where('acmeshop_customers.id', '=', $telegramCustomerId)
|
||||||
|
->firstOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $ocCustomerId): ?array
|
||||||
|
{
|
||||||
|
return $this->builder->newQuery()
|
||||||
|
->select(['oc_customers.*'])
|
||||||
|
->from(db_table('customer'), 'oc_customers')
|
||||||
|
->where('oc_customers.customer_id', '=', $ocCustomerId)
|
||||||
|
->firstOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOrCreateByMegapayCustomerId(int $acmeshopCustomerId, array $orderData): ?array
|
||||||
|
{
|
||||||
|
$ocCustomer = $this->findByMegapayCustomerId($acmeshopCustomerId);
|
||||||
|
|
||||||
|
if (! $ocCustomer) {
|
||||||
|
$ocCustomerId = $this->create($orderData, $acmeshopCustomerId);
|
||||||
|
|
||||||
|
return $this->findById($ocCustomerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ocCustomer;
|
||||||
|
}
|
||||||
|
}
|
||||||
316
backend/src/app/Services/OrderCreateService.php
Executable file
316
backend/src/app/Services/OrderCreateService.php
Executable file
@@ -0,0 +1,316 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Exception;
|
||||||
|
use JsonException;
|
||||||
|
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class OrderCreateService
|
||||||
|
{
|
||||||
|
private ConnectionInterface $database;
|
||||||
|
private CartService $cartService;
|
||||||
|
private OcRegistryDecorator $oc;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private TelegramService $telegramService;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
private MegapayCustomerService $acmeshopCustomerService;
|
||||||
|
private OcCustomerService $ocCustomerService;
|
||||||
|
private OrderMetaService $orderMetaService;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
ConnectionInterface $database,
|
||||||
|
CartService $cartService,
|
||||||
|
OcRegistryDecorator $registry,
|
||||||
|
SettingsService $settings,
|
||||||
|
TelegramService $telegramService,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
MegapayCustomerService $telegramCustomerService,
|
||||||
|
OcCustomerService $ocCustomerService,
|
||||||
|
OrderMetaService $orderMetaService
|
||||||
|
) {
|
||||||
|
$this->database = $database;
|
||||||
|
$this->cartService = $cartService;
|
||||||
|
$this->oc = $registry;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->telegramService = $telegramService;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->acmeshopCustomerService = $telegramCustomerService;
|
||||||
|
$this->ocCustomerService = $ocCustomerService;
|
||||||
|
$this->orderMetaService = $orderMetaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws Throwable
|
||||||
|
* @throws JsonException
|
||||||
|
*/
|
||||||
|
public function create(array $data, array $meta = []): array
|
||||||
|
{
|
||||||
|
$now = Carbon::now();
|
||||||
|
$storeId = $this->settings->get('store.oc_store_id');
|
||||||
|
$storeName = $this->settings->config()->getApp()->getAppName();
|
||||||
|
$orderStatusId = $this->settings->config()->getOrders()->getOrderDefaultStatusId();
|
||||||
|
$customerGroupId = $this->settings->config()->getOrders()->getOcCustomerGroupId();
|
||||||
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
$currencyId = $this->oc->currency->getId($this->oc->session->data['currency']);
|
||||||
|
$currencyCode = $this->oc->session->data['currency'];
|
||||||
|
$currencyValue = $this->oc->currency->getValue($this->oc->session->data['currency']);
|
||||||
|
|
||||||
|
$cart = $this->cartService->getCart();
|
||||||
|
$total = $cart['total'] ?? 0;
|
||||||
|
$products = $cart['products'] ?? [];
|
||||||
|
$totals = $cart['totals'] ?? [];
|
||||||
|
|
||||||
|
// Получаем telegram_user_id из tgData
|
||||||
|
$telegramUserId = Arr::get($data['tgData'] ?? [], 'user.id');
|
||||||
|
$telegramUserdata = Arr::get($data['tgData'] ?? [], 'user');
|
||||||
|
|
||||||
|
if (! $telegramUserId) {
|
||||||
|
throw new RuntimeException('Telegram user id is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$customOrderFields = $this->customOrderFields($data);
|
||||||
|
|
||||||
|
$orderData = [
|
||||||
|
'store_id' => $storeId,
|
||||||
|
'store_name' => $storeName,
|
||||||
|
'firstname' => $data['firstname'] ?? '',
|
||||||
|
'lastname' => $data['lastname'] ?? '',
|
||||||
|
'email' => $data['email'] ?? '',
|
||||||
|
'telephone' => $data['telephone'] ?? '',
|
||||||
|
'comment' => $data['comment'] ?? '',
|
||||||
|
'payment_method' => $data['payment_method'] ?? '',
|
||||||
|
'shipping_address_1' => $data['shipping_address_1'] ?? '',
|
||||||
|
'shipping_city' => $data['shipping_city'] ?? '',
|
||||||
|
'shipping_zone' => $data['shipping_zone'] ?? '',
|
||||||
|
'shipping_postcode' => $data['shipping_postcode'] ?? '',
|
||||||
|
'total' => $total,
|
||||||
|
'order_status_id' => $orderStatusId,
|
||||||
|
'ip' => $meta['ip'] ?? '',
|
||||||
|
'forwarded_ip' => $meta['ip'] ?? '',
|
||||||
|
'user_agent' => $meta['user_agent'] ?? '',
|
||||||
|
'date_added' => $now,
|
||||||
|
'date_modified' => $now,
|
||||||
|
'language_id' => $languageId,
|
||||||
|
'currency_id' => $currencyId,
|
||||||
|
'currency_code' => $currencyCode,
|
||||||
|
'currency_value' => $currencyValue,
|
||||||
|
'customer_group_id' => $customerGroupId,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->database->beginTransaction();
|
||||||
|
|
||||||
|
$acmeshopCustomer = $this->acmeshopCustomerService->saveOrUpdate($telegramUserdata);
|
||||||
|
$acmeshopCustomerId = (int) $acmeshopCustomer['id'];
|
||||||
|
$ocCustomer = $this->ocCustomerService->findOrCreateByMegapayCustomerId($acmeshopCustomerId, $orderData);
|
||||||
|
$ocCustomerId = (int) $ocCustomer['customer_id'];
|
||||||
|
|
||||||
|
$orderData['customer_id'] = $ocCustomerId;
|
||||||
|
|
||||||
|
$this->database->insert(db_table('order'), $orderData);
|
||||||
|
$orderId = $this->database->lastInsertId();
|
||||||
|
|
||||||
|
// Insert products
|
||||||
|
$this->insertProducts($products, $orderId);
|
||||||
|
|
||||||
|
// Insert totals
|
||||||
|
$this->insertTotals($totals, $orderId);
|
||||||
|
|
||||||
|
// Insert history
|
||||||
|
$this->insertHistory($orderId, $orderStatusId, $customOrderFields, $now);
|
||||||
|
|
||||||
|
// Insert order meta data
|
||||||
|
if ($customOrderFields) {
|
||||||
|
$this->orderMetaService->insert($orderId, $storeId, $customOrderFields, $acmeshopCustomerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->acmeshopCustomerService->increaseOrdersCount($acmeshopCustomerId);
|
||||||
|
|
||||||
|
$this->database->commitTransaction();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->database->rollBackTransaction();
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->cartService->flush();
|
||||||
|
|
||||||
|
$orderData['order_id'] = $orderId;
|
||||||
|
$orderData['total_numeric'] = $orderData['total'] ?? 0;
|
||||||
|
$orderData['total'] = $cart['total_text'] ?? '';
|
||||||
|
|
||||||
|
$this->sendNotifications($orderData, $data['tgData']);
|
||||||
|
|
||||||
|
$dateTimeFormatted = '';
|
||||||
|
try {
|
||||||
|
$dateTimeFormatted = $now->format('d.m.Y H:i');
|
||||||
|
} catch (Exception $exception) {
|
||||||
|
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $orderData['order_id'],
|
||||||
|
'created_at' => $dateTimeFormatted,
|
||||||
|
'total' => $orderData['total'],
|
||||||
|
'final_total_numeric' => $orderData['total_numeric'],
|
||||||
|
'currency' => $currencyCode,
|
||||||
|
'products' => $products,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendNotifications(array $orderData, array $tgInitData): void
|
||||||
|
{
|
||||||
|
$variables = [
|
||||||
|
'{store_name}' => $orderData['store_name'],
|
||||||
|
'{order_id}' => $orderData['order_id'],
|
||||||
|
'{customer}' => $orderData['firstname'] . ' ' . $orderData['lastname'],
|
||||||
|
'{email}' => $orderData['email'],
|
||||||
|
'{phone}' => $orderData['telephone'],
|
||||||
|
'{comment}' => $orderData['comment'],
|
||||||
|
'{address}' => $orderData['shipping_address_1'],
|
||||||
|
'{total}' => $orderData['total'],
|
||||||
|
'{ip}' => $orderData['ip'],
|
||||||
|
'{created_at}' => $orderData['date_added'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$chatId = $this->settings->config()->getTelegram()->getChatId();
|
||||||
|
$template = $this->settings->config()->getTelegram()->getOwnerNotificationTemplate();
|
||||||
|
|
||||||
|
if ($chatId && $template) {
|
||||||
|
$message = $this->telegramService->prepareMessage($template, $variables);
|
||||||
|
try {
|
||||||
|
$this->telegramService->sendMessage($chatId, $message);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->logger->error(
|
||||||
|
'Telegram sendMessage to owner error.',
|
||||||
|
[
|
||||||
|
'exception' => $exception,
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
'message' => $message,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowsWriteToPm = Arr::get($tgInitData, 'user.allows_write_to_pm', false);
|
||||||
|
$customerChatId = Arr::get($tgInitData, 'user.id');
|
||||||
|
$template = $this->settings->config()->getTelegram()->getCustomerNotificationTemplate();
|
||||||
|
|
||||||
|
if ($allowsWriteToPm && $customerChatId && $template) {
|
||||||
|
$message = $this->telegramService->prepareMessage($template, $variables);
|
||||||
|
try {
|
||||||
|
$this->telegramService->sendMessage($customerChatId, $message);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->logger->error(
|
||||||
|
"Telegram sendMessage to customer error.",
|
||||||
|
[
|
||||||
|
'exception' => $exception,
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
'message' => $message,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatHistoryComment(array $customFields): string
|
||||||
|
{
|
||||||
|
$additionalString = '';
|
||||||
|
if ($customFields) {
|
||||||
|
$additionalString = "\n\nДополнительная информация по заказу:\n";
|
||||||
|
foreach ($customFields as $field => $value) {
|
||||||
|
$additionalString .= $field . ': ' . $value . "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Заказ оформлен через Telegram Mini App.$additionalString";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function customOrderFields(array $data): array
|
||||||
|
{
|
||||||
|
return Arr::except($data, [
|
||||||
|
'firstname',
|
||||||
|
'lastname',
|
||||||
|
'email',
|
||||||
|
'telephone',
|
||||||
|
'comment',
|
||||||
|
'shipping_address_1',
|
||||||
|
'shipping_city',
|
||||||
|
'shipping_zone',
|
||||||
|
'shipping_postcode',
|
||||||
|
'payment_method',
|
||||||
|
'tgData',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insertTotals(array $totals, int $orderId): void
|
||||||
|
{
|
||||||
|
foreach ($totals as $total) {
|
||||||
|
$this->database->insert(db_table('order_total'), [
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'code' => $total['code'],
|
||||||
|
'title' => $total['title'],
|
||||||
|
'value' => $total['value'],
|
||||||
|
'sort_order' => $total['sort_order'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $orderId
|
||||||
|
* @param int $orderStatusId
|
||||||
|
* @param array $customOrderFields
|
||||||
|
* @param Carbon $now
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function insertHistory(int $orderId, int $orderStatusId, array $customOrderFields, Carbon $now): void
|
||||||
|
{
|
||||||
|
$history = [
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'order_status_id' => $orderStatusId,
|
||||||
|
'notify' => 0,
|
||||||
|
'comment' => $this->formatHistoryComment($customOrderFields),
|
||||||
|
'date_added' => $now,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->database->insert(db_table('order_history'), $history);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertProducts($products, int $orderId): void
|
||||||
|
{
|
||||||
|
foreach ($products as $product) {
|
||||||
|
$this->database->insert(db_table('order_product'), [
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'product_id' => $product['product_id'],
|
||||||
|
'name' => $product['name'],
|
||||||
|
'model' => $product['model'],
|
||||||
|
'quantity' => $product['quantity'],
|
||||||
|
'price' => $product['price_numeric'],
|
||||||
|
'total' => $product['total_numeric'],
|
||||||
|
'reward' => $product['reward_numeric'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$orderProductId = $this->database->lastInsertId();
|
||||||
|
foreach ($product['option'] as $option) {
|
||||||
|
$this->database->insert(db_table('order_option'), [
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'order_product_id' => $orderProductId,
|
||||||
|
'product_option_id' => $option['product_option_id'],
|
||||||
|
'product_option_value_id' => $option['product_option_value_id'],
|
||||||
|
'name' => $option['name'],
|
||||||
|
'value' => $option['value'],
|
||||||
|
'type' => $option['type'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/src/app/Services/OrderMetaService.php
Executable file
27
backend/src/app/Services/OrderMetaService.php
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||||
|
|
||||||
|
class OrderMetaService
|
||||||
|
{
|
||||||
|
private ConnectionInterface $connection;
|
||||||
|
|
||||||
|
public function __construct(ConnectionInterface $connection)
|
||||||
|
{
|
||||||
|
$this->connection = $connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insert(int $orderId, int $storeId, array $fields, ?int $acmeshopCustomerId = null): void
|
||||||
|
{
|
||||||
|
$orderMeta = [
|
||||||
|
'oc_order_id' => $orderId,
|
||||||
|
'oc_store_id' => $storeId,
|
||||||
|
'acmeshop_customer_id' => $acmeshopCustomerId,
|
||||||
|
'meta_data' => json_encode($fields, JSON_THROW_ON_ERROR),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->connection->insert('acmeshop_order_meta', $orderMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
494
backend/src/app/Services/ProductsService.php
Executable file
494
backend/src/app/Services/ProductsService.php
Executable file
@@ -0,0 +1,494 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Cart\Currency;
|
||||||
|
use Cart\Tax;
|
||||||
|
use Exception;
|
||||||
|
use Acme\ECommerceFramework\CriteriaBuilder\CriteriaBuilder;
|
||||||
|
use Acme\ECommerceFramework\Exceptions\EntityNotFoundException;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageFactory;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageNotFoundException;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageUtils;
|
||||||
|
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
|
||||||
|
use Acme\ECommerceFramework\ECommerce\PriceCalculator;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Table;
|
||||||
|
use Acme\ECommerceFramework\Sentry\SentryService;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\Support\PaginationHelper;
|
||||||
|
use Acme\ECommerceFramework\Support\Str;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class ProductsService
|
||||||
|
{
|
||||||
|
private Builder $queryBuilder;
|
||||||
|
private Currency $currency;
|
||||||
|
private Tax $tax;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private ImageFactory $image;
|
||||||
|
private OcRegistryDecorator $oc;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
private CriteriaBuilder $criteriaBuilder;
|
||||||
|
private PriceCalculator $priceCalculator;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
Builder $queryBuilder,
|
||||||
|
Currency $currency,
|
||||||
|
Tax $tax,
|
||||||
|
SettingsService $settings,
|
||||||
|
ImageFactory $image,
|
||||||
|
OcRegistryDecorator $registry,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
CriteriaBuilder $criteriaBuilder,
|
||||||
|
PriceCalculator $priceCalculator
|
||||||
|
) {
|
||||||
|
$this->queryBuilder = $queryBuilder;
|
||||||
|
$this->currency = $currency;
|
||||||
|
$this->tax = $tax;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->image = $image;
|
||||||
|
$this->oc = $registry;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->criteriaBuilder = $criteriaBuilder;
|
||||||
|
$this->priceCalculator = $priceCalculator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ImageNotFoundException
|
||||||
|
*/
|
||||||
|
public function getProductsResponse(array $params, int $languageId, int $storeId): array
|
||||||
|
{
|
||||||
|
$page = $params['page'];
|
||||||
|
$perPage = $params['perPage'];
|
||||||
|
$search = $params['search'] ?? false;
|
||||||
|
$categoryName = '';
|
||||||
|
$maxPages = 200;
|
||||||
|
$filters = $params['filters'] ?? [];
|
||||||
|
|
||||||
|
$aspectRatio = $this->settings->get('app.image_aspect_ratio', '1:1');
|
||||||
|
$cropAlgorithm = $this->settings->get('app.image_crop_algorithm', 'cover');
|
||||||
|
|
||||||
|
[$imageWidth, $imageHeight] = ImageUtils::aspectRatioToSize($aspectRatio);
|
||||||
|
|
||||||
|
$customerGroupId = $this->settings->config()->getOrders()->getOcCustomerGroupId();
|
||||||
|
$currency = $this->settings->config()->getStore()->getOcDefaultCurrency();
|
||||||
|
|
||||||
|
$specialPriceSql = "(SELECT price
|
||||||
|
FROM oc_product_special ps
|
||||||
|
WHERE ps.product_id = products.product_id
|
||||||
|
AND ps.customer_group_id = $customerGroupId
|
||||||
|
AND ((ps.date_start = '0000-00-00' OR ps.date_start < NOW()) AND
|
||||||
|
(ps.date_end = '0000-00-00' OR ps.date_end > NOW()))
|
||||||
|
ORDER BY ps.priority ASC, ps.price ASC
|
||||||
|
LIMIT 1) AS special";
|
||||||
|
|
||||||
|
$productsQuery = $this->queryBuilder->newQuery()
|
||||||
|
->select([
|
||||||
|
'products.product_id' => 'product_id',
|
||||||
|
'products.quantity' => 'product_quantity',
|
||||||
|
'product_description.name' => 'product_name',
|
||||||
|
'products.price' => 'price',
|
||||||
|
'products.image' => 'product_image',
|
||||||
|
'products.tax_class_id' => 'tax_class_id',
|
||||||
|
'manufacturer.name' => 'manufacturer_name',
|
||||||
|
'category_description.name' => 'category_name',
|
||||||
|
new RawExpression($specialPriceSql),
|
||||||
|
])
|
||||||
|
->from(db_table('product'), 'products')
|
||||||
|
->join(
|
||||||
|
db_table('product_description') . ' AS product_description',
|
||||||
|
function (JoinClause $join) use ($languageId) {
|
||||||
|
$join->on('products.product_id', '=', 'product_description.product_id')
|
||||||
|
->where('product_description.language_id', '=', $languageId);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->join(
|
||||||
|
new Table(db_table('product_to_store'), 'product_to_store'),
|
||||||
|
function (JoinClause $join) use ($storeId) {
|
||||||
|
$join->on('product_to_store.product_id', '=', 'products.product_id')
|
||||||
|
->where('product_to_store.store_id', '=', $storeId);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->leftJoin(new Table(db_table('manufacturer'), 'manufacturer'), function (JoinClause $join) {
|
||||||
|
$join->on('products.manufacturer_id', '=', 'manufacturer.manufacturer_id');
|
||||||
|
})
|
||||||
|
->leftJoin(new Table(db_table('product_to_category'), 'product_to_category'), function (JoinClause $join) {
|
||||||
|
$join->on('products.product_id', '=', 'product_to_category.product_id')
|
||||||
|
->where('product_to_category.main_category', '=', 1);
|
||||||
|
})
|
||||||
|
->leftJoin(
|
||||||
|
new Table(db_table('category_description'), 'category_description'),
|
||||||
|
function (JoinClause $join) use ($languageId) {
|
||||||
|
$join->on('product_to_category.category_id', '=', 'category_description.category_id')
|
||||||
|
->where('category_description.language_id', '=', $languageId);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->where('products.status', '=', 1)
|
||||||
|
->whereRaw('products.date_available < NOW()')
|
||||||
|
->when($search, function (Builder $query) use ($search) {
|
||||||
|
$query->where('product_description.name', 'LIKE', '%' . $search . '%');
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->criteriaBuilder->apply($productsQuery, $filters);
|
||||||
|
|
||||||
|
$total = $productsQuery->count();
|
||||||
|
$lastPage = min(PaginationHelper::calculateLastPage($total, $perPage), $maxPages);
|
||||||
|
$hasMore = $page + 1 <= $lastPage;
|
||||||
|
|
||||||
|
$products = $productsQuery
|
||||||
|
->forPage($page, $perPage)
|
||||||
|
->orderBy('date_modified', 'DESC')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$productIds = Arr::pluck($products, 'product_id');
|
||||||
|
$productsImages = [];
|
||||||
|
|
||||||
|
if ($productIds) {
|
||||||
|
$productsImages = $this->queryBuilder->newQuery()
|
||||||
|
->select([
|
||||||
|
'products_images.product_id' => 'product_id',
|
||||||
|
'products_images.image' => 'image',
|
||||||
|
])
|
||||||
|
->from(db_table('product_image'), 'products_images')
|
||||||
|
->orderBy('products_images.sort_order')
|
||||||
|
->whereIn('product_id', $productIds)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
$span = SentryService::startSpan('crop_images', 'image.process');
|
||||||
|
$productsImagesMap = [];
|
||||||
|
foreach ($productsImages as $item) {
|
||||||
|
$productId = $item['product_id'];
|
||||||
|
|
||||||
|
// Ограничиваем количество картинок для каждого товара до 3
|
||||||
|
if (! isset($productsImagesMap[$productId])) {
|
||||||
|
$productsImagesMap[$productId] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($productsImagesMap[$productId]) < 2) {
|
||||||
|
$productsImagesMap[$productId][] = [
|
||||||
|
'url' => $this->image->make($item['image'])->crop($cropAlgorithm, $imageWidth, $imageHeight)->url(),
|
||||||
|
'alt' => 'Product Image',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SentryService::endSpan($span);
|
||||||
|
|
||||||
|
$debug = [];
|
||||||
|
if (env('APP_DEBUG')) {
|
||||||
|
$debug = [
|
||||||
|
'sql' => $productsQuery->toRawSql(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => array_map(
|
||||||
|
function ($product) use ($productsImagesMap, $cropAlgorithm, $imageWidth, $imageHeight, $currency) {
|
||||||
|
$allImages = [];
|
||||||
|
|
||||||
|
$image = $this->image->make($product['product_image'], false)
|
||||||
|
->crop($cropAlgorithm, $imageWidth, $imageHeight)
|
||||||
|
->url();
|
||||||
|
|
||||||
|
$allImages[] = [
|
||||||
|
'url' => $image,
|
||||||
|
'alt' => Str::htmlEntityEncode($product['product_name']),
|
||||||
|
];
|
||||||
|
|
||||||
|
$price = $this->priceCalculator->format($product['price'], $product['tax_class_id']);
|
||||||
|
$priceNumeric = $this->priceCalculator->getPriceNumeric(
|
||||||
|
$product['price'],
|
||||||
|
$product['tax_class_id']
|
||||||
|
);
|
||||||
|
|
||||||
|
$special = false;
|
||||||
|
$specialPriceNumeric = null;
|
||||||
|
if ($product['special'] && (float) $product['special'] >= 0) {
|
||||||
|
$specialPriceNumeric = $this->tax->calculate(
|
||||||
|
$product['special'],
|
||||||
|
$product['tax_class_id'],
|
||||||
|
$this->settings->config()->getStore()->isOcConfigTax(),
|
||||||
|
);
|
||||||
|
$special = $this->currency->format(
|
||||||
|
$specialPriceNumeric,
|
||||||
|
$currency,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($productsImagesMap[$product['product_id']])) {
|
||||||
|
$allImages = array_merge($allImages, $productsImagesMap[$product['product_id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $product['product_id'],
|
||||||
|
'product_quantity' => (int) $product['product_quantity'],
|
||||||
|
'name' => Str::htmlEntityEncode($product['product_name']),
|
||||||
|
'price' => $price,
|
||||||
|
'special' => $special,
|
||||||
|
'image' => $image,
|
||||||
|
'images' => $allImages,
|
||||||
|
'special_numeric' => $specialPriceNumeric,
|
||||||
|
'price_numeric' => $priceNumeric,
|
||||||
|
'final_price_numeric' => $specialPriceNumeric ?: $priceNumeric,
|
||||||
|
'manufacturer_name' => $product['manufacturer_name'],
|
||||||
|
'category_name' => $product['category_name'],
|
||||||
|
];
|
||||||
|
},
|
||||||
|
$products
|
||||||
|
),
|
||||||
|
|
||||||
|
'meta' => [
|
||||||
|
'currentCategoryName' => $categoryName,
|
||||||
|
'hasMore' => $hasMore,
|
||||||
|
'debug' => $debug,
|
||||||
|
'total' => $total,
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws EntityNotFoundException
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function getProductById(int $productId): array
|
||||||
|
{
|
||||||
|
$this->oc->load->language('product/product');
|
||||||
|
$this->oc->load->model('catalog/category');
|
||||||
|
$this->oc->load->model('catalog/manufacturer');
|
||||||
|
$this->oc->load->model('catalog/product');
|
||||||
|
$this->oc->load->model('catalog/review');
|
||||||
|
$this->oc->load->model('tool/image');
|
||||||
|
|
||||||
|
$configTax = $this->oc->config->get('config_tax');
|
||||||
|
|
||||||
|
$product_info = $this->oc->model_catalog_product->getProduct($productId);
|
||||||
|
$currency = $this->oc->session->data['currency'];
|
||||||
|
|
||||||
|
if (! $product_info) {
|
||||||
|
throw new EntityNotFoundException('Product with id ' . $productId . ' not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [];
|
||||||
|
$data['text_minimum'] = sprintf($this->oc->language->get('text_minimum'), $product_info['minimum']);
|
||||||
|
|
||||||
|
$data['tab_review'] = sprintf($this->oc->language->get('tab_review'), $product_info['reviews']);
|
||||||
|
|
||||||
|
$data['product_id'] = $productId;
|
||||||
|
$data['name'] = Str::htmlEntityEncode($product_info['name']);
|
||||||
|
$data['manufacturer'] = $product_info['manufacturer'];
|
||||||
|
$data['model'] = $product_info['model'];
|
||||||
|
$data['reward'] = $product_info['reward'];
|
||||||
|
$data['points'] = (int) $product_info['points'];
|
||||||
|
$data['description'] = Str::htmlEntityEncode($product_info['description']);
|
||||||
|
$data['share'] = Str::htmlEntityEncode(
|
||||||
|
$this->oc->url->link('product/product', [
|
||||||
|
'product_id' => $productId,
|
||||||
|
'utm_source' => 'acmeshop',
|
||||||
|
'utm_medium' => 'telegram',
|
||||||
|
'utm_campaign' => 'product_click',
|
||||||
|
'utm_content' => 'product_button',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($product_info['quantity'] <= 0) {
|
||||||
|
$data['stock'] = $product_info['stock_status'];
|
||||||
|
} elseif ($this->oc->config->get('config_stock_display')) {
|
||||||
|
$data['stock'] = $product_info['quantity'];
|
||||||
|
} else {
|
||||||
|
$data['stock'] = $this->oc->language->get('text_instock');
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['images'] = [];
|
||||||
|
|
||||||
|
$price = $this->priceCalculator->format($product_info['price'], $product_info['tax_class_id']);
|
||||||
|
$priceNumeric = $this->priceCalculator->getPriceNumeric($product_info['price'], $product_info['tax_class_id']);
|
||||||
|
|
||||||
|
$data['price'] = $price;
|
||||||
|
$data['currency'] = $currency;
|
||||||
|
$data['final_price_numeric'] = $priceNumeric;
|
||||||
|
|
||||||
|
if (! is_null($product_info['special']) && (float) $product_info['special'] >= 0) {
|
||||||
|
$productSpecialPrice = $this->tax->calculate(
|
||||||
|
$product_info['special'],
|
||||||
|
$product_info['tax_class_id'],
|
||||||
|
$configTax,
|
||||||
|
);
|
||||||
|
|
||||||
|
$data['special'] = $this->currency->format($productSpecialPrice, $currency);
|
||||||
|
$data['final_price_numeric'] = $productSpecialPrice;
|
||||||
|
$tax_price = (float) $product_info['special'];
|
||||||
|
} else {
|
||||||
|
$data['special'] = false;
|
||||||
|
$tax_price = (float) $product_info['price'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($configTax) {
|
||||||
|
$data['tax'] = $this->currency->format($tax_price, $currency);
|
||||||
|
} else {
|
||||||
|
$data['tax'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$discounts = $this->oc->model_catalog_product->getProductDiscounts($productId);
|
||||||
|
|
||||||
|
$data['discounts'] = [];
|
||||||
|
|
||||||
|
foreach ($discounts as $discount) {
|
||||||
|
$data['discounts'][] = array(
|
||||||
|
'quantity' => $discount['quantity'],
|
||||||
|
'price' => $this->currency->format(
|
||||||
|
$this->tax->calculate(
|
||||||
|
$discount['price'],
|
||||||
|
$product_info['tax_class_id'],
|
||||||
|
$configTax,
|
||||||
|
),
|
||||||
|
$currency
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['options'] = [];
|
||||||
|
|
||||||
|
foreach ($this->oc->model_catalog_product->getProductOptions($productId) as $option) {
|
||||||
|
$product_option_value_data = [];
|
||||||
|
|
||||||
|
foreach ($option['product_option_value'] as $option_value) {
|
||||||
|
if (! $option_value['subtract'] || ($option_value['quantity'] > 0)) {
|
||||||
|
$price = $this->currency->format(
|
||||||
|
$this->tax->calculate(
|
||||||
|
$option_value['price'],
|
||||||
|
$product_info['tax_class_id'],
|
||||||
|
$configTax ? 'P' : false
|
||||||
|
),
|
||||||
|
$currency
|
||||||
|
);
|
||||||
|
|
||||||
|
$product_option_value_data[] = array(
|
||||||
|
'product_option_value_id' => (int) $option_value['product_option_value_id'],
|
||||||
|
'option_value_id' => (int) $option_value['option_value_id'],
|
||||||
|
'name' => $option_value['name'],
|
||||||
|
'image' => $this->oc->model_tool_image->resize($option_value['image'], 50, 50),
|
||||||
|
'price' => $price,
|
||||||
|
'price_prefix' => $option_value['price_prefix'],
|
||||||
|
'selected' => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['options'][] = array(
|
||||||
|
'product_option_id' => $option['product_option_id'],
|
||||||
|
'product_option_value' => $product_option_value_data,
|
||||||
|
'option_id' => $option['option_id'],
|
||||||
|
'name' => $option['name'],
|
||||||
|
'type' => $option['type'],
|
||||||
|
'value' => $option['value'],
|
||||||
|
'required' => filter_var($option['required'], FILTER_VALIDATE_BOOLEAN),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($product_info['minimum']) {
|
||||||
|
$data['minimum'] = (int) $product_info['minimum'];
|
||||||
|
} else {
|
||||||
|
$data['minimum'] = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['review_status'] = $this->oc->config->get('config_review_status');
|
||||||
|
|
||||||
|
$data['review_guest'] = true;
|
||||||
|
|
||||||
|
$data['customer_name'] = 'John Doe';
|
||||||
|
|
||||||
|
$data['reviews'] = sprintf($this->oc->language->get('text_reviews'), (int) $product_info['reviews']);
|
||||||
|
$data['rating'] = (int) $product_info['rating'];
|
||||||
|
|
||||||
|
$data['attribute_groups'] = $this->oc->model_catalog_product->getProductAttributes($productId);
|
||||||
|
|
||||||
|
$data['tags'] = array();
|
||||||
|
|
||||||
|
if ($product_info['tag']) {
|
||||||
|
$tags = explode(',', $product_info['tag']);
|
||||||
|
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$data['tags'][] = array(
|
||||||
|
'tag' => trim($tag),
|
||||||
|
'href' => $this->oc->url->link('product/search', 'tag=' . trim($tag))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['recurrings'] = $this->oc->model_catalog_product->getProfiles($productId);
|
||||||
|
|
||||||
|
$data['category'] = $this->getProductMainCategory($productId);
|
||||||
|
$data['id'] = $productId;
|
||||||
|
|
||||||
|
$this->oc->model_catalog_product->updateViewed($productId);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getProductMainCategory(int $productId): ?array
|
||||||
|
{
|
||||||
|
return $this->queryBuilder->newQuery()
|
||||||
|
->select([
|
||||||
|
'category_description.category_id' => 'id',
|
||||||
|
'category_description.name' => 'name',
|
||||||
|
])
|
||||||
|
->from(db_table('category_description'), 'category_description')
|
||||||
|
->join(new Table(db_table('product_to_category'), 'product_to_category'), function (JoinClause $join) {
|
||||||
|
$join->on('product_to_category.category_id', '=', 'category_description.category_id')
|
||||||
|
->where('product_to_category.main_category', '=', 1);
|
||||||
|
})
|
||||||
|
->where('product_to_category.product_id', '=', $productId)
|
||||||
|
->firstOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProductImages(int $productId): array
|
||||||
|
{
|
||||||
|
$aspectRatio = $this->settings->get('app.image_aspect_ratio', '1:1');
|
||||||
|
$cropAlgorithm = $this->settings->get('app.image_crop_algorithm', 'cover');
|
||||||
|
|
||||||
|
[$imageWidth, $imageHeight] = ImageUtils::aspectRatioToSize($aspectRatio);
|
||||||
|
|
||||||
|
$imageFullWidth = 1000;
|
||||||
|
$imageFullHeight = 1000;
|
||||||
|
|
||||||
|
$product_info = $this->oc->model_catalog_product->getProduct($productId);
|
||||||
|
|
||||||
|
if (! $product_info) {
|
||||||
|
throw new EntityNotFoundException('Product with id ' . $productId . ' not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allImages = [];
|
||||||
|
if ($product_info['image']) {
|
||||||
|
$allImages[] = $product_info['image'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $this->oc->model_catalog_product->getProductImages($productId);
|
||||||
|
foreach ($results as $result) {
|
||||||
|
$allImages[] = $result['image'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = [];
|
||||||
|
foreach ($allImages as $imagePath) {
|
||||||
|
try {
|
||||||
|
[$width, $height] = $this->image->make($imagePath)->getRealSize();
|
||||||
|
$images[] = [
|
||||||
|
'thumbnailURL' => $this->image->make($imagePath)
|
||||||
|
->crop($cropAlgorithm, $imageWidth, $imageHeight)
|
||||||
|
->url(),
|
||||||
|
'largeURL' => $this->image->make($imagePath)->resize($imageFullWidth, $imageFullHeight)->url(),
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
'alt' => Str::htmlEntityEncode($product_info['name']),
|
||||||
|
];
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->logger->error($e->getMessage(), ['exception' => $e]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $images;
|
||||||
|
}
|
||||||
|
}
|
||||||
449
backend/src/app/Services/SettingsSerializerService.php
Executable file
449
backend/src/app/Services/SettingsSerializerService.php
Executable file
@@ -0,0 +1,449 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\DTO\Settings\AppDTO;
|
||||||
|
use App\DTO\Settings\ConfigDTO;
|
||||||
|
use App\DTO\Settings\DatabaseDTO;
|
||||||
|
use App\DTO\Settings\LogsDTO;
|
||||||
|
use App\DTO\Settings\MetricsDTO;
|
||||||
|
use App\DTO\Settings\OrdersDTO;
|
||||||
|
use App\DTO\Settings\StoreDTO;
|
||||||
|
use App\DTO\Settings\TelegramDTO;
|
||||||
|
use App\DTO\Settings\TextsDTO;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
class SettingsSerializerService
|
||||||
|
{
|
||||||
|
public function fromArray(array $data): ConfigDTO
|
||||||
|
{
|
||||||
|
$keys = ['app', 'telegram', 'metrics', 'store', 'orders', 'texts', 'database', 'logs'];
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (! array_key_exists($key, $data)) {
|
||||||
|
throw new InvalidArgumentException("Settings key '$key' is required!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validateApp($data['app']);
|
||||||
|
$this->validateTelegram($data['telegram']);
|
||||||
|
$this->validateMetrics($data['metrics']);
|
||||||
|
$this->validateStore($data['store']);
|
||||||
|
$this->validateOrders($data['orders']);
|
||||||
|
$this->validateTexts($data['texts']);
|
||||||
|
$this->validateDatabase($data['database']);
|
||||||
|
$this->validateLogs($data['logs']);
|
||||||
|
|
||||||
|
return new ConfigDTO(
|
||||||
|
$this->deserializeApp($data['app']),
|
||||||
|
$this->deserializeTelegram($data['telegram']),
|
||||||
|
$this->deserializeMetrics($data['metrics']),
|
||||||
|
$this->deserializeStore($data['store']),
|
||||||
|
$this->deserializeOrders($data['orders']),
|
||||||
|
$this->deserializeTexts($data['texts']),
|
||||||
|
$this->deserializeDatabase($data['database']),
|
||||||
|
$this->deserializeLogs($data['logs']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws \JsonException
|
||||||
|
*/
|
||||||
|
public function serialize(ConfigDTO $settings): string
|
||||||
|
{
|
||||||
|
return json_encode($settings->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private function deserializeApp(array $data): AppDTO
|
||||||
|
{
|
||||||
|
if (! isset($data['language_id'])) {
|
||||||
|
throw new InvalidArgumentException('app.language_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($data['language_id'])) {
|
||||||
|
throw new InvalidArgumentException('app.language_id must be an integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['shop_base_url'])) {
|
||||||
|
throw new InvalidArgumentException('app.shop_base_url is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($data['shop_base_url'])) {
|
||||||
|
throw new InvalidArgumentException('app.shop_base_url must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AppDTO(
|
||||||
|
$data['app_enabled'] ?? false,
|
||||||
|
$data['app_name'] ?? '',
|
||||||
|
$data['app_icon'] ?? null,
|
||||||
|
$data['theme_light'] ?? 'light',
|
||||||
|
$data['theme_dark'] ?? 'dark',
|
||||||
|
$data['app_debug'] ?? false,
|
||||||
|
$data['language_id'],
|
||||||
|
$data['shop_base_url'],
|
||||||
|
$data['haptic_enabled'] ?? true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deserializeTelegram(array $data): TelegramDTO
|
||||||
|
{
|
||||||
|
if (! isset($data['mini_app_url'])) {
|
||||||
|
throw new InvalidArgumentException('telegram.mini_app_url is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($data['mini_app_url'])) {
|
||||||
|
throw new InvalidArgumentException('telegram.mini_app_url must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TelegramDTO(
|
||||||
|
$data['bot_token'],
|
||||||
|
$data['chat_id'],
|
||||||
|
$data['owner_notification_template'],
|
||||||
|
$data['customer_notification_template'],
|
||||||
|
$data['mini_app_url']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deserializeMetrics(array $data): MetricsDTO
|
||||||
|
{
|
||||||
|
return new MetricsDTO(
|
||||||
|
$data['yandex_metrika_enabled'] ?? false,
|
||||||
|
$data['yandex_metrika_counter'] ?? ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deserializeStore(array $data): StoreDTO
|
||||||
|
{
|
||||||
|
if (! isset($data['oc_default_currency'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_default_currency is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($data['oc_default_currency'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_default_currency must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['oc_config_tax'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_config_tax is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_bool($data['oc_config_tax'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_config_tax must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['oc_store_id'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_store_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($data['oc_store_id'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_store_id must be an integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StoreDTO(
|
||||||
|
$data['feature_coupons'] ?? true,
|
||||||
|
$data['feature_vouchers'] ?? true,
|
||||||
|
$data['show_category_products_button'] ?? true,
|
||||||
|
$data['product_interaction_mode'] ?? 'browser',
|
||||||
|
$data['manager_username'] ?? null,
|
||||||
|
$data['oc_default_currency'],
|
||||||
|
$data['oc_config_tax'],
|
||||||
|
$data['oc_store_id']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deserializeOrders(array $data): OrdersDTO
|
||||||
|
{
|
||||||
|
if (! isset($data['oc_customer_group_id'])) {
|
||||||
|
throw new InvalidArgumentException('orders.oc_customer_group_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($data['oc_customer_group_id'])) {
|
||||||
|
throw new InvalidArgumentException('orders.oc_customer_group_id must be an integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OrdersDTO(
|
||||||
|
$data['order_default_status_id'] ?? 1,
|
||||||
|
$data['oc_customer_group_id']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deserializeTexts(array $data): TextsDTO
|
||||||
|
{
|
||||||
|
return new TextsDTO(
|
||||||
|
$data['text_no_more_products'],
|
||||||
|
$data['text_empty_cart'],
|
||||||
|
$data['text_order_created_success'],
|
||||||
|
$data['text_manager_button'] ?? ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Validation Methods ====================
|
||||||
|
|
||||||
|
private function validateApp(array $data): void
|
||||||
|
{
|
||||||
|
if (! is_bool($data['app_enabled'])) {
|
||||||
|
throw new InvalidArgumentException('app.app_enabled must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($data['app_name'])) {
|
||||||
|
throw new InvalidArgumentException('app.app_name must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['app_icon']) && ! is_string($data['app_icon'])) {
|
||||||
|
throw new InvalidArgumentException('app.app_icon must be a string or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($data['theme_light'])) {
|
||||||
|
throw new InvalidArgumentException('app.theme_light must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($data['theme_dark'])) {
|
||||||
|
throw new InvalidArgumentException('app.theme_dark must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_bool($data['app_debug'])) {
|
||||||
|
throw new InvalidArgumentException('app.app_debug must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['language_id'])) {
|
||||||
|
throw new InvalidArgumentException('app.language_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($data['language_id'])) {
|
||||||
|
throw new InvalidArgumentException('app.language_id must be an integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['language_id'] <= 0) {
|
||||||
|
throw new InvalidArgumentException('app.language_id must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['shop_base_url'])) {
|
||||||
|
throw new InvalidArgumentException('app.shop_base_url is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($data['shop_base_url'])) {
|
||||||
|
throw new InvalidArgumentException('app.shop_base_url must be a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateTelegram(array $data): void
|
||||||
|
{
|
||||||
|
if (isset($data['bot_token']) && ! is_string($data['bot_token'])) {
|
||||||
|
throw new InvalidArgumentException('telegram.bot_token must be a string or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['chat_id']) && ! is_numeric($data['chat_id'])) {
|
||||||
|
throw new InvalidArgumentException('telegram.chat_id must be an integer or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isset($data['owner_notification_template']) && ! is_string(
|
||||||
|
$data['owner_notification_template']
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('telegram.owner_notification_template must be a string or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isset($data['customer_notification_template']) && ! is_string(
|
||||||
|
$data['customer_notification_template']
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('telegram.customer_notification_template must be a string or null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['mini_app_url'])) {
|
||||||
|
throw new InvalidArgumentException('telegram.mini_app_url is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($data['mini_app_url'])) {
|
||||||
|
throw new InvalidArgumentException('telegram.mini_app_url must be a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateMetrics(array $data): void
|
||||||
|
{
|
||||||
|
if (isset($data['yandex_metrika_enabled']) && ! is_bool($data['yandex_metrika_enabled'])) {
|
||||||
|
throw new InvalidArgumentException('metrics.yandex_metrika_enabled must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['yandex_metrika_counter']) && ! is_string($data['yandex_metrika_counter'])) {
|
||||||
|
throw new InvalidArgumentException('metrics.yandex_metrika_counter must be a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateStore(array $data): void
|
||||||
|
{
|
||||||
|
// enable_store больше не валидируется, так как заменен на product_interaction_mode
|
||||||
|
|
||||||
|
if (isset($data['feature_coupons']) && ! is_bool($data['feature_coupons'])) {
|
||||||
|
throw new InvalidArgumentException('store.feature_coupons must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['feature_vouchers']) && ! is_bool($data['feature_vouchers'])) {
|
||||||
|
throw new InvalidArgumentException('store.feature_vouchers must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['show_category_products_button']) && ! is_bool($data['show_category_products_button'])) {
|
||||||
|
throw new InvalidArgumentException('store.show_category_products_button must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['product_interaction_mode']) && ! is_string($data['product_interaction_mode'])) {
|
||||||
|
throw new InvalidArgumentException('store.product_interaction_mode must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isset($data['product_interaction_mode'])
|
||||||
|
&& ! in_array($data['product_interaction_mode'], ['order', 'manager', 'browser'], true)
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'store.product_interaction_mode must be one of: order, manager, browser'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['manager_username']) && $data['manager_username'] !== null) {
|
||||||
|
if (! is_string($data['manager_username'])) {
|
||||||
|
throw new InvalidArgumentException('store.manager_username must be a string or null');
|
||||||
|
}
|
||||||
|
// Проверяем, что это username (не числовой ID)
|
||||||
|
$managerUsername = trim($data['manager_username']);
|
||||||
|
if ($managerUsername !== '' && preg_match('/^-?\d+$/', $managerUsername)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
'store.manager_username must be a username (e.g., @username), not a numeric ID'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['oc_default_currency'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_default_currency is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($data['oc_default_currency'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_default_currency must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['oc_config_tax'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_config_tax is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_bool($data['oc_config_tax'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_config_tax must be a boolean');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['oc_store_id'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_store_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($data['oc_store_id'])) {
|
||||||
|
throw new InvalidArgumentException('store.oc_store_id must be an integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['oc_store_id'] < 0) {
|
||||||
|
throw new InvalidArgumentException('store.oc_store_id must be a positive integer or equals 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateOrders(array $data): void
|
||||||
|
{
|
||||||
|
if (isset($data['order_default_status_id'])) {
|
||||||
|
if (! is_numeric($data['order_default_status_id'])) {
|
||||||
|
throw new InvalidArgumentException('orders.order_default_status_id must be an integer');
|
||||||
|
}
|
||||||
|
if ($data['order_default_status_id'] <= 0) {
|
||||||
|
throw new InvalidArgumentException('orders.order_default_status_id must be a positive integer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! isset($data['oc_customer_group_id'])) {
|
||||||
|
throw new InvalidArgumentException('orders.oc_customer_group_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($data['oc_customer_group_id'])) {
|
||||||
|
throw new InvalidArgumentException('orders.oc_customer_group_id must be an integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($data['oc_customer_group_id'] <= 0) {
|
||||||
|
throw new InvalidArgumentException('orders.oc_customer_group_id must be a positive integer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateTexts(array $data): void
|
||||||
|
{
|
||||||
|
if (isset($data['text_no_more_products']) && ! is_string($data['text_no_more_products'])) {
|
||||||
|
throw new InvalidArgumentException('texts.text_no_more_products must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['text_empty_cart']) && ! is_string($data['text_empty_cart'])) {
|
||||||
|
throw new InvalidArgumentException('texts.text_empty_cart must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['text_order_created_success']) && ! is_string($data['text_order_created_success'])) {
|
||||||
|
throw new InvalidArgumentException('texts.text_order_created_success must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['text_manager_button']) && ! is_string($data['text_manager_button'])) {
|
||||||
|
throw new InvalidArgumentException('texts.text_manager_button must be a string');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deserializeLogs(array $logs): LogsDTO
|
||||||
|
{
|
||||||
|
return new LogsDTO(
|
||||||
|
$logs['path'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deserializeDatabase(array $data): DatabaseDTO
|
||||||
|
{
|
||||||
|
return new DatabaseDTO(
|
||||||
|
$data['host'] ?? '',
|
||||||
|
$data['database'] ?? '',
|
||||||
|
$data['username'] ?? '',
|
||||||
|
$data['password'] ?? '',
|
||||||
|
$data['prefix'] ?? '',
|
||||||
|
$data['port'] ?? 3306
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateDatabase(array $data): void
|
||||||
|
{
|
||||||
|
if (isset($data['host']) && ! is_string($data['host'])) {
|
||||||
|
throw new InvalidArgumentException('database.host must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['database']) && ! is_string($data['database'])) {
|
||||||
|
throw new InvalidArgumentException('database.database must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['username']) && ! is_string($data['username'])) {
|
||||||
|
throw new InvalidArgumentException('database.username must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['password']) && ! is_string($data['password'])) {
|
||||||
|
throw new InvalidArgumentException('database.password must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['prefix']) && ! is_string($data['prefix'])) {
|
||||||
|
throw new InvalidArgumentException('database.prefix must be a string');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['port'])) {
|
||||||
|
if (is_string($data['port']) && ctype_digit($data['port'])) {
|
||||||
|
$data['port'] = (int) $data['port'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_numeric($data['port'])) {
|
||||||
|
throw new InvalidArgumentException('database.port must be an integer');
|
||||||
|
}
|
||||||
|
if ($data['port'] <= 0 || $data['port'] > 65535) {
|
||||||
|
throw new InvalidArgumentException('database.port must be between 1 and 65535');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateLogs(array $logs): void
|
||||||
|
{
|
||||||
|
if (! isset($logs['path'])) {
|
||||||
|
throw new InvalidArgumentException('Logs path must be set');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/app/Services/SettingsService.php
Executable file
23
backend/src/app/Services/SettingsService.php
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\DTO\Settings\ConfigDTO;
|
||||||
|
use Acme\ECommerceFramework\Config\Settings;
|
||||||
|
|
||||||
|
class SettingsService extends Settings
|
||||||
|
{
|
||||||
|
private ConfigDTO $config;
|
||||||
|
|
||||||
|
public function __construct(array $config, SettingsSerializerService $serializer)
|
||||||
|
{
|
||||||
|
parent::__construct($config);
|
||||||
|
|
||||||
|
$this->config = $serializer->fromArray($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function config(): ConfigDTO
|
||||||
|
{
|
||||||
|
return $this->config;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/src/app/Support/Utils.php
Executable file
16
backend/src/app/Support/Utils.php
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
final class Utils
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $string
|
||||||
|
* @return string
|
||||||
|
* @deprecated use Str::htmlEntityEncode instead
|
||||||
|
*/
|
||||||
|
public static function htmlEntityEncode(string $string): string
|
||||||
|
{
|
||||||
|
return html_entity_decode($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
149
backend/src/app/Telegram/LinkCommand.php
Executable file
149
backend/src/app/Telegram/LinkCommand.php
Executable file
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Telegram;
|
||||||
|
|
||||||
|
use GuzzleHttp\Exception\ClientException;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use JsonException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Commands\TelegramCommand;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Enums\ChatAction;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramBotStateManager;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramService;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class LinkCommand extends TelegramCommand
|
||||||
|
{
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
TelegramService $telegram,
|
||||||
|
TelegramBotStateManager $stateManager,
|
||||||
|
LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
parent::__construct($telegram, $stateManager);
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws GuzzleException
|
||||||
|
* @throws JsonException
|
||||||
|
*/
|
||||||
|
public function handle(array $update): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$userId = $update['message']['from']['id'];
|
||||||
|
$chatId = $update['message']['chat']['id'];
|
||||||
|
|
||||||
|
$state = $this->state->getState($userId, $chatId);
|
||||||
|
|
||||||
|
if (! $state) {
|
||||||
|
$greeting = $this->telegram->escapeTgSpecialCharacters(
|
||||||
|
<<<HTML
|
||||||
|
Это удобный инструмент, который поможет вам 📎 создать красивое
|
||||||
|
сообщение с кнопкой для открытия вашего 🛒 Megapay магазина.
|
||||||
|
|
||||||
|
📌 Такое сообщение можно закрепить в канале или группе.
|
||||||
|
📤 Переслать клиентам в личные сообщения.
|
||||||
|
🚀 Или использовать повторно, когда нужно поделиться магазином.
|
||||||
|
|
||||||
|
Давайте начнём — отправьте текст, который вы хотите разместить в сообщении 👇
|
||||||
|
HTML
|
||||||
|
);
|
||||||
|
$this->telegram->sendMessage($chatId, $greeting);
|
||||||
|
|
||||||
|
$this->state->setState(self::class, $userId, $chatId, [
|
||||||
|
'step' => 'message_text',
|
||||||
|
'data' => [
|
||||||
|
'message_text' => '',
|
||||||
|
'btn_text' => '',
|
||||||
|
'btn_link' => '',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$step = $state['data']['step'];
|
||||||
|
|
||||||
|
if ($step === 'message_text') {
|
||||||
|
$message = Arr::get($update, 'message.text', 'Недопустимый текст сообщения');
|
||||||
|
$state['data']['data']['message_text'] = $message;
|
||||||
|
$state['data']['step'] = 'btn_text';
|
||||||
|
$this->state->setState(self::class, $userId, $chatId, $state['data']);
|
||||||
|
|
||||||
|
$text = <<<HTML
|
||||||
|
🔸 Отлично!
|
||||||
|
Теперь укажите, какой текст будет на кнопке 👇
|
||||||
|
|
||||||
|
✍️ Напишите короткую, понятную фразу, например:
|
||||||
|
• Открыть магазин
|
||||||
|
• Каталог товаров
|
||||||
|
• Начать покупки
|
||||||
|
HTML;
|
||||||
|
$this->telegram->sendMessage($chatId, $text);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($step === 'btn_text') {
|
||||||
|
$message = $update['message']['text'];
|
||||||
|
$state['data']['data']['btn_text'] = $message;
|
||||||
|
$state['data']['step'] = 'btn_link';
|
||||||
|
$this->state->setState(self::class, $userId, $chatId, $state['data']);
|
||||||
|
|
||||||
|
$template = <<<MARKDOWN
|
||||||
|
🌐 Теперь отправьте ссылку на Telegram Mini App.
|
||||||
|
Ссылка должна начинаться с <pre>https://</pre>
|
||||||
|
|
||||||
|
📎 Инструкция, где взять ссылку:
|
||||||
|
👉 {LINK}
|
||||||
|
MARKDOWN;
|
||||||
|
|
||||||
|
$text = $this->telegram->prepareMessage($template, [
|
||||||
|
'{LINK}' => 'https://acme-inc.github.io/docs/telegram/telegram/#direct-link',
|
||||||
|
]);
|
||||||
|
$this->telegram->sendMessage($chatId, $text);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($step === 'btn_link') {
|
||||||
|
$message = $update['message']['text'];
|
||||||
|
$state['data']['data']['btn_link'] = $message;
|
||||||
|
$this->state->setState(self::class, $userId, $chatId, $state['data']);
|
||||||
|
|
||||||
|
$messageText = Arr::get($state, 'data.data.message_text', 'Текст сообщения');
|
||||||
|
$btnText = $this->telegram->escapeTgSpecialCharacters(
|
||||||
|
Arr::get($state, 'data.data.btn_text', 'Открыть магазин')
|
||||||
|
);
|
||||||
|
$btnLink = $message;
|
||||||
|
|
||||||
|
$replyMarkup = [
|
||||||
|
'inline_keyboard' => [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'text' => $btnText,
|
||||||
|
'url' => $btnLink,
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->telegram->sendMessage(
|
||||||
|
$chatId,
|
||||||
|
$this->telegram->escapeTgSpecialCharacters($messageText),
|
||||||
|
$replyMarkup,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->state->clearState($userId, $chatId);
|
||||||
|
} catch (ClientException $exception) {
|
||||||
|
$this->telegram->sendMessage($chatId, 'Ошибка: ' . $exception->getResponse()->getBody()->getContents());
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||||
|
$this->telegram->sendMessage($chatId, 'Произошла ошибка');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/src/app/routes.php
Executable file
44
backend/src/app/routes.php
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Handlers\BlocksHandler;
|
||||||
|
use App\Handlers\CartHandler;
|
||||||
|
use App\Handlers\CategoriesHandler;
|
||||||
|
use App\Handlers\CronHandler;
|
||||||
|
use App\Handlers\ETLHandler;
|
||||||
|
use App\Handlers\FiltersHandler;
|
||||||
|
use App\Handlers\FormsHandler;
|
||||||
|
use App\Handlers\HealthCheckHandler;
|
||||||
|
use App\Handlers\OrderHandler;
|
||||||
|
use App\Handlers\PrivacyPolicyHandler;
|
||||||
|
use App\Handlers\ProductsHandler;
|
||||||
|
use App\Handlers\SettingsHandler;
|
||||||
|
use App\Handlers\TelegramCustomerHandler;
|
||||||
|
use App\Handlers\TelegramHandler;
|
||||||
|
use App\Handlers\TelemetryHandler;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'categoriesList' => [CategoriesHandler::class, 'index'],
|
||||||
|
'checkIsUserPrivacyConsented' => [PrivacyPolicyHandler::class, 'checkIsUserPrivacyConsented'],
|
||||||
|
'checkout' => [CartHandler::class, 'checkout'],
|
||||||
|
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
|
||||||
|
'getCart' => [CartHandler::class, 'index'],
|
||||||
|
'getForm' => [FormsHandler::class, 'getForm'],
|
||||||
|
'health' => [HealthCheckHandler::class, 'handle'],
|
||||||
|
'ingest' => [TelemetryHandler::class, 'ingest'],
|
||||||
|
'runSchedule' => [CronHandler::class, 'runSchedule'],
|
||||||
|
'heartbeat' => [TelemetryHandler::class, 'heartbeat'],
|
||||||
|
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
||||||
|
'product_show' => [ProductsHandler::class, 'show'],
|
||||||
|
'products' => [ProductsHandler::class, 'index'],
|
||||||
|
'productsSearchPlaceholder' => [ProductsHandler::class, 'getSearchPlaceholder'],
|
||||||
|
'saveTelegramCustomer' => [TelegramCustomerHandler::class, 'saveOrUpdate'],
|
||||||
|
'getCurrentCustomer' => [TelegramCustomerHandler::class, 'getCurrent'],
|
||||||
|
'settings' => [SettingsHandler::class, 'index'],
|
||||||
|
'storeOrder' => [OrderHandler::class, 'store'],
|
||||||
|
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
||||||
|
'userPrivacyConsent' => [PrivacyPolicyHandler::class, 'userPrivacyConsent'],
|
||||||
|
'webhook' => [TelegramHandler::class, 'webhook'],
|
||||||
|
'etlCustomers' => [ETLHandler::class, 'customers'],
|
||||||
|
'etlCustomersMeta' => [ETLHandler::class, 'getCustomersMeta'],
|
||||||
|
'getProductImages' => [ProductsHandler::class, 'getProductImages'],
|
||||||
|
];
|
||||||
40
backend/src/bastion/ApplicationFactory.php
Executable file
40
backend/src/bastion/ApplicationFactory.php
Executable file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bastion;
|
||||||
|
|
||||||
|
use App\ServiceProviders\AppServiceProvider;
|
||||||
|
use App\ServiceProviders\SettingsServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Application;
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageToolServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\QueryBuilderServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Router\RouteServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramServiceProvider;
|
||||||
|
|
||||||
|
class ApplicationFactory
|
||||||
|
{
|
||||||
|
public static function create(array $settings): Application
|
||||||
|
{
|
||||||
|
$defaultConfig = require __DIR__ . '/../configs/app.php';
|
||||||
|
$routes = require __DIR__ . '/routes.php';
|
||||||
|
|
||||||
|
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
|
||||||
|
|
||||||
|
return (new Application($merged))
|
||||||
|
->withRoutes(fn() => $routes)
|
||||||
|
->withServiceProviders([
|
||||||
|
SettingsServiceProvider::class,
|
||||||
|
QueryBuilderServiceProvider::class,
|
||||||
|
RouteServiceProvider::class,
|
||||||
|
AppServiceProvider::class,
|
||||||
|
CacheServiceProvider::class,
|
||||||
|
TelegramServiceProvider::class,
|
||||||
|
AcmeShopPulseServiceProvider::class,
|
||||||
|
ImageToolServiceProvider::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/src/bastion/Exceptions/BotTokenConfiguratorException.php
Executable file
9
backend/src/bastion/Exceptions/BotTokenConfiguratorException.php
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class BotTokenConfiguratorException extends Exception
|
||||||
|
{
|
||||||
|
}
|
||||||
33
backend/src/bastion/Handlers/AcmeShopPulseStatsHandler.php
Executable file
33
backend/src/bastion/Handlers/AcmeShopPulseStatsHandler.php
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheInterface;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopEvent;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
|
||||||
|
class AcmeShopPulseStatsHandler
|
||||||
|
{
|
||||||
|
private AcmeShopEvent $eventModel;
|
||||||
|
private CacheInterface $cache;
|
||||||
|
private const CACHE_KEY = 'acmeshop_pulse_stats';
|
||||||
|
private const CACHE_TTL = 3600; // 1 час
|
||||||
|
|
||||||
|
public function __construct(AcmeShopEvent $eventModel, CacheInterface $cache)
|
||||||
|
{
|
||||||
|
$this->eventModel = $eventModel;
|
||||||
|
$this->cache = $cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStats(): JsonResponse
|
||||||
|
{
|
||||||
|
$stats = $this->cache->get(self::CACHE_KEY);
|
||||||
|
|
||||||
|
if ($stats === null) {
|
||||||
|
$stats = $this->eventModel->getStats();
|
||||||
|
$this->cache->set(self::CACHE_KEY, $stats, self::CACHE_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(['data' => $stats]);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
backend/src/bastion/Handlers/AutocompleteHandler.php
Executable file
146
backend/src/bastion/Handlers/AutocompleteHandler.php
Executable file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use App\Services\SettingsService;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
use Acme\ECommerceFramework\Support\Str;
|
||||||
|
|
||||||
|
class AutocompleteHandler
|
||||||
|
{
|
||||||
|
private OcRegistryDecorator $registry;
|
||||||
|
private Builder $queryBuilder;
|
||||||
|
private SettingsService $settings;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
OcRegistryDecorator $registry,
|
||||||
|
Builder $queryBuilder,
|
||||||
|
SettingsService $settings
|
||||||
|
) {
|
||||||
|
$this->registry = $registry;
|
||||||
|
$this->queryBuilder = $queryBuilder;
|
||||||
|
$this->settings = $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategoriesFlat(): JsonResponse
|
||||||
|
{
|
||||||
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
$categoriesFlat = $this->getFlatCategories($languageId);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $categoriesFlat,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategories(): JsonResponse
|
||||||
|
{
|
||||||
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
|
||||||
|
$categoriesFlat = $this->getFlatCategories($languageId);
|
||||||
|
|
||||||
|
$categories = $this->buildCategoryTree($categoriesFlat);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $categories,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProductsById(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$productIds = $request->json('product_ids', []);
|
||||||
|
$products = [];
|
||||||
|
|
||||||
|
if ($productIds) {
|
||||||
|
$products = array_map(function ($productId) {
|
||||||
|
$item = [
|
||||||
|
'id' => (int) $productId,
|
||||||
|
];
|
||||||
|
$product = $this->registry->model_catalog_product->getProduct($productId);
|
||||||
|
|
||||||
|
$item['name'] = $product ? Str::htmlEntityEncode($product['name']) : 'No name';
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}, $productIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $products,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategoriesById(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$ids = $request->json('category_ids', []);
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
if ($ids) {
|
||||||
|
$items = array_map(function ($id) {
|
||||||
|
$item = [
|
||||||
|
'id' => (int) $id,
|
||||||
|
];
|
||||||
|
$entity = $this->registry->model_catalog_category->getCategory($id);
|
||||||
|
|
||||||
|
$item['name'] = $entity ? Str::htmlEntityEncode($entity['name']) : 'No name';
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}, $ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => $items,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFlatCategories(int $languageId): array
|
||||||
|
{
|
||||||
|
return $this->queryBuilder->newQuery()
|
||||||
|
->select([
|
||||||
|
'categories.category_id' => 'id',
|
||||||
|
'categories.parent_id' => 'parent_id',
|
||||||
|
'descriptions.name' => 'name',
|
||||||
|
'descriptions.description' => 'description',
|
||||||
|
])
|
||||||
|
->from(db_table('category'), 'categories')
|
||||||
|
->join(
|
||||||
|
db_table('category_description') . ' AS descriptions',
|
||||||
|
function (JoinClause $join) use ($languageId) {
|
||||||
|
$join->on('categories.category_id', '=', 'descriptions.category_id')
|
||||||
|
->where('descriptions.language_id', '=', $languageId);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->where('categories.status', '=', 1)
|
||||||
|
->orderBy('parent_id')
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCategoryTree(array $flat, $parentId = 0): array
|
||||||
|
{
|
||||||
|
$branch = [];
|
||||||
|
|
||||||
|
foreach ($flat as $category) {
|
||||||
|
if ((int) $category['parent_id'] === (int) $parentId) {
|
||||||
|
$children = $this->buildCategoryTree($flat, $category['id']);
|
||||||
|
if ($children) {
|
||||||
|
$category['children'] = $children;
|
||||||
|
}
|
||||||
|
|
||||||
|
$branch[] = [
|
||||||
|
'key' => (int) $category['id'],
|
||||||
|
'label' => Str::htmlEntityEncode($category['name']),
|
||||||
|
'data' => [
|
||||||
|
'description' => Str::htmlEntityEncode($category['description']),
|
||||||
|
],
|
||||||
|
'icon' => null,
|
||||||
|
'children' => $category['children'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $branch;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
backend/src/bastion/Handlers/DictionariesHandler.php
Executable file
55
backend/src/bastion/Handlers/DictionariesHandler.php
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use App\Services\SettingsService;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
|
||||||
|
class DictionariesHandler
|
||||||
|
{
|
||||||
|
private Builder $queryBuilder;
|
||||||
|
private SettingsService $settings;
|
||||||
|
|
||||||
|
public function __construct(Builder $queryBuilder, SettingsService $settings)
|
||||||
|
{
|
||||||
|
$this->queryBuilder = $queryBuilder;
|
||||||
|
$this->settings = $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCategories(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$perPage = $request->get('perPage', 20);
|
||||||
|
$categoryIds = $request->json('category_ids', []);
|
||||||
|
$languageId = $this->settings->config()->getApp()->getLanguageId();
|
||||||
|
|
||||||
|
$data = $this->queryBuilder->newQuery()
|
||||||
|
->select([
|
||||||
|
'categories.category_id' => 'id',
|
||||||
|
'categories.parent_id' => 'parent_id',
|
||||||
|
'categories.image' => 'image',
|
||||||
|
'descriptions.name' => 'name',
|
||||||
|
'descriptions.description' => 'description',
|
||||||
|
])
|
||||||
|
->from(db_table('category'), 'categories')
|
||||||
|
->join(
|
||||||
|
db_table('category_description') . ' AS descriptions',
|
||||||
|
function (JoinClause $join) use ($languageId) {
|
||||||
|
$join->on('categories.category_id', '=', 'descriptions.category_id')
|
||||||
|
->where('descriptions.language_id', '=', $languageId);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->where('categories.status', '=', 1)
|
||||||
|
->when($categoryIds, function (Builder $query) use ($categoryIds) {
|
||||||
|
$query->whereIn('categories.category_id', $categoryIds);
|
||||||
|
})
|
||||||
|
->orderBy('parent_id')
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->limit($perPage)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return new JsonResponse(compact('data'));
|
||||||
|
}
|
||||||
|
}
|
||||||
57
backend/src/bastion/Handlers/FormsHandler.php
Executable file
57
backend/src/bastion/Handlers/FormsHandler.php
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use JsonException;
|
||||||
|
use Acme\ECommerceFramework\Exceptions\EntityNotFoundException;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
|
||||||
|
class FormsHandler
|
||||||
|
{
|
||||||
|
private Builder $builder;
|
||||||
|
|
||||||
|
public function __construct(Builder $builder)
|
||||||
|
{
|
||||||
|
$this->builder = $builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws EntityNotFoundException
|
||||||
|
* @throws JsonException
|
||||||
|
*/
|
||||||
|
public function getFormByAlias(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$alias = 'checkout';
|
||||||
|
//$request->json('alias');
|
||||||
|
if (! $alias) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'error' => 'Form alias is required',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->builder->newQuery()
|
||||||
|
->from('acmeshop_forms')
|
||||||
|
->where('alias', '=', $alias)
|
||||||
|
->firstOrNull();
|
||||||
|
|
||||||
|
if (! $form) {
|
||||||
|
throw new EntityNotFoundException("Form with alias `{$alias}` not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
$schema = json_decode($form['schema'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'alias' => $alias,
|
||||||
|
'friendly_name' => $form['friendly_name'],
|
||||||
|
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'schema' => $schema,
|
||||||
|
'created_at' => $form['created_at'],
|
||||||
|
'updated_at' => $form['updated_at'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
backend/src/bastion/Handlers/ImageHandler.php
Executable file
39
backend/src/bastion/Handlers/ImageHandler.php
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageFactory;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ImageHandler
|
||||||
|
{
|
||||||
|
private ImageFactory $image;
|
||||||
|
|
||||||
|
public function __construct(ImageFactory $image)
|
||||||
|
{
|
||||||
|
$this->image = $image;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getImage(Request $request): Response
|
||||||
|
{
|
||||||
|
$path = $request->query->get('path');
|
||||||
|
[$width, $height] = $this->parseSize($request->query->get('size'));
|
||||||
|
|
||||||
|
return $this->image
|
||||||
|
->make($path)
|
||||||
|
->resize($width, $height)
|
||||||
|
->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseSize(?string $size = null): array
|
||||||
|
{
|
||||||
|
if (! $size) {
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sizes = explode('x', $size);
|
||||||
|
|
||||||
|
return array_map(static fn($value) => is_numeric($value) ? (int) $value : null, $sizes);
|
||||||
|
}
|
||||||
|
}
|
||||||
205
backend/src/bastion/Handlers/LogsHandler.php
Executable file
205
backend/src/bastion/Handlers/LogsHandler.php
Executable file
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Config\Settings;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
|
||||||
|
class LogsHandler
|
||||||
|
{
|
||||||
|
private Settings $settings;
|
||||||
|
|
||||||
|
public function __construct(Settings $settings)
|
||||||
|
{
|
||||||
|
$this->settings = $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLogs(): JsonResponse
|
||||||
|
{
|
||||||
|
$parsedLogs = [];
|
||||||
|
|
||||||
|
$logsPath = $this->findLastLogsFileInDir(
|
||||||
|
$this->settings->get('logs.path')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($logsPath) {
|
||||||
|
$lines = $this->readLastLogsRows($logsPath, 100);
|
||||||
|
$parsedLogs = $this->parseLogLines($lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(['data' => $parsedLogs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseLogLines(array $lines): array
|
||||||
|
{
|
||||||
|
$parsed = [];
|
||||||
|
|
||||||
|
$pattern = '/^\[([^\]]+)\]\s+([^.]+)\.(\w+):\s+(.+)$/s';
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if (empty($line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match($pattern, $line, $matches)) {
|
||||||
|
$datetime = $matches[1] ?? '';
|
||||||
|
$channel = $matches[2] ?? '';
|
||||||
|
$level = $matches[3] ?? '';
|
||||||
|
$rest = $matches[4] ?? '';
|
||||||
|
|
||||||
|
// Извлекаем сообщение и контекст
|
||||||
|
// Контекст начинается с { и заканчивается соответствующим }
|
||||||
|
$message = $rest;
|
||||||
|
$context = null;
|
||||||
|
|
||||||
|
// Ищем JSON контекст (начинается с {, может быть после пробела или сразу)
|
||||||
|
$jsonStart = strpos($rest, ' {');
|
||||||
|
if ($jsonStart === false) {
|
||||||
|
$jsonStart = strpos($rest, '{');
|
||||||
|
} else {
|
||||||
|
$jsonStart++; // Пропускаем пробел перед {
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($jsonStart !== false) {
|
||||||
|
$message = trim(substr($rest, 0, $jsonStart));
|
||||||
|
$jsonPart = substr($rest, $jsonStart);
|
||||||
|
|
||||||
|
// Находим конец JSON объекта, учитывая вложенность
|
||||||
|
$jsonEnd = $this->findJsonEnd($jsonPart);
|
||||||
|
if ($jsonEnd !== false) {
|
||||||
|
$contextJson = substr($jsonPart, 0, $jsonEnd + 1);
|
||||||
|
$decoded = json_decode($contextJson, true);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
$context = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматируем дату для отображения (убираем микросекунды и временную зону для читаемости)
|
||||||
|
$formattedDatetime = $this->formatDateTime($datetime);
|
||||||
|
|
||||||
|
$message = rtrim($message, ' [] []');
|
||||||
|
|
||||||
|
$parsed[] = [
|
||||||
|
'datetime' => $formattedDatetime,
|
||||||
|
'datetime_raw' => $datetime,
|
||||||
|
'channel' => $channel,
|
||||||
|
'level' => $level,
|
||||||
|
'message' => $message,
|
||||||
|
'context' => $context,
|
||||||
|
'raw' => $line,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Если строка не соответствует формату, сохраняем как есть
|
||||||
|
$parsed[] = [
|
||||||
|
'datetime' => '',
|
||||||
|
'datetime_raw' => '',
|
||||||
|
'channel' => '',
|
||||||
|
'level' => '',
|
||||||
|
'message' => $line,
|
||||||
|
'context' => null,
|
||||||
|
'raw' => $line,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Находит позицию конца JSON объекта, учитывая вложенность
|
||||||
|
* @param string $json JSON строка, начинающаяся с {
|
||||||
|
* @return int|false Позиция закрывающей скобки или false, если не найдено
|
||||||
|
*/
|
||||||
|
private function findJsonEnd(string $json)
|
||||||
|
{
|
||||||
|
$depth = 0;
|
||||||
|
$inString = false;
|
||||||
|
$escape = false;
|
||||||
|
$len = strlen($json);
|
||||||
|
|
||||||
|
for ($i = 0; $i < $len; $i++) {
|
||||||
|
$char = $json[$i];
|
||||||
|
|
||||||
|
if ($escape) {
|
||||||
|
$escape = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '\\') {
|
||||||
|
$escape = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '"') {
|
||||||
|
$inString = !$inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($inString) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($char === '{') {
|
||||||
|
$depth++;
|
||||||
|
} elseif ($char === '}') {
|
||||||
|
$depth--;
|
||||||
|
if ($depth === 0) {
|
||||||
|
return $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatDateTime(string $datetime): string
|
||||||
|
{
|
||||||
|
// Парсим ISO 8601 формат: 2025-11-23T14:28:21.772518+00:00
|
||||||
|
// Преобразуем в более читаемый формат: 2025-11-23 14:28:21
|
||||||
|
if (preg_match('/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})/', $datetime, $dateMatches)) {
|
||||||
|
return $dateMatches[1] . ' ' . $dateMatches[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $datetime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readLastLogsRows(string $path, int $lines = 1000, int $buffer = 4096): array
|
||||||
|
{
|
||||||
|
$f = fopen($path, 'rb');
|
||||||
|
if (! $f) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$lineCount = 0;
|
||||||
|
$chunk = '';
|
||||||
|
|
||||||
|
fseek($f, 0, SEEK_END);
|
||||||
|
$filesize = ftell($f);
|
||||||
|
|
||||||
|
while ($filesize > 0 && $lineCount < $lines) {
|
||||||
|
$seek = max($filesize - $buffer, 0);
|
||||||
|
$readLength = $filesize - $seek;
|
||||||
|
|
||||||
|
fseek($f, $seek);
|
||||||
|
$chunk = fread($f, $readLength) . $chunk;
|
||||||
|
|
||||||
|
$filesize = $seek;
|
||||||
|
$lineCount = substr_count($chunk, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($f);
|
||||||
|
|
||||||
|
$linesArray = explode("\n", $chunk);
|
||||||
|
|
||||||
|
return array_slice($linesArray, -$lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findLastLogsFileInDir(string $dir): ?string
|
||||||
|
{
|
||||||
|
$files = glob($dir . '/acmeshop-*.log');
|
||||||
|
|
||||||
|
return $files ? end($files) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
backend/src/bastion/Handlers/SendMessageHandler.php
Executable file
123
backend/src/bastion/Handlers/SendMessageHandler.php
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use App\Exceptions\TelegramCustomerNotFoundException;
|
||||||
|
use App\Exceptions\TelegramCustomerWriteNotAllowedException;
|
||||||
|
use App\Models\TelegramCustomer;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramService;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler для отправки сообщений Telegram-пользователям из админ-панели
|
||||||
|
*
|
||||||
|
* @package Bastion\Handlers
|
||||||
|
*/
|
||||||
|
class SendMessageHandler
|
||||||
|
{
|
||||||
|
private TelegramService $telegramService;
|
||||||
|
private TelegramCustomer $telegramCustomerModel;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
TelegramService $telegramService,
|
||||||
|
TelegramCustomer $telegramCustomerModel,
|
||||||
|
LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
$this->telegramService = $telegramService;
|
||||||
|
$this->telegramCustomerModel = $telegramCustomerModel;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить сообщение Telegram-пользователю
|
||||||
|
*
|
||||||
|
* @param Request $request HTTP запрос с id (ID записи в таблице) и message
|
||||||
|
* @return JsonResponse JSON ответ с результатом операции
|
||||||
|
* @throws TelegramCustomerNotFoundException Если пользователь не найден
|
||||||
|
* @throws TelegramCustomerWriteNotAllowedException Если пользователь не разрешил писать в PM
|
||||||
|
* @throws RuntimeException Если данные невалидны
|
||||||
|
* @throws \Exception
|
||||||
|
* @throws GuzzleException
|
||||||
|
*/
|
||||||
|
public function sendMessage(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$customerId = $this->extractCustomerId($request);
|
||||||
|
$message = $this->extractMessage($request);
|
||||||
|
|
||||||
|
// Находим запись по ID
|
||||||
|
$customer = $this->telegramCustomerModel->findById($customerId);
|
||||||
|
if (! $customer) {
|
||||||
|
throw new TelegramCustomerNotFoundException($customerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$telegramUserId = (int) $customer['telegram_user_id'];
|
||||||
|
|
||||||
|
// Проверяем, что пользователь разрешил писать ему в PM
|
||||||
|
if (! $customer['allows_write_to_pm']) {
|
||||||
|
throw new TelegramCustomerWriteNotAllowedException($telegramUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем сообщение (telegram_user_id используется как chat_id)
|
||||||
|
// Используем пустую строку для parse_mode чтобы отправлять обычный текст
|
||||||
|
$this->telegramService->sendMessage(
|
||||||
|
$telegramUserId,
|
||||||
|
$message,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->logger->info('Message sent to Telegram user', [
|
||||||
|
'oc_customer_id' => $customerId,
|
||||||
|
'telegram_user_id' => $telegramUserId,
|
||||||
|
'message_length' => strlen($message),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Message sent successfully',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлечь ID записи из запроса
|
||||||
|
*
|
||||||
|
* @param Request $request HTTP запрос
|
||||||
|
* @return int ID записи в таблице acmeshop_customers
|
||||||
|
* @throws RuntimeException Если ID отсутствует или невалиден
|
||||||
|
*/
|
||||||
|
private function extractCustomerId(Request $request): int
|
||||||
|
{
|
||||||
|
$jsonData = $request->json();
|
||||||
|
$customerId = isset($jsonData['id']) ? (int) $jsonData['id'] : 0;
|
||||||
|
|
||||||
|
if ($customerId <= 0) {
|
||||||
|
throw new RuntimeException('Customer ID is required and must be positive');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $customerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Извлечь сообщение из запроса
|
||||||
|
*
|
||||||
|
* @param Request $request HTTP запрос
|
||||||
|
* @return string Текст сообщения
|
||||||
|
* @throws RuntimeException Если сообщение отсутствует или пустое
|
||||||
|
*/
|
||||||
|
private function extractMessage(Request $request): string
|
||||||
|
{
|
||||||
|
$jsonData = $request->json();
|
||||||
|
$message = isset($jsonData['message']) ? trim($jsonData['message']) : '';
|
||||||
|
|
||||||
|
if (empty($message)) {
|
||||||
|
throw new RuntimeException('Message is required and cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
}
|
||||||
306
backend/src/bastion/Handlers/SettingsHandler.php
Executable file
306
backend/src/bastion/Handlers/SettingsHandler.php
Executable file
@@ -0,0 +1,306 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use Bastion\Exceptions\BotTokenConfiguratorException;
|
||||||
|
use Bastion\Services\BotTokenConfigurator;
|
||||||
|
use Bastion\Services\CronApiKeyRegenerator;
|
||||||
|
use Bastion\Services\SettingsService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Exception;
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheInterface;
|
||||||
|
use Acme\ECommerceFramework\Config\Settings;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||||
|
use Acme\ECommerceFramework\Scheduler\Models\ScheduledJob;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
class SettingsHandler
|
||||||
|
{
|
||||||
|
private BotTokenConfigurator $botTokenConfigurator;
|
||||||
|
private CronApiKeyRegenerator $cronApiKeyRegenerator;
|
||||||
|
private Settings $settings;
|
||||||
|
private SettingsService $settingsUpdateService;
|
||||||
|
private CacheInterface $cache;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
private Builder $builder;
|
||||||
|
private ConnectionInterface $connection;
|
||||||
|
private ScheduledJob $scheduledJob;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
BotTokenConfigurator $botTokenConfigurator,
|
||||||
|
CronApiKeyRegenerator $cronApiKeyRegenerator,
|
||||||
|
Settings $settings,
|
||||||
|
SettingsService $settingsUpdateService,
|
||||||
|
CacheInterface $cache,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
Builder $builder,
|
||||||
|
ConnectionInterface $connection,
|
||||||
|
ScheduledJob $scheduledJob
|
||||||
|
) {
|
||||||
|
$this->botTokenConfigurator = $botTokenConfigurator;
|
||||||
|
$this->cronApiKeyRegenerator = $cronApiKeyRegenerator;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->settingsUpdateService = $settingsUpdateService;
|
||||||
|
$this->cache = $cache;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->builder = $builder;
|
||||||
|
$this->connection = $connection;
|
||||||
|
$this->scheduledJob = $scheduledJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перегенерировать секретный ключ в URL для cron-job.org (сохраняет cron.api_key).
|
||||||
|
*/
|
||||||
|
public function regenerateCronScheduleUrl(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$newApiKey = $this->cronApiKeyRegenerator->regenerate();
|
||||||
|
$scheduleUrl = $this->buildCronScheduleUrl(
|
||||||
|
$this->settings->get('app.shop_base_url', ''),
|
||||||
|
$newApiKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return new JsonResponse(['api_key' => $newApiKey, 'schedule_url' => $scheduleUrl]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureBotToken(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$data = $this->botTokenConfigurator->configure(trim($request->json('botToken', '')));
|
||||||
|
|
||||||
|
return new JsonResponse($data);
|
||||||
|
} catch (BotTokenConfiguratorException $e) {
|
||||||
|
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSettingsForm(): JsonResponse
|
||||||
|
{
|
||||||
|
$data = Arr::getWithKeys($this->settings->getAll(), [
|
||||||
|
'app',
|
||||||
|
'telegram',
|
||||||
|
'metrics',
|
||||||
|
'store',
|
||||||
|
'orders',
|
||||||
|
'texts',
|
||||||
|
'sliders',
|
||||||
|
'mainpage_blocks',
|
||||||
|
'pulse',
|
||||||
|
'cron',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isset($data['cron']['mode'])) {
|
||||||
|
$data['cron']['mode'] = 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['forms'] = [];
|
||||||
|
|
||||||
|
// Add CRON system details (read-only)
|
||||||
|
$data['cron']['cli_path'] = BP_REAL_BASE_PATH . '/cli.php';
|
||||||
|
$data['cron']['last_run'] = $this->getLastCronRunDate();
|
||||||
|
$data['cron']['schedule_url'] = $this->buildCronScheduleUrl(
|
||||||
|
$this->settings->get('app.shop_base_url', ''),
|
||||||
|
$this->settings->get('cron.api_key', '')
|
||||||
|
);
|
||||||
|
|
||||||
|
$data['scheduled_jobs'] = $this->scheduledJob->all();
|
||||||
|
|
||||||
|
$forms = $this->builder->newQuery()
|
||||||
|
->from('acmeshop_forms')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($forms) {
|
||||||
|
foreach ($forms as $form) {
|
||||||
|
try {
|
||||||
|
$schema = json_decode($form['schema'] ?? '[]', true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
} catch (\JsonException $exception) {
|
||||||
|
$schema = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['forms'][$form['alias']] = [
|
||||||
|
'alias' => $form['alias'],
|
||||||
|
'friendly_name' => $form['friendly_name'],
|
||||||
|
'is_custom' => filter_var($form['is_custom'], FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'schema' => $schema,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(compact('data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCronScheduleUrl(string $shopBaseUrl, string $apiKey): string
|
||||||
|
{
|
||||||
|
$base = rtrim($shopBaseUrl, '/');
|
||||||
|
if ($base === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$params = http_build_query([
|
||||||
|
'route' => 'extension/tgshop/handle',
|
||||||
|
'api_action' => 'runSchedule',
|
||||||
|
'api_key' => $apiKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $base . '/index.php?' . $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveSettingsForm(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$input = $request->json();
|
||||||
|
|
||||||
|
$this->validate($input);
|
||||||
|
|
||||||
|
// Remove dynamic properties before saving
|
||||||
|
if (isset($input['cron'])) {
|
||||||
|
unset($input['cron']['cli_path']);
|
||||||
|
unset($input['cron']['last_run']);
|
||||||
|
unset($input['cron']['schedule_url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->settingsUpdateService->update(
|
||||||
|
Arr::getWithKeys($input, [
|
||||||
|
'app',
|
||||||
|
'telegram',
|
||||||
|
'metrics',
|
||||||
|
'store',
|
||||||
|
'orders',
|
||||||
|
'texts',
|
||||||
|
'sliders',
|
||||||
|
'mainpage_blocks',
|
||||||
|
'pulse',
|
||||||
|
'cron',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update forms
|
||||||
|
$forms = Arr::get($input, 'forms', []);
|
||||||
|
foreach ($forms as $form) {
|
||||||
|
$schema = json_encode($form['schema'], JSON_THROW_ON_ERROR);
|
||||||
|
$this->builder->newQuery()
|
||||||
|
->where('alias', '=', $form['alias'])
|
||||||
|
->update('acmeshop_forms', [
|
||||||
|
'friendly_name' => $form['friendly_name'],
|
||||||
|
'is_custom' => $form['is_custom'],
|
||||||
|
'schema' => $schema,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update scheduled jobs is_enabled and cron_expression
|
||||||
|
$scheduledJobs = Arr::get($input, 'scheduled_jobs', []);
|
||||||
|
foreach ($scheduledJobs as $job) {
|
||||||
|
$id = (int) ($job['id'] ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$isEnabled = filter_var($job['is_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
if ($isEnabled) {
|
||||||
|
$this->scheduledJob->enable($id);
|
||||||
|
} else {
|
||||||
|
$this->scheduledJob->disable($id);
|
||||||
|
}
|
||||||
|
$cronExpression = trim((string) ($job['cron_expression'] ?? ''));
|
||||||
|
if ($cronExpression !== '') {
|
||||||
|
$this->scheduledJob->updateCronExpression($id, $cronExpression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([], Response::HTTP_ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(array $input): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetCache(): JsonResponse
|
||||||
|
{
|
||||||
|
$this->cache->clear();
|
||||||
|
|
||||||
|
$this->logger->info('Cache cleared manually.');
|
||||||
|
|
||||||
|
return new JsonResponse([], Response::HTTP_ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getLastCronRunDate(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Since we are in SettingsHandler, we already have access to container or we can inject SchedulerService
|
||||||
|
// But SettingsHandler is constructed via DI. Let's add SchedulerService to constructor.
|
||||||
|
// For now, let's use global retrieval via cache if possible, or assume it's injected.
|
||||||
|
// But wait, getLastCronRunDate logic was in controller.
|
||||||
|
// SchedulerService stores last run in cache. We have $this->cache here.
|
||||||
|
|
||||||
|
$lastRunTimestamp = $this->cache->get("scheduler.global_last_run");
|
||||||
|
|
||||||
|
if ($lastRunTimestamp) {
|
||||||
|
return Carbon::createFromTimestamp($lastRunTimestamp)->toDateTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSystemInfo(): JsonResponse
|
||||||
|
{
|
||||||
|
$info = [];
|
||||||
|
|
||||||
|
$info['PHP Version'] = PHP_VERSION;
|
||||||
|
$info['PHP SAPI'] = PHP_SAPI;
|
||||||
|
$info['PHP Memory Limit'] = ini_get('memory_limit');
|
||||||
|
$info['PHP Memory Usage'] = $this->formatBytes(memory_get_usage(true));
|
||||||
|
$info['PHP Peak Memory Usage'] = $this->formatBytes(memory_get_peak_usage(true));
|
||||||
|
$info['PHP Max Execution Time'] = ini_get('max_execution_time') . 's';
|
||||||
|
$info['PHP Upload Max Filesize'] = ini_get('upload_max_filesize');
|
||||||
|
$info['PHP Post Max Size'] = ini_get('post_max_size');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$mysqlVersion = $this->connection->select('SELECT VERSION() as version');
|
||||||
|
$info['MySQL Version'] = $mysqlVersion[0]['version'] ?? 'Unknown';
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$info['MySQL Version'] = 'Error: ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheDriver = env('MEGAPAY_CACHE_DRIVER', 'mysql');
|
||||||
|
$cacheClass = get_class($this->cache);
|
||||||
|
$info['Cache Driver'] = $cacheDriver . ' (' . basename(str_replace('\\', '/', $cacheClass)) . ')';
|
||||||
|
|
||||||
|
$info['Module Version'] = module_version();
|
||||||
|
$info['ECommerce Version'] = defined('VERSION') ? VERSION : 'Unknown';
|
||||||
|
$info['ECommerce Core Version'] = defined('VERSION_CORE') ? VERSION_CORE : 'Unknown';
|
||||||
|
|
||||||
|
$info['Operating System'] = PHP_OS;
|
||||||
|
$info['Server Software'] = $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown';
|
||||||
|
$info['Document Root'] = $_SERVER['DOCUMENT_ROOT'] ?? 'Unknown';
|
||||||
|
|
||||||
|
$info['PHP Timezone'] = date_default_timezone_get();
|
||||||
|
$info['Server Time'] = date('Y-m-d H:i:s');
|
||||||
|
$info['UTC Time'] = gmdate('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$info['Loaded PHP Extensions'] = implode(', ', get_loaded_extensions());
|
||||||
|
|
||||||
|
$infoText = '';
|
||||||
|
foreach ($info as $key => $value) {
|
||||||
|
$infoText .= $key . ': ' . $value . "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(['data' => $infoText]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatBytes(int $bytes, int $precision = 2): string
|
||||||
|
{
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
|
||||||
|
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
||||||
|
$bytes /= 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($bytes, $precision) . ' ' . $units[$i];
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/src/bastion/Handlers/StatsHandler.php
Executable file
60
backend/src/bastion/Handlers/StatsHandler.php
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\JoinClause;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
|
||||||
|
|
||||||
|
class StatsHandler
|
||||||
|
{
|
||||||
|
private Builder $builder;
|
||||||
|
|
||||||
|
public function __construct(Builder $builder)
|
||||||
|
{
|
||||||
|
$this->builder = $builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDashboardStats(): JsonResponse
|
||||||
|
{
|
||||||
|
$data = [
|
||||||
|
'orders_count' => 0,
|
||||||
|
'orders_total_amount' => 0,
|
||||||
|
'customers_count' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$ordersTotalAmount = $this->builder->newQuery()
|
||||||
|
->select([
|
||||||
|
new RawExpression('COUNT(DISTINCT orders.order_id) AS orders_total_count'),
|
||||||
|
new RawExpression('SUM(orders.total) AS orders_total_amount'),
|
||||||
|
])
|
||||||
|
->from(db_table('order'), 'orders')
|
||||||
|
->join('acmeshop_customers', function (JoinClause $join) {
|
||||||
|
$join->on('orders.customer_id', '=', 'acmeshop_customers.oc_customer_id');
|
||||||
|
})
|
||||||
|
->join('acmeshop_order_meta', function (JoinClause $join) {
|
||||||
|
$join->on('orders.order_id', '=', 'acmeshop_order_meta.oc_order_id')
|
||||||
|
->whereRaw('orders.store_id = acmeshop_order_meta.oc_store_id');
|
||||||
|
})
|
||||||
|
->firstOrNull();
|
||||||
|
|
||||||
|
|
||||||
|
if ($ordersTotalAmount) {
|
||||||
|
$data = [
|
||||||
|
'orders_count' => (int) $ordersTotalAmount['orders_total_count'],
|
||||||
|
'orders_total_amount' => (int) $ordersTotalAmount['orders_total_amount'],
|
||||||
|
'customers_count' => $this->countCustomersCount(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(compact('data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countCustomersCount(): int
|
||||||
|
{
|
||||||
|
return $this->builder->newQuery()
|
||||||
|
->from('acmeshop_customers')
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
344
backend/src/bastion/Handlers/TelegramCustomersHandler.php
Executable file
344
backend/src/bastion/Handlers/TelegramCustomersHandler.php
Executable file
@@ -0,0 +1,344 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Builder;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\RawExpression;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
|
||||||
|
class TelegramCustomersHandler
|
||||||
|
{
|
||||||
|
private const TABLE_NAME = 'acmeshop_customers';
|
||||||
|
private const DEFAULT_PAGE = 1;
|
||||||
|
private const DEFAULT_ROWS = 20;
|
||||||
|
private const DEFAULT_SORT_FIELD = 'last_seen_at';
|
||||||
|
private const DEFAULT_SORT_ORDER = 'DESC';
|
||||||
|
|
||||||
|
private Builder $builder;
|
||||||
|
|
||||||
|
public function __construct(Builder $builder)
|
||||||
|
{
|
||||||
|
$this->builder = $builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить список Telegram-кастомеров с пагинацией, фильтрацией и сортировкой
|
||||||
|
*
|
||||||
|
* @param Request $request HTTP запрос с параметрами пагинации, сортировки и фильтров
|
||||||
|
* @return JsonResponse JSON ответ с данными и метаинформацией
|
||||||
|
*/
|
||||||
|
public function getCustomers(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$page = max(1, (int) $request->json('page', self::DEFAULT_PAGE));
|
||||||
|
$rows = max(1, (int) $request->json('rows', self::DEFAULT_ROWS));
|
||||||
|
$first = ($page - 1) * $rows;
|
||||||
|
|
||||||
|
$sortField = $request->json('sortField', self::DEFAULT_SORT_FIELD) ?? self::DEFAULT_SORT_FIELD;
|
||||||
|
$sortOrder = $this->normalizeSortOrder((string)$request->json('sortOrder', self::DEFAULT_SORT_ORDER));
|
||||||
|
|
||||||
|
$filters = $request->json('filters', []);
|
||||||
|
$globalFilter = Arr::get($filters, 'global.value');
|
||||||
|
|
||||||
|
// Создаем базовый query с фильтрами
|
||||||
|
$query = $this->buildBaseQuery();
|
||||||
|
$this->applyFilters($query, $filters, $globalFilter);
|
||||||
|
|
||||||
|
// Получаем общее количество записей
|
||||||
|
$countQuery = $this->buildCountQuery();
|
||||||
|
$this->applyFilters($countQuery, $filters, $globalFilter);
|
||||||
|
$totalRecords = (int) ($countQuery->value('total') ?? 0);
|
||||||
|
|
||||||
|
// Применяем сортировку и пагинацию
|
||||||
|
$customers = $query
|
||||||
|
->orderBy($sortField, $sortOrder)
|
||||||
|
->offset($first)
|
||||||
|
->limit($rows)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'data' => $this->mapToResponse($customers),
|
||||||
|
'totalRecords' => $totalRecords,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать базовый query для выборки данных
|
||||||
|
*
|
||||||
|
* @return Builder
|
||||||
|
*/
|
||||||
|
private function buildBaseQuery(): Builder
|
||||||
|
{
|
||||||
|
return $this->builder->newQuery()
|
||||||
|
->select([
|
||||||
|
'id',
|
||||||
|
'telegram_user_id',
|
||||||
|
'oc_customer_id',
|
||||||
|
'tracking_id',
|
||||||
|
'username',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'language_code',
|
||||||
|
'is_premium',
|
||||||
|
'allows_write_to_pm',
|
||||||
|
'photo_url',
|
||||||
|
'last_seen_at',
|
||||||
|
'referral',
|
||||||
|
'orders_count',
|
||||||
|
'privacy_consented_at',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
])
|
||||||
|
->from(self::TABLE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать query для подсчета общего количества записей
|
||||||
|
*
|
||||||
|
* @return Builder
|
||||||
|
*/
|
||||||
|
private function buildCountQuery(): Builder
|
||||||
|
{
|
||||||
|
return $this->builder->newQuery()
|
||||||
|
->select([new RawExpression('COUNT(*) as total')])
|
||||||
|
->from(self::TABLE_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить фильтры к query
|
||||||
|
*
|
||||||
|
* @param Builder $query Query builder
|
||||||
|
* @param array $filters Массив фильтров
|
||||||
|
* @param string|null $globalFilter Глобальный фильтр поиска
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function applyFilters(Builder $query, array $filters, ?string $globalFilter): void
|
||||||
|
{
|
||||||
|
// Применяем глобальный фильтр
|
||||||
|
if ($globalFilter) {
|
||||||
|
$this->applyGlobalFilter($query, $globalFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем фильтры по колонкам
|
||||||
|
$this->applyColumnFilters($query, $filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить глобальный фильтр поиска
|
||||||
|
*
|
||||||
|
* @param Builder $query Query builder
|
||||||
|
* @param string $searchTerm Поисковый запрос
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function applyGlobalFilter(Builder $query, string $searchTerm): void
|
||||||
|
{
|
||||||
|
$query->whereNested(function ($q) use ($searchTerm) {
|
||||||
|
$q->where('telegram_user_id', 'LIKE', "%{$searchTerm}%")
|
||||||
|
->orWhere('username', 'LIKE', "%{$searchTerm}%")
|
||||||
|
->orWhere('first_name', 'LIKE', "%{$searchTerm}%")
|
||||||
|
->orWhere('last_name', 'LIKE', "%{$searchTerm}%")
|
||||||
|
->orWhere('language_code', 'LIKE', "%{$searchTerm}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить фильтры по колонкам
|
||||||
|
*
|
||||||
|
* @param Builder $query Query builder
|
||||||
|
* @param array $filters Массив фильтров
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function applyColumnFilters(Builder $query, array $filters): void
|
||||||
|
{
|
||||||
|
foreach ($filters as $field => $filter) {
|
||||||
|
if ($field === 'global') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка сложных фильтров (constraints)
|
||||||
|
if (isset($filter['constraints']) && is_array($filter['constraints'])) {
|
||||||
|
$this->applyConstraintFilters($query, $field, $filter);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка простых фильтров (обратная совместимость)
|
||||||
|
if (! isset($filter['value']) || $filter['value'] === null || $filter['value'] === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $filter['value'];
|
||||||
|
$matchMode = Arr::get($filter, 'matchMode', 'contains');
|
||||||
|
|
||||||
|
$this->applyColumnFilter($query, $field, $value, $matchMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить сложные фильтры с условиями (AND/OR)
|
||||||
|
*
|
||||||
|
* @param Builder $query Query builder
|
||||||
|
* @param string $field Имя поля
|
||||||
|
* @param array $filter Данные фильтра
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function applyConstraintFilters(Builder $query, string $field, array $filter): void
|
||||||
|
{
|
||||||
|
$operator = strtolower($filter['operator'] ?? 'and');
|
||||||
|
$constraints = $filter['constraints'];
|
||||||
|
|
||||||
|
// Фильтруем пустые значения (но учитываем false как валидное значение для boolean полей)
|
||||||
|
$activeConstraints = array_filter($constraints, function ($constraint) {
|
||||||
|
if (!isset($constraint['value'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$value = $constraint['value'];
|
||||||
|
// null означает "любой", пропускаем
|
||||||
|
if ($value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Пустая строка пропускаем
|
||||||
|
if ($value === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// false - валидное значение для boolean полей
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (empty($activeConstraints)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereNested(function ($q) use ($field, $activeConstraints, $operator) {
|
||||||
|
// Для первого элемента всегда используем where, чтобы начать группу
|
||||||
|
$first = true;
|
||||||
|
|
||||||
|
foreach ($activeConstraints as $constraint) {
|
||||||
|
$value = $constraint['value'];
|
||||||
|
$matchMode = $constraint['matchMode'] ?? 'contains';
|
||||||
|
|
||||||
|
if ($first) {
|
||||||
|
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||||||
|
$first = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($operator === 'or') {
|
||||||
|
$q->orWhere(function ($subQ) use ($field, $value, $matchMode) {
|
||||||
|
$this->applyColumnFilter($subQ, $field, $value, $matchMode);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применить фильтр для одной колонки
|
||||||
|
*
|
||||||
|
* @param Builder $query Query builder
|
||||||
|
* @param string $field Имя поля
|
||||||
|
* @param mixed $value Значение фильтра
|
||||||
|
* @param string $matchMode Режим совпадения (contains, startsWith, endsWith, equals, notEquals)
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function applyColumnFilter(Builder $query, string $field, $value, string $matchMode): void
|
||||||
|
{
|
||||||
|
if (in_array($matchMode, ['contains', 'startsWith', 'endsWith'], true)) {
|
||||||
|
$likeValue = $this->buildLikeValue($value, $matchMode);
|
||||||
|
$query->where($field, 'LIKE', $likeValue);
|
||||||
|
} elseif ($matchMode === 'equals') {
|
||||||
|
$query->where($field, '=', $value);
|
||||||
|
} elseif ($matchMode === 'notEquals') {
|
||||||
|
$query->where($field, '!=', $value);
|
||||||
|
} elseif ($matchMode === 'gt') {
|
||||||
|
$query->where($field, '>', $value);
|
||||||
|
} elseif ($matchMode === 'lt') {
|
||||||
|
$query->where($field, '<', $value);
|
||||||
|
} elseif ($matchMode === 'gte') {
|
||||||
|
$query->where($field, '>=', $value);
|
||||||
|
} elseif ($matchMode === 'lte') {
|
||||||
|
$query->where($field, '<=', $value);
|
||||||
|
} elseif ($matchMode === 'dateIs') {
|
||||||
|
// Для точного совпадения даты используем диапазон от 00:00:00 до 23:59:59
|
||||||
|
$date = date('Y-m-d', strtotime($value));
|
||||||
|
$query->where($field, '>=', $date . ' 00:00:00')
|
||||||
|
->where($field, '<=', $date . ' 23:59:59');
|
||||||
|
} elseif ($matchMode === 'dateIsNot') {
|
||||||
|
// Для отрицания проверяем, что дата меньше начала дня ИЛИ больше конца дня
|
||||||
|
$date = date('Y-m-d', strtotime($value));
|
||||||
|
$query->whereNested(function ($q) use ($field, $date) {
|
||||||
|
$q->where($field, '<', $date . ' 00:00:00')
|
||||||
|
->orWhere($field, '>', $date . ' 23:59:59');
|
||||||
|
});
|
||||||
|
} elseif ($matchMode === 'dateBefore') {
|
||||||
|
$query->where($field, '<', date('Y-m-d 00:00:00', strtotime($value)));
|
||||||
|
} elseif ($matchMode === 'dateAfter') {
|
||||||
|
// "После" означает после конца указанного дня
|
||||||
|
$query->where($field, '>', date('Y-m-d 23:59:59', strtotime($value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Построить значение для LIKE запроса
|
||||||
|
*
|
||||||
|
* @param string $value Значение
|
||||||
|
* @param string $matchMode Режим совпадения
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function buildLikeValue(string $value, string $matchMode): string
|
||||||
|
{
|
||||||
|
if ($matchMode === 'startsWith') {
|
||||||
|
return "{$value}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($matchMode === 'endsWith') {
|
||||||
|
return "%{$value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "%{$value}%";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Нормализовать порядок сортировки
|
||||||
|
*
|
||||||
|
* @param string $sortOrder Порядок сортировки
|
||||||
|
* @return string 'ASC' или 'DESC'
|
||||||
|
*/
|
||||||
|
private function normalizeSortOrder(string $sortOrder): string
|
||||||
|
{
|
||||||
|
$normalized = strtoupper($sortOrder);
|
||||||
|
|
||||||
|
return in_array($normalized, ['ASC', 'DESC'], true) ? $normalized : self::DEFAULT_SORT_ORDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapToResponse(array $customers): array
|
||||||
|
{
|
||||||
|
return array_map(static function (array $customer) {
|
||||||
|
return [
|
||||||
|
'id' => (int) $customer['id'],
|
||||||
|
'telegram_user_id' => (int) $customer['telegram_user_id'],
|
||||||
|
'oc_customer_id' => (int) $customer['oc_customer_id'],
|
||||||
|
'tracking_id' => $customer['tracking_id'],
|
||||||
|
'username' => $customer['username'],
|
||||||
|
'first_name' => $customer['first_name'],
|
||||||
|
'last_name' => $customer['last_name'],
|
||||||
|
'language_code' => $customer['language_code'],
|
||||||
|
'is_premium' => filter_var($customer['is_premium'], FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'allows_write_to_pm' => filter_var($customer['allows_write_to_pm'], FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'photo_url' => $customer['photo_url'],
|
||||||
|
'last_seen_at' => $customer['last_seen_at'],
|
||||||
|
'referral' => $customer['referral'],
|
||||||
|
'orders_count' => (int) $customer['orders_count'],
|
||||||
|
'privacy_consented_at' => $customer['privacy_consented_at'],
|
||||||
|
'created_at' => $customer['created_at'],
|
||||||
|
'updated_at' => $customer['updated_at'],
|
||||||
|
];
|
||||||
|
}, $customers);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
backend/src/bastion/Handlers/TelegramHandler.php
Executable file
140
backend/src/bastion/Handlers/TelegramHandler.php
Executable file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Handlers;
|
||||||
|
|
||||||
|
use App\Services\SettingsService;
|
||||||
|
use Exception;
|
||||||
|
use GuzzleHttp\Exception\ClientException;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheInterface;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Enums\ChatAction;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Acme\ECommerceFramework\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Exceptions\TelegramClientException;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramService;
|
||||||
|
|
||||||
|
class TelegramHandler
|
||||||
|
{
|
||||||
|
private CacheInterface $cache;
|
||||||
|
private TelegramService $telegramService;
|
||||||
|
private SettingsService $settings;
|
||||||
|
|
||||||
|
public function __construct(CacheInterface $cache, TelegramService $telegramService, SettingsService $settings)
|
||||||
|
{
|
||||||
|
$this->cache = $cache;
|
||||||
|
$this->telegramService = $telegramService;
|
||||||
|
$this->settings = $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getChatId(): JsonResponse
|
||||||
|
{
|
||||||
|
$message = $this->cache->get('tg_latest_msg');
|
||||||
|
|
||||||
|
if (! $message) {
|
||||||
|
return new JsonResponse([
|
||||||
|
// phpcs:ignore Generic.Files.LineLength
|
||||||
|
'message' => 'Сообщение не найдено. Убедитесь что отправили кодовое слово в чат с ботом и повторите через 10 секунд. У Вас есть 60 секунд после отправки сообщения в чат, чтобы нажать на кнопку! Это сделано в целях безопасности.'
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = Arr::get($message, 'text');
|
||||||
|
if ($text !== 'ecommerce_get_chatid') {
|
||||||
|
return new JsonResponse(
|
||||||
|
['message' => 'Последнее сообщение в чате не содержит кодовое слово.'],
|
||||||
|
Response::HTTP_UNPROCESSABLE_ENTITY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$chatId = Arr::get($message, 'chat.id');
|
||||||
|
|
||||||
|
if (! $chatId) {
|
||||||
|
return new JsonResponse([
|
||||||
|
// phpcs:ignore Generic.Files.LineLength
|
||||||
|
'message' => 'ChatID не найден. Убедитесь что отправили кодовое слово в чат с ботом и повторите через 10 секунд.'
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'data' => [
|
||||||
|
'chat_id' => $chatId,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTgMessage(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$template = $request->json('template', 'Нет шаблона');
|
||||||
|
$token = $request->json('token');
|
||||||
|
$chatId = $request->json('chat_id');
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => 'Не задан Telegram BotToken',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $chatId) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => 'Не задан ChatID.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variables = [
|
||||||
|
'{store_name}' => $this->settings->config()->getApp()->getAppName(),
|
||||||
|
'{order_id}' => 777,
|
||||||
|
'{customer}' => 'Иван Васильевич',
|
||||||
|
'{email}' => 'telegram@ecommerce.com',
|
||||||
|
'{phone}' => '+79999999999',
|
||||||
|
'{comment}' => 'Это тестовый заказ',
|
||||||
|
'{address}' => 'г. Москва',
|
||||||
|
'{total}' => 100000,
|
||||||
|
'{ip}' => '127.0.0.1',
|
||||||
|
'{created_at}' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$message = $this->telegramService->prepareMessage($template, $variables);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->telegramService
|
||||||
|
->setBotToken($token)
|
||||||
|
->sendMessage($chatId, $message);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => 'Сообщение отправлено. Проверьте Telegram.',
|
||||||
|
]);
|
||||||
|
} catch (ClientException $exception) {
|
||||||
|
$json = json_decode($exception->getResponse()->getBody(), true);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => $json['description'],
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws GuzzleException
|
||||||
|
* @throws TelegramClientException
|
||||||
|
* @throws \JsonException
|
||||||
|
*/
|
||||||
|
public function tgGetMe(): JsonResponse
|
||||||
|
{
|
||||||
|
if (! $this->settings->config()->getTelegram()->getBotToken()) {
|
||||||
|
return new JsonResponse(['data' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->cache->get('tg_me_info');
|
||||||
|
|
||||||
|
if (! $data) {
|
||||||
|
$data = $this->telegramService->exec('getMe');
|
||||||
|
$this->cache->set('tg_me_info', $data, 60 * 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonResponse(compact('data'));
|
||||||
|
}
|
||||||
|
}
|
||||||
175
backend/src/bastion/ScheduledTasks/AcmeShopPulseSendEventsTask.php
Executable file
175
backend/src/bastion/ScheduledTasks/AcmeShopPulseSendEventsTask.php
Executable file
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\ScheduledTasks;
|
||||||
|
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheInterface;
|
||||||
|
use Acme\ECommerceFramework\Config\Settings;
|
||||||
|
use Acme\ECommerceFramework\Scheduler\TaskInterface;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopEvent;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseEventsSender;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class AcmeShopPulseSendEventsTask implements TaskInterface
|
||||||
|
{
|
||||||
|
private AcmeShopEvent $eventModel;
|
||||||
|
private AcmeShopPulseEventsSender $eventsSender;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
private CacheInterface $cache;
|
||||||
|
private Settings $settings;
|
||||||
|
private int $maxAttempts;
|
||||||
|
private int $batchSize;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
Settings $settings,
|
||||||
|
AcmeShopEvent $eventModel,
|
||||||
|
AcmeShopPulseEventsSender $eventsSender,
|
||||||
|
LoggerInterface $logger,
|
||||||
|
CacheInterface $cache
|
||||||
|
) {
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->eventModel = $eventModel;
|
||||||
|
$this->eventsSender = $eventsSender;
|
||||||
|
$this->logger = $logger;
|
||||||
|
$this->cache = $cache;
|
||||||
|
|
||||||
|
// Получаем конфигурацию из настроек пользователя
|
||||||
|
$this->maxAttempts = (int) $this->settings->get('pulse.max_attempts', env('PULSE_MAX_ATTEMPTS', 3));
|
||||||
|
$this->batchSize = (int) $this->settings->get('pulse.batch_size', env('PULSE_BATCH_SIZE', 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Получаем события со статусом pending
|
||||||
|
$events = $this->eventModel->findPending($this->batchSize);
|
||||||
|
|
||||||
|
if (empty($events)) {
|
||||||
|
$this->logger->debug('No pending events to send');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$count = count($events);
|
||||||
|
$this->logger->info("Processing pending events: $count", [
|
||||||
|
'count' => $count,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$processed = 0;
|
||||||
|
$succeeded = 0;
|
||||||
|
$failed = 0;
|
||||||
|
|
||||||
|
foreach ($events as $event) {
|
||||||
|
try {
|
||||||
|
$result = $this->processEvent($event);
|
||||||
|
$result ? $succeeded++ : $failed++;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error("Failed to process event {$event['id']}: " . $e->getMessage(), [
|
||||||
|
'event_id' => $event['id'],
|
||||||
|
'event' => $event['event'] ?? null,
|
||||||
|
'payload' => $event['payload'] ?? null,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
$failed++;
|
||||||
|
} finally {
|
||||||
|
$processed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logger->info("Events processing completed", [
|
||||||
|
'processed' => $processed,
|
||||||
|
'succeeded' => $succeeded,
|
||||||
|
'failed' => $failed,
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->logger->error("AcmeShopPulseSendEventsTask failed: " . $e->getMessage(), [
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
// Сбрасываем кеш статистики после каждого прогона
|
||||||
|
$this->clearStatsCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработать одно событие
|
||||||
|
*
|
||||||
|
* @param array $event Данные события из БД
|
||||||
|
* @return bool true если событие успешно отправлено, false если требуется повторная попытка
|
||||||
|
* @throws Throwable
|
||||||
|
*/
|
||||||
|
private function processEvent(array $event): bool
|
||||||
|
{
|
||||||
|
$eventId = (int) $event['id'];
|
||||||
|
$attemptsCount = (int) $event['attempts_count'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Пытаемся отправить событие
|
||||||
|
$success = $this->eventsSender->sendEvent($event);
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
// Успешная отправка
|
||||||
|
$this->eventModel->updateStatus($eventId, 'sent');
|
||||||
|
$this->logger->debug("Event {$eventId} sent successfully", [
|
||||||
|
'event_id' => $eventId,
|
||||||
|
'event' => $event['event'],
|
||||||
|
]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcmeShop Pulse не вернул подтверждение
|
||||||
|
$errorReason = 'No confirmation received from AcmeShop Pulse';
|
||||||
|
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
|
||||||
|
} catch (GuzzleException $e) {
|
||||||
|
// Ошибка HTTP запроса
|
||||||
|
$errorReason = 'HTTP error: ' . $e->getMessage();
|
||||||
|
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// Другие ошибки (валидация, подпись и т.д.)
|
||||||
|
$errorReason = 'Error: ' . $e->getMessage();
|
||||||
|
$this->handleFailedAttempt($eventId, $attemptsCount, $errorReason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработать неудачную попытку отправки
|
||||||
|
*
|
||||||
|
* @param int $eventId ID события
|
||||||
|
* @param int $currentAttempts Текущее количество попыток
|
||||||
|
* @param string $errorReason Причина ошибки
|
||||||
|
*/
|
||||||
|
private function handleFailedAttempt(int $eventId, int $currentAttempts, string $errorReason): void
|
||||||
|
{
|
||||||
|
$newAttempts = $currentAttempts + 1;
|
||||||
|
|
||||||
|
if ($newAttempts >= $this->maxAttempts) {
|
||||||
|
// Превышен лимит попыток - переводим в failed
|
||||||
|
$this->eventModel->updateStatus($eventId, 'failed', $errorReason);
|
||||||
|
$this->logger->warning("Event {$eventId} marked as failed after {$newAttempts} attempts", [
|
||||||
|
'event_id' => $eventId,
|
||||||
|
'attempts' => $newAttempts,
|
||||||
|
'error' => $errorReason,
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Увеличиваем счетчик попыток, оставляем статус pending
|
||||||
|
$this->eventModel->incrementAttempts($eventId);
|
||||||
|
$this->logger->debug("Event {$eventId} attempt failed, will retry", [
|
||||||
|
'event_id' => $eventId,
|
||||||
|
'attempts' => $newAttempts,
|
||||||
|
'max_attempts' => $this->maxAttempts,
|
||||||
|
'error' => $errorReason,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбросить кеш статистики
|
||||||
|
*/
|
||||||
|
private function clearStatsCache(): void
|
||||||
|
{
|
||||||
|
$this->cache->delete('acmeshop_pulse_stats');
|
||||||
|
}
|
||||||
|
}
|
||||||
90
backend/src/bastion/Services/BotTokenConfigurator.php
Executable file
90
backend/src/bastion/Services/BotTokenConfigurator.php
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bastion\Services;
|
||||||
|
|
||||||
|
use App\Services\SettingsService;
|
||||||
|
use Bastion\Exceptions\BotTokenConfiguratorException;
|
||||||
|
use Exception;
|
||||||
|
use GuzzleHttp\Exception\GuzzleException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
use Acme\ECommerceFramework\Router\Router;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\Telegram\Exceptions\TelegramClientException;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramService;
|
||||||
|
|
||||||
|
class BotTokenConfigurator
|
||||||
|
{
|
||||||
|
private TelegramService $telegramService;
|
||||||
|
private SettingsService $settings;
|
||||||
|
private Router $router;
|
||||||
|
private LoggerInterface $logger;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
TelegramService $telegramService,
|
||||||
|
SettingsService $settings,
|
||||||
|
Router $router,
|
||||||
|
LoggerInterface $logger
|
||||||
|
) {
|
||||||
|
$this->telegramService = $telegramService;
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->router = $router;
|
||||||
|
$this->logger = $logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws BotTokenConfiguratorException
|
||||||
|
*/
|
||||||
|
public function configure(string $botToken): array
|
||||||
|
{
|
||||||
|
$this->telegramService->setBotToken($botToken);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$me = $this->telegramService->exec('getMe');
|
||||||
|
$webhookUrl = $this->telegramService->getWebhookUrl();
|
||||||
|
|
||||||
|
if (! $webhookUrl) {
|
||||||
|
$this->telegramService->exec('setWebhook', [
|
||||||
|
'url' => $this->getWebhookUrl(),
|
||||||
|
]);
|
||||||
|
$webhookUrl = $this->telegramService->getWebhookUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'first_name' => Arr::get($me, 'result.first_name'),
|
||||||
|
'username' => Arr::get($me, 'result.username'),
|
||||||
|
'id' => Arr::get($me, 'result.id'),
|
||||||
|
'webhook_url' => $webhookUrl,
|
||||||
|
];
|
||||||
|
} catch (TelegramClientException $exception) {
|
||||||
|
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||||
|
if ($exception->getCode() === 404 || $exception->getCode() === 401) {
|
||||||
|
throw new BotTokenConfiguratorException(
|
||||||
|
'Telegram сообщает, что BotToken не верный. Проверьте корректность.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new BotTokenConfiguratorException($exception->getMessage());
|
||||||
|
} catch (Exception | GuzzleException $exception) {
|
||||||
|
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||||
|
throw new BotTokenConfiguratorException($exception->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws BotTokenConfiguratorException
|
||||||
|
*/
|
||||||
|
private function getWebhookUrl(): string
|
||||||
|
{
|
||||||
|
$publicUrl = rtrim($this->settings->config()->getApp()->getShopBaseUrl(), '/');
|
||||||
|
|
||||||
|
if (! $publicUrl) {
|
||||||
|
throw new BotTokenConfiguratorException('Public URL is not set in configuration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$webhook = $this->router->url('webhook');
|
||||||
|
|
||||||
|
return $publicUrl . $webhook;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/src/bastion/Services/CronApiKeyRegenerator.php
Normal file
37
backend/src/bastion/Services/CronApiKeyRegenerator.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Bastion\Services;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Config\Settings;
|
||||||
|
|
||||||
|
class CronApiKeyRegenerator
|
||||||
|
{
|
||||||
|
private Settings $settings;
|
||||||
|
private SettingsService $settingsUpdateService;
|
||||||
|
|
||||||
|
public function __construct(Settings $settings, SettingsService $settingsUpdateService)
|
||||||
|
{
|
||||||
|
$this->settings = $settings;
|
||||||
|
$this->settingsUpdateService = $settingsUpdateService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерирует новый API-ключ для URL cron-job.org и сохраняет в настройки.
|
||||||
|
*
|
||||||
|
* @return string новый api_key
|
||||||
|
*/
|
||||||
|
public function regenerate(): string
|
||||||
|
{
|
||||||
|
$newApiKey = bin2hex(random_bytes(32));
|
||||||
|
$all = $this->settings->getAll();
|
||||||
|
if (! isset($all['cron'])) {
|
||||||
|
$all['cron'] = [];
|
||||||
|
}
|
||||||
|
$all['cron']['api_key'] = $newApiKey;
|
||||||
|
$this->settingsUpdateService->update($all);
|
||||||
|
|
||||||
|
return $newApiKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/src/bastion/Services/SettingsService.php
Executable file
37
backend/src/bastion/Services/SettingsService.php
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Services;
|
||||||
|
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheInterface;
|
||||||
|
use Acme\ECommerceFramework\ECommerce\Decorators\OcRegistryDecorator;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
|
||||||
|
class SettingsService
|
||||||
|
{
|
||||||
|
private OcRegistryDecorator $registry;
|
||||||
|
private CacheInterface $cache;
|
||||||
|
private ConnectionInterface $connection;
|
||||||
|
|
||||||
|
public function __construct(OcRegistryDecorator $registry, CacheInterface $cache, ConnectionInterface $connection)
|
||||||
|
{
|
||||||
|
$this->registry = $registry;
|
||||||
|
$this->cache = $cache;
|
||||||
|
$this->connection = $connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(array $data): void
|
||||||
|
{
|
||||||
|
$this->connection->transaction(function () use ($data) {
|
||||||
|
$this->registry->model_setting_setting->editSetting('module_acmeshop', [
|
||||||
|
'module_acmeshop_settings' => $data,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->registry->model_setting_setting->editSetting('module_tgshop', [
|
||||||
|
'module_tgshop_status' => Arr::get($data, 'app.app_enabled', false) ? 1 : 0,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->cache->clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/src/bastion/Tasks/CleanUpOldAssetsTask.php
Executable file
73
backend/src/bastion/Tasks/CleanUpOldAssetsTask.php
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bastion\Tasks;
|
||||||
|
|
||||||
|
use DateInterval;
|
||||||
|
use Exception;
|
||||||
|
use JsonException;
|
||||||
|
use Acme\ECommerceFramework\MaintenanceTasks\BaseMaintenanceTask;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class CleanUpOldAssetsTask extends BaseMaintenanceTask
|
||||||
|
{
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$spaPath = rtrim(DIR_IMAGE, '/') . '/catalog/tgshopspa';
|
||||||
|
$assetsPath = $spaPath . '/assets';
|
||||||
|
$manifestPath = $spaPath . '/manifest.json';
|
||||||
|
if (! file_exists($manifestPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$contents = json_decode(file_get_contents($manifestPath), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$entry = $contents['index.html'] ?? null;
|
||||||
|
if (! $entry) {
|
||||||
|
throw new RuntimeException('Некорректный manifest.json — отсутствует ключ index.html.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$keep = [$entry['file']];
|
||||||
|
if (! empty($entry['css'])) {
|
||||||
|
foreach ($entry['css'] as $css) {
|
||||||
|
$keep[] = $css;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$deletedFiles = 0;
|
||||||
|
$keptFiles = 0;
|
||||||
|
|
||||||
|
foreach (glob($assetsPath . '/*') as $file) {
|
||||||
|
$ext = pathinfo($file, PATHINFO_EXTENSION);
|
||||||
|
if (! in_array($ext, ['js', 'css', 'map'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$relative = 'assets/' . basename($file);
|
||||||
|
if (in_array($relative, $keep, true)) {
|
||||||
|
$keptFiles++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($file)) {
|
||||||
|
unlink($file);
|
||||||
|
$deletedFiles++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($deletedFiles > 0) {
|
||||||
|
$this->logger->info(
|
||||||
|
sprintf('Очистка assets завершена. Удалено: %d, оставлено: %d', $deletedFiles, $keptFiles)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (JsonException $e) {
|
||||||
|
$this->logger->error('Ошибка декодирования файла manifest.json: ' . $e->getMessage());
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->logger->error('Ошибка удаления старых assets: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function interval(): ?DateInterval
|
||||||
|
{
|
||||||
|
return new DateInterval('PT1H');
|
||||||
|
}
|
||||||
|
}
|
||||||
37
backend/src/bastion/routes.php
Executable file
37
backend/src/bastion/routes.php
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Bastion\Handlers\AutocompleteHandler;
|
||||||
|
use Bastion\Handlers\DictionariesHandler;
|
||||||
|
use Bastion\Handlers\FormsHandler;
|
||||||
|
use Bastion\Handlers\ImageHandler;
|
||||||
|
use Bastion\Handlers\LogsHandler;
|
||||||
|
use Bastion\Handlers\SendMessageHandler;
|
||||||
|
use Bastion\Handlers\SettingsHandler;
|
||||||
|
use Bastion\Handlers\StatsHandler;
|
||||||
|
use Bastion\Handlers\AcmeShopPulseStatsHandler;
|
||||||
|
use Bastion\Handlers\TelegramCustomersHandler;
|
||||||
|
use Bastion\Handlers\TelegramHandler;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'configureBotToken' => [SettingsHandler::class, 'configureBotToken'],
|
||||||
|
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
|
||||||
|
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
|
||||||
|
'getCategories' => [DictionariesHandler::class, 'getCategories'],
|
||||||
|
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
|
||||||
|
'getChatId' => [TelegramHandler::class, 'getChatId'],
|
||||||
|
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
|
||||||
|
'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'],
|
||||||
|
'getImage' => [ImageHandler::class, 'getImage'],
|
||||||
|
'getLogs' => [LogsHandler::class, 'getLogs'],
|
||||||
|
'getProductsById' => [AutocompleteHandler::class, 'getProductsById'],
|
||||||
|
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
|
||||||
|
'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'],
|
||||||
|
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
||||||
|
'regenerateCronScheduleUrl' => [SettingsHandler::class, 'regenerateCronScheduleUrl'],
|
||||||
|
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
|
||||||
|
'getSystemInfo' => [SettingsHandler::class, 'getSystemInfo'],
|
||||||
|
'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'],
|
||||||
|
'testTgMessage' => [TelegramHandler::class, 'testTgMessage'],
|
||||||
|
'tgGetMe' => [TelegramHandler::class, 'tgGetMe'],
|
||||||
|
'getAcmeShopPulseStats' => [AcmeShopPulseStatsHandler::class, 'getStats'],
|
||||||
|
];
|
||||||
101
backend/src/cli.php
Executable file
101
backend/src/cli.php
Executable file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Console\ApplicationFactory;
|
||||||
|
use Console\Commands\CacheClearCommand;
|
||||||
|
use Console\Commands\CustomerCountsCommand;
|
||||||
|
use Console\Commands\PulseSendEventsCommand;
|
||||||
|
use Console\Commands\ScheduleRunCommand;
|
||||||
|
use Console\Commands\VersionCommand;
|
||||||
|
use Console\Commands\ImagesWarmupCacheCommand;
|
||||||
|
use Console\Commands\ImagesCacheClearCommand;
|
||||||
|
use Monolog\Handler\RotatingFileHandler;
|
||||||
|
use Monolog\Logger;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\Connections\MySqlConnection;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Symfony\Component\Console\Application;
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
die("This script can only be run from CLI.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseDir = __DIR__;
|
||||||
|
$debug = false;
|
||||||
|
|
||||||
|
if (is_readable($baseDir . '/acmeshop.phar')) {
|
||||||
|
require_once "phar://{$baseDir}/acmeshop.phar/vendor/autoload.php";
|
||||||
|
require_once $baseDir . '/../../../admin/config.php';
|
||||||
|
} elseif (is_dir("$baseDir/acmeshop")) {
|
||||||
|
require_once "$baseDir/acmeshop/vendor/autoload.php";
|
||||||
|
require_once '/web/upload/admin/config.php';
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException('Unable to locate application directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get Settings from Database
|
||||||
|
$host = DB_HOSTNAME;
|
||||||
|
$username = DB_USERNAME;
|
||||||
|
$password = DB_PASSWORD;
|
||||||
|
$port = (int) DB_PORT;
|
||||||
|
$dbName = DB_DATABASE;
|
||||||
|
$prefix = DB_PREFIX;
|
||||||
|
$dsn = "mysql:host=$host;port=$port;dbname=$dbName";
|
||||||
|
$pdo = new PDO($dsn, $username, $password);
|
||||||
|
$connection = new MySqlConnection($pdo);
|
||||||
|
$raw = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'module_acmeshop_settings'");
|
||||||
|
$timezone = $connection->select("SELECT value FROM `{$prefix}setting` WHERE `key` = 'config_timezone'");
|
||||||
|
$timezone = $timezone[0]['value'] ?? 'UTC';
|
||||||
|
$json = json_decode($raw[0]['value'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$items = Arr::mergeArraysRecursively($json, [
|
||||||
|
'app' => [
|
||||||
|
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
|
||||||
|
'language_id' => 1,
|
||||||
|
'oc_timezone' => $timezone,
|
||||||
|
],
|
||||||
|
'paths' => [
|
||||||
|
'images' => DIR_IMAGE,
|
||||||
|
],
|
||||||
|
'logs' => [
|
||||||
|
'path' => DIR_LOGS,
|
||||||
|
],
|
||||||
|
'database' => [
|
||||||
|
'host' => DB_HOSTNAME,
|
||||||
|
'database' => DB_DATABASE,
|
||||||
|
'username' => DB_USERNAME,
|
||||||
|
'password' => DB_PASSWORD,
|
||||||
|
'prefix' => DB_PREFIX,
|
||||||
|
'port' => (int) DB_PORT,
|
||||||
|
],
|
||||||
|
'store' => [
|
||||||
|
'oc_store_id' => 0,
|
||||||
|
'oc_default_currency' => 'RUB',
|
||||||
|
'oc_config_tax' => false,
|
||||||
|
],
|
||||||
|
'orders' => [
|
||||||
|
'oc_customer_group_id' => 1,
|
||||||
|
],
|
||||||
|
'telegram' => [
|
||||||
|
'mini_app_url' => rtrim(HTTPS_CATALOG, '/') . '/image/catalog/tgshopspa/#/',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$logger = new Logger('AcmeShop_CLI', [], [], new DateTimeZone('UTC'));
|
||||||
|
$logger->pushHandler(
|
||||||
|
new RotatingFileHandler(
|
||||||
|
DIR_LOGS . '/acmeshop.log', 14, $debug ? Logger::DEBUG : Logger::INFO
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$app = ApplicationFactory::create($items);
|
||||||
|
$app->setLogger($logger);
|
||||||
|
$app->boot();
|
||||||
|
|
||||||
|
$console = new Application('AcmeShop', module_version());
|
||||||
|
$console->add($app->get(VersionCommand::class));
|
||||||
|
$console->add($app->get(ScheduleRunCommand::class));
|
||||||
|
$console->add($app->get(PulseSendEventsCommand::class));
|
||||||
|
$console->add($app->get(ImagesWarmupCacheCommand::class));
|
||||||
|
$console->add($app->get(ImagesCacheClearCommand::class));
|
||||||
|
$console->add($app->get(CacheClearCommand::class));
|
||||||
|
$console->add($app->get(CustomerCountsCommand::class));
|
||||||
|
$console->run();
|
||||||
51
backend/src/composer.json
Executable file
51
backend/src/composer.json
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "nikitakiselev/acmeshop",
|
||||||
|
"version": "v2.2.1",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Acme\\ECommerceFramework\\": "framework/",
|
||||||
|
"App\\": "app/",
|
||||||
|
"Bastion\\": "bastion/",
|
||||||
|
"Console\\": "console/",
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"framework/Support/helpers.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nikita Kiselev",
|
||||||
|
"email": "dev@example.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"doctrine/dbal": "^3.10",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-pdo": "*",
|
||||||
|
"guzzlehttp/guzzle": "^7.9",
|
||||||
|
"intervention/image": "^2.7",
|
||||||
|
"monolog/monolog": "^2.10",
|
||||||
|
"nesbot/carbon": "^2.73",
|
||||||
|
"php": "^7.4",
|
||||||
|
"predis/predis": "^2.0",
|
||||||
|
"psr/container": "^2.0",
|
||||||
|
"psr/log": "^1.1",
|
||||||
|
"symfony/cache": "^5.4",
|
||||||
|
"vlucas/phpdotenv": "^5.6",
|
||||||
|
"ramsey/uuid": "^4.2",
|
||||||
|
"symfony/http-foundation": "^5.4",
|
||||||
|
"symfony/console": "^5.4",
|
||||||
|
"dragonmantank/cron-expression": "^3.5",
|
||||||
|
"sentry/sentry": "^4.19"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/sql-formatter": "^1.3",
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"phpstan/phpstan": "^2.1",
|
||||||
|
"phpunit/phpunit": "^9.6",
|
||||||
|
"roave/security-advisories": "dev-latest",
|
||||||
|
"squizlabs/php_codesniffer": "*",
|
||||||
|
"marcocesarato/php-conventional-changelog": "^1.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
6584
backend/src/composer.lock
generated
Executable file
6584
backend/src/composer.lock
generated
Executable file
File diff suppressed because it is too large
Load Diff
122
backend/src/configs/app.php
Executable file
122
backend/src/configs/app.php
Executable file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'app' => [
|
||||||
|
'app_enabled' => true,
|
||||||
|
'app_name' => 'Megapay',
|
||||||
|
'app_icon' => null,
|
||||||
|
"theme_light" => "light",
|
||||||
|
"theme_dark" => "dark",
|
||||||
|
"app_debug" => false,
|
||||||
|
'image_aspect_ratio' => '1:1',
|
||||||
|
'image_crop_algorithm' => 'cover',
|
||||||
|
'haptic_enabled' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
'telegram' => [
|
||||||
|
"bot_token" => "",
|
||||||
|
"chat_id" => null,
|
||||||
|
"owner_notification_template" => <<<HTML
|
||||||
|
📦 <b>Новый заказ №{order_id}</b>
|
||||||
|
Магазин: <b>{store_name}</b>
|
||||||
|
|
||||||
|
<b>Покупатель</b>
|
||||||
|
Имя: {customer}
|
||||||
|
Email: {email}
|
||||||
|
Телефон: {phone}
|
||||||
|
IP: {ip}
|
||||||
|
|
||||||
|
<b>Комментарий к заказу</b>
|
||||||
|
{comment}
|
||||||
|
|
||||||
|
<b>Сумма заказа:</b> {total}
|
||||||
|
<b>Дата оформления:</b> {created_at}
|
||||||
|
HTML,
|
||||||
|
"customer_notification_template" => <<<HTML
|
||||||
|
✅ <b>Заказ оформлен</b>
|
||||||
|
|
||||||
|
Спасибо за ваш заказ в магазине <b>{store_name}</b>.
|
||||||
|
|
||||||
|
<b>Номер заказа:</b> №{order_id}
|
||||||
|
<b>Сумма заказа:</b> {total}р.
|
||||||
|
<b>Дата оформления:</b> {created_at}
|
||||||
|
|
||||||
|
Информация о заказе сохранена.
|
||||||
|
При необходимости с вами свяжутся представители магазина.
|
||||||
|
HTML,
|
||||||
|
"mini_app_url" => "",
|
||||||
|
],
|
||||||
|
|
||||||
|
"metrics" => [
|
||||||
|
"yandex_metrika_enabled" => false,
|
||||||
|
"yandex_metrika_counter" => "",
|
||||||
|
],
|
||||||
|
|
||||||
|
'store' => [
|
||||||
|
'feature_coupons' => true,
|
||||||
|
'feature_vouchers' => true,
|
||||||
|
'show_category_products_button' => true,
|
||||||
|
'product_interaction_mode' => 'browser',
|
||||||
|
'manager_username' => null,
|
||||||
|
],
|
||||||
|
|
||||||
|
'texts' => [
|
||||||
|
'text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
|
||||||
|
'text_empty_cart' => 'Ваша корзина пуста.',
|
||||||
|
'text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.',
|
||||||
|
'text_manager_button' => '💬 Связаться с менеджером',
|
||||||
|
'start_message' => <<<HTML
|
||||||
|
👋 <b>Добро пожаловать!</b>
|
||||||
|
|
||||||
|
Вы находитесь в официальном магазине.
|
||||||
|
Здесь вы можете ознакомиться с товарами, узнать подробности и оформить заказ прямо в Telegram.
|
||||||
|
|
||||||
|
Нажмите кнопку ниже, чтобы перейти в каталог.
|
||||||
|
HTML,
|
||||||
|
'start_image' => null,
|
||||||
|
'start_button' => [
|
||||||
|
'text' => '🛍 Перейти в каталог',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'orders' => [
|
||||||
|
'order_default_status_id' => 1,
|
||||||
|
],
|
||||||
|
|
||||||
|
'pulse' => [
|
||||||
|
'api_key' => '',
|
||||||
|
'batch_size' => 50,
|
||||||
|
'max_attempts' => 3,
|
||||||
|
],
|
||||||
|
|
||||||
|
'mainpage_blocks' => [
|
||||||
|
[
|
||||||
|
'type' => 'products_feed',
|
||||||
|
'title' => '',
|
||||||
|
'description' => '',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'goal_name' => '',
|
||||||
|
'data' => [
|
||||||
|
'max_page_count' => 10,
|
||||||
|
'image_aspect_ratio' => '1:1',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
'namespace' => 'acmeshop',
|
||||||
|
'default_lifetime' => 60 * 60 * 24,
|
||||||
|
'options' => [
|
||||||
|
'db_table' => 'acmeshop_cache_items',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'paths' => [
|
||||||
|
'images_cache' => 'cache/acmeshop',
|
||||||
|
],
|
||||||
|
|
||||||
|
'cron' => [
|
||||||
|
'mode' => 'disabled',
|
||||||
|
'api_key' => '',
|
||||||
|
],
|
||||||
|
];
|
||||||
9
backend/src/configs/maintenance.php
Executable file
9
backend/src/configs/maintenance.php
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Bastion\Tasks\CleanUpOldAssetsTask;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tasks' => [
|
||||||
|
CleanUpOldAssetsTask::class,
|
||||||
|
],
|
||||||
|
];
|
||||||
36
backend/src/console/ApplicationFactory.php
Executable file
36
backend/src/console/ApplicationFactory.php
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Console;
|
||||||
|
|
||||||
|
use App\ServiceProviders\AppServiceProvider;
|
||||||
|
use App\ServiceProviders\SettingsServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Application;
|
||||||
|
use Acme\ECommerceFramework\Cache\CacheServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\ImageTool\ImageToolServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\QueryBuilder\QueryBuilderServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Scheduler\SchedulerServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Support\Arr;
|
||||||
|
use Acme\ECommerceFramework\AcmeShopPulse\AcmeShopPulseServiceProvider;
|
||||||
|
use Acme\ECommerceFramework\Telegram\TelegramServiceProvider;
|
||||||
|
|
||||||
|
class ApplicationFactory
|
||||||
|
{
|
||||||
|
public static function create(array $settings): Application
|
||||||
|
{
|
||||||
|
$defaultConfig = require __DIR__ . '/../configs/app.php';
|
||||||
|
|
||||||
|
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
|
||||||
|
|
||||||
|
return (new Application($merged))
|
||||||
|
->withServiceProviders([
|
||||||
|
SettingsServiceProvider::class,
|
||||||
|
QueryBuilderServiceProvider::class,
|
||||||
|
AppServiceProvider::class,
|
||||||
|
CacheServiceProvider::class,
|
||||||
|
TelegramServiceProvider::class,
|
||||||
|
AcmeShopPulseServiceProvider::class,
|
||||||
|
SchedulerServiceProvider::class,
|
||||||
|
ImageToolServiceProvider::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
backend/src/console/Commands/AcmeShopCommand.php
Executable file
13
backend/src/console/Commands/AcmeShopCommand.php
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Console\Commands;
|
||||||
|
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
|
||||||
|
abstract class AcmeShopCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user