Compare commits
2 Commits
800aaafb24
...
25178247e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25178247e7 | ||
|
|
99e5edd535 |
193
CALCULATOR_GUIDE.md
Normal file
193
CALCULATOR_GUIDE.md
Normal file
@ -0,0 +1,193 @@
|
||||
# Руководство по добавлению нового калькулятора
|
||||
|
||||
Это руководство описывает, как добавить новый калькулятор в систему DoSoapCalc.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Система использует модульную архитектуру с регистрацией калькуляторов:
|
||||
- Каждый калькулятор — отдельный модуль с формулой, схемой полей и шаблоном сообщения
|
||||
- Общие компоненты автоматически генерируют формы и обрабатывают запросы
|
||||
- Динамическая маршрутизация на основе типа калькулятора
|
||||
|
||||
## Шаги добавления нового калькулятора
|
||||
|
||||
### 1. Создание модуля калькулятора на Backend
|
||||
|
||||
Создайте файл `backend/calculators/[calculator-name].js` (например, `candle.js`).
|
||||
|
||||
#### Структура модуля:
|
||||
|
||||
```javascript
|
||||
// calculators/[calculator-name].js
|
||||
const fieldSchema = [
|
||||
{ id: 'itemName', name: 'itemName', label: 'Название изделия', type: 'text', required: true },
|
||||
{ id: 'weight', name: 'weight', label: 'Вес, г', type: 'number', required: true },
|
||||
// ... другие поля
|
||||
];
|
||||
|
||||
function calculate(data) {
|
||||
// Ваша формула расчёта
|
||||
const total = /* вычисления */;
|
||||
return {
|
||||
// промежуточные результаты
|
||||
total: round(total),
|
||||
};
|
||||
}
|
||||
|
||||
function formatMessage(data, result) {
|
||||
// Шаблон сообщения для Telegram
|
||||
let text = `...`;
|
||||
return text;
|
||||
}
|
||||
|
||||
function getRequiredFields() {
|
||||
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||
}
|
||||
|
||||
function getNumericFields() {
|
||||
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'calculator-name', // уникальный ID
|
||||
name: 'Название калькулятора',
|
||||
fieldSchema,
|
||||
calculate,
|
||||
formatMessage,
|
||||
getRequiredFields,
|
||||
getNumericFields,
|
||||
};
|
||||
```
|
||||
|
||||
#### Пример поля:
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 'fieldId', // уникальный ID поля
|
||||
name: 'fieldName', // имя поля (используется в данных)
|
||||
label: 'Название поля', // метка для пользователя
|
||||
type: 'text' | 'number', // тип поля
|
||||
required: true | false, // обязательное ли поле
|
||||
group: 'packaging', // (опционально) группа для группировки полей
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Регистрация на Backend
|
||||
|
||||
Откройте `backend/calculators/index.js` и добавьте калькулятор:
|
||||
|
||||
```javascript
|
||||
const newCalculator = require('./[calculator-name]');
|
||||
|
||||
const calculators = {
|
||||
soap: soapCalculator,
|
||||
'[calculator-name]': newCalculator, // добавьте здесь
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Создание модуля калькулятора на Frontend
|
||||
|
||||
Создайте файл `frontend/lib/calculators/[calculator-name].ts`.
|
||||
|
||||
#### Структура модуля:
|
||||
|
||||
```typescript
|
||||
// lib/calculators/[calculator-name].ts
|
||||
import type { Calculator, CalculatorField, CalculationResult } from '@/types/calculator';
|
||||
|
||||
const fieldSchema: CalculatorField[] = [
|
||||
{ id: 'itemName', name: 'itemName', label: 'Название изделия', type: 'text', required: true },
|
||||
// ... другие поля (синхронно с backend!)
|
||||
];
|
||||
|
||||
function calculate(data: Record<string, any>): CalculationResult {
|
||||
// Та же формула, что и на backend
|
||||
return {
|
||||
total: round(total),
|
||||
};
|
||||
}
|
||||
|
||||
const newCalculator: Calculator = {
|
||||
id: '[calculator-name]',
|
||||
name: 'Название калькулятора',
|
||||
fieldSchema,
|
||||
calculate,
|
||||
getRequiredFields: () => fieldSchema.filter((f) => f.required).map((f) => f.name),
|
||||
getNumericFields: () => fieldSchema.filter((f) => f.type === 'number').map((f) => f.name),
|
||||
};
|
||||
|
||||
export default newCalculator;
|
||||
```
|
||||
|
||||
**Важно:** Схема полей и формула должны совпадать с backend!
|
||||
|
||||
### 4. Регистрация на Frontend
|
||||
|
||||
Откройте `frontend/lib/calculators/index.ts` и добавьте:
|
||||
|
||||
```typescript
|
||||
import newCalculator from './[calculator-name]';
|
||||
|
||||
const calculators: Record<string, Calculator> = {
|
||||
soap: soapCalculator,
|
||||
'[calculator-name]': newCalculator, // добавьте здесь
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Проверка работы
|
||||
|
||||
1. Перезапустите backend: `node bot.js`
|
||||
2. Пересоберите frontend: `npm run build`
|
||||
3. Откройте в браузере: `/[calculator-name]?chat_id=YOUR_CHAT_ID`
|
||||
|
||||
## Примеры
|
||||
|
||||
### Простой калькулятор (только базовые поля)
|
||||
|
||||
См. файлы:
|
||||
- `backend/calculators/candle.js` - пример калькулятора свечей
|
||||
- `frontend/lib/calculators/candle.ts` - frontend версия
|
||||
|
||||
### Калькулятор с группированными полями
|
||||
|
||||
Для группировки полей используйте параметр `group`:
|
||||
|
||||
```javascript
|
||||
{ id: 'box', name: 'box', label: 'Пакет', type: 'number', group: 'packaging' },
|
||||
{ id: 'ribbon', name: 'ribbon', label: 'Лента', type: 'number', group: 'packaging' },
|
||||
```
|
||||
|
||||
Поля с одинаковой группой будут автоматически сгруппированы в форме.
|
||||
|
||||
## Советы
|
||||
|
||||
1. **Синхронизация:** Всегда поддерживайте синхронизацию между backend и frontend схемами
|
||||
2. **Валидация:** Используйте `required: true` для обязательных полей
|
||||
3. **Форматирование:** В `formatMessage` используйте HTML для форматирования (поддерживается Telegram)
|
||||
4. **Округление:** Используйте функцию `round()` для округления результатов
|
||||
|
||||
## Структура файлов
|
||||
|
||||
```
|
||||
backend/
|
||||
├── calculators/
|
||||
│ ├── index.js # Регистрация калькуляторов
|
||||
│ ├── soap.js # Пример: калькулятор мыла
|
||||
│ └── [new-calculator].js # Ваш новый калькулятор
|
||||
|
||||
frontend/
|
||||
├── lib/
|
||||
│ └── calculators/
|
||||
│ ├── index.ts # Регистрация калькуляторов
|
||||
│ ├── soap.ts # Пример: калькулятор мыла
|
||||
│ └── [new-calculator].ts # Ваш новый калькулятор
|
||||
```
|
||||
|
||||
## Что дальше?
|
||||
|
||||
После добавления калькулятора:
|
||||
1. Протестируйте расчёты вручную
|
||||
2. Проверьте отправку в Telegram
|
||||
3. Убедитесь, что валидация работает корректно
|
||||
4. Добавьте обработку специфичных данных (если нужно)
|
||||
|
||||
76
CHANGELOG.md
Normal file
76
CHANGELOG.md
Normal file
@ -0,0 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
Все значимые изменения в проекте DoSoapCalc документируются в этом файле.
|
||||
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/),
|
||||
и проект следует [Semantic Versioning](https://semver.org/lang/ru/).
|
||||
|
||||
## [2.0.0] - 2024-11-01
|
||||
|
||||
### Добавлено
|
||||
|
||||
- **Модульная архитектура калькуляторов**: Система поддержки множества калькуляторов с единой логикой
|
||||
- **Система регистрации калькуляторов**: Плагинная архитектура для backend и frontend
|
||||
- **Универсальный компонент DynamicCalculator**: Автоматическая генерация форм из схемы полей
|
||||
- **Пример нового калькулятора**: Калькулятор свечей (candle) как пример добавления новых калькуляторов
|
||||
- **Динамические роуты**: Поддержка `/[calculatorType]` для любых зарегистрированных калькуляторов
|
||||
- **Переменные окружения**: Использование `.env` файлов вместо хардкода
|
||||
- **Валидация данных**: Проверка обязательных и числовых полей на backend
|
||||
- **Улучшенная обработка ошибок**: Детальное логирование и обработка ошибок Telegram API
|
||||
- **PM2 конфигурация**: Файл `ecosystem.config.js` для управления процессами
|
||||
- **Конфигурация CORS**: Настройка разрешённых доменов через переменные окружения
|
||||
- **Документация**:
|
||||
- `PROJECT_INFO.md` - сводная информация о проекте для быстрого понимания
|
||||
- `CALCULATOR_GUIDE.md` - руководство по добавлению новых калькуляторов
|
||||
- `README.md` - обновлённая документация проекта
|
||||
- `CHANGELOG.md` - этот файл для отслеживания изменений
|
||||
|
||||
### Изменено
|
||||
|
||||
- **Рефакторинг backend**: Разделение на модули (calculators, lib, routes, config)
|
||||
- **Рефакторинг frontend**: Переход на модульную систему с TypeScript типами
|
||||
- **API маршруты**: Изменён с `/api/submit` на `/api/submit/:calculatorType`
|
||||
- **Компонент SoapCalculator**: Заменён на универсальный DynamicCalculator
|
||||
- **Структура проекта**: Реорганизована для масштабируемости
|
||||
|
||||
### Улучшено
|
||||
|
||||
- **Безопасность**: Токены и URL вынесены в переменные окружения
|
||||
- **Масштабируемость**: Легко добавлять новые калькуляторы без изменения основной логики
|
||||
- **Типизация**: Добавлены TypeScript типы для калькуляторов
|
||||
- **Код-организация**: Разделение ответственности между модулями
|
||||
|
||||
### Технические детали
|
||||
|
||||
#### Backend структура:
|
||||
- `backend/config/env.js` - загрузка переменных окружения
|
||||
- `backend/lib/telegram.js` - общая логика отправки в Telegram
|
||||
- `backend/lib/validator.js` - валидация данных
|
||||
- `backend/calculators/` - модули калькуляторов
|
||||
- `backend/routes/api.js` - динамические API маршруты
|
||||
|
||||
#### Frontend структура:
|
||||
- `frontend/types/calculator.ts` - TypeScript типы
|
||||
- `frontend/lib/calculators/` - модули калькуляторов (frontend)
|
||||
- `frontend/components/DynamicCalculator.tsx` - универсальный компонент
|
||||
- `frontend/app/[calculator]/page.tsx` - динамические роуты
|
||||
|
||||
### Зависимости
|
||||
|
||||
- **Добавлено**: `dotenv@^16.4.5` для работы с переменными окружения
|
||||
|
||||
## [1.0.0] - 2024-10-XX
|
||||
|
||||
### Добавлено
|
||||
|
||||
- Базовый функционал калькулятора мыла
|
||||
- Интеграция с Telegram ботом
|
||||
- Веб-интерфейс на Next.js
|
||||
- Отправка результатов расчёта в Telegram
|
||||
- Поддержка загрузки фото
|
||||
|
||||
---
|
||||
|
||||
[2.0.0]: https://github.com/yourusername/DoSoapCalc/compare/v1.0.0...v2.0.0
|
||||
[1.0.0]: https://github.com/yourusername/DoSoapCalc/releases/tag/v1.0.0
|
||||
|
||||
242
PROJECT_INFO.md
Normal file
242
PROJECT_INFO.md
Normal file
@ -0,0 +1,242 @@
|
||||
# DoSoapCalc - Сводная информация о проекте
|
||||
|
||||
## Быстрый обзор
|
||||
|
||||
DoSoapCalc - система для расчёта себестоимости изделий ручной работы с веб-интерфейсом и интеграцией Telegram-бота. Проект использует модульную архитектуру для легкого добавления новых калькуляторов.
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Структура проекта
|
||||
|
||||
```
|
||||
DoSoapCalc/
|
||||
├── backend/ # Node.js + Express + Telegram Bot
|
||||
│ ├── bot.js # Главный файл - запускает бота и сервер
|
||||
│ ├── config/
|
||||
│ │ └── env.js # Загрузка переменных окружения (.env)
|
||||
│ ├── calculators/ # Модули калькуляторов
|
||||
│ │ ├── index.js # Регистрация всех калькуляторов
|
||||
│ │ ├── soap.js # Калькулятор мыла
|
||||
│ │ └── candle.js # Пример: калькулятор свечей
|
||||
│ ├── lib/
|
||||
│ │ ├── telegram.js # Отправка сообщений в Telegram
|
||||
│ │ └── validator.js # Валидация данных форм
|
||||
│ ├── routes/
|
||||
│ │ └── api.js # API маршруты (/api/submit/:calculatorType)
|
||||
│ └── .env # Переменные окружения (не в Git)
|
||||
│
|
||||
└── frontend/ # Next.js + React + TypeScript
|
||||
├── app/
|
||||
│ ├── [calculator]/ # Динамический роут для калькуляторов
|
||||
│ │ └── page.tsx # Страница калькулятора
|
||||
│ └── page.tsx # Редирект на /soap
|
||||
├── components/
|
||||
│ ├── DynamicCalculator.tsx # Универсальный компонент формы
|
||||
│ └── FormField.tsx # Переиспользуемые поля ввода
|
||||
├── lib/
|
||||
│ ├── calculators/ # Модули калькуляторов (frontend)
|
||||
│ │ ├── index.ts # Регистрация калькуляторов
|
||||
│ │ ├── soap.ts # Калькулятор мыла
|
||||
│ │ └── candle.ts # Пример: калькулятор свечей
|
||||
│ ├── api.ts # Отправка данных на backend
|
||||
│ └── config.ts # Конфигурация (API URL)
|
||||
└── types/
|
||||
└── calculator.ts # TypeScript типы
|
||||
```
|
||||
|
||||
## Технологический стек
|
||||
|
||||
### Backend:
|
||||
- Node.js + Express
|
||||
- node-telegram-bot-api (Telegram Bot)
|
||||
- multer (загрузка файлов)
|
||||
- dotenv (переменные окружения)
|
||||
- PM2 (менеджер процессов)
|
||||
|
||||
### Frontend:
|
||||
- Next.js 15 (App Router)
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Tailwind CSS 4
|
||||
- Статический экспорт (`output: 'export'`)
|
||||
|
||||
## Ключевые концепции
|
||||
|
||||
### Система калькуляторов
|
||||
|
||||
Каждый калькулятор состоит из двух модулей (backend + frontend):
|
||||
|
||||
**Backend модуль** (`backend/calculators/[name].js`):
|
||||
- `id` - уникальный идентификатор
|
||||
- `name` - название для пользователя
|
||||
- `fieldSchema` - схема полей формы
|
||||
- `calculate(data)` - функция расчёта
|
||||
- `formatMessage(data, result)` - форматирование для Telegram
|
||||
- `getRequiredFields()` - обязательные поля
|
||||
- `getNumericFields()` - числовые поля
|
||||
|
||||
**Frontend модуль** (`frontend/lib/calculators/[name].ts`):
|
||||
- Та же структура, синхронная с backend
|
||||
- TypeScript типы
|
||||
|
||||
### Регистрация калькуляторов
|
||||
|
||||
**Backend**: `backend/calculators/index.js`
|
||||
```javascript
|
||||
const calculators = {
|
||||
soap: require('./soap'),
|
||||
candle: require('./candle'),
|
||||
};
|
||||
```
|
||||
|
||||
**Frontend**: `frontend/lib/calculators/index.ts`
|
||||
```typescript
|
||||
const calculators: Record<string, Calculator> = {
|
||||
soap: soapCalculator,
|
||||
candle: candleCalculator,
|
||||
};
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
- `POST /api/submit/:calculatorType` - отправка расчёта
|
||||
- Параметры: `calculatorType` (soap, candle, etc.)
|
||||
- Body: FormData (поля формы + опционально photo)
|
||||
- Ответ: 200 OK или ошибка
|
||||
|
||||
### Telegram Bot
|
||||
|
||||
- Команда `/menu` - открывает Web App с калькулятором
|
||||
- URL формата: `{WEBAPP_BASE_URL}/{calculatorType}?chat_id={chatId}`
|
||||
- Отправка результатов через `sendMessage` или `sendPhoto`
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
### Backend (.env в `/backend/`):
|
||||
|
||||
```env
|
||||
BOT_TOKEN=your_telegram_bot_token
|
||||
WEBAPP_BASE_URL=https://your-domain.com
|
||||
API_BASE_URL=https://api.your-domain.com
|
||||
HTTP_PORT=3001
|
||||
CORS_ORIGINS=https://your-domain.com
|
||||
```
|
||||
|
||||
### Frontend (.env.local в `/frontend/`):
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.your-domain.com
|
||||
```
|
||||
|
||||
## Запуск проекта
|
||||
|
||||
### PM2 (Production)
|
||||
|
||||
```bash
|
||||
# Production режим (через node)
|
||||
pm2 start ecosystem.config.js --env production
|
||||
|
||||
# Development режим (через nodemon с автоперезагрузкой)
|
||||
pm2 start ecosystem.config.js --env development
|
||||
|
||||
# Управление
|
||||
pm2 restart ecosystem.config.js
|
||||
pm2 stop ecosystem.config.js
|
||||
pm2 logs dosoap-backend
|
||||
pm2 logs dosoap-frontend
|
||||
```
|
||||
|
||||
### Ручной запуск
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
# Создать .env файл
|
||||
node bot.js
|
||||
# или с nodemon:
|
||||
nodemon bot.js
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Development
|
||||
npm run build # Production build
|
||||
npm run export # Статический экспорт
|
||||
```
|
||||
|
||||
## Динамические роуты
|
||||
|
||||
- `/soap` - калькулятор мыла
|
||||
- `/candle` - калькулятор свечей
|
||||
- `/[calculatorType]` - любой зарегистрированный калькулятор
|
||||
|
||||
## Добавление нового калькулятора
|
||||
|
||||
1. Создать `backend/calculators/[name].js` с модулем
|
||||
2. Создать `frontend/lib/calculators/[name].ts` (синхронная структура)
|
||||
3. Зарегистрировать в `calculators/index.js` (backend) и `calculators/index.ts` (frontend)
|
||||
4. Перезапустить процессы
|
||||
|
||||
Подробное руководство: `CALCULATOR_GUIDE.md`
|
||||
|
||||
## Файлы конфигурации
|
||||
|
||||
- `ecosystem.config.js` - PM2 конфигурация
|
||||
- `.gitignore` - игнорирует `.env`, но не `.env.example`
|
||||
- `next.config.ts` - Next.js конфигурация (static export)
|
||||
|
||||
## Логи
|
||||
|
||||
PM2 логи находятся в:
|
||||
- Backend: `~/.pm2/logs/dosoap-backend-*.log`
|
||||
- Frontend: `~/.pm2/logs/dosoap-frontend-*.log`
|
||||
|
||||
## Важные детали
|
||||
|
||||
1. **Синхронизация схем**: Frontend и backend схемы полей должны совпадать
|
||||
2. **Рабочая директория**: Backend должен запускаться из `/backend/` для загрузки `.env`
|
||||
3. **CORS**: Настроен для конкретных доменов через `CORS_ORIGINS`
|
||||
4. **Статический экспорт**: Frontend экспортируется статически (Next.js export)
|
||||
5. **Фото**: Поддерживается загрузка фото, отправляется в Telegram как caption
|
||||
|
||||
## Структура данных калькулятора
|
||||
|
||||
### Поле схемы:
|
||||
```typescript
|
||||
{
|
||||
id: string; // Уникальный ID
|
||||
name: string; // Имя поля в данных
|
||||
label: string; // Метка для пользователя
|
||||
type: 'text' | 'number';
|
||||
required: boolean;
|
||||
group?: string; // Группировка полей (опционально)
|
||||
}
|
||||
```
|
||||
|
||||
### Результат расчёта:
|
||||
```typescript
|
||||
{
|
||||
[key: string]: number; // Промежуточные значения
|
||||
total: number; // Итоговая себестоимость (обязательно)
|
||||
}
|
||||
```
|
||||
|
||||
## Команды Telegram бота
|
||||
|
||||
- `/menu` - открыть калькулятор (по умолчанию мыло)
|
||||
|
||||
## Порты
|
||||
|
||||
- Backend: 3001 (HTTP_PORT из .env)
|
||||
- Frontend: 3000 (в development), статический экспорт (production)
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Токен бота в `.env` (не в Git)
|
||||
- CORS настроен для конкретных доменов
|
||||
- Валидация обязательных и числовых полей на backend
|
||||
- HTML форматирование в Telegram сообщениях (parse_mode: 'HTML')
|
||||
|
||||
147
README.md
Normal file
147
README.md
Normal file
@ -0,0 +1,147 @@
|
||||
# DoSoapCalc
|
||||
|
||||
Калькулятор себестоимости изделий ручной работы с отправкой результатов в Telegram.
|
||||
|
||||
## Описание
|
||||
|
||||
DoSoapCalc - это система для расчёта себестоимости различных изделий ручной работы (мыло, свечи и др.) с веб-интерфейсом и интеграцией с Telegram-ботом.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Проект состоит из двух частей:
|
||||
- **Backend** (Node.js + Express) - API сервер и Telegram-бот
|
||||
- **Frontend** (Next.js + React + TypeScript) - веб-интерфейс калькуляторов
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Backend
|
||||
|
||||
1. Перейдите в папку `backend`:
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. Установите зависимости:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Создайте файл `.env` на основе `.env.example`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
4. Заполните переменные окружения в `.env`:
|
||||
```env
|
||||
BOT_TOKEN=your_telegram_bot_token
|
||||
WEBAPP_BASE_URL=https://your-domain.com
|
||||
API_BASE_URL=https://api.your-domain.com
|
||||
HTTP_PORT=3001
|
||||
CORS_ORIGINS=https://your-domain.com
|
||||
```
|
||||
|
||||
5. Запустите сервер:
|
||||
```bash
|
||||
node bot.js
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
1. Перейдите в папку `frontend`:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. Установите зависимости:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. (Опционально) Создайте файл `.env.local` для переопределения API URL:
|
||||
```env
|
||||
NEXT_PUBLIC_API_BASE_URL=https://api.your-domain.com
|
||||
```
|
||||
|
||||
4. Запустите dev-сервер:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
5. Для production сборки:
|
||||
```bash
|
||||
npm run build
|
||||
npm run export
|
||||
```
|
||||
|
||||
## Добавление новых калькуляторов
|
||||
|
||||
См. подробное руководство в файле [CALCULATOR_GUIDE.md](./CALCULATOR_GUIDE.md)
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
DoSoapCalc/
|
||||
├── backend/
|
||||
│ ├── calculators/ # Модули калькуляторов
|
||||
│ │ ├── index.js # Регистрация калькуляторов
|
||||
│ │ ├── soap.js # Калькулятор мыла
|
||||
│ │ └── candle.js # Пример: калькулятор свечей
|
||||
│ ├── config/
|
||||
│ │ └── env.js # Загрузка переменных окружения
|
||||
│ ├── lib/
|
||||
│ │ ├── telegram.js # Логика отправки в Telegram
|
||||
│ │ └── validator.js # Валидация данных
|
||||
│ ├── routes/
|
||||
│ │ └── api.js # API маршруты
|
||||
│ └── bot.js # Главный файл backend
|
||||
│
|
||||
└── frontend/
|
||||
├── app/
|
||||
│ ├── [calculator]/ # Динамический роут для калькуляторов
|
||||
│ └── page.tsx # Редирект на /soap
|
||||
├── components/
|
||||
│ ├── DynamicCalculator.tsx # Универсальный компонент
|
||||
│ └── FormField.tsx # Переиспользуемые поля
|
||||
├── lib/
|
||||
│ ├── calculators/ # Модули калькуляторов (frontend)
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── soap.ts
|
||||
│ │ └── candle.ts
|
||||
│ ├── api.ts # Логика отправки данных
|
||||
│ └── config.ts # Конфигурация
|
||||
└── types/
|
||||
└── calculator.ts # TypeScript типы
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
1. Откройте Telegram-бота
|
||||
2. Отправьте команду `/menu`
|
||||
3. Нажмите на кнопку для открытия калькулятора
|
||||
4. Заполните форму
|
||||
5. Нажмите "Отправить расчёт в Telegram"
|
||||
6. Результаты будут отправлены в чат с ботом
|
||||
|
||||
## Доступные калькуляторы
|
||||
|
||||
- **Мыло** (`/soap`) - расчёт себестоимости мыла ручной работы
|
||||
- **Свеча** (`/candle`) - пример калькулятора свечей
|
||||
|
||||
## Технологии
|
||||
|
||||
**Backend:**
|
||||
- Node.js
|
||||
- Express
|
||||
- node-telegram-bot-api
|
||||
- multer (загрузка файлов)
|
||||
|
||||
**Frontend:**
|
||||
- Next.js 15
|
||||
- React 19
|
||||
- TypeScript
|
||||
- Tailwind CSS 4
|
||||
|
||||
## Лицензия
|
||||
|
||||
ISC
|
||||
|
||||
134
backend/bot.js
134
backend/bot.js
@ -14,25 +14,30 @@ const bodyParser = require('body-parser');
|
||||
const TelegramBot = require('node-telegram-bot-api');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Добавляем multer и streamifier
|
||||
const multer = require('multer');
|
||||
const streamifier = require('streamifier');
|
||||
|
||||
// Загружаем переменные окружения
|
||||
const config = require('./config/env');
|
||||
|
||||
// Импортируем маршруты API
|
||||
const apiRoutes = require('./routes/api');
|
||||
|
||||
const app = express();
|
||||
const HTTP_PORT = 3001; // порт для BACKEND API
|
||||
const BOT_TOKEN = '7801636590:AAFphqOK0Dqta7v9VCLkTPGYC1OujNIFgXA'; // ← замените на свой токен
|
||||
const WEBAPP_BASE_URL = 'https://dosoap.duckdns.org'; // ← например: https://xyz123.ngrok.io
|
||||
const HTTP_PORT = config.HTTP_PORT;
|
||||
const BOT_TOKEN = config.BOT_TOKEN;
|
||||
const WEBAPP_BASE_URL = config.WEBAPP_BASE_URL;
|
||||
|
||||
// 1) Запускаем бота (polling)
|
||||
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
||||
|
||||
// 2) Настраиваем multer (храним файлы в памяти)
|
||||
const storage = multer.memoryStorage();
|
||||
const upload = multer({ storage });
|
||||
// Сохраняем бота в app.locals для использования в роутах
|
||||
app.locals.bot = bot;
|
||||
|
||||
// 3) Разрешаем CORS для фронтенда
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
const origin = req.headers.origin;
|
||||
if (config.CORS_ORIGINS.includes('*') || (origin && config.CORS_ORIGINS.includes(origin))) {
|
||||
res.header('Access-Control-Allow-Origin', origin || '*');
|
||||
}
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
next();
|
||||
});
|
||||
@ -40,108 +45,8 @@ app.use((req, res, next) => {
|
||||
// 4) JSON-парсер (не обязателен для multipart/form-data)
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// 5) Обработчик POST /api/submit (multipart/form-data)
|
||||
app.post(
|
||||
'/api/submit',
|
||||
upload.single('photo'), // поле "photo" — это файл, если есть
|
||||
(req, res) => {
|
||||
try {
|
||||
// Текстовые поля придут в req.body, файл — в req.file
|
||||
const {
|
||||
chat_id,
|
||||
soapName,
|
||||
weight,
|
||||
basePrice,
|
||||
aromaPrice,
|
||||
aromaWeight,
|
||||
pigmentPrice,
|
||||
pigmentWeight,
|
||||
moldPrice,
|
||||
box,
|
||||
filler,
|
||||
ribbon,
|
||||
labelValue,
|
||||
markup,
|
||||
totalCost,
|
||||
finalPrice,
|
||||
pricePer100g,
|
||||
} = req.body;
|
||||
|
||||
// Проверяем обязательные поля
|
||||
if (!chat_id) {
|
||||
return res.status(400).send('chat_id не передан');
|
||||
}
|
||||
if (!soapName) {
|
||||
return res.status(400).send('soapName не передан');
|
||||
}
|
||||
|
||||
// Соберём сообщение так, чтобы в чат пришло всё, что ввели:
|
||||
// 1. Название мыла
|
||||
// 2. Вес и цена основы
|
||||
// 3. Отдушка
|
||||
// 4. Пигмент
|
||||
// 5. Форма
|
||||
// 6. Упаковка
|
||||
// 7. Наценка
|
||||
// 8. Итоги
|
||||
|
||||
|
||||
|
||||
let text = `🧼 <b>Расчёт мыла:</b> <i>${soapName}</i>\n\n`;
|
||||
|
||||
text += `⚖️ <b>Вес мыла:</b> ${weight} г\n\n`;
|
||||
// text += `🔹 <b>Цена за 1 кг основы:</b> ${basePrice} ₽/кг\n\n`;
|
||||
|
||||
// text += `🔹 <b>Отдушка:</b> ${aromaWeight} г по ${aromaPrice} ₽/фасовка\n`;
|
||||
// text += `🔹 <b>Пигмент:</b> ${pigmentWeight} г по ${pigmentPrice} ₽/фасовка\n\n`;
|
||||
|
||||
// text += `🔹 <b>Цена формы:</b> ${moldPrice} ₽\n\n`;
|
||||
|
||||
text += `📦 <b>Упаковка:</b>\n`;
|
||||
text += ` 📥 Пакет/коробка: ${box} ₽\n`;
|
||||
text += ` 🌾 Наполнитель: ${filler} ₽\n`;
|
||||
text += ` 🎀 Лента: ${ribbon} ₽\n`;
|
||||
text += ` 🏷️ Наклейка: ${labelValue} ₽\n\n`;
|
||||
|
||||
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||
|
||||
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||
text += ` 💵 Себестоимость: ${Number(totalCost).toFixed(1)} ₽\n`;
|
||||
text += ` 🎯 Итоговая цена с наценкой: ${Number(finalPrice).toFixed(1)} ₽\n`;
|
||||
text += ` ⚗️ Цена за 100 г: ${Number(pricePer100g).toFixed(1)} ₽`;
|
||||
|
||||
|
||||
// Если пользователь прикрепил фото (req.file), шлём sendPhoto
|
||||
if (req.file) {
|
||||
const bufferStream = streamifier.createReadStream(req.file.buffer);
|
||||
|
||||
return bot
|
||||
.sendPhoto(
|
||||
Number(chat_id),
|
||||
bufferStream,
|
||||
{ caption: text, parse_mode: 'HTML' }
|
||||
)
|
||||
.then(() => res.sendStatus(200))
|
||||
.catch((err) => {
|
||||
console.error('Ошибка при отправке фото:', err);
|
||||
res.status(500).send('Ошибка при отправке фото ботом');
|
||||
});
|
||||
}
|
||||
|
||||
// Если фото не было, просто шлём текст
|
||||
bot
|
||||
.sendMessage(Number(chat_id), text, { parse_mode: 'HTML' })
|
||||
.then(() => res.sendStatus(200))
|
||||
.catch((err) => {
|
||||
console.error('Ошибка при отправке сообщения:', err);
|
||||
res.sendStatus(500);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка в /api/submit:', err);
|
||||
res.status(500).send('Внутренняя ошибка сервера');
|
||||
}
|
||||
}
|
||||
);
|
||||
// 5) Подключаем API маршруты
|
||||
app.use('/api', apiRoutes);
|
||||
|
||||
// 6) Команда /menu — отправляем inline-кнопку с chat_id
|
||||
bot.setMyCommands([
|
||||
@ -151,7 +56,8 @@ bot.setMyCommands([
|
||||
bot.onText(/\/menu/, (msg) => {
|
||||
try {
|
||||
const chatId = msg.chat.id;
|
||||
const url = `${WEBAPP_BASE_URL}/?chat_id=${chatId}`;
|
||||
// По умолчанию открываем калькулятор мыла
|
||||
const url = `${WEBAPP_BASE_URL}/soap?chat_id=${chatId}`;
|
||||
|
||||
bot.sendMessage(
|
||||
chatId,
|
||||
@ -161,7 +67,7 @@ bot.onText(/\/menu/, (msg) => {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{
|
||||
text: 'Открыть калькулятор',
|
||||
text: 'Открыть калькулятор мыла',
|
||||
web_app: { url },
|
||||
},
|
||||
],
|
||||
|
||||
114
backend/calculators/candle.js
Normal file
114
backend/calculators/candle.js
Normal file
@ -0,0 +1,114 @@
|
||||
// calculators/candle.js
|
||||
// Калькулятор свечей (пример нового калькулятора)
|
||||
|
||||
/**
|
||||
* Схема полей калькулятора
|
||||
*/
|
||||
const fieldSchema = [
|
||||
{ id: 'candleName', name: 'candleName', label: 'Название свечи', type: 'text', required: true },
|
||||
{ id: 'weight', name: 'weight', label: 'Вес свечи, г', type: 'number', required: true },
|
||||
{ id: 'waxPrice', name: 'waxPrice', label: 'Цена воска за 1 кг, руб', type: 'number', required: false },
|
||||
{ id: 'wickPrice', name: 'wickPrice', label: 'Цена фитиля, руб', type: 'number', required: false },
|
||||
{ id: 'aromaPrice', name: 'aromaPrice', label: 'Цена отдушки, руб', type: 'number', required: false },
|
||||
{ id: 'aromaWeight', name: 'aromaWeight', label: 'Фасовка отдушки, г', type: 'number', required: false },
|
||||
{ id: 'moldPrice', name: 'moldPrice', label: 'Цена формы, руб', type: 'number', required: false },
|
||||
{ id: 'box', name: 'box', label: 'Упаковка, руб', type: 'number', required: false },
|
||||
{ id: 'markup', name: 'markup', label: 'Наценка, %', type: 'number', required: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Расчёт себестоимости свечи
|
||||
* @param {Object} data - Данные для расчёта
|
||||
* @returns {Object} Результаты расчёта
|
||||
*/
|
||||
function calculate(data) {
|
||||
const {
|
||||
weight = 0,
|
||||
waxPrice = 0,
|
||||
wickPrice = 0,
|
||||
aromaPrice = 0,
|
||||
aromaWeight = 1,
|
||||
moldPrice = 0,
|
||||
box = 0,
|
||||
} = data;
|
||||
|
||||
const wax = (weight / 1000) * waxPrice;
|
||||
const wick = wickPrice;
|
||||
const aroma = ((weight * 0.08) / aromaWeight) * aromaPrice; // 8% отдушки для свечей
|
||||
const mold = moldPrice / 50; // Амортизация формы на 50 свечей
|
||||
const packaging = box;
|
||||
|
||||
const subtotal = wax + wick + aroma + mold + packaging;
|
||||
const operational = subtotal * 0.05;
|
||||
const total = subtotal + operational;
|
||||
|
||||
return {
|
||||
wax: round(wax),
|
||||
wick: round(wick),
|
||||
aroma: round(aroma),
|
||||
mold: round(mold),
|
||||
packaging: round(packaging),
|
||||
operational: round(operational),
|
||||
total: round(total),
|
||||
};
|
||||
}
|
||||
|
||||
function round(val) {
|
||||
return Math.round(val * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует сообщение для Telegram
|
||||
* @param {Object} data - Исходные данные
|
||||
* @param {Object} result - Результаты расчёта
|
||||
* @returns {string} Отформатированное сообщение
|
||||
*/
|
||||
function formatMessage(data, result) {
|
||||
const {
|
||||
candleName,
|
||||
weight = 0,
|
||||
box = 0,
|
||||
markup = 0,
|
||||
totalCost,
|
||||
finalPrice,
|
||||
pricePer100g,
|
||||
} = data;
|
||||
|
||||
let text = `🕯️ <b>Расчёт свечи:</b> <i>${candleName}</i>\n\n`;
|
||||
text += `⚖️ <b>Вес свечи:</b> ${weight} г\n\n`;
|
||||
text += `📦 <b>Упаковка:</b> ${box} ₽\n\n`;
|
||||
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||
text += ` 💵 Себестоимость: ${Number(totalCost).toFixed(1)} ₽\n`;
|
||||
text += ` 🎯 Итоговая цена с наценкой: ${Number(finalPrice).toFixed(1)} ₽\n`;
|
||||
text += ` ⚗️ Цена за 100 г: ${Number(pricePer100g).toFixed(1)} ₽`;
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить обязательные поля для валидации
|
||||
* @returns {string[]} Массив имён обязательных полей
|
||||
*/
|
||||
function getRequiredFields() {
|
||||
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить числовые поля для валидации
|
||||
* @returns {string[]} Массив имён числовых полей
|
||||
*/
|
||||
function getNumericFields() {
|
||||
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'candle',
|
||||
name: 'Свеча',
|
||||
fieldSchema,
|
||||
calculate,
|
||||
formatMessage,
|
||||
getRequiredFields,
|
||||
getNumericFields,
|
||||
};
|
||||
|
||||
47
backend/calculators/index.js
Normal file
47
backend/calculators/index.js
Normal file
@ -0,0 +1,47 @@
|
||||
// calculators/index.js
|
||||
// Регистрация всех калькуляторов
|
||||
|
||||
// Регистрация калькуляторов
|
||||
// Ключ - это тип калькулятора (используется в URL и командах)
|
||||
const soapCalculator = require('./soap');
|
||||
const candleCalculator = require('./candle');
|
||||
|
||||
const calculators = {
|
||||
soap: soapCalculator,
|
||||
candle: candleCalculator,
|
||||
// Здесь будут добавляться новые калькуляторы:
|
||||
// bathBomb: bathBombCalculator,
|
||||
};
|
||||
|
||||
/**
|
||||
* Получить калькулятор по типу
|
||||
* @param {string} type - Тип калькулятора
|
||||
* @returns {Object|null} Модуль калькулятора или null
|
||||
*/
|
||||
function getCalculator(type) {
|
||||
return calculators[type] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список всех доступных калькуляторов
|
||||
* @returns {string[]} Массив типов калькуляторов
|
||||
*/
|
||||
function getAvailableCalculators() {
|
||||
return Object.keys(calculators);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, существует ли калькулятор
|
||||
* @param {string} type - Тип калькулятора
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasCalculator(type) {
|
||||
return type in calculators;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCalculator,
|
||||
getAvailableCalculators,
|
||||
hasCalculator,
|
||||
};
|
||||
|
||||
125
backend/calculators/soap.js
Normal file
125
backend/calculators/soap.js
Normal file
@ -0,0 +1,125 @@
|
||||
// calculators/soap.js
|
||||
// Калькулятор мыла
|
||||
|
||||
/**
|
||||
* Схема полей калькулятора
|
||||
*/
|
||||
const fieldSchema = [
|
||||
{ id: 'soapName', name: 'soapName', label: 'Название мыла', type: 'text', required: true },
|
||||
{ id: 'weight', name: 'weight', label: 'Вес мыла, г', type: 'number', required: true },
|
||||
{ id: 'basePrice', name: 'basePrice', label: 'Цена основы, руб', type: 'number', required: false },
|
||||
{ id: 'aromaPrice', name: 'aromaPrice', label: 'Цена отдушки, руб', type: 'number', required: false },
|
||||
{ id: 'aromaWeight', name: 'aromaWeight', label: 'Фасовка отдушки, г', type: 'number', required: false },
|
||||
{ id: 'pigmentPrice', name: 'pigmentPrice', label: 'Цена пигмента, руб', type: 'number', required: false },
|
||||
{ id: 'pigmentWeight', name: 'pigmentWeight', label: 'Фасовка пигмента, г', type: 'number', required: false },
|
||||
{ id: 'moldPrice', name: 'moldPrice', label: 'Цена формы, руб', type: 'number', required: false },
|
||||
{ id: 'box', name: 'box', label: 'Пакет/коробка, руб', type: 'number', required: false },
|
||||
{ id: 'filler', name: 'filler', label: 'Наполнитель, руб', type: 'number', required: false },
|
||||
{ id: 'ribbon', name: 'ribbon', label: 'Лента, руб', type: 'number', required: false },
|
||||
{ id: 'labelValue', name: 'labelValue', label: 'Наклейка, руб', type: 'number', required: false },
|
||||
{ id: 'markup', name: 'markup', label: 'Наценка, %', type: 'number', required: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Расчёт себестоимости мыла
|
||||
* @param {Object} data - Данные для расчёта
|
||||
* @returns {Object} Результаты расчёта
|
||||
*/
|
||||
function calculate(data) {
|
||||
const {
|
||||
weight = 0,
|
||||
basePrice = 0,
|
||||
aromaPrice = 0,
|
||||
aromaWeight = 1,
|
||||
pigmentPrice = 0,
|
||||
pigmentWeight = 1,
|
||||
moldPrice = 0,
|
||||
packaging = { box: 0, filler: 0, ribbon: 0, label: 0 },
|
||||
} = data;
|
||||
|
||||
const base = (weight / 1000) * basePrice;
|
||||
const aroma = ((weight * 0.01) / aromaWeight) * aromaPrice;
|
||||
const pigment = ((weight * 0.005) / pigmentWeight) * pigmentPrice;
|
||||
const mold = moldPrice / 100;
|
||||
const packagingCost = (packaging.box || 0) + (packaging.filler || 0) + (packaging.ribbon || 0) + (packaging.label || 0);
|
||||
const subtotal = base + aroma + pigment + mold + packagingCost;
|
||||
const operational = subtotal * 0.05;
|
||||
const total = subtotal + operational;
|
||||
|
||||
return {
|
||||
base: round(base),
|
||||
aroma: round(aroma),
|
||||
pigment: round(pigment),
|
||||
mold: round(mold),
|
||||
packaging: round(packagingCost),
|
||||
operational: round(operational),
|
||||
total: round(total),
|
||||
};
|
||||
}
|
||||
|
||||
function round(val) {
|
||||
return Math.round(val * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует сообщение для Telegram
|
||||
* @param {Object} data - Исходные данные
|
||||
* @param {Object} result - Результаты расчёта
|
||||
* @returns {string} Отформатированное сообщение
|
||||
*/
|
||||
function formatMessage(data, result) {
|
||||
const {
|
||||
soapName,
|
||||
weight = 0,
|
||||
box = 0,
|
||||
filler = 0,
|
||||
ribbon = 0,
|
||||
labelValue = 0,
|
||||
markup = 0,
|
||||
totalCost,
|
||||
finalPrice,
|
||||
pricePer100g,
|
||||
} = data;
|
||||
|
||||
let text = `🧼 <b>Расчёт мыла:</b> <i>${soapName}</i>\n\n`;
|
||||
text += `⚖️ <b>Вес мыла:</b> ${weight} г\n\n`;
|
||||
text += `📦 <b>Упаковка:</b>\n`;
|
||||
text += ` 📥 Пакет/коробка: ${box} ₽\n`;
|
||||
text += ` 🌾 Наполнитель: ${filler} ₽\n`;
|
||||
text += ` 🎀 Лента: ${ribbon} ₽\n`;
|
||||
text += ` 🏷️ Наклейка: ${labelValue} ₽\n\n`;
|
||||
text += `💹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||
text += ` 💵 Себестоимость: ${Number(totalCost).toFixed(1)} ₽\n`;
|
||||
text += ` 🎯 Итоговая цена с наценкой: ${Number(finalPrice).toFixed(1)} ₽\n`;
|
||||
text += ` ⚗️ Цена за 100 г: ${Number(pricePer100g).toFixed(1)} ₽`;
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить обязательные поля для валидации
|
||||
* @returns {string[]} Массив имён обязательных полей
|
||||
*/
|
||||
function getRequiredFields() {
|
||||
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить числовые поля для валидации
|
||||
* @returns {string[]} Массив имён числовых полей
|
||||
*/
|
||||
function getNumericFields() {
|
||||
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'soap',
|
||||
name: 'Мыло',
|
||||
fieldSchema,
|
||||
calculate,
|
||||
formatMessage,
|
||||
getRequiredFields,
|
||||
getNumericFields,
|
||||
};
|
||||
|
||||
30
backend/config/env.js
Normal file
30
backend/config/env.js
Normal file
@ -0,0 +1,30 @@
|
||||
// config/env.js
|
||||
require('dotenv').config();
|
||||
|
||||
const requiredEnvVars = [
|
||||
'BOT_TOKEN',
|
||||
'WEBAPP_BASE_URL',
|
||||
];
|
||||
|
||||
function validateEnv() {
|
||||
const missing = requiredEnvVars.filter((key) => !process.env[key]);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Отсутствуют обязательные переменные окружения: ${missing.join(', ')}\n` +
|
||||
'Создайте файл .env на основе .env.example'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
validateEnv();
|
||||
|
||||
module.exports = {
|
||||
BOT_TOKEN: process.env.BOT_TOKEN,
|
||||
WEBAPP_BASE_URL: process.env.WEBAPP_BASE_URL,
|
||||
API_BASE_URL: process.env.API_BASE_URL || process.env.WEBAPP_BASE_URL.replace('dosoap', 'api-dosoap'),
|
||||
HTTP_PORT: parseInt(process.env.HTTP_PORT || '3001', 10),
|
||||
CORS_ORIGINS: process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((origin) => origin.trim())
|
||||
: ['*'],
|
||||
};
|
||||
|
||||
30
backend/lib/telegram.js
Normal file
30
backend/lib/telegram.js
Normal file
@ -0,0 +1,30 @@
|
||||
// lib/telegram.js
|
||||
// Общая логика для отправки сообщений в Telegram
|
||||
|
||||
const streamifier = require('streamifier');
|
||||
|
||||
/**
|
||||
* Отправляет сообщение в Telegram (с фото или без)
|
||||
* @param {TelegramBot} bot - Экземпляр бота
|
||||
* @param {number} chatId - ID чата
|
||||
* @param {string} text - Текст сообщения
|
||||
* @param {Buffer|null} photoBuffer - Буфер фото (опционально)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function sendTelegramMessage(bot, chatId, text, photoBuffer = null) {
|
||||
if (photoBuffer) {
|
||||
const bufferStream = streamifier.createReadStream(photoBuffer);
|
||||
await bot.sendPhoto(
|
||||
Number(chatId),
|
||||
bufferStream,
|
||||
{ caption: text, parse_mode: 'HTML' }
|
||||
);
|
||||
} else {
|
||||
await bot.sendMessage(Number(chatId), text, { parse_mode: 'HTML' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendTelegramMessage,
|
||||
};
|
||||
|
||||
47
backend/lib/validator.js
Normal file
47
backend/lib/validator.js
Normal file
@ -0,0 +1,47 @@
|
||||
// lib/validator.js
|
||||
// Валидация данных для калькуляторов
|
||||
|
||||
/**
|
||||
* Валидирует обязательные поля
|
||||
* @param {Object} data - Данные для проверки
|
||||
* @param {string[]} requiredFields - Массив обязательных полей
|
||||
* @returns {{ valid: boolean, error?: string }}
|
||||
*/
|
||||
function validateRequiredFields(data, requiredFields) {
|
||||
for (const field of requiredFields) {
|
||||
if (!data[field] || data[field] === '') {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Поле "${field}" обязательно для заполнения`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует числовые поля
|
||||
* @param {Object} data - Данные для проверки
|
||||
* @param {string[]} numericFields - Массив полей, которые должны быть числами
|
||||
* @returns {{ valid: boolean, error?: string }}
|
||||
*/
|
||||
function validateNumericFields(data, numericFields) {
|
||||
for (const field of numericFields) {
|
||||
if (data[field] !== undefined && data[field] !== null && data[field] !== '') {
|
||||
const num = Number(data[field]);
|
||||
if (isNaN(num) || num < 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Поле "${field}" должно быть положительным числом`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateRequiredFields,
|
||||
validateNumericFields,
|
||||
};
|
||||
|
||||
12
backend/package-lock.json
generated
12
backend/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"body-parser": "^2.2.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.1",
|
||||
"node-telegram-bot-api": "^0.66.0",
|
||||
@ -600,6 +601,17 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"body-parser": "^2.2.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.1",
|
||||
"node-telegram-bot-api": "^0.66.0",
|
||||
|
||||
140
backend/routes/api.js
Normal file
140
backend/routes/api.js
Normal file
@ -0,0 +1,140 @@
|
||||
// routes/api.js
|
||||
// API маршруты для калькуляторов
|
||||
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const { getCalculator, hasCalculator } = require('../calculators');
|
||||
const { sendTelegramMessage } = require('../lib/telegram');
|
||||
const { validateRequiredFields, validateNumericFields } = require('../lib/validator');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Настраиваем multer (храним файлы в памяти)
|
||||
const storage = multer.memoryStorage();
|
||||
const upload = multer({ storage });
|
||||
|
||||
/**
|
||||
* Обработчик POST /api/submit/:calculatorType
|
||||
* Принимает данные калькулятора и отправляет результат в Telegram
|
||||
*/
|
||||
router.post(
|
||||
'/submit/:calculatorType',
|
||||
upload.single('photo'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { calculatorType } = req.params;
|
||||
const { chat_id, ...formData } = req.body;
|
||||
const photoFile = req.file;
|
||||
|
||||
// Проверяем, существует ли калькулятор
|
||||
if (!hasCalculator(calculatorType)) {
|
||||
return res.status(404).json({
|
||||
error: `Калькулятор "${calculatorType}" не найден`,
|
||||
});
|
||||
}
|
||||
|
||||
// Получаем модуль калькулятора
|
||||
const calculator = getCalculator(calculatorType);
|
||||
|
||||
// Валидация обязательных полей
|
||||
const requiredValidation = validateRequiredFields(
|
||||
{ chat_id, ...formData },
|
||||
['chat_id', ...calculator.getRequiredFields()]
|
||||
);
|
||||
if (!requiredValidation.valid) {
|
||||
return res.status(400).json({ error: requiredValidation.error });
|
||||
}
|
||||
|
||||
// Валидация числовых полей
|
||||
const numericValidation = validateNumericFields(
|
||||
formData,
|
||||
calculator.getNumericFields()
|
||||
);
|
||||
if (!numericValidation.valid) {
|
||||
return res.status(400).json({ error: numericValidation.error });
|
||||
}
|
||||
|
||||
// Подготавливаем данные для расчёта
|
||||
const calculationData = prepareCalculationData(formData, calculator.fieldSchema);
|
||||
|
||||
// Выполняем расчёт
|
||||
const calculationResult = calculator.calculate(calculationData);
|
||||
|
||||
// Вычисляем финальную цену и цену за 100г (если применимо)
|
||||
const markup = parseFloat(formData.markup || 0);
|
||||
const totalCost = calculationResult.total;
|
||||
const finalPrice = totalCost * (1 + markup / 100);
|
||||
const weight = parseFloat(formData.weight || 0);
|
||||
const pricePer100g = weight > 0 ? (finalPrice / weight) * 100 : 0;
|
||||
|
||||
// Формируем сообщение для Telegram
|
||||
const messageData = {
|
||||
...formData,
|
||||
totalCost,
|
||||
finalPrice,
|
||||
pricePer100g,
|
||||
};
|
||||
const telegramMessage = calculator.formatMessage(messageData, calculationResult);
|
||||
|
||||
// Отправляем в Telegram
|
||||
try {
|
||||
await sendTelegramMessage(
|
||||
req.app.locals.bot,
|
||||
chat_id,
|
||||
telegramMessage,
|
||||
photoFile ? photoFile.buffer : null
|
||||
);
|
||||
res.sendStatus(200);
|
||||
} catch (telegramError) {
|
||||
console.error('Ошибка при отправке в Telegram:', telegramError);
|
||||
res.status(500).json({
|
||||
error: 'Ошибка при отправке сообщения в Telegram',
|
||||
details: telegramError.message,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка в /api/submit:', err);
|
||||
res.status(500).json({
|
||||
error: 'Внутренняя ошибка сервера',
|
||||
details: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Подготавливает данные для расчёта из формы
|
||||
* @param {Object} formData - Данные из формы
|
||||
* @param {Array} fieldSchema - Схема полей калькулятора
|
||||
* @returns {Object} Подготовленные данные для расчёта
|
||||
*/
|
||||
function prepareCalculationData(formData, fieldSchema) {
|
||||
const data = {};
|
||||
|
||||
for (const field of fieldSchema) {
|
||||
if (formData[field.name] !== undefined) {
|
||||
const value = formData[field.name];
|
||||
if (field.type === 'number') {
|
||||
data[field.name] = parseFloat(value) || 0;
|
||||
} else {
|
||||
data[field.name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Специфичная обработка для мыла (упаковка)
|
||||
if (formData.box !== undefined || formData.filler !== undefined ||
|
||||
formData.ribbon !== undefined || formData.labelValue !== undefined) {
|
||||
data.packaging = {
|
||||
box: parseFloat(formData.box || 0),
|
||||
filler: parseFloat(formData.filler || 0),
|
||||
ribbon: parseFloat(formData.ribbon || 0),
|
||||
label: parseFloat(formData.labelValue || 0),
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
|
||||
85
ecosystem.config.js
Normal file
85
ecosystem.config.js
Normal file
@ -0,0 +1,85 @@
|
||||
// ecosystem.config.js
|
||||
// PM2 конфигурация для DoSoapCalc
|
||||
//
|
||||
// Использование:
|
||||
// pm2 start ecosystem.config.js # Production режим (node)
|
||||
// pm2 start ecosystem.config.js --env development # Development режим (nodemon)
|
||||
// pm2 restart ecosystem.config.js # Перезапуск всех процессов
|
||||
// pm2 stop ecosystem.config.js # Остановка всех процессов
|
||||
// pm2 delete ecosystem.config.js # Удаление всех процессов
|
||||
// pm2 logs dosoap-backend # Просмотр логов backend
|
||||
// pm2 logs dosoap-frontend # Просмотр логов frontend
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'dosoap-backend',
|
||||
// Production: запуск через node напрямую
|
||||
// Development: запуск через nodemon для авто-перезагрузки
|
||||
script: 'bot.js',
|
||||
cwd: '/home/dosai/projects/DoSoapCalc/backend',
|
||||
interpreter: 'node',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
watch: false,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
env_development: {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
// Для запуска через nodemon используйте команду:
|
||||
// pm2 start nodemon --name dosoap-backend -- bot.js --cwd /home/dosai/projects/DoSoapCalc/backend
|
||||
// Или создайте отдельный скрипт start-dev.sh
|
||||
error_file: '/home/dosai/.pm2/logs/dosoap-backend-error.log',
|
||||
out_file: '/home/dosai/.pm2/logs/dosoap-backend-out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
autorestart: true,
|
||||
max_restarts: 10,
|
||||
min_uptime: '10s',
|
||||
},
|
||||
{
|
||||
name: 'dosoap-backend-dev',
|
||||
// Development версия с nodemon (запускайте отдельно при необходимости)
|
||||
script: 'nodemon',
|
||||
args: 'bot.js',
|
||||
cwd: '/home/dosai/projects/DoSoapCalc/backend',
|
||||
interpreter: 'node',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
watch: false,
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
},
|
||||
error_file: '/home/dosai/.pm2/logs/dosoap-backend-dev-error.log',
|
||||
out_file: '/home/dosai/.pm2/logs/dosoap-backend-dev-out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
autorestart: true,
|
||||
max_restarts: 10,
|
||||
min_uptime: '10s',
|
||||
// Не запускается по умолчанию - используйте: pm2 start ecosystem.config.js --only dosoap-backend-dev
|
||||
},
|
||||
{
|
||||
name: 'dosoap-frontend',
|
||||
script: 'npx',
|
||||
args: 'serve -s out -l 3000',
|
||||
cwd: '/home/dosai/projects/DoSoapCalc/frontend',
|
||||
interpreter: 'node',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
watch: false,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
error_file: '/home/dosai/.pm2/logs/dosoap-frontend-error.log',
|
||||
out_file: '/home/dosai/.pm2/logs/dosoap-frontend-out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
autorestart: true,
|
||||
max_restarts: 10,
|
||||
min_uptime: '10s',
|
||||
},
|
||||
],
|
||||
};
|
||||
32
frontend/app/[calculator]/page.tsx
Normal file
32
frontend/app/[calculator]/page.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
// app/[calculator]/page.tsx
|
||||
// Динамический роут для калькуляторов
|
||||
|
||||
import DynamicCalculator from '@/components/DynamicCalculator';
|
||||
import { getCalculator } from '@/lib/calculators';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
calculator: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function CalculatorPage({ params }: PageProps) {
|
||||
const { calculator } = await params;
|
||||
const calculatorModule = getCalculator(calculator);
|
||||
|
||||
if (!calculatorModule) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <DynamicCalculator calculatorType={calculator} />;
|
||||
}
|
||||
|
||||
// Генерация статических путей (для Next.js static export)
|
||||
export async function generateStaticParams() {
|
||||
// Экспортируем все доступные калькуляторы
|
||||
const { getAvailableCalculators } = await import('@/lib/calculators');
|
||||
const calculators = getAvailableCalculators();
|
||||
return calculators.map((calc) => ({ calculator: calc }));
|
||||
}
|
||||
|
||||
@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "DoSoapCalc - Калькулятор себестоимости",
|
||||
description: "Калькулятор себестоимости изделий ручной работы с отправкой результатов в Telegram",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import SoapCalculator from "@/components/SoapCalculator";
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<SoapCalculator />
|
||||
);
|
||||
// Редирект на калькулятор мыла по умолчанию
|
||||
redirect('/soap');
|
||||
}
|
||||
|
||||
329
frontend/components/DynamicCalculator.tsx
Normal file
329
frontend/components/DynamicCalculator.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
// components/DynamicCalculator.tsx
|
||||
// Универсальный компонент для генерации калькуляторов из схемы
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { getCalculator } from '@/lib/calculators';
|
||||
import { submitCalculator } from '@/lib/api';
|
||||
import FormField from './FormField';
|
||||
import type { Calculator } from '@/types/calculator';
|
||||
|
||||
interface DynamicCalculatorProps {
|
||||
calculatorType: string;
|
||||
}
|
||||
|
||||
// Форматирование меток результатов
|
||||
function formatResultLabel(key: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
base: 'Себестоимость основы',
|
||||
aroma: 'Себестоимость отдушки',
|
||||
pigment: 'Себестоимость пигмента',
|
||||
mold: 'Себестоимость формы',
|
||||
packaging: 'Стоимость упаковки',
|
||||
operational: 'Операционные расходы (5%)',
|
||||
wax: 'Себестоимость воска',
|
||||
wick: 'Себестоимость фитиля',
|
||||
};
|
||||
return labels[key] || key;
|
||||
}
|
||||
|
||||
export default function DynamicCalculator({ calculatorType }: DynamicCalculatorProps) {
|
||||
const calculator = getCalculator(calculatorType);
|
||||
|
||||
if (!calculator) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto p-6 bg-gray-800 text-gray-200 rounded-lg">
|
||||
<h1 className="text-2xl font-bold text-red-400">
|
||||
Калькулятор "{calculatorType}" не найден
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [formData, setFormData] = useState<Record<string, string>>({});
|
||||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
|
||||
// Инициализация формы с пустыми значениями
|
||||
useEffect(() => {
|
||||
const initialData: Record<string, string> = {};
|
||||
calculator.fieldSchema.forEach((field) => {
|
||||
initialData[field.name] = '';
|
||||
});
|
||||
setFormData(initialData);
|
||||
}, [calculator]);
|
||||
|
||||
// Получаем chat_id из URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const id = params.get('chat_id');
|
||||
if (id) {
|
||||
setChatId(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Преобразование строки в число
|
||||
const toNum = (str: string): number => {
|
||||
const n = parseFloat(str.replace(',', '.'));
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
|
||||
// Обновление значения поля
|
||||
const updateField = (fieldName: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
// Подготовка данных для расчёта
|
||||
const prepareCalculationData = (): Record<string, any> => {
|
||||
const data: Record<string, any> = {};
|
||||
|
||||
for (const field of calculator.fieldSchema) {
|
||||
if (field.type === 'number') {
|
||||
data[field.name] = toNum(formData[field.name] || '0');
|
||||
} else {
|
||||
data[field.name] = formData[field.name] || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Специфичная обработка для мыла (упаковка)
|
||||
if (calculator.id === 'soap') {
|
||||
data.packaging = {
|
||||
box: toNum(formData.box || '0'),
|
||||
filler: toNum(formData.filler || '0'),
|
||||
ribbon: toNum(formData.ribbon || '0'),
|
||||
label: toNum(formData.labelValue || '0'),
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
// Выполнение расчёта
|
||||
const calculationData = prepareCalculationData();
|
||||
const result = calculator.calculate(calculationData);
|
||||
|
||||
// Вычисление финальной цены
|
||||
const markup = toNum(formData.markup || '0');
|
||||
const finalPrice = result.total * (1 + markup / 100);
|
||||
const weight = toNum(formData.weight || '0');
|
||||
const pricePer100g = weight > 0 ? (finalPrice / weight) * 100 : 0;
|
||||
|
||||
// Группировка полей по группам
|
||||
const groupedFields = calculator.fieldSchema.reduce((acc, field) => {
|
||||
const group = field.group || 'general';
|
||||
if (!acc[group]) {
|
||||
acc[group] = [];
|
||||
}
|
||||
acc[group].push(field);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof calculator.fieldSchema>);
|
||||
|
||||
// Обработка изменения фото
|
||||
const handlePhotoChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setPhotoFile(e.target.files[0]);
|
||||
} else {
|
||||
setPhotoFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработка отправки формы
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!chatId) {
|
||||
alert('❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Валидация обязательных полей
|
||||
const requiredFields = calculator.getRequiredFields();
|
||||
for (const fieldName of requiredFields) {
|
||||
if (!formData[fieldName] || formData[fieldName].trim() === '') {
|
||||
const field = calculator.fieldSchema.find((f) => f.name === fieldName);
|
||||
alert(`❗ Поле "${field?.label || fieldName}" обязательно для заполнения`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Подготовка данных для отправки
|
||||
const submitData: Record<string, string | number> = {};
|
||||
for (const field of calculator.fieldSchema) {
|
||||
if (field.type === 'number') {
|
||||
const value = toNum(formData[field.name] || '0');
|
||||
submitData[field.name] = value;
|
||||
} else {
|
||||
submitData[field.name] = formData[field.name] || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Специфичная обработка для мыла (упаковка отправляется как отдельные поля)
|
||||
// Для других калькуляторов поля отправляются как есть
|
||||
if (calculator.id === 'soap') {
|
||||
submitData.box = toNum(formData.box || '0');
|
||||
submitData.filler = toNum(formData.filler || '0');
|
||||
submitData.ribbon = toNum(formData.ribbon || '0');
|
||||
submitData.labelValue = toNum(formData.labelValue || '0');
|
||||
}
|
||||
|
||||
// Добавляем результаты расчёта
|
||||
submitData.totalCost = result.total;
|
||||
submitData.finalPrice = finalPrice;
|
||||
submitData.pricePer100g = pricePer100g;
|
||||
|
||||
// Отправка на backend
|
||||
const response = await submitCalculator(
|
||||
calculatorType,
|
||||
chatId,
|
||||
submitData,
|
||||
photoFile
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
alert('✅ Расчёт успешно отправлен в Telegram!');
|
||||
// Очистка формы
|
||||
const initialData: Record<string, string> = {};
|
||||
calculator.fieldSchema.forEach((field) => {
|
||||
initialData[field.name] = '';
|
||||
});
|
||||
setFormData(initialData);
|
||||
setPhotoFile(null);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
alert(`❌ Ошибка: ${response.error || 'Неизвестная ошибка'}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="
|
||||
max-w-xl mx-auto p-6 space-y-6
|
||||
bg-gray-800 text-gray-200
|
||||
rounded-lg shadow-lg
|
||||
"
|
||||
>
|
||||
{/* Логотип */}
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Logo"
|
||||
width={150}
|
||||
height={50}
|
||||
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Предупреждение о chat_id */}
|
||||
{chatId === null && (
|
||||
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
|
||||
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Заголовок калькулятора */}
|
||||
<h1 className="text-2xl font-bold text-center">
|
||||
Калькулятор: {calculator.name}
|
||||
</h1>
|
||||
|
||||
{/* Основные поля (без группы) */}
|
||||
{groupedFields.general && (
|
||||
<div className="space-y-4">
|
||||
{groupedFields.general.map((field) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={formData[field.name] || ''}
|
||||
onChange={(value) => updateField(field.name, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Поля с фото */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-gray-300">Фото (необязательно)</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handlePhotoChange}
|
||||
className="
|
||||
bg-gray-700
|
||||
text-gray-200
|
||||
rounded-md
|
||||
border border-gray-600
|
||||
p-2
|
||||
focus:outline-none focus:border-sky-500
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
{photoFile && (
|
||||
<img
|
||||
src={URL.createObjectURL(photoFile)}
|
||||
alt="Предпросмотр"
|
||||
className="w-32 h-32 object-cover rounded-lg border border-sky-400"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Группированные поля (например, упаковка) */}
|
||||
{Object.entries(groupedFields)
|
||||
.filter(([group]) => group !== 'general')
|
||||
.map(([group, fields]) => (
|
||||
<div key={group} className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-300 capitalize">
|
||||
{group === 'packaging' ? 'Упаковка' : group}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{fields.map((field) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={formData[field.name] || ''}
|
||||
onChange={(value) => updateField(field.name, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Блоки с результатами расчёта */}
|
||||
<div className="space-y-2">
|
||||
{Object.entries(result)
|
||||
.filter(([key]) => key !== 'total')
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="p-2 bg-gray-700 text-gray-200 rounded">
|
||||
{formatResultLabel(key)}: {value.toFixed(1)} ₽
|
||||
</div>
|
||||
))}
|
||||
<div className="p-2 bg-gray-600 font-semibold text-gray-200 rounded">
|
||||
Итого себестоимость: {result.total.toFixed(1)} ₽
|
||||
</div>
|
||||
<div className="p-2 bg-gray-700 text-gray-200 rounded">
|
||||
Итоговая цена с наценкой: {finalPrice.toFixed(1)} ₽
|
||||
</div>
|
||||
{weight > 0 && (
|
||||
<div className="p-2 bg-gray-700 text-gray-200 rounded">
|
||||
Цена за 100 г: {pricePer100g.toFixed(1)} ₽
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка отправки */}
|
||||
<button
|
||||
type="submit"
|
||||
className="
|
||||
w-full
|
||||
py-2
|
||||
rounded-md
|
||||
bg-sky-500 hover:bg-sky-600
|
||||
text-gray-100 font-semibold
|
||||
focus:outline-none focus:ring focus:ring-offset-2 focus:ring-sky-500 focus:ring-opacity-60
|
||||
"
|
||||
>
|
||||
Отправить расчёт в Telegram
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
103
frontend/components/FormField.tsx
Normal file
103
frontend/components/FormField.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
// components/FormField.tsx
|
||||
// Переиспользуемые поля формы
|
||||
|
||||
import { ChangeEvent } from 'react';
|
||||
import type { CalculatorField } from '@/types/calculator';
|
||||
|
||||
interface FormFieldProps {
|
||||
field: CalculatorField;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function FormField({ field, value, onChange }: FormFieldProps) {
|
||||
const id = `field-${field.id}`;
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<div className="relative mt-6">
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder=" "
|
||||
className={`
|
||||
peer
|
||||
h-10 w-full
|
||||
bg-gray-700
|
||||
border-2 border-gray-600
|
||||
rounded-md
|
||||
text-gray-200
|
||||
placeholder-transparent
|
||||
pl-3
|
||||
focus:outline-none focus:border-sky-500
|
||||
appearance-none
|
||||
`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`
|
||||
absolute left-3
|
||||
-top-5
|
||||
text-gray-400
|
||||
transition-all
|
||||
text-xs sm:text-sm md:text-base lg:text-lg
|
||||
peer-placeholder-shown:top-2
|
||||
peer-placeholder-shown:text-gray-500
|
||||
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||||
peer-focus:-top-5
|
||||
peer-focus:text-gray-200
|
||||
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||||
`}
|
||||
>
|
||||
{field.label} {field.required && '*'}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text field
|
||||
return (
|
||||
<div className="relative mt-6">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder=" "
|
||||
className={`
|
||||
peer
|
||||
h-10 w-full
|
||||
bg-gray-700
|
||||
border-2 border-gray-600
|
||||
rounded-md
|
||||
text-gray-200
|
||||
placeholder-transparent
|
||||
pl-3
|
||||
focus:outline-none focus:border-sky-500
|
||||
appearance-none
|
||||
`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={`
|
||||
absolute left-3
|
||||
-top-5
|
||||
text-gray-400
|
||||
transition-all
|
||||
text-xs sm:text-sm md:text-base lg:text-lg
|
||||
peer-placeholder-shown:top-2
|
||||
peer-placeholder-shown:text-gray-500
|
||||
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||||
peer-focus:-top-5
|
||||
peer-focus:text-gray-200
|
||||
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||||
`}
|
||||
>
|
||||
{field.label} {field.required && '*'}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { calculateTotal } from '@/lib/calc';
|
||||
import { API_BASE_URL } from '@/lib/config';
|
||||
|
||||
type InputNumberProps = {
|
||||
label: string;
|
||||
@ -161,7 +162,7 @@ export default function SoapCalculator() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api-dosoap.duckdns.org/api/submit', {
|
||||
const res = await fetch(`${API_BASE_URL}/api/submit`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
61
frontend/lib/api.ts
Normal file
61
frontend/lib/api.ts
Normal file
@ -0,0 +1,61 @@
|
||||
// lib/api.ts
|
||||
// Общая логика отправки данных калькулятора на backend
|
||||
|
||||
import { API_BASE_URL } from './config';
|
||||
|
||||
export interface SubmitResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет данные калькулятора на backend
|
||||
* @param calculatorType - Тип калькулятора (например, 'soap')
|
||||
* @param chatId - ID чата Telegram
|
||||
* @param formData - Данные формы
|
||||
* @param photoFile - Файл фото (опционально)
|
||||
* @returns Promise с результатом отправки
|
||||
*/
|
||||
export async function submitCalculator(
|
||||
calculatorType: string,
|
||||
chatId: string,
|
||||
formData: Record<string, string | number>,
|
||||
photoFile: File | null = null
|
||||
): Promise<SubmitResponse> {
|
||||
const data = new FormData();
|
||||
|
||||
data.append('chat_id', chatId);
|
||||
|
||||
// Добавляем все поля формы
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
data.append(key, value.toString());
|
||||
}
|
||||
|
||||
// Добавляем фото, если есть
|
||||
if (photoFile) {
|
||||
data.append('photo', photoFile);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/submit/${calculatorType}`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({ error: await res.text() }));
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.error || `Ошибка ${res.status}: ${res.statusText}`,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Ошибка сети при отправке расчёта',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
72
frontend/lib/calculators/candle.ts
Normal file
72
frontend/lib/calculators/candle.ts
Normal file
@ -0,0 +1,72 @@
|
||||
// lib/calculators/candle.ts
|
||||
// Калькулятор свечей для frontend (пример)
|
||||
|
||||
import type { Calculator, CalculatorField, CalculationResult } from '@/types/calculator';
|
||||
|
||||
const fieldSchema: CalculatorField[] = [
|
||||
{ id: 'candleName', name: 'candleName', label: 'Название свечи', type: 'text', required: true },
|
||||
{ id: 'weight', name: 'weight', label: 'Вес свечи, г', type: 'number', required: true },
|
||||
{ id: 'waxPrice', name: 'waxPrice', label: 'Цена воска за 1 кг, руб', type: 'number', required: false },
|
||||
{ id: 'wickPrice', name: 'wickPrice', label: 'Цена фитиля, руб', type: 'number', required: false },
|
||||
{ id: 'aromaPrice', name: 'aromaPrice', label: 'Цена отдушки, руб', type: 'number', required: false },
|
||||
{ id: 'aromaWeight', name: 'aromaWeight', label: 'Фасовка отдушки, г', type: 'number', required: false },
|
||||
{ id: 'moldPrice', name: 'moldPrice', label: 'Цена формы, руб', type: 'number', required: false },
|
||||
{ id: 'box', name: 'box', label: 'Упаковка, руб', type: 'number', required: false },
|
||||
{ id: 'markup', name: 'markup', label: 'Наценка, %', type: 'number', required: false },
|
||||
];
|
||||
|
||||
function round(val: number): number {
|
||||
return Math.round(val * 10) / 10;
|
||||
}
|
||||
|
||||
function calculate(data: Record<string, any>): CalculationResult {
|
||||
const {
|
||||
weight = 0,
|
||||
waxPrice = 0,
|
||||
wickPrice = 0,
|
||||
aromaPrice = 0,
|
||||
aromaWeight = 1,
|
||||
moldPrice = 0,
|
||||
box = 0,
|
||||
} = data;
|
||||
|
||||
const wax = (weight / 1000) * waxPrice;
|
||||
const wick = wickPrice;
|
||||
const aroma = ((weight * 0.08) / aromaWeight) * aromaPrice; // 8% отдушки для свечей
|
||||
const mold = moldPrice / 50; // Амортизация формы на 50 свечей
|
||||
const packaging = box;
|
||||
|
||||
const subtotal = wax + wick + aroma + mold + packaging;
|
||||
const operational = subtotal * 0.05;
|
||||
const total = subtotal + operational;
|
||||
|
||||
return {
|
||||
wax: round(wax),
|
||||
wick: round(wick),
|
||||
aroma: round(aroma),
|
||||
mold: round(mold),
|
||||
packaging: round(packaging),
|
||||
operational: round(operational),
|
||||
total: round(total),
|
||||
};
|
||||
}
|
||||
|
||||
function getRequiredFields(): string[] {
|
||||
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||
}
|
||||
|
||||
function getNumericFields(): string[] {
|
||||
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||
}
|
||||
|
||||
const candleCalculator: Calculator = {
|
||||
id: 'candle',
|
||||
name: 'Свеча',
|
||||
fieldSchema,
|
||||
calculate,
|
||||
getRequiredFields,
|
||||
getNumericFields,
|
||||
};
|
||||
|
||||
export default candleCalculator;
|
||||
|
||||
43
frontend/lib/calculators/index.ts
Normal file
43
frontend/lib/calculators/index.ts
Normal file
@ -0,0 +1,43 @@
|
||||
// lib/calculators/index.ts
|
||||
// Регистрация всех калькуляторов на frontend
|
||||
|
||||
import soapCalculator from './soap';
|
||||
import candleCalculator from './candle';
|
||||
import type { Calculator } from '@/types/calculator';
|
||||
|
||||
// Регистрация калькуляторов
|
||||
const calculators: Record<string, Calculator> = {
|
||||
soap: soapCalculator,
|
||||
candle: candleCalculator,
|
||||
// Здесь будут добавляться новые калькуляторы:
|
||||
// bathBomb: bathBombCalculator,
|
||||
};
|
||||
|
||||
/**
|
||||
* Получить калькулятор по типу
|
||||
*/
|
||||
export function getCalculator(type: string): Calculator | null {
|
||||
return calculators[type] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список всех доступных калькуляторов
|
||||
*/
|
||||
export function getAvailableCalculators(): string[] {
|
||||
return Object.keys(calculators);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, существует ли калькулятор
|
||||
*/
|
||||
export function hasCalculator(type: string): boolean {
|
||||
return type in calculators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все калькуляторы
|
||||
*/
|
||||
export function getAllCalculators(): Record<string, Calculator> {
|
||||
return calculators;
|
||||
}
|
||||
|
||||
76
frontend/lib/calculators/soap.ts
Normal file
76
frontend/lib/calculators/soap.ts
Normal file
@ -0,0 +1,76 @@
|
||||
// lib/calculators/soap.ts
|
||||
// Калькулятор мыла для frontend
|
||||
|
||||
import type { Calculator, CalculatorField, CalculationResult } from '@/types/calculator';
|
||||
|
||||
const fieldSchema: CalculatorField[] = [
|
||||
{ id: 'soapName', name: 'soapName', label: 'Название мыла', type: 'text', required: true },
|
||||
{ id: 'weight', name: 'weight', label: 'Вес мыла, г', type: 'number', required: true },
|
||||
{ id: 'basePrice', name: 'basePrice', label: 'Цена основы, руб', type: 'number', required: false },
|
||||
{ id: 'aromaPrice', name: 'aromaPrice', label: 'Цена отдушки, руб', type: 'number', required: false },
|
||||
{ id: 'aromaWeight', name: 'aromaWeight', label: 'Фасовка отдушки, г', type: 'number', required: false },
|
||||
{ id: 'pigmentPrice', name: 'pigmentPrice', label: 'Цена пигмента, руб', type: 'number', required: false },
|
||||
{ id: 'pigmentWeight', name: 'pigmentWeight', label: 'Фасовка пигмента, г', type: 'number', required: false },
|
||||
{ id: 'moldPrice', name: 'moldPrice', label: 'Цена формы, руб', type: 'number', required: false },
|
||||
{ id: 'box', name: 'box', label: 'Пакет/коробка, руб', type: 'number', required: false, group: 'packaging' },
|
||||
{ id: 'filler', name: 'filler', label: 'Наполнитель, руб', type: 'number', required: false, group: 'packaging' },
|
||||
{ id: 'ribbon', name: 'ribbon', label: 'Лента, руб', type: 'number', required: false, group: 'packaging' },
|
||||
{ id: 'labelValue', name: 'labelValue', label: 'Наклейка, руб', type: 'number', required: false, group: 'packaging' },
|
||||
{ id: 'markup', name: 'markup', label: 'Наценка, %', type: 'number', required: false },
|
||||
];
|
||||
|
||||
function round(val: number): number {
|
||||
return Math.round(val * 10) / 10;
|
||||
}
|
||||
|
||||
function calculate(data: Record<string, any>): CalculationResult {
|
||||
const {
|
||||
weight = 0,
|
||||
basePrice = 0,
|
||||
aromaPrice = 0,
|
||||
aromaWeight = 1,
|
||||
pigmentPrice = 0,
|
||||
pigmentWeight = 1,
|
||||
moldPrice = 0,
|
||||
packaging = { box: 0, filler: 0, ribbon: 0, label: 0 },
|
||||
} = data;
|
||||
|
||||
const base = (weight / 1000) * basePrice;
|
||||
const aroma = ((weight * 0.01) / aromaWeight) * aromaPrice;
|
||||
const pigment = ((weight * 0.005) / pigmentWeight) * pigmentPrice;
|
||||
const mold = moldPrice / 100;
|
||||
const packagingCost = (packaging.box || 0) + (packaging.filler || 0) + (packaging.ribbon || 0) + (packaging.label || 0);
|
||||
const subtotal = base + aroma + pigment + mold + packagingCost;
|
||||
const operational = subtotal * 0.05;
|
||||
const total = subtotal + operational;
|
||||
|
||||
return {
|
||||
base: round(base),
|
||||
aroma: round(aroma),
|
||||
pigment: round(pigment),
|
||||
mold: round(mold),
|
||||
packaging: round(packagingCost),
|
||||
operational: round(operational),
|
||||
total: round(total),
|
||||
};
|
||||
}
|
||||
|
||||
function getRequiredFields(): string[] {
|
||||
return fieldSchema.filter((f) => f.required).map((f) => f.name);
|
||||
}
|
||||
|
||||
function getNumericFields(): string[] {
|
||||
return fieldSchema.filter((f) => f.type === 'number').map((f) => f.name);
|
||||
}
|
||||
|
||||
const soapCalculator: Calculator = {
|
||||
id: 'soap',
|
||||
name: 'Мыло',
|
||||
fieldSchema,
|
||||
calculate,
|
||||
getRequiredFields,
|
||||
getNumericFields,
|
||||
};
|
||||
|
||||
export default soapCalculator;
|
||||
|
||||
9
frontend/lib/config.ts
Normal file
9
frontend/lib/config.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// lib/config.ts
|
||||
// Конфигурация для frontend
|
||||
|
||||
// Для Next.js в production можно использовать переменные окружения
|
||||
// В development и статическом экспорте используем значения по умолчанию
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
||||
'https://api-dosoap.duckdns.org';
|
||||
|
||||
32
frontend/types/calculator.ts
Normal file
32
frontend/types/calculator.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// types/calculator.ts
|
||||
// Типы для системы калькуляторов
|
||||
|
||||
export type FieldType = 'text' | 'number';
|
||||
|
||||
export interface CalculatorField {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
required: boolean;
|
||||
group?: string; // Группировка полей (например, "packaging", "ingredients")
|
||||
}
|
||||
|
||||
export interface CalculationResult {
|
||||
[key: string]: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface Calculator {
|
||||
id: string;
|
||||
name: string;
|
||||
fieldSchema: CalculatorField[];
|
||||
calculate: (data: Record<string, any>) => CalculationResult;
|
||||
getRequiredFields: () => string[];
|
||||
getNumericFields: () => string[];
|
||||
}
|
||||
|
||||
export interface CalculatorFormData {
|
||||
[key: string]: string | number | File | null;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user