From 99e5edd535c5a7b97841f3d9ba38280f8d5958a5 Mon Sep 17 00:00:00 2001 From: dosai Date: Sat, 1 Nov 2025 19:08:08 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20CHANGELOG=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CALCULATOR_GUIDE.md | 193 +++++++++++++ CHANGELOG.md | 76 +++++ PROJECT_INFO.md | 242 ++++++++++++++++ README.md | 147 ++++++++++ backend/bot.js | 134 ++------- backend/calculators/candle.js | 114 ++++++++ backend/calculators/index.js | 47 ++++ backend/calculators/soap.js | 125 ++++++++ backend/config/env.js | 30 ++ backend/lib/telegram.js | 30 ++ backend/lib/validator.js | 47 ++++ backend/package.json | 1 + backend/routes/api.js | 140 +++++++++ ecosystem.config.js | 85 ++++++ frontend/app/[calculator]/page.tsx | 32 +++ frontend/app/layout.tsx | 4 +- frontend/app/page.tsx | 7 +- frontend/components/DynamicCalculator.tsx | 329 ++++++++++++++++++++++ frontend/components/FormField.tsx | 103 +++++++ frontend/components/SoapCalculator.tsx | 3 +- frontend/lib/api.ts | 61 ++++ frontend/lib/calculators/candle.ts | 72 +++++ frontend/lib/calculators/index.ts | 43 +++ frontend/lib/calculators/soap.ts | 76 +++++ frontend/lib/config.ts | 9 + frontend/types/calculator.ts | 32 +++ 26 files changed, 2061 insertions(+), 121 deletions(-) create mode 100644 CALCULATOR_GUIDE.md create mode 100644 CHANGELOG.md create mode 100644 PROJECT_INFO.md create mode 100644 README.md create mode 100644 backend/calculators/candle.js create mode 100644 backend/calculators/index.js create mode 100644 backend/calculators/soap.js create mode 100644 backend/config/env.js create mode 100644 backend/lib/telegram.js create mode 100644 backend/lib/validator.js create mode 100644 backend/routes/api.js create mode 100644 ecosystem.config.js create mode 100644 frontend/app/[calculator]/page.tsx create mode 100644 frontend/components/DynamicCalculator.tsx create mode 100644 frontend/components/FormField.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/lib/calculators/candle.ts create mode 100644 frontend/lib/calculators/index.ts create mode 100644 frontend/lib/calculators/soap.ts create mode 100644 frontend/lib/config.ts create mode 100644 frontend/types/calculator.ts diff --git a/CALCULATOR_GUIDE.md b/CALCULATOR_GUIDE.md new file mode 100644 index 0000000..e6b6024 --- /dev/null +++ b/CALCULATOR_GUIDE.md @@ -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): 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 = { + 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. Добавьте обработку специфичных данных (если нужно) + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..101fd8f --- /dev/null +++ b/CHANGELOG.md @@ -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 + diff --git a/PROJECT_INFO.md b/PROJECT_INFO.md new file mode 100644 index 0000000..f04aa78 --- /dev/null +++ b/PROJECT_INFO.md @@ -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 = { + 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') + diff --git a/README.md b/README.md new file mode 100644 index 0000000..90e26cd --- /dev/null +++ b/README.md @@ -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 + diff --git a/backend/bot.js b/backend/bot.js index f2ab48e..25a7c76 100644 --- a/backend/bot.js +++ b/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 = `🧼 Расчёт мыла: ${soapName}\n\n`; - - text += `⚖️ Вес мыла: ${weight} г\n\n`; - // text += `🔹 Цена за 1 кг основы: ${basePrice} ₽/кг\n\n`; - - // text += `🔹 Отдушка: ${aromaWeight} г по ${aromaPrice} ₽/фасовка\n`; - // text += `🔹 Пигмент: ${pigmentWeight} г по ${pigmentPrice} ₽/фасовка\n\n`; - - // text += `🔹 Цена формы: ${moldPrice} ₽\n\n`; - - text += `📦 Упаковка:\n`; - text += ` 📥 Пакет/коробка: ${box} ₽\n`; - text += ` 🌾 Наполнитель: ${filler} ₽\n`; - text += ` 🎀 Лента: ${ribbon} ₽\n`; - text += ` 🏷️ Наклейка: ${labelValue} ₽\n\n`; - - text += `💹 Наценка: ${markup}%\n\n`; - - text += `📊 Итоги расчёта:\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 }, }, ], diff --git a/backend/calculators/candle.js b/backend/calculators/candle.js new file mode 100644 index 0000000..702e1a0 --- /dev/null +++ b/backend/calculators/candle.js @@ -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 = `🕯️ Расчёт свечи: ${candleName}\n\n`; + text += `⚖️ Вес свечи: ${weight} г\n\n`; + text += `📦 Упаковка: ${box} ₽\n\n`; + text += `💹 Наценка: ${markup}%\n\n`; + text += `📊 Итоги расчёта:\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, +}; + diff --git a/backend/calculators/index.js b/backend/calculators/index.js new file mode 100644 index 0000000..344524d --- /dev/null +++ b/backend/calculators/index.js @@ -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, +}; + diff --git a/backend/calculators/soap.js b/backend/calculators/soap.js new file mode 100644 index 0000000..80f29d6 --- /dev/null +++ b/backend/calculators/soap.js @@ -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 = `🧼 Расчёт мыла: ${soapName}\n\n`; + text += `⚖️ Вес мыла: ${weight} г\n\n`; + text += `📦 Упаковка:\n`; + text += ` 📥 Пакет/коробка: ${box} ₽\n`; + text += ` 🌾 Наполнитель: ${filler} ₽\n`; + text += ` 🎀 Лента: ${ribbon} ₽\n`; + text += ` 🏷️ Наклейка: ${labelValue} ₽\n\n`; + text += `💹 Наценка: ${markup}%\n\n`; + text += `📊 Итоги расчёта:\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, +}; + diff --git a/backend/config/env.js b/backend/config/env.js new file mode 100644 index 0000000..ed62c4b --- /dev/null +++ b/backend/config/env.js @@ -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()) + : ['*'], +}; + diff --git a/backend/lib/telegram.js b/backend/lib/telegram.js new file mode 100644 index 0000000..73f39bd --- /dev/null +++ b/backend/lib/telegram.js @@ -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} + */ +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, +}; + diff --git a/backend/lib/validator.js b/backend/lib/validator.js new file mode 100644 index 0000000..b28a240 --- /dev/null +++ b/backend/lib/validator.js @@ -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, +}; + diff --git a/backend/package.json b/backend/package.json index 43c7e1a..b823526 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/routes/api.js b/backend/routes/api.js new file mode 100644 index 0000000..ce7884f --- /dev/null +++ b/backend/routes/api.js @@ -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; + diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..2eb6562 --- /dev/null +++ b/ecosystem.config.js @@ -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', + }, + ], +}; diff --git a/frontend/app/[calculator]/page.tsx b/frontend/app/[calculator]/page.tsx new file mode 100644 index 0000000..58d805e --- /dev/null +++ b/frontend/app/[calculator]/page.tsx @@ -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 ; +} + +// Генерация статических путей (для Next.js static export) +export async function generateStaticParams() { + // Экспортируем все доступные калькуляторы + const { getAvailableCalculators } = await import('@/lib/calculators'); + const calculators = getAvailableCalculators(); + return calculators.map((calc) => ({ calculator: calc })); +} + diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 1e6f515..187be1a 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -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({ diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index a61d727..d3c321d 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,7 +1,6 @@ -import SoapCalculator from "@/components/SoapCalculator"; +import { redirect } from 'next/navigation'; export default function Home() { - return ( - - ); + // Редирект на калькулятор мыла по умолчанию + redirect('/soap'); } diff --git a/frontend/components/DynamicCalculator.tsx b/frontend/components/DynamicCalculator.tsx new file mode 100644 index 0000000..652694f --- /dev/null +++ b/frontend/components/DynamicCalculator.tsx @@ -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 = { + 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 ( +
+

