Compare commits

...

2 Commits

27 changed files with 2073 additions and 121 deletions

193
CALCULATOR_GUIDE.md Normal file
View 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
View 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
View 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
View 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

View File

@ -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 },
},
],

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

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

View File

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

View File

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

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

View File

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

View File

@ -1,7 +1,6 @@
import SoapCalculator from "@/components/SoapCalculator";
import { redirect } from 'next/navigation';
export default function Home() {
return (
<SoapCalculator />
);
// Редирект на калькулятор мыла по умолчанию
redirect('/soap');
}

View 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">
Калькулятор &quot;{calculatorType}&quot; не найден
</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>
);
}

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

View File

@ -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
View 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 : 'Ошибка сети при отправке расчёта',
};
}
}

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

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

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

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