Created modular system for calculators, added soap and candles calculators, universal components, updated backend
549 lines
16 KiB
TypeScript
549 lines
16 KiB
TypeScript
'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 (
|
||
<div className={gridCols === 2 ? '' : 'col-span-2'}>
|
||
<div className="relative mt-6">
|
||
<input
|
||
id={id}
|
||
type="number"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder=" "
|
||
className={`
|
||
peer
|
||
h-10 w-full
|
||
bg-gray-700
|
||
border-2 border-gray-600
|
||
rounded-md
|
||
text-gray-200
|
||
placeholder-transparent
|
||
pl-3
|
||
focus:outline-none focus:border-sky-500
|
||
appearance-none
|
||
`}
|
||
/>
|
||
<label
|
||
htmlFor={id}
|
||
className={`
|
||
absolute left-3
|
||
-top-5
|
||
text-gray-400
|
||
transition-all
|
||
text-xs sm:text-sm md:text-base lg:text-lg
|
||
peer-placeholder-shown:top-2
|
||
peer-placeholder-shown:text-gray-500
|
||
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||
peer-focus:-top-5
|
||
peer-focus:text-gray-200
|
||
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||
`}
|
||
>
|
||
{label}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const InputText = ({
|
||
id,
|
||
label,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
id: string;
|
||
label: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
}) => {
|
||
return (
|
||
<div className="relative mt-6 col-span-2">
|
||
<input
|
||
id={id}
|
||
type="text"
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder=" "
|
||
className={`
|
||
peer
|
||
h-10 w-full
|
||
bg-gray-700
|
||
border-2 border-gray-600
|
||
rounded-md
|
||
text-gray-200
|
||
placeholder-transparent
|
||
pl-3
|
||
focus:outline-none focus:border-sky-500
|
||
appearance-none
|
||
`}
|
||
/>
|
||
<label
|
||
htmlFor={id}
|
||
className={`
|
||
absolute left-3
|
||
-top-5
|
||
text-gray-400
|
||
transition-all
|
||
text-xs sm:text-sm md:text-base lg:text-lg
|
||
peer-placeholder-shown:top-2
|
||
peer-placeholder-shown:text-gray-500
|
||
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
|
||
peer-focus:-top-5
|
||
peer-focus:text-gray-200
|
||
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
|
||
`}
|
||
>
|
||
{label}
|
||
</label>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const CostBlock = ({
|
||
title,
|
||
value,
|
||
highlight = false,
|
||
}: {
|
||
title: string;
|
||
value: number;
|
||
highlight?: boolean;
|
||
}) => {
|
||
const displayValue = isNaN(value) || !isFinite(value) ? 0 : value;
|
||
return (
|
||
<div
|
||
className={`
|
||
p-2
|
||
${highlight ? 'bg-gray-600 font-semibold' : 'bg-gray-700'}
|
||
text-gray-200
|
||
rounded
|
||
`}
|
||
>
|
||
{title}: {displayValue.toFixed(1)} руб
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default function CalculatorEngine({
|
||
calculatorId,
|
||
chatId: externalChatId,
|
||
onBack,
|
||
}: CalculatorEngineProps) {
|
||
const [config, setConfig] = useState<CalculatorConfig | null>(null);
|
||
const [values, setValues] = useState<CalculatorValues>({});
|
||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||
const [chatId, setChatId] = useState<string | null>(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 (
|
||
<div className="max-w-xl mx-auto p-6 bg-gray-800 text-gray-200 rounded-lg">
|
||
<p>Калькулятор не найден</p>
|
||
{onBack && (
|
||
<button
|
||
onClick={onBack}
|
||
className="mt-4 px-4 py-2 bg-sky-500 hover:bg-sky-600 text-white rounded"
|
||
>
|
||
Назад к меню
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Вычисляем все значения как числа
|
||
const numValues: Record<string, number> = {};
|
||
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<HTMLInputElement>) => {
|
||
if (e.target.files && e.target.files[0]) {
|
||
setPhotoFile(e.target.files[0]);
|
||
} else {
|
||
setPhotoFile(null);
|
||
}
|
||
};
|
||
|
||
const handleSubmit = async (e: FormEvent) => {
|
||
e.preventDefault();
|
||
if (!chatId) {
|
||
alert('❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.');
|
||
return;
|
||
}
|
||
|
||
const 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<string, string | number> = {};
|
||
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<string, typeof numberFields> = {};
|
||
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<string>();
|
||
|
||
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 (
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="
|
||
max-w-xl mx-auto p-6 space-y-6
|
||
bg-gray-800 text-gray-200
|
||
rounded-lg shadow-lg
|
||
"
|
||
>
|
||
{/* Кнопка назад */}
|
||
{onBack && (
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
className="mb-4 px-4 py-2 bg-gray-600 hover:bg-gray-500 text-gray-200 rounded"
|
||
>
|
||
← Назад к меню
|
||
</button>
|
||
)}
|
||
|
||
{/* Логотип */}
|
||
<div className="flex justify-center">
|
||
<Image
|
||
src="/logo.svg"
|
||
alt="Logo"
|
||
width={150}
|
||
height={50}
|
||
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
|
||
/>
|
||
</div>
|
||
|
||
{chatId === null && (
|
||
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
|
||
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
||
</div>
|
||
)}
|
||
|
||
{/* Текстовые поля */}
|
||
{textFields.map((field) => (
|
||
<InputText
|
||
key={field.id}
|
||
id={field.id}
|
||
label={field.label}
|
||
value={values[field.id] || ''}
|
||
onChange={(value) => handleFieldChange(field.id, value)}
|
||
/>
|
||
))}
|
||
|
||
{/* Поле для фото */}
|
||
{photoField && (
|
||
<>
|
||
<label className="flex flex-col gap-1 col-span-2">
|
||
<span className="text-gray-300">{photoField.label}</span>
|
||
<input
|
||
type="file"
|
||
accept={photoField.accept || 'image/*'}
|
||
onChange={handlePhotoChange}
|
||
className="
|
||
bg-gray-700
|
||
text-gray-200
|
||
rounded-md
|
||
border border-gray-600
|
||
p-2
|
||
focus:outline-none focus:border-sky-500
|
||
"
|
||
/>
|
||
</label>
|
||
{photoFile && (
|
||
<img
|
||
src={URL.createObjectURL(photoFile)}
|
||
alt="Предпросмотр"
|
||
className="w-32 h-32 object-cover rounded-lg border border-sky-400"
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Группированные поля с расчетами между ними */}
|
||
{groupOrder.map((groupName) => {
|
||
const groupFields = fieldsByGroup[groupName] || [];
|
||
const firstField = groupFields[0];
|
||
const showStepId = firstField?.showStepAfter;
|
||
|
||
return (
|
||
<div key={groupName} className="space-y-4">
|
||
{/* Поля группы */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{groupFields.map((field) => (
|
||
<InputNumber
|
||
key={field.id}
|
||
id={field.id}
|
||
label={field.label}
|
||
value={values[field.id] || ''}
|
||
onChange={(value) => handleFieldChange(field.id, value)}
|
||
gridCols={field.gridCols}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Блок расчета после группы */}
|
||
{showStepId && (
|
||
<CostBlock
|
||
title={config.calculationSteps.find((s) => s.id === showStepId)?.name || ''}
|
||
value={results.steps[showStepId] || 0}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Несгруппированные поля */}
|
||
{ungroupedFields.length > 0 && (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
{ungroupedFields.map((field) => (
|
||
<InputNumber
|
||
key={field.id}
|
||
id={field.id}
|
||
label={field.label}
|
||
value={values[field.id] || ''}
|
||
onChange={(value) => handleFieldChange(field.id, value)}
|
||
gridCols={field.gridCols}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Подитоги */}
|
||
{config.subtotals.map((subtotal) => (
|
||
<CostBlock
|
||
key={subtotal.id}
|
||
title={subtotal.name}
|
||
value={results.subtotals[subtotal.id]}
|
||
highlight={subtotal.highlight}
|
||
/>
|
||
))}
|
||
|
||
{/* Дополнительные расчеты */}
|
||
{config.additionalCalculations?.map((calc) => (
|
||
<CostBlock
|
||
key={calc.id}
|
||
title={calc.name}
|
||
value={results.additional![calc.id]}
|
||
/>
|
||
))}
|
||
|
||
{/* Кнопка отправки */}
|
||
<button
|
||
type="submit"
|
||
className="
|
||
w-full
|
||
py-2
|
||
rounded-md
|
||
bg-sky-500 hover:bg-sky-600
|
||
text-gray-100 font-semibold
|
||
focus:outline-none focus:ring focus:ring-offset-2 focus:ring-sky-500 focus:ring-opacity-60
|
||
"
|
||
>
|
||
Отправить расчёт в Telegram
|
||
</button>
|
||
</form>
|
||
);
|
||
}
|
||
|