+ Калькулятор "{calculatorType}" не найден +

+
+ ); + } + + const [formData, setFormData] = useState>({}); + const [photoFile, setPhotoFile] = useState(null); + const [chatId, setChatId] = useState(null); + + // Инициализация формы с пустыми значениями + useEffect(() => { + const initialData: Record = {}; + 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 => { + const data: Record = {}; + + 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); + + // Обработка изменения фото + const handlePhotoChange = (e: ChangeEvent) => { + 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 = {}; + 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 = {}; + calculator.fieldSchema.forEach((field) => { + initialData[field.name] = ''; + }); + setFormData(initialData); + setPhotoFile(null); + window.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + alert(`❌ Ошибка: ${response.error || 'Неизвестная ошибка'}`); + } + }; + + return ( +
+ {/* Логотип */} +
+ Logo +
+ + {/* Предупреждение о chat_id */} + {chatId === null && ( +
+ ❗ Не найден chat_id. Откройте калькулятор через Telegram-бота. +
+ )} + + {/* Заголовок калькулятора */} +

+ Калькулятор: {calculator.name} +

+ + {/* Основные поля (без группы) */} + {groupedFields.general && ( +
+ {groupedFields.general.map((field) => ( + updateField(field.name, value)} + /> + ))} +
+ )} + + {/* Поля с фото */} + + {photoFile && ( + Предпросмотр + )} + + {/* Группированные поля (например, упаковка) */} + {Object.entries(groupedFields) + .filter(([group]) => group !== 'general') + .map(([group, fields]) => ( +
+

+ {group === 'packaging' ? 'Упаковка' : group} +

+
+ {fields.map((field) => ( + updateField(field.name, value)} + /> + ))} +
+
+ ))} + + {/* Блоки с результатами расчёта */} +
+ {Object.entries(result) + .filter(([key]) => key !== 'total') + .map(([key, value]) => ( +
+ {formatResultLabel(key)}: {value.toFixed(1)} ₽ +
+ ))} +
+ Итого себестоимость: {result.total.toFixed(1)} ₽ +
+
+ Итоговая цена с наценкой: {finalPrice.toFixed(1)} ₽ +
+ {weight > 0 && ( +
+ Цена за 100 г: {pricePer100g.toFixed(1)} ₽ +
+ )} +
+ + {/* Кнопка отправки */} + +
+ ); +} + diff --git a/frontend/components/FormField.tsx b/frontend/components/FormField.tsx new file mode 100644 index 0000000..7485c8b --- /dev/null +++ b/frontend/components/FormField.tsx @@ -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 ( +
+ 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 + `} + /> + +
+ ); + } + + // text field + return ( +
+ 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 + `} + /> + +
+ ); +} + diff --git a/frontend/components/SoapCalculator.tsx b/frontend/components/SoapCalculator.tsx index b09c24c..683ed81 100644 --- a/frontend/components/SoapCalculator.tsx +++ b/frontend/components/SoapCalculator.tsx @@ -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, }); diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts new file mode 100644 index 0000000..1e3a4d6 --- /dev/null +++ b/frontend/lib/api.ts @@ -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, + photoFile: File | null = null +): Promise { + 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 : 'Ошибка сети при отправке расчёта', + }; + } +} + diff --git a/frontend/lib/calculators/candle.ts b/frontend/lib/calculators/candle.ts new file mode 100644 index 0000000..d5b9314 --- /dev/null +++ b/frontend/lib/calculators/candle.ts @@ -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): 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; + diff --git a/frontend/lib/calculators/index.ts b/frontend/lib/calculators/index.ts new file mode 100644 index 0000000..8642db4 --- /dev/null +++ b/frontend/lib/calculators/index.ts @@ -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 = { + 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 { + return calculators; +} + diff --git a/frontend/lib/calculators/soap.ts b/frontend/lib/calculators/soap.ts new file mode 100644 index 0000000..30b7204 --- /dev/null +++ b/frontend/lib/calculators/soap.ts @@ -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): 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; + diff --git a/frontend/lib/config.ts b/frontend/lib/config.ts new file mode 100644 index 0000000..a0838ca --- /dev/null +++ b/frontend/lib/config.ts @@ -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'; + diff --git a/frontend/types/calculator.ts b/frontend/types/calculator.ts new file mode 100644 index 0000000..8182c64 --- /dev/null +++ b/frontend/types/calculator.ts @@ -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) => CalculationResult; + getRequiredFields: () => string[]; + getNumericFields: () => string[]; +} + +export interface CalculatorFormData { + [key: string]: string | number | File | null; +} +