diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..b27ee9f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,49 @@
+# История изменений
+
+## [Не опубликовано] - Рефакторинг под модульную архитектуру
+
+### Добавлено
+
+- **Модульная система калькуляторов**: Создана универсальная архитектура для добавления новых калькуляторов через конфигурационные файлы
+- **Система типов** (`frontend/lib/calculator-types.ts`): Типизация для полей, шагов расчета, подитогов и конфигураций калькуляторов
+- **Реестр калькуляторов** (`frontend/lib/calculator-registry.ts`): Централизованная система регистрации и управления калькуляторами
+- **Универсальный компонент CalculatorEngine** (`frontend/components/CalculatorEngine.tsx`): Динамический рендеринг форм на основе конфигурации
+- **Компонент меню выбора** (`frontend/components/CalculatorMenu.tsx`): Интерфейс для выбора калькулятора
+- **Модуль калькулятора мыла** (`frontend/calculators/soap/`): Калькулятор мыла вынесен в отдельный модуль с конфигурацией
+- **Модуль калькулятора свечей** (`frontend/calculators/candles/`): Добавлен пример калькулятора свечей для демонстрации модульной системы
+- **Универсальный бэкенд**: Адаптирован `backend/bot.js` для работы с любыми типами калькуляторов
+- **Команда /myid**: Добавлена команда в бота для получения chat_id пользователя
+- **Документация**: Создано руководство по созданию новых калькуляторов (`frontend/docs/calculator-creation-guide.md`)
+
+### Изменено
+
+- **Главная страница** (`frontend/app/page.tsx`): Теперь отображает меню выбора калькулятора вместо прямого отображения калькулятора мыла
+- **Бэкенд API** (`backend/bot.js`): Универсализирован для обработки данных от любых калькуляторов с поддержкой форматирования сообщений через конфигурацию
+
+### Удалено
+
+- **Старый компонент SoapCalculator** (`frontend/components/SoapCalculator.tsx`): Заменен на модульную систему
+- **Старая логика расчетов** (`frontend/lib/calc.ts`): Перенесена в модуль калькулятора мыла
+
+### Исправлено
+
+- **Отступы между полями и блоками расчета**: Добавлены правильные отступы между группами полей и соответствующими блоками расчета
+- **Расчет цены за 100г**: Исправлена формула, теперь корректно использует дополнительные расчеты
+- **NaN в расчетах**: Добавлены проверки на деление на ноль и пустые значения
+- **URL API для локальной разработки**: Исправлен URL бэкенда для работы с localhost
+
+### Технические детали
+
+- Каждый калькулятор теперь состоит из конфигурационного файла (`config.ts`) и опционального файла с функциями расчета (`calc.ts`)
+- Добавление нового калькулятора требует только создания модуля и регистрации в реестре
+- Все поля, формулы и подитоги настраиваются через конфигурацию
+- Поддержка различных типов полей: text, number, file
+- Группировка полей через `groupName` с показом блоков расчета после групп через `showStepAfter`
+- Динамическое форматирование сообщений для Telegram через функцию `formatTelegramMessage` в конфигурации
+- Дополнительные расчеты могут зависеть от других дополнительных расчетов (передается 4-й параметр `additional`)
+- Автоматическое определение URL API (localhost для разработки, продакшн для деплоя)
+
+## Добавлено после первоначального релиза
+
+- **Калькулятор свечей**: Добавлен полнофункциональный пример калькулятора для свечей (`frontend/calculators/candles/`) с демонстрацией всех возможностей системы
+
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..e696a35
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,102 @@
+# План работ: Модульная архитектура калькуляторов
+
+## Статус выполнения
+
+- [x] Создать систему типов (calculator-types.ts) с FieldConfig, CalculationStep, SubtotalConfig, CalculatorConfig
+- [x] Вынести калькулятор мыла в модуль (calculators/soap/config.ts и calc.ts)
+- [x] Создать универсальный компонент CalculatorEngine.tsx для динамического рендеринга и расчетов
+- [x] Создать компонент CalculatorMenu.tsx для выбора калькулятора на главной странице
+- [x] Создать систему регистрации калькуляторов (calculator-registry.ts)
+- [x] Обновить app/page.tsx для отображения меню выбора калькулятора
+- [x] Адаптировать backend/bot.js для универсальной обработки разных калькуляторов
+- [x] Удалить старые файлы SoapCalculator.tsx и calc.ts после миграции
+- [x] Создать документацию по созданию калькуляторов (calculator-creation-guide.md)
+- [x] Создать CHANGELOG.md с описанием изменений
+- [x] Создать PLAN.md с планом работ и отслеживанием прогресса
+
+## Выполненные задачи
+
+### ✅ Этап 1: Система типов
+- Создан файл `frontend/lib/calculator-types.ts`
+- Определены типы: `FieldConfig`, `CalculationStep`, `SubtotalConfig`, `CalculatorConfig`
+- Поддержка типов полей: text, number, file
+- Типы для результатов расчетов: `CalculatorValues`, `CalculationResults`
+
+### ✅ Этап 2: Модуль калькулятора мыла
+- Создана папка `frontend/calculators/soap/`
+- Создан файл `calc.ts` с функциями расчета
+- Создан файл `config.ts` с полной конфигурацией калькулятора мыла
+- Все поля, формулы и подитоги перенесены в модуль
+
+### ✅ Этап 3: Универсальные компоненты
+- Создан `CalculatorEngine.tsx` — динамический рендеринг на основе конфигурации
+- Создан `CalculatorMenu.tsx` — меню выбора калькулятора
+- Обновлен `app/page.tsx` для использования меню
+
+### ✅ Этап 4: Система регистрации
+- Создан `calculator-registry.ts` для управления калькуляторами
+- Реализованы функции: `registerCalculator`, `getCalculator`, `getAllCalculators`
+- Автоматическая инициализация при импорте
+
+### ✅ Этап 5: Адаптация бэкенда
+- Обновлен `backend/bot.js` для универсальной обработки
+- Поддержка готовых сообщений из конфигурации калькулятора
+- Fallback на универсальное форматирование при отсутствии `formatTelegramMessage`
+
+### ✅ Этап 6: Очистка
+- Удален старый `SoapCalculator.tsx`
+- Удален старый `calc.ts`
+- Все зависимости обновлены
+
+### ✅ Этап 7: Документация
+- Создано руководство `docs/calculator-creation-guide.md`
+- Создан `CHANGELOG.md`
+- Создан `PLAN.md` (этот файл)
+
+## Следующие шаги (будущие улучшения)
+
+### Возможные улучшения
+- [ ] Добавить валидацию полей на основе конфигурации
+- [ ] Добавить поддержку условных полей (показывать/скрывать в зависимости от значений)
+- [x] Добавить поддержку групп полей с показом расчетов между группами
+- [x] Создать пример калькулятора свечей для демонстрации
+- [ ] Добавить тесты для модульной системы
+- [ ] Улучшить обработку ошибок валидации
+- [ ] Добавить поддержку единиц измерения (г, кг, шт и т.д.)
+- [ ] Добавить сохранение истории расчетов (локально)
+
+## Структура проекта
+
+```
+frontend/
+ calculators/ # Модули калькуляторов
+ soap/
+ config.ts # Конфигурация калькулятора мыла
+ calc.ts # Функции расчета
+ candles/
+ config.ts # Конфигурация калькулятора свечей
+ calc.ts # Функции расчета
+ components/
+ CalculatorEngine.tsx # Универсальный компонент
+ CalculatorMenu.tsx # Меню выбора
+ lib/
+ calculator-types.ts # Типы системы
+ calculator-registry.ts # Реестр калькуляторов
+ app/
+ page.tsx # Главная страница с меню
+ docs/
+ calculator-creation-guide.md # Руководство
+
+backend/
+ bot.js # Универсальный обработчик API
+```
+
+## Как добавить новый калькулятор
+
+1. Создать папку `frontend/calculators/[название]/`
+2. Создать файл `config.ts` с конфигурацией
+3. Опционально создать `calc.ts` для сложных расчетов
+4. Зарегистрировать в `calculator-registry.ts`
+
+Подробные инструкции в `frontend/docs/calculator-creation-guide.md`
+
diff --git a/backend/bot.js b/backend/bot.js
index f2ab48e..a037fa3 100644
--- a/backend/bot.js
+++ b/backend/bot.js
@@ -49,68 +49,76 @@ app.post(
// Текстовые поля придут в req.body, файл — в req.file
const {
chat_id,
- soapName,
- weight,
- basePrice,
- aromaPrice,
- aromaWeight,
- pigmentPrice,
- pigmentWeight,
- moldPrice,
- box,
- filler,
- ribbon,
- labelValue,
- markup,
- totalCost,
- finalPrice,
- pricePer100g,
+ calculator_id,
+ calculator_name,
+ telegram_message,
} = req.body;
// Проверяем обязательные поля
if (!chat_id) {
return res.status(400).send('chat_id не передан');
}
- if (!soapName) {
- return res.status(400).send('soapName не передан');
+
+ // Если есть готовое сообщение для Telegram (форматированное на фронтенде),
+ // используем его, иначе формируем универсальное
+ let text = '';
+
+ if (telegram_message) {
+ // Используем готовое сообщение из конфигурации калькулятора
+ text = telegram_message;
+ } else {
+ // Формируем универсальное сообщение из всех полей
+ // (fallback для старых версий или калькуляторов без formatTelegramMessage)
+ text = `📊 Расчёт: ${calculator_name || 'Калькулятор'}\n\n`;
+
+ // Добавляем все пользовательские поля (кроме служебных)
+ const excludeFields = ['chat_id', 'calculator_id', 'calculator_name', 'telegram_message'];
+ Object.keys(req.body).forEach(key => {
+ if (!excludeFields.includes(key) && !key.startsWith('step_') && !key.startsWith('subtotal_') && !key.startsWith('additional_')) {
+ const value = req.body[key];
+ if (value !== undefined && value !== '' && value !== null) {
+ text += `${key}: ${value}\n`;
+ }
+ }
+ });
+
+ // Добавляем результаты расчетов
+ const steps = {};
+ const subtotals = {};
+ const additional = {};
+
+ Object.keys(req.body).forEach(key => {
+ if (key.startsWith('step_')) {
+ steps[key.replace('step_', '')] = req.body[key];
+ } else if (key.startsWith('subtotal_')) {
+ subtotals[key.replace('subtotal_', '')] = req.body[key];
+ } else if (key.startsWith('additional_')) {
+ additional[key.replace('additional_', '')] = req.body[key];
+ }
+ });
+
+ if (Object.keys(steps).length > 0) {
+ text += '\nШаги расчёта: \n';
+ Object.keys(steps).forEach(key => {
+ text += ` ${key}: ${Number(steps[key]).toFixed(2)} ₽\n`;
+ });
+ }
+
+ if (Object.keys(subtotals).length > 0) {
+ text += '\nПодитоги: \n';
+ Object.keys(subtotals).forEach(key => {
+ text += ` ${key}: ${Number(subtotals[key]).toFixed(2)} ₽\n`;
+ });
+ }
+
+ if (Object.keys(additional).length > 0) {
+ text += '\nДополнительно: \n';
+ Object.keys(additional).forEach(key => {
+ text += ` ${key}: ${Number(additional[key]).toFixed(2)} ₽\n`;
+ });
+ }
}
- // Соберём сообщение так, чтобы в чат пришло всё, что ввели:
- // 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);
@@ -146,6 +154,7 @@ app.post(
// 6) Команда /menu — отправляем inline-кнопку с chat_id
bot.setMyCommands([
{ command: 'menu', description: 'Открыть калькулятор' },
+ { command: 'myid', description: 'Узнать мой chat_id' },
]);
bot.onText(/\/menu/, (msg) => {
@@ -174,6 +183,20 @@ bot.onText(/\/menu/, (msg) => {
}
});
+// Команда для получения chat_id
+bot.onText(/\/myid/, (msg) => {
+ try {
+ const chatId = msg.chat.id;
+ bot.sendMessage(
+ chatId,
+ `Ваш chat_id: ${chatId}\n\nВы можете открыть калькулятор напрямую по ссылке:\n${WEBAPP_BASE_URL}/?chat_id=${chatId}`,
+ { parse_mode: 'HTML' }
+ );
+ } catch (err) {
+ console.error('Ошибка в обработчике /myid:', err);
+ }
+});
+
// 7) Ловим ошибки polling-а и логируем детали
bot.on('polling_error', (err) => {
console.error('Polling error:', err);
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index a61d727..ba730f6 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -1,7 +1,9 @@
-import SoapCalculator from "@/components/SoapCalculator";
+import CalculatorMenu from "@/components/CalculatorMenu";
export default function Home() {
return (
-
+
+
+
);
}
diff --git a/frontend/calculators/candles/calc.ts b/frontend/calculators/candles/calc.ts
new file mode 100644
index 0000000..059c32e
--- /dev/null
+++ b/frontend/calculators/candles/calc.ts
@@ -0,0 +1,73 @@
+// Функции расчета для калькулятора свечей
+
+export function calculateCandleStep(
+ stepId: string,
+ values: Record
+): number {
+ const {
+ waxWeight = 0,
+ waxPrice = 0,
+ wickCount = 0,
+ wickPrice = 0,
+ fragrancePrice = 0,
+ fragranceWeight = 0,
+ dyePrice = 0,
+ dyeWeight = 0,
+ } = values;
+
+ switch (stepId) {
+ case 'wax':
+ if (waxWeight <= 0 || waxPrice <= 0) return 0;
+ return (waxWeight / 1000) * waxPrice;
+
+ case 'wick':
+ if (wickCount <= 0 || wickPrice <= 0) return 0;
+ return wickCount * wickPrice;
+
+ case 'fragrance':
+ if (waxWeight <= 0 || fragranceWeight <= 0 || fragrancePrice <= 0) return 0;
+ // 10% отдушки от веса воска
+ return ((waxWeight * 0.10) / fragranceWeight) * fragrancePrice;
+
+ case 'dye':
+ if (waxWeight <= 0 || dyeWeight <= 0 || dyePrice <= 0) return 0;
+ // 1% красителя от веса воска
+ return ((waxWeight * 0.01) / dyeWeight) * dyePrice;
+
+ default:
+ return 0;
+ }
+}
+
+export function calculateCandleSubtotal(
+ subtotalId: string,
+ values: Record,
+ steps: Record
+): number {
+ switch (subtotalId) {
+ case 'operational':
+ const subtotal =
+ (steps.wax || 0) +
+ (steps.wick || 0) +
+ (steps.fragrance || 0) +
+ (steps.dye || 0);
+ return subtotal * 0.05;
+
+ case 'total':
+ return (
+ (steps.wax || 0) +
+ (steps.wick || 0) +
+ (steps.fragrance || 0) +
+ (steps.dye || 0) +
+ (steps.operational || 0)
+ );
+
+ default:
+ return 0;
+ }
+}
+
+export function round(val: number): number {
+ return Math.round(val * 10) / 10;
+}
+
diff --git a/frontend/calculators/candles/config.ts b/frontend/calculators/candles/config.ts
new file mode 100644
index 0000000..0213b3d
--- /dev/null
+++ b/frontend/calculators/candles/config.ts
@@ -0,0 +1,246 @@
+// Конфигурация калькулятора свечей
+import { CalculatorConfig } from '@/lib/calculator-types';
+import { calculateCandleStep, calculateCandleSubtotal, round } from './calc';
+
+export const candlesCalculatorConfig: CalculatorConfig = {
+ id: 'candles',
+ name: 'Калькулятор свечей',
+ description: 'Расчет себестоимости свечей ручной работы',
+ icon: '🕯️',
+
+ fields: [
+ {
+ id: 'candleName',
+ type: 'text',
+ label: 'Название свечи',
+ placeholder: 'Введите название',
+ defaultValue: '',
+ gridCols: 1,
+ required: false,
+ },
+ {
+ id: 'photo',
+ type: 'file',
+ label: 'Фото свечи (необязательно)',
+ accept: 'image/*',
+ gridCols: 1,
+ required: false,
+ },
+ {
+ id: 'waxWeight',
+ type: 'number',
+ label: 'Вес воска, г',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'wax',
+ showStepAfter: 'wax',
+ },
+ {
+ id: 'waxPrice',
+ type: 'number',
+ label: 'Цена воска за 1 кг, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'wax',
+ },
+ {
+ id: 'wickCount',
+ type: 'number',
+ label: 'Количество фитилей',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'wick',
+ showStepAfter: 'wick',
+ },
+ {
+ id: 'wickPrice',
+ type: 'number',
+ label: 'Цена одного фитиля, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'wick',
+ },
+ {
+ id: 'fragrancePrice',
+ type: 'number',
+ label: 'Цена отдушки, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'fragrance',
+ showStepAfter: 'fragrance',
+ },
+ {
+ id: 'fragranceWeight',
+ type: 'number',
+ label: 'Фасовка отдушки, г',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'fragrance',
+ },
+ {
+ id: 'dyePrice',
+ type: 'number',
+ label: 'Цена красителя, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'dye',
+ showStepAfter: 'dye',
+ },
+ {
+ id: 'dyeWeight',
+ type: 'number',
+ label: 'Фасовка красителя, г',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'dye',
+ },
+ {
+ id: 'moldPrice',
+ type: 'number',
+ label: 'Стоимость формы/банки, руб',
+ defaultValue: '',
+ gridCols: 1,
+ required: false,
+ groupName: 'mold',
+ showStepAfter: 'mold',
+ },
+ {
+ id: 'packaging',
+ type: 'number',
+ label: 'Упаковка, руб',
+ defaultValue: '',
+ gridCols: 1,
+ required: false,
+ groupName: 'packaging',
+ showStepAfter: 'packaging',
+ },
+ {
+ id: 'markup',
+ type: 'number',
+ label: 'Наценка, %',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ },
+ ],
+
+ calculationSteps: [
+ {
+ id: 'wax',
+ name: 'Себестоимость воска',
+ formula: (values) => round(calculateCandleStep('wax', values)),
+ formulaDescription: '(вес_воска / 1000) * цена_воска',
+ },
+ {
+ id: 'wick',
+ name: 'Себестоимость фитилей',
+ formula: (values) => round(calculateCandleStep('wick', values)),
+ formulaDescription: 'количество_фитилей * цена_фитиля',
+ },
+ {
+ id: 'fragrance',
+ name: 'Себестоимость отдушки (10 %)',
+ formula: (values) => round(calculateCandleStep('fragrance', values)),
+ formulaDescription: '((вес_воска * 0.10) / фасовка_отдушки) * цена_отдушки',
+ },
+ {
+ id: 'dye',
+ name: 'Себестоимость красителя (1 %)',
+ formula: (values) => round(calculateCandleStep('dye', values)),
+ formulaDescription: '((вес_воска * 0.01) / фасовка_красителя) * цена_красителя',
+ },
+ {
+ id: 'mold',
+ name: 'Стоимость формы/банки',
+ formula: (values) => {
+ const moldPrice = values.moldPrice || 0;
+ // Предположим, форма рассчитана на 100 использований
+ return round(moldPrice / 100);
+ },
+ formulaDescription: 'стоимость_формы / 100',
+ },
+ {
+ id: 'packaging',
+ name: 'Стоимость упаковки',
+ formula: (values) => {
+ const packaging = values.packaging || 0;
+ return round(packaging);
+ },
+ formulaDescription: 'стоимость_упаковки',
+ },
+ ],
+
+ subtotals: [
+ {
+ id: 'operational',
+ name: 'Операционные расходы (5 %)',
+ formula: (values, steps) => round(calculateCandleSubtotal('operational', values, steps)),
+ formulaDescription: '(воск + фитили + отдушка + краситель + форма + упаковка) * 0.05',
+ },
+ {
+ id: 'total',
+ name: 'Итого себестоимость',
+ formula: (values, steps) => round(calculateCandleSubtotal('total', values, steps)),
+ highlight: true,
+ formulaDescription: 'воск + фитили + отдушка + краситель + форма + упаковка + операционные',
+ },
+ ],
+
+ additionalCalculations: [
+ {
+ id: 'finalPrice',
+ name: 'Итоговая цена с наценкой',
+ formula: (values, steps, subtotals) => {
+ const total = subtotals.total || 0;
+ const markup = values.markup || 0;
+ return round(total * (1 + markup / 100));
+ },
+ formulaDescription: 'итого_себестоимость * (1 + наценка / 100)',
+ },
+ {
+ id: 'pricePer100g',
+ name: 'Цена за 100 г',
+ formula: (values, steps, subtotals, additional) => {
+ const weight = values.waxWeight || 0;
+ const finalPrice = additional?.finalPrice || 0;
+ if (weight > 0) {
+ return round((finalPrice / weight) * 100);
+ }
+ return 0;
+ },
+ formulaDescription: '(итоговая_цена / вес_воска) * 100',
+ },
+ ],
+
+ formatTelegramMessage: (values, steps, subtotals, additional) => {
+ const candleName = values.candleName || 'Без названия';
+ const waxWeight = values.waxWeight || 0;
+ const wickCount = values.wickCount || 0;
+ const packaging = values.packaging || 0;
+ const markup = values.markup || 0;
+ const totalCost = subtotals.total || 0;
+ const finalPrice = additional?.finalPrice || 0;
+ const pricePer100g = additional?.pricePer100g || 0;
+
+ let text = `🕯️ Расчёт свечи: ${candleName} \n\n`;
+ text += `⚖️ Вес воска: ${waxWeight} г\n`;
+ text += `🕯️ Количество фитилей: ${wickCount} шт\n`;
+ text += `📦 Упаковка: ${packaging} ₽\n\n`;
+ text += `💹 Наценка: ${markup}%\n\n`;
+ text += `📊 Итоги расчёта: \n`;
+ text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`;
+ text += ` 🎯 Итоговая цена с наценкой: ${finalPrice.toFixed(1)} ₽\n`;
+ text += ` ⚗️ Цена за 100 г: ${pricePer100g.toFixed(1)} ₽`;
+
+ return text;
+ },
+};
+
diff --git a/frontend/calculators/soap/calc.ts b/frontend/calculators/soap/calc.ts
new file mode 100644
index 0000000..6268911
--- /dev/null
+++ b/frontend/calculators/soap/calc.ts
@@ -0,0 +1,79 @@
+// Функции расчета для калькулятора мыла
+
+export function calculateSoapStep(
+ stepId: string,
+ values: Record
+): number {
+ const {
+ weight = 0,
+ basePrice = 0,
+ aromaPrice = 0,
+ aromaWeight = 0,
+ pigmentPrice = 0,
+ pigmentWeight = 0,
+ moldPrice = 0,
+ box = 0,
+ filler = 0,
+ ribbon = 0,
+ label = 0,
+ } = values;
+
+ switch (stepId) {
+ case 'base':
+ if (weight <= 0 || basePrice <= 0) return 0;
+ return (weight / 1000) * basePrice;
+
+ case 'aroma':
+ if (weight <= 0 || aromaWeight <= 0 || aromaPrice <= 0) return 0;
+ return ((weight * 0.01) / aromaWeight) * aromaPrice;
+
+ case 'pigment':
+ if (weight <= 0 || pigmentWeight <= 0 || pigmentPrice <= 0) return 0;
+ return ((weight * 0.005) / pigmentWeight) * pigmentPrice;
+
+ case 'mold':
+ if (moldPrice <= 0) return 0;
+ return moldPrice / 100;
+
+ case 'packaging':
+ return (box || 0) + (filler || 0) + (ribbon || 0) + (label || 0);
+
+ default:
+ return 0;
+ }
+}
+
+export function calculateSoapSubtotal(
+ subtotalId: string,
+ values: Record,
+ steps: Record
+): number {
+ switch (subtotalId) {
+ case 'operational':
+ const subtotal =
+ (steps.base || 0) +
+ (steps.aroma || 0) +
+ (steps.pigment || 0) +
+ (steps.mold || 0) +
+ (steps.packaging || 0);
+ return subtotal * 0.05;
+
+ case 'total':
+ return (
+ (steps.base || 0) +
+ (steps.aroma || 0) +
+ (steps.pigment || 0) +
+ (steps.mold || 0) +
+ (steps.packaging || 0) +
+ (steps.operational || 0)
+ );
+
+ default:
+ return 0;
+ }
+}
+
+export function round(val: number): number {
+ return Math.round(val * 10) / 10;
+}
+
diff --git a/frontend/calculators/soap/config.ts b/frontend/calculators/soap/config.ts
new file mode 100644
index 0000000..3ca0d26
--- /dev/null
+++ b/frontend/calculators/soap/config.ts
@@ -0,0 +1,246 @@
+// Конфигурация калькулятора мыла
+import { CalculatorConfig } from '@/lib/calculator-types';
+import { calculateSoapStep, calculateSoapSubtotal, round } from './calc';
+
+export const soapCalculatorConfig: CalculatorConfig = {
+ id: 'soap',
+ name: 'Калькулятор мыла',
+ description: 'Расчет себестоимости мыла ручной работы',
+ icon: '🧼',
+
+ fields: [
+ {
+ id: 'soapName',
+ type: 'text',
+ label: 'Название мыла',
+ placeholder: 'Введите название',
+ defaultValue: '',
+ gridCols: 1,
+ required: false,
+ },
+ {
+ id: 'photo',
+ type: 'file',
+ label: 'Фото мыла (необязательно)',
+ accept: 'image/*',
+ gridCols: 1,
+ required: false,
+ },
+ {
+ id: 'weight',
+ type: 'number',
+ label: 'Вес мыла, г',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'base',
+ showStepAfter: 'base',
+ },
+ {
+ id: 'basePrice',
+ type: 'number',
+ label: 'Цена основы, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'base',
+ },
+ {
+ id: 'aromaPrice',
+ type: 'number',
+ label: 'Цена отдушки, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'aroma',
+ showStepAfter: 'aroma',
+ },
+ {
+ id: 'aromaWeight',
+ type: 'number',
+ label: 'Фасовка отдушки, г',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'aroma',
+ },
+ {
+ id: 'pigmentPrice',
+ type: 'number',
+ label: 'Цена пигмента, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'pigment',
+ showStepAfter: 'pigment',
+ },
+ {
+ id: 'pigmentWeight',
+ type: 'number',
+ label: 'Фасовка пигмента, г',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'pigment',
+ },
+ {
+ id: 'moldPrice',
+ type: 'number',
+ label: 'Цена формы, руб',
+ defaultValue: '',
+ gridCols: 1,
+ required: false,
+ groupName: 'mold',
+ showStepAfter: 'mold',
+ },
+ {
+ id: 'box',
+ type: 'number',
+ label: 'Пакет/коробка, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'packaging',
+ showStepAfter: 'packaging',
+ },
+ {
+ id: 'filler',
+ type: 'number',
+ label: 'Наполнитель, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'packaging',
+ },
+ {
+ id: 'ribbon',
+ type: 'number',
+ label: 'Лента, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'packaging',
+ },
+ {
+ id: 'label',
+ type: 'number',
+ label: 'Наклейка, руб',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ groupName: 'packaging',
+ },
+ {
+ id: 'markup',
+ type: 'number',
+ label: 'Наценка, %',
+ defaultValue: '',
+ gridCols: 2,
+ required: false,
+ },
+ ],
+
+ calculationSteps: [
+ {
+ id: 'base',
+ name: 'Себестоимость основы',
+ formula: (values) => round(calculateSoapStep('base', values)),
+ formulaDescription: '(вес / 1000) * цена_основы',
+ },
+ {
+ id: 'aroma',
+ name: 'Себестоимость отдушки (1 %)',
+ formula: (values) => round(calculateSoapStep('aroma', values)),
+ formulaDescription: '((вес * 0.01) / фасовка_отдушки) * цена_отдушки',
+ },
+ {
+ id: 'pigment',
+ name: 'Себестоимость пигмента (0.5 %)',
+ formula: (values) => round(calculateSoapStep('pigment', values)),
+ formulaDescription: '((вес * 0.005) / фасовка_пигмента) * цена_пигмента',
+ },
+ {
+ id: 'mold',
+ name: 'Себестоимость формы',
+ formula: (values) => round(calculateSoapStep('mold', values)),
+ formulaDescription: 'цена_формы / 100',
+ },
+ {
+ id: 'packaging',
+ name: 'Стоимость упаковки',
+ formula: (values) => round(calculateSoapStep('packaging', values)),
+ formulaDescription: 'пакет + наполнитель + лента + наклейка',
+ },
+ ],
+
+ subtotals: [
+ {
+ id: 'operational',
+ name: 'Операционные расходы (5 %)',
+ formula: (values, steps) => round(calculateSoapSubtotal('operational', values, steps)),
+ formulaDescription: '(основа + отдушка + пигмент + форма + упаковка) * 0.05',
+ },
+ {
+ id: 'total',
+ name: 'Итого себестоимость',
+ formula: (values, steps) => round(calculateSoapSubtotal('total', values, steps)),
+ highlight: true,
+ formulaDescription: 'основа + отдушка + пигмент + форма + упаковка + операционные',
+ },
+ ],
+
+ additionalCalculations: [
+ {
+ id: 'finalPrice',
+ name: 'Итоговая цена с наценкой',
+ formula: (values, steps, subtotals) => {
+ const total = subtotals.total || 0;
+ const markup = values.markup || 0;
+ return round(total * (1 + markup / 100));
+ },
+ formulaDescription: 'итого_себестоимость * (1 + наценка / 100)',
+ },
+ {
+ id: 'pricePer100g',
+ name: 'Цена за 100 г',
+ formula: (values, steps, subtotals, additional) => {
+ const weight = values.weight || 0;
+ const finalPrice = additional?.finalPrice || 0;
+ if (weight > 0) {
+ return round((finalPrice / weight) * 100);
+ }
+ return 0;
+ },
+ formulaDescription: '(итоговая_цена / вес) * 100',
+ },
+ ],
+
+ formatTelegramMessage: (values, steps, subtotals, additional) => {
+ const soapName = values.soapName || 'Без названия';
+ const weight = values.weight || 0;
+ const box = values.box || 0;
+ const filler = values.filler || 0;
+ const ribbon = values.ribbon || 0;
+ const label = values.label || 0;
+ const markup = values.markup || 0;
+ const totalCost = subtotals.total || 0;
+ const finalPrice = additional?.finalPrice || 0;
+ const pricePer100g = additional?.pricePer100g || 0;
+
+ let text = `🧼 Расчёт мыла: ${soapName} \n\n`;
+ text += `⚖️ Вес мыла: ${weight} г\n\n`;
+ text += `📦 Упаковка: \n`;
+ text += ` 📥 Пакет/коробка: ${box} ₽\n`;
+ text += ` 🌾 Наполнитель: ${filler} ₽\n`;
+ text += ` 🎀 Лента: ${ribbon} ₽\n`;
+ text += ` 🏷️ Наклейка: ${label} ₽\n\n`;
+ text += `💹 Наценка: ${markup}%\n\n`;
+ text += `📊 Итоги расчёта: \n`;
+ text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`;
+ text += ` 🎯 Итоговая цена с наценкой: ${finalPrice.toFixed(1)} ₽\n`;
+ text += ` ⚗️ Цена за 100 г: ${pricePer100g.toFixed(1)} ₽`;
+
+ return text;
+ },
+};
+
diff --git a/frontend/components/CalculatorEngine.tsx b/frontend/components/CalculatorEngine.tsx
new file mode 100644
index 0000000..cba0279
--- /dev/null
+++ b/frontend/components/CalculatorEngine.tsx
@@ -0,0 +1,548 @@
+'use client';
+
+import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
+import Image from 'next/image';
+import { CalculatorConfig, CalculatorValues, CalculationResults } from '@/lib/calculator-types';
+import { getCalculator } from '@/lib/calculator-registry';
+
+type CalculatorEngineProps = {
+ calculatorId: string;
+ chatId?: string | null;
+ onBack?: () => void;
+};
+
+const toNum = (str: string): number => {
+ const n = parseFloat(str.replace(',', '.'));
+ return isNaN(n) ? 0 : n;
+};
+
+const InputNumber = ({
+ id,
+ label,
+ value,
+ onChange,
+ gridCols = 2,
+}: {
+ id: string;
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+ gridCols?: 1 | 2;
+}) => {
+ 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
+ `}
+ />
+
+ {label}
+
+
+
+ );
+};
+
+const InputText = ({
+ id,
+ label,
+ value,
+ onChange,
+}: {
+ id: string;
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+}) => {
+ 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
+ `}
+ />
+
+ {label}
+
+
+ );
+};
+
+const CostBlock = ({
+ title,
+ value,
+ highlight = false,
+}: {
+ title: string;
+ value: number;
+ highlight?: boolean;
+}) => {
+ const displayValue = isNaN(value) || !isFinite(value) ? 0 : value;
+ return (
+
+ {title}: {displayValue.toFixed(1)} руб
+
+ );
+};
+
+export default function CalculatorEngine({
+ calculatorId,
+ chatId: externalChatId,
+ onBack,
+}: CalculatorEngineProps) {
+ const [config, setConfig] = useState(null);
+ const [values, setValues] = useState({});
+ const [photoFile, setPhotoFile] = useState(null);
+ const [chatId, setChatId] = useState(null);
+
+ // Загружаем конфигурацию калькулятора
+ useEffect(() => {
+ const calculatorConfig = getCalculator(calculatorId);
+ if (calculatorConfig) {
+ setConfig(calculatorConfig);
+ // Инициализируем значения по умолчанию
+ const initialValues: CalculatorValues = {};
+ calculatorConfig.fields.forEach((field) => {
+ initialValues[field.id] = field.defaultValue || '';
+ });
+ setValues(initialValues);
+ }
+ }, [calculatorId]);
+
+ // Получаем chat_id из URL или пропсов
+ useEffect(() => {
+ if (externalChatId) {
+ setChatId(externalChatId);
+ } else {
+ const params = new URLSearchParams(window.location.search);
+ const id = params.get('chat_id');
+ if (id) {
+ setChatId(id);
+ }
+ }
+ }, [externalChatId]);
+
+ if (!config) {
+ return (
+
+
Калькулятор не найден
+ {onBack && (
+
+ Назад к меню
+
+ )}
+
+ );
+ }
+
+ // Вычисляем все значения как числа
+ const numValues: Record = {};
+ Object.keys(values).forEach((key) => {
+ if (config.fields.find((f) => f.id === key && f.type !== 'file' && f.type !== 'text')) {
+ numValues[key] = toNum(values[key]);
+ } else if (config.fields.find((f) => f.id === key && f.type === 'text')) {
+ numValues[key] = values[key] as any; // Для текстовых полей сохраняем строку
+ }
+ });
+
+ // Выполняем расчеты
+ const results: CalculationResults = {
+ steps: {},
+ subtotals: {},
+ additional: {},
+ };
+
+ // Шаги расчета
+ config.calculationSteps.forEach((step) => {
+ results.steps[step.id] = step.formula(numValues);
+ });
+
+ // Подитоги
+ config.subtotals.forEach((subtotal) => {
+ results.subtotals[subtotal.id] = subtotal.formula(numValues, results.steps);
+ });
+
+ // Дополнительные расчеты (нужно делать последовательно, так как некоторые зависят от других)
+ if (config.additionalCalculations) {
+ config.additionalCalculations.forEach((calc) => {
+ results.additional![calc.id] = calc.formula(numValues, results.steps, results.subtotals, results.additional);
+ });
+ }
+
+ const handleFieldChange = (fieldId: string, newValue: string) => {
+ setValues((prev) => ({ ...prev, [fieldId]: newValue }));
+ };
+
+ 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 formData = new FormData();
+ formData.append('chat_id', chatId);
+ formData.append('calculator_id', config.id);
+ formData.append('calculator_name', config.name);
+
+ // Добавляем все поля
+ config.fields.forEach((field) => {
+ if (field.type !== 'file') {
+ const value = values[field.id] || '';
+ if (field.type === 'number') {
+ formData.append(field.id, toNum(value).toString());
+ } else {
+ formData.append(field.id, value);
+ }
+ }
+ });
+
+ // Добавляем результаты расчетов
+ Object.keys(results.steps).forEach((key) => {
+ formData.append(`step_${key}`, results.steps[key].toString());
+ });
+ Object.keys(results.subtotals).forEach((key) => {
+ formData.append(`subtotal_${key}`, results.subtotals[key].toString());
+ });
+ if (results.additional) {
+ Object.keys(results.additional).forEach((key) => {
+ formData.append(`additional_${key}`, results.additional![key].toString());
+ });
+ }
+
+ // Добавляем фото если есть
+ if (photoFile) {
+ formData.append('photo', photoFile);
+ }
+
+ // Формируем данные для форматирования сообщения
+ const allValues: Record = {};
+ Object.keys(values).forEach((key) => {
+ const field = config.fields.find((f) => f.id === key);
+ if (field && field.type === 'number') {
+ allValues[key] = toNum(values[key]);
+ } else {
+ allValues[key] = values[key];
+ }
+ });
+
+ // Форматируем сообщение для Telegram
+ const telegramMessage = config.formatTelegramMessage
+ ? config.formatTelegramMessage(allValues, results.steps, results.subtotals, results.additional || {})
+ : JSON.stringify({ values, results }, null, 2);
+
+ formData.append('telegram_message', telegramMessage);
+
+ try {
+ // Для локальной разработки используем localhost, для продакшена - api-dosoap.duckdns.org
+ const isLocalhost = typeof window !== 'undefined' && window.location.hostname === 'localhost';
+ const apiUrl = isLocalhost
+ ? 'http://localhost:3001/api/submit'
+ : 'https://api-dosoap.duckdns.org/api/submit';
+
+ const res = await fetch(apiUrl, {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (res.ok) {
+ alert('✅ Расчёт успешно отправлен в Telegram!');
+ // Сброс формы
+ const resetValues: CalculatorValues = {};
+ config.fields.forEach((field) => {
+ resetValues[field.id] = field.defaultValue || '';
+ });
+ setValues(resetValues);
+ setPhotoFile(null);
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ } else {
+ const text = await res.text();
+ alert(`Ошибка при отправке: ${text}`);
+ }
+ } catch (err) {
+ console.error(err);
+ alert('Ошибка сети при отправке расчёта');
+ }
+ };
+
+ // Находим поле для фото
+ const photoField = config.fields.find((f) => f.type === 'file');
+ const textFields = config.fields.filter((f) => f.type === 'text');
+
+ // Группируем числовые поля по группам
+ const numberFields = config.fields.filter((f) => f.type === 'number');
+ const fieldsByGroup: Record = {};
+ const ungroupedFields: typeof numberFields = [];
+
+ numberFields.forEach((field) => {
+ if (field.groupName) {
+ if (!fieldsByGroup[field.groupName]) {
+ fieldsByGroup[field.groupName] = [];
+ }
+ fieldsByGroup[field.groupName].push(field);
+ } else {
+ ungroupedFields.push(field);
+ }
+ });
+
+ // Определяем порядок групп на основе порядка полей в конфигурации
+ const groupOrder: string[] = [];
+ const seenGroups = new Set();
+
+ numberFields.forEach((field) => {
+ if (field.groupName && !seenGroups.has(field.groupName)) {
+ groupOrder.push(field.groupName);
+ seenGroups.add(field.groupName);
+ }
+ });
+
+ // Добавляем группы, которые не были найдены (на всякий случай)
+ Object.keys(fieldsByGroup).forEach((groupName) => {
+ if (!seenGroups.has(groupName)) {
+ groupOrder.push(groupName);
+ }
+ });
+
+ return (
+
+ );
+}
+
diff --git a/frontend/components/CalculatorMenu.tsx b/frontend/components/CalculatorMenu.tsx
new file mode 100644
index 0000000..156e201
--- /dev/null
+++ b/frontend/components/CalculatorMenu.tsx
@@ -0,0 +1,108 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import Image from 'next/image';
+import { getAllCalculators } from '@/lib/calculator-registry';
+import CalculatorEngine from './CalculatorEngine';
+
+type CalculatorMenuProps = {
+ chatId?: string | null;
+};
+
+export default function CalculatorMenu({ chatId: externalChatId }: CalculatorMenuProps) {
+ const [selectedCalculator, setSelectedCalculator] = useState(null);
+ const [chatId, setChatId] = useState(null);
+ const calculators = getAllCalculators();
+
+ // Получаем chat_id из URL или пропсов
+ useEffect(() => {
+ if (externalChatId) {
+ setChatId(externalChatId);
+ } else {
+ const params = new URLSearchParams(window.location.search);
+ const id = params.get('chat_id');
+ if (id) {
+ setChatId(id);
+ }
+ }
+ }, [externalChatId]);
+
+ // Если выбран калькулятор, показываем его
+ if (selectedCalculator) {
+ return (
+ setSelectedCalculator(null)}
+ />
+ );
+ }
+
+ // Иначе показываем меню выбора
+ return (
+
+ {/* Логотип */}
+
+
+
+
+ {chatId === null && (
+
+ ❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
+
+ )}
+
+ {/* Заголовок */}
+
+ Выберите калькулятор
+
+
+ {/* Список калькуляторов */}
+ {calculators.length === 0 ? (
+
+ Нет доступных калькуляторов
+
+ ) : (
+
+ {calculators.map((calc) => (
+
setSelectedCalculator(calc.id)}
+ className="
+ p-6
+ bg-gray-700
+ hover:bg-gray-600
+ rounded-lg
+ border-2 border-gray-600
+ hover:border-sky-500
+ transition-all
+ text-left
+ focus:outline-none focus:ring-2 focus:ring-sky-500
+ "
+ >
+
+ {calc.icon && {calc.icon} }
+
{calc.name}
+
+ {calc.description && (
+ {calc.description}
+ )}
+
+ ))}
+
+ )}
+
+ {/* Подсказка */}
+
+ Выберите калькулятор для начала расчёта
+
+
+ );
+}
+
diff --git a/frontend/components/SoapCalculator.tsx b/frontend/components/SoapCalculator.tsx
deleted file mode 100644
index b09c24c..0000000
--- a/frontend/components/SoapCalculator.tsx
+++ /dev/null
@@ -1,397 +0,0 @@
-// components/SoapCalculator.tsx
-'use client';
-
-import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
-import Image from 'next/image';
-import { calculateTotal } from '@/lib/calc';
-
-type InputNumberProps = {
- label: string;
- value: string;
- onChange: (v: string) => void;
-};
-
-const InputNumber = ({ label, value, onChange }: InputNumberProps) => {
- const id = label.toLowerCase().replace(/\s+/g, '-');
-
- 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
- `}
- />
-
- {label}
-
-
- );
-};
-
-export default function SoapCalculator() {
- const [soapName, setSoapName] = useState('');
- const [weight, setWeight] = useState('');
- const [basePrice, setBasePrice] = useState('');
- const [aromaPrice, setAromaPrice] = useState('');
- const [aromaWeight, setAromaWeight] = useState('');
- const [pigmentPrice, setPigmentPrice] = useState('');
- const [pigmentWeight, setPigmentWeight] = useState('');
- const [moldPrice, setMoldPrice] = useState('');
- const [box, setBox] = useState('');
- const [filler, setFiller] = useState('');
- const [ribbon, setRibbon] = useState('');
- const [labelValue, setLabelValue] = useState('');
- const [markup, setMarkup] = useState('');
-
- const [photoFile, setPhotoFile] = useState(null);
- const [chatId, setChatId] = useState(null);
-
- useEffect(() => {
- const params = new URLSearchParams(window.location.search);
- const id = params.get('chat_id');
- if (id) {
- setChatId(id);
- }
- }, []);
-
- const toNum = (str: string) => {
- const n = parseFloat(str.replace(',', '.'));
- return isNaN(n) ? 0 : n;
- };
- const weightNum = toNum(weight);
- const basePriceNum = toNum(basePrice);
- const aromaPriceNum = toNum(aromaPrice);
- const aromaWeightNum = toNum(aromaWeight);
- const pigmentPriceNum = toNum(pigmentPrice);
- const pigmentWeightNum = toNum(pigmentWeight);
- const moldPriceNum = toNum(moldPrice);
- const boxNum = toNum(box);
- const fillerNum = toNum(filler);
- const ribbonNum = toNum(ribbon);
- const labelNum = toNum(labelValue);
- const markupNum = toNum(markup);
-
- const result = calculateTotal({
- weight: weightNum,
- basePrice: basePriceNum,
- aromaPrice: aromaPriceNum,
- aromaWeight: aromaWeightNum,
- pigmentPrice: pigmentPriceNum,
- pigmentWeight: pigmentWeightNum,
- moldPrice: moldPriceNum,
- packaging: {
- box: boxNum,
- filler: fillerNum,
- ribbon: ribbonNum,
- label: labelNum,
- },
- });
-
- const finalPrice = result.total * (1 + markupNum / 100);
- const pricePer100g = weightNum > 0 ? (finalPrice / weightNum) * 100 : 0;
-
- 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 formData = new FormData();
- formData.append('chat_id', chatId);
- formData.append('soapName', soapName || '');
- formData.append('weight', weightNum.toString());
- formData.append('basePrice', basePriceNum.toString());
- formData.append('aromaPrice', aromaPriceNum.toString());
- formData.append('aromaWeight', aromaWeightNum.toString());
- formData.append('pigmentPrice', pigmentPriceNum.toString());
- formData.append('pigmentWeight', pigmentWeightNum.toString());
- formData.append('moldPrice', moldPriceNum.toString());
- formData.append('box', boxNum.toString());
- formData.append('filler', fillerNum.toString());
- formData.append('ribbon', ribbonNum.toString());
- formData.append('labelValue', labelNum.toString());
- formData.append('markup', markupNum.toString());
- formData.append('totalCost', result.total.toString());
- formData.append('finalPrice', finalPrice.toString());
- formData.append('pricePer100g', pricePer100g.toString());
-
- if (photoFile) {
- formData.append('photo', photoFile);
- }
-
- try {
- const res = await fetch('https://api-dosoap.duckdns.org/api/submit', {
- method: 'POST',
- body: formData,
- });
-
- if (res.ok) {
- alert('✅ Расчёт успешно отправлен в Telegram!');
- setSoapName('');
- setWeight('');
- setBasePrice('');
- setAromaPrice('');
- setAromaWeight('');
- setPigmentPrice('');
- setPigmentWeight('');
- setMoldPrice('');
- setBox('');
- setFiller('');
- setRibbon('');
- setLabelValue('');
- setMarkup('');
- setPhotoFile(null);
- window.scrollTo({ top: 0, behavior: 'smooth' });
- } else {
- const text = await res.text();
- alert(`Ошибка при отправке: ${text}`);
- }
- } catch (err) {
- console.error(err);
- alert('Ошибка сети при отправке расчёта');
- }
- };
-
- return (
-
- );
-}
-
-type CostBlockProps = {
- title: string;
- value: number;
- highlight?: boolean;
-};
-
-const CostBlock = ({ title, value, highlight = false }: CostBlockProps) => (
-
- {title}: {value.toFixed(1)} руб
-
-);
diff --git a/frontend/docs/calculator-creation-guide.md b/frontend/docs/calculator-creation-guide.md
new file mode 100644
index 0000000..ea39520
--- /dev/null
+++ b/frontend/docs/calculator-creation-guide.md
@@ -0,0 +1,386 @@
+# Руководство по созданию новых калькуляторов
+
+Это руководство поможет вам быстро создать новый калькулятор в модульной системе.
+
+## Структура модуля калькулятора
+
+Каждый калькулятор состоит из двух файлов в папке `frontend/calculators/[название]/`:
+
+- `config.ts` — конфигурация калькулятора (поля, формулы, подитоги)
+- `calc.ts` — функции расчета (опционально, если нужны сложные вычисления)
+
+## Шаг 1: Создание папки модуля
+
+Создайте папку для вашего калькулятора:
+
+```
+frontend/calculators/candles/
+```
+
+## Шаг 2: Создание файла calc.ts (если нужны функции расчета)
+
+Если у вас простые формулы, можно обойтись без этого файла и указать формулы прямо в конфигурации.
+
+Пример для калькулятора свечей:
+
+```typescript
+// calculators/candles/calc.ts
+
+export function calculateCandleStep(
+ stepId: string,
+ values: Record
+): number {
+ const { waxWeight = 0, waxPrice = 0, wickCount = 0, wickPrice = 0 } = values;
+
+ switch (stepId) {
+ case 'wax':
+ return (waxWeight / 1000) * waxPrice;
+
+ case 'wick':
+ return wickCount * wickPrice;
+
+ default:
+ return 0;
+ }
+}
+
+export function round(val: number): number {
+ return Math.round(val * 10) / 10;
+}
+```
+
+## Шаг 3: Создание файла config.ts
+
+Это основной файл конфигурации калькулятора:
+
+```typescript
+// calculators/candles/config.ts
+
+import { CalculatorConfig } from '@/lib/calculator-types';
+import { calculateCandleStep, round } from './calc';
+
+export const candlesCalculatorConfig: CalculatorConfig = {
+ id: 'candles',
+ name: 'Калькулятор свечей',
+ description: 'Расчет себестоимости свечей',
+ icon: '🕯️',
+
+ // Поля ввода
+ fields: [
+ {
+ id: 'candleName',
+ type: 'text',
+ label: 'Название свечи',
+ placeholder: 'Введите название',
+ defaultValue: '',
+ gridCols: 1,
+ required: false,
+ },
+ {
+ id: 'waxWeight',
+ type: 'number',
+ label: 'Вес воска, г',
+ defaultValue: '',
+ gridCols: 2,
+ },
+ {
+ id: 'waxPrice',
+ type: 'number',
+ label: 'Цена воска за 1 кг, руб',
+ defaultValue: '',
+ gridCols: 2,
+ },
+ {
+ id: 'wickCount',
+ type: 'number',
+ label: 'Количество фитилей',
+ defaultValue: '',
+ gridCols: 2,
+ },
+ {
+ id: 'wickPrice',
+ type: 'number',
+ label: 'Цена одного фитиля, руб',
+ defaultValue: '',
+ gridCols: 2,
+ },
+ ],
+
+ // Шаги расчета
+ calculationSteps: [
+ {
+ id: 'wax',
+ name: 'Себестоимость воска',
+ formula: (values) => round(calculateCandleStep('wax', values)),
+ formulaDescription: '(вес_воска / 1000) * цена_воска',
+ },
+ {
+ id: 'wick',
+ name: 'Себестоимость фитилей',
+ formula: (values) => round(calculateCandleStep('wick', values)),
+ formulaDescription: 'количество_фитилей * цена_фитиля',
+ },
+ ],
+
+ // Подитоги
+ subtotals: [
+ {
+ id: 'total',
+ name: 'Итого себестоимость',
+ formula: (values, steps) => {
+ return round((steps.wax || 0) + (steps.wick || 0));
+ },
+ highlight: true,
+ formulaDescription: 'воск + фитили',
+ },
+ ],
+
+ // Дополнительные расчеты (опционально)
+ additionalCalculations: [
+ {
+ id: 'pricePerCandle',
+ name: 'Цена за свечу',
+ formula: (values, steps, subtotals) => {
+ const total = subtotals.total || 0;
+ return round(total);
+ },
+ },
+ ],
+
+ // Форматирование сообщения для Telegram (опционально)
+ formatTelegramMessage: (values, steps, subtotals, additional) => {
+ const candleName = values.candleName || 'Без названия';
+ const totalCost = subtotals.total || 0;
+
+ let text = `🕯️ Расчёт свечи: ${candleName} \n\n`;
+ text += `📊 Итоги расчёта: \n`;
+ text += ` 💵 Себестоимость: ${totalCost.toFixed(1)} ₽\n`;
+
+ return text;
+ },
+};
+```
+
+## Шаг 4: Регистрация калькулятора
+
+Откройте файл `frontend/lib/calculator-registry.ts` и добавьте регистрацию:
+
+```typescript
+import { candlesCalculatorConfig } from '@/calculators/candles/config';
+
+// В функции initializeCalculators():
+registerCalculator(candlesCalculatorConfig);
+```
+
+## Типы полей
+
+### text
+Текстовое поле ввода:
+```typescript
+{
+ id: 'productName',
+ type: 'text',
+ label: 'Название продукта',
+ defaultValue: '',
+ gridCols: 1, // 1 или 2 колонки
+}
+```
+
+### number
+Числовое поле ввода:
+```typescript
+{
+ id: 'weight',
+ type: 'number',
+ label: 'Вес, г',
+ defaultValue: '',
+ gridCols: 2,
+ validation: {
+ min: 0,
+ max: 10000,
+ },
+ // Группировка полей (опционально)
+ groupName: 'base', // Имя группы для группировки связанных полей
+ showStepAfter: 'base', // ID шага расчета, который показать после этой группы
+}
+```
+
+### file
+Поле для загрузки файла (обычно фото):
+```typescript
+{
+ id: 'photo',
+ type: 'file',
+ label: 'Фото продукта',
+ accept: 'image/*',
+ gridCols: 1,
+}
+```
+
+## Группировка полей
+
+Для правильного расположения блоков расчета сразу после соответствующих полей используйте группировку:
+
+```typescript
+fields: [
+ // Группа "base" - после этих полей покажется расчет "base"
+ {
+ id: 'weight',
+ type: 'number',
+ label: 'Вес, г',
+ groupName: 'base',
+ showStepAfter: 'base', // Первое поле группы должно иметь showStepAfter
+ },
+ {
+ id: 'price',
+ type: 'number',
+ label: 'Цена, руб',
+ groupName: 'base', // Та же группа
+ },
+ // Блок расчета "base" автоматически появится здесь
+
+ // Группа "packaging"
+ {
+ id: 'box',
+ type: 'number',
+ label: 'Коробка, руб',
+ groupName: 'packaging',
+ showStepAfter: 'packaging',
+ },
+ {
+ id: 'ribbon',
+ type: 'number',
+ label: 'Лента, руб',
+ groupName: 'packaging',
+ },
+ // Блок расчета "packaging" автоматически появится здесь
+]
+```
+
+## Формулы расчета
+
+Формулы — это функции, которые принимают значения полей и возвращают число:
+
+```typescript
+{
+ id: 'baseCost',
+ name: 'Базовая стоимость',
+ formula: (values) => {
+ const weight = values.weight || 0;
+ const price = values.price || 0;
+ return weight * price;
+ },
+ formulaDescription: 'вес * цена', // Опционально, для документации
+}
+```
+
+В формуле доступны все значения полей через объект `values`, где ключ — это `id` поля.
+
+## Подитоги
+
+Подитоги могут зависеть от шагов расчета:
+
+```typescript
+{
+ id: 'total',
+ name: 'Итого',
+ formula: (values, steps) => {
+ // values — значения полей
+ // steps — результаты всех шагов расчета
+ return (steps.base || 0) + (steps.packaging || 0);
+ },
+ highlight: true, // Выделить итоговое значение
+}
+```
+
+## Дополнительные расчеты
+
+Используются для расчетов, которые зависят от подитогов или других дополнительных расчетов:
+
+```typescript
+{
+ id: 'finalPrice',
+ name: 'Итоговая цена',
+ formula: (values, steps, subtotals, additional) => {
+ const total = subtotals.total || 0;
+ const markup = values.markup || 0;
+ return total * (1 + markup / 100);
+ },
+}
+```
+
+**Важно**: Дополнительные расчеты выполняются последовательно. Если один расчет зависит от другого, расположите зависимый расчет **после** того, от которого он зависит:
+
+```typescript
+additionalCalculations: [
+ {
+ id: 'finalPrice',
+ name: 'Итоговая цена с наценкой',
+ formula: (values, steps, subtotals) => {
+ const total = subtotals.total || 0;
+ const markup = values.markup || 0;
+ return total * (1 + markup / 100);
+ },
+ },
+ {
+ id: 'pricePer100g',
+ name: 'Цена за 100 г',
+ formula: (values, steps, subtotals, additional) => {
+ // Используем уже рассчитанный finalPrice из additional
+ const finalPrice = additional?.finalPrice || 0;
+ const weight = values.weight || 0;
+ if (weight > 0) {
+ return (finalPrice / weight) * 100;
+ }
+ return 0;
+ },
+ },
+]
+```
+
+## Форматирование сообщения для Telegram
+
+Если не указать `formatTelegramMessage`, будет использовано универсальное форматирование. Но лучше создать свою функцию:
+
+```typescript
+formatTelegramMessage: (values, steps, subtotals, additional) => {
+ // values — все поля формы (строки или числа)
+ // steps — результаты шагов расчета
+ // subtotals — результаты подитогов
+ // additional — результаты дополнительных расчетов
+
+ let text = `📊 Расчёт: \n\n`;
+ text += `Итого: ${subtotals.total.toFixed(1)} ₽\n`;
+
+ return text;
+}
+```
+
+## Проверка работы
+
+1. Убедитесь, что калькулятор появился в меню на главной странице
+2. Заполните форму и проверьте расчеты
+3. Отправьте результат в Telegram и убедитесь, что сообщение корректно форматировано
+
+## Советы
+
+- Используйте функцию `round()` для округления результатов (обычно до 1 знака после запятой)
+- Для сложных расчетов выносите логику в `calc.ts`
+- Группируйте связанные поля визуально, используя `gridCols`
+- Всегда указывайте `formulaDescription` для документации
+- Тестируйте с нулевыми и пустыми значениями
+
+## Примеры
+
+Полные примеры калькуляторов можно найти в:
+
+**Калькулятор мыла:**
+- `frontend/calculators/soap/config.ts`
+- `frontend/calculators/soap/calc.ts`
+
+**Калькулятор свечей (пример простого калькулятора):**
+- `frontend/calculators/candles/config.ts`
+- `frontend/calculators/candles/calc.ts`
+
+Оба примера демонстрируют использование группировки полей, шагов расчета, подитогов и дополнительных расчетов.
+
diff --git a/frontend/lib/calc.ts b/frontend/lib/calc.ts
deleted file mode 100644
index 55a1029..0000000
--- a/frontend/lib/calc.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-// lib/calc.ts
-
-type Input = {
- weight: number;
- basePrice: number;
- aromaPrice: number;
- aromaWeight: number;
- pigmentPrice: number;
- pigmentWeight: number;
- moldPrice: number;
- packaging: {
- box: number;
- filler: number;
- ribbon: number;
- label: number;
- };
-};
-
-export function calculateTotal(data: Input) {
- const {
- weight,
- basePrice,
- aromaPrice,
- aromaWeight,
- pigmentPrice,
- pigmentWeight,
- moldPrice,
- packaging,
- } = 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 + packaging.filler + packaging.ribbon + packaging.label;
- 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: number) {
- return Math.round(val * 10) / 10;
-}
diff --git a/frontend/lib/calculator-registry.ts b/frontend/lib/calculator-registry.ts
new file mode 100644
index 0000000..f58a2ed
--- /dev/null
+++ b/frontend/lib/calculator-registry.ts
@@ -0,0 +1,46 @@
+// Система регистрации и управления калькуляторами
+import { CalculatorConfig } from './calculator-types';
+import { soapCalculatorConfig } from '@/calculators/soap/config';
+import { candlesCalculatorConfig } from '@/calculators/candles/config';
+
+// Реестр всех доступных калькуляторов
+const calculators: Map = new Map();
+
+// Регистрация калькуляторов
+export function registerCalculator(config: CalculatorConfig): void {
+ if (calculators.has(config.id)) {
+ console.warn(`Калькулятор с ID "${config.id}" уже зарегистрирован. Перезаписываем.`);
+ }
+ calculators.set(config.id, config);
+}
+
+// Получить калькулятор по ID
+export function getCalculator(id: string): CalculatorConfig | undefined {
+ return calculators.get(id);
+}
+
+// Получить список всех калькуляторов
+export function getAllCalculators(): CalculatorConfig[] {
+ return Array.from(calculators.values());
+}
+
+// Получить список ID всех калькуляторов
+export function getAllCalculatorIds(): string[] {
+ return Array.from(calculators.keys());
+}
+
+// Инициализация: регистрация всех калькуляторов
+export function initializeCalculators(): void {
+ // Регистрируем калькулятор мыла
+ registerCalculator(soapCalculatorConfig);
+
+ // Регистрируем калькулятор свечей
+ registerCalculator(candlesCalculatorConfig);
+
+ // Здесь будут регистрироваться другие калькуляторы
+ // registerCalculator(otherCalculatorConfig);
+}
+
+// Автоматическая инициализация при импорте модуля
+initializeCalculators();
+
diff --git a/frontend/lib/calculator-types.ts b/frontend/lib/calculator-types.ts
new file mode 100644
index 0000000..461070a
--- /dev/null
+++ b/frontend/lib/calculator-types.ts
@@ -0,0 +1,91 @@
+// Система типов для модульной архитектуры калькуляторов
+
+export type FieldType = 'text' | 'number' | 'file';
+
+export interface FieldConfig {
+ id: string;
+ type: FieldType;
+ label: string;
+ placeholder?: string;
+ defaultValue?: string;
+ gridCols?: 1 | 2; // Колонки в grid (1 или 2)
+ required?: boolean;
+ validation?: {
+ min?: number;
+ max?: number;
+ pattern?: string;
+ };
+ // Для полей типа file
+ accept?: string;
+ // ID шага расчета, который нужно показать после этой группы полей
+ showStepAfter?: string;
+ // Группа полей (поля с одинаковым groupName будут сгруппированы вместе)
+ groupName?: string;
+}
+
+export interface CalculationStep {
+ id: string;
+ name: string;
+ // Формула может быть функцией или строкой для динамического вычисления
+ formula: (values: Record) => number;
+ // Опционально: выражение в виде строки для документации
+ formulaDescription?: string;
+}
+
+export interface SubtotalConfig {
+ id: string;
+ name: string;
+ // Может быть ссылкой на шаг расчета или собственной формулой
+ formula: (values: Record, steps: Record) => number;
+ highlight?: boolean; // Для выделения итоговых значений
+ formulaDescription?: string;
+}
+
+export interface CalculatorConfig {
+ id: string; // Уникальный идентификатор (например, 'soap', 'candles')
+ name: string; // Отображаемое название
+ description?: string; // Опциональное описание
+ icon?: string; // Эмодзи или иконка для меню
+
+ // Группировка полей (опционально)
+ fieldGroups?: {
+ name: string;
+ fields: string[]; // ID полей в этой группе
+ }[];
+
+ // Конфигурация полей ввода
+ fields: FieldConfig[];
+
+ // Шаги расчета
+ calculationSteps: CalculationStep[];
+
+ // Подитоги
+ subtotals: SubtotalConfig[];
+
+ // Дополнительные расчеты (например, цена за 100г, с наценкой и т.д.)
+ additionalCalculations?: {
+ id: string;
+ name: string;
+ formula: (values: Record, steps: Record, subtotals: Record, additional?: Record) => number;
+ formulaDescription?: string;
+ }[];
+
+ // Функция форматирования результата для Telegram
+ formatTelegramMessage?: (
+ values: Record,
+ steps: Record,
+ subtotals: Record,
+ additional: Record
+ ) => string;
+}
+
+// Тип для значений формы
+export type CalculatorValues = Record;
+
+// Тип для результатов расчета
+export interface CalculationResults {
+ steps: Record;
+ subtotals: Record;
+ additional?: Record;
+}
+