Squashed commit message
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled

This commit is contained in:
2026-03-11 22:08:41 +03:00
commit 393bbb286b
585 changed files with 65605 additions and 0 deletions

64
.cursor/agents.md Normal file
View 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
View 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
}
}

View 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 2second 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.

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

View 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 (12 sentences)
- Add 12 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** nonbackward 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 / valuedriven 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 lowlevel 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

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

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

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

View 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();
});
```

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

@@ -0,0 +1,83 @@
.PHONY: build
fresh:
$(MAKE) stop && \
rm -rf ./src && \
./scripts/download_oc_store.sh && \
./scripts/install_ocstore.sh && \
$(MAKE) start
setup:
$(MAKE) stop && \
rm -rf ./src && \
./scripts/download_oc_store.sh && \
./scripts/install_ocstore.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
View 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
View 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

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Adapters;
class OcCartAdapter
{
}

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

View 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,
]);
}
}

View 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(),
];
}
}

View 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(),
];
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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 $ocStoreId;
public function __construct(
bool $featureCoupons,
bool $featureVouchers,
bool $showCategoryProductsButton,
string $productInteractionMode,
?string $managerUsername,
string $ocDefaultCurrency,
bool $ocConfigTax,
int $ocStoreId
) {
$this->featureCoupons = $featureCoupons;
$this->featureVouchers = $featureVouchers;
$this->showCategoryProductsButton = $showCategoryProductsButton;
$this->productInteractionMode = $productInteractionMode;
$this->managerUsername = $managerUsername;
$this->ocDefaultCurrency = $ocDefaultCurrency;
$this->ocConfigTax = $ocConfigTax;
$this->ocStoreId = $ocStoreId;
}
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 getOcStoreId(): int
{
return $this->ocStoreId;
}
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->ocStoreId,
];
}
}

View 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,
];
}
}

View 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,
];
}
}

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

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

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

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

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

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

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

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

View 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');
}
}
}
}
}

View 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];
}
}

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

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

View 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'));
}
}

View 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,
]);
}
}

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

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

View 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');
}
}
}

View 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,
]);
}
}

View 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,
],
]);
}
}

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

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

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

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

View 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(),
]);
}
}
}

View 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');
}
}

View 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([]);
}
}

View 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']);
}
}

View 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]);
}
}

View 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,
]);
}
}

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

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

View 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();
}
}

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

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

View 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'],
]);
}
}
}
}

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

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

View 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');
}
}
}

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

View 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');
}
}

View 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
View 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'],
];

View 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,
]);
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Bastion\Exceptions;
use Exception;
class BotTokenConfiguratorException extends Exception
{
}

View 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]);
}
}

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

View 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'));
}
}

View 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'],
],
]);
}
}

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

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

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

View 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];
}
}

View 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();
}
}

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

View 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'));
}
}

View 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');
}
}

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

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

View 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();
}
}

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

File diff suppressed because it is too large Load Diff

122
backend/src/configs/app.php Executable file
View 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' => '',
],
];

View File

@@ -0,0 +1,9 @@
<?php
use Bastion\Tasks\CleanUpOldAssetsTask;
return [
'tasks' => [
CleanUpOldAssetsTask::class,
],
];

View 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,
]);
}
}

View 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