DoSoapCalc/frontend/components/DynamicCalculator.tsx
dosai fc7c42861c fix: Восстановлена оригинальная структура формы мыла и исправлена обработка chat_id
- Восстановлена структура формы мыла с промежуточными стоимостями под каждым блоком
- Добавлен компонент CostBlock для отображения промежуточных стоимостей
- Улучшена обработка chat_id при отправке (проверка URL и sessionStorage)
- Улучшена навигация между калькуляторами с более понятным интерфейсом
- Добавлена подсказка для пользователя о переключении калькуляторов
2025-11-01 20:27:36 +03:00

520 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 CalculatorNav from './CalculatorNav';
import CostBlock from './CostBlock';
import type { Calculator } from '@/types/calculator';
interface DynamicCalculatorProps {
calculatorType: string;
}
// Форматирование меток результатов
function formatResultLabel(key: string): string {
const labels: Record<string, string> = {
base: 'Себестоимость основы',
aroma: 'Себестоимость отдушки',
pigment: 'Себестоимость пигмента',
mold: 'Себестоимость формы',
packaging: 'Стоимость упаковки',
operational: 'Операционные расходы (5%)',
wax: 'Себестоимость воска',
wick: 'Себестоимость фитиля',
};
return labels[key] || key;
}
export default function DynamicCalculator({ calculatorType }: DynamicCalculatorProps) {
const calculator = getCalculator(calculatorType);
if (!calculator) {
return (
<div className="max-w-xl mx-auto p-6 bg-gray-800 text-gray-200 rounded-lg">
<h1 className="text-2xl font-bold text-red-400">
Калькулятор &quot;{calculatorType}&quot; не найден
</h1>
</div>
);
}
const [formData, setFormData] = useState<Record<string, string>>({});
const [photoFile, setPhotoFile] = useState<File | null>(null);
const [chatId, setChatId] = useState<string | null>(null);
// Инициализация формы с пустыми значениями
useEffect(() => {
const initialData: Record<string, string> = {};
calculator.fieldSchema.forEach((field) => {
initialData[field.name] = '';
});
setFormData(initialData);
}, [calculator]);
// Получаем chat_id из URL (для статического экспорта используем window.location напрямую)
useEffect(() => {
if (typeof window === 'undefined') return;
const updateChatId = () => {
// Получаем chat_id из query параметров URL
const params = new URLSearchParams(window.location.search);
let id = params.get('chat_id');
// Если не найден в search params, проверяем hash
if (!id && window.location.hash) {
const hashParams = new URLSearchParams(window.location.hash.replace('#', ''));
id = hashParams.get('chat_id');
}
// Если всё ещё не найден, проверяем весь URL строку
if (!id) {
const fullUrl = window.location.href;
const chatIdMatch = fullUrl.match(/[?&#]chat_id=([^&#]+)/);
if (chatIdMatch) {
id = decodeURIComponent(chatIdMatch[1]);
}
}
if (id) {
setChatId(id);
// Сохраняем в sessionStorage для сохранения при переходах
if (typeof Storage !== 'undefined') {
sessionStorage.setItem('chat_id', id);
}
} else {
// Пробуем восстановить из sessionStorage
if (typeof Storage !== 'undefined') {
const savedChatId = sessionStorage.getItem('chat_id');
if (savedChatId) {
setChatId(savedChatId);
}
}
}
};
updateChatId();
// Слушаем изменения URL (popstate для навигации назад/вперёд)
window.addEventListener('popstate', updateChatId);
return () => {
window.removeEventListener('popstate', updateChatId);
};
}, [calculatorType]);
// Преобразование строки в число
const toNum = (str: string): number => {
const n = parseFloat(str.replace(',', '.'));
return isNaN(n) ? 0 : n;
};
// Обновление значения поля
const updateField = (fieldName: string, value: string) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
};
// Подготовка данных для расчёта
const prepareCalculationData = (): Record<string, any> => {
const data: Record<string, any> = {};
for (const field of calculator.fieldSchema) {
if (field.type === 'number') {
data[field.name] = toNum(formData[field.name] || '0');
} else {
data[field.name] = formData[field.name] || '';
}
}
// Специфичная обработка для мыла (упаковка)
if (calculator.id === 'soap') {
data.packaging = {
box: toNum(formData.box || '0'),
filler: toNum(formData.filler || '0'),
ribbon: toNum(formData.ribbon || '0'),
label: toNum(formData.labelValue || '0'),
};
}
return data;
};
// Выполнение расчёта
const calculationData = prepareCalculationData();
const result = calculator.calculate(calculationData);
// Вычисление финальной цены
const markup = toNum(formData.markup || '0');
const finalPrice = result.total * (1 + markup / 100);
const weight = toNum(formData.weight || '0');
const pricePer100g = weight > 0 ? (finalPrice / weight) * 100 : 0;
// Группировка полей по группам
const groupedFields = calculator.fieldSchema.reduce((acc, field) => {
const group = field.group || 'general';
if (!acc[group]) {
acc[group] = [];
}
acc[group].push(field);
return acc;
}, {} as Record<string, typeof calculator.fieldSchema>);
// Обработка изменения фото
const handlePhotoChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setPhotoFile(e.target.files[0]);
} else {
setPhotoFile(null);
}
};
// Обработка отправки формы
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// Получаем chat_id из разных источников
let currentChatId = chatId;
if (!currentChatId && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
currentChatId = params.get('chat_id');
if (!currentChatId && typeof Storage !== 'undefined') {
currentChatId = sessionStorage.getItem('chat_id');
}
if (currentChatId) {
setChatId(currentChatId);
}
}
if (!currentChatId) {
alert('❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.');
return;
}
// Валидация обязательных полей
const requiredFields = calculator.getRequiredFields();
for (const fieldName of requiredFields) {
if (!formData[fieldName] || formData[fieldName].trim() === '') {
const field = calculator.fieldSchema.find((f) => f.name === fieldName);
alert(`❗ Поле "${field?.label || fieldName}" обязательно для заполнения`);
return;
}
}
// Подготовка данных для отправки
const submitData: Record<string, string | number> = {};
for (const field of calculator.fieldSchema) {
if (field.type === 'number') {
const value = toNum(formData[field.name] || '0');
submitData[field.name] = value;
} else {
submitData[field.name] = formData[field.name] || '';
}
}
// Специфичная обработка для мыла (упаковка отправляется как отдельные поля)
// Для других калькуляторов поля отправляются как есть
if (calculator.id === 'soap') {
submitData.box = toNum(formData.box || '0');
submitData.filler = toNum(formData.filler || '0');
submitData.ribbon = toNum(formData.ribbon || '0');
submitData.labelValue = toNum(formData.labelValue || '0');
}
// Добавляем результаты расчёта
submitData.totalCost = result.total;
submitData.finalPrice = finalPrice;
submitData.pricePer100g = pricePer100g;
// Отправка на backend
const response = await submitCalculator(
calculatorType,
currentChatId!,
submitData,
photoFile
);
if (response.success) {
alert('✅ Расчёт успешно отправлен в Telegram!');
// Очистка формы
const initialData: Record<string, string> = {};
calculator.fieldSchema.forEach((field) => {
initialData[field.name] = '';
});
setFormData(initialData);
setPhotoFile(null);
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
alert(`❌ Ошибка: ${response.error || 'Неизвестная ошибка'}`);
}
};
return (
<form
onSubmit={handleSubmit}
className="
max-w-xl mx-auto p-6 space-y-6
bg-gray-800 text-gray-200
rounded-lg shadow-lg
"
>
{/* Логотип */}
<div className="flex justify-center">
<Image
src="/logo.svg"
alt="Logo"
width={150}
height={50}
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
/>
</div>
{/* Навигация между калькуляторами */}
<CalculatorNav />
{/* Предупреждение о chat_id */}
{chatId === null && (
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
Не найден chat_id. Откройте калькулятор через Telegram-бота.
</div>
)}
{/* Заголовок калькулятора */}
<h1 className="text-2xl font-bold text-center">
Калькулятор: {calculator.name}
</h1>
{/* Специфичная структура для мыла с промежуточными стоимостями */}
{calculator.id === 'soap' && (
<>
{/* Название мыла */}
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'soapName')!}
value={formData.soapName || ''}
onChange={(value) => updateField('soapName', value)}
/>
{/* Фото */}
<label className="flex flex-col gap-1">
<span className="text-gray-300">Фото мыла (необязательно)</span>
<input
type="file"
accept="image/*"
onChange={handlePhotoChange}
className="
bg-gray-700
text-gray-200
rounded-md
border border-gray-600
p-2
focus:outline-none focus:border-sky-500
"
/>
</label>
{photoFile && (
<img
src={URL.createObjectURL(photoFile)}
alt="Предпросмотр мыла"
className="w-32 h-32 object-cover rounded-lg border border-sky-400"
/>
)}
{/* Блок «Основа» */}
<div className="grid grid-cols-2 gap-4">
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'weight')!}
value={formData.weight || ''}
onChange={(value) => updateField('weight', value)}
/>
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'basePrice')!}
value={formData.basePrice || ''}
onChange={(value) => updateField('basePrice', value)}
/>
</div>
<CostBlock title="Себестоимость основы" value={result.base} />
{/* Блок «Отдушка» */}
<div className="grid grid-cols-2 gap-4">
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'aromaPrice')!}
value={formData.aromaPrice || ''}
onChange={(value) => updateField('aromaPrice', value)}
/>
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'aromaWeight')!}
value={formData.aromaWeight || ''}
onChange={(value) => updateField('aromaWeight', value)}
/>
</div>
<CostBlock title="Себестоимость отдушки (1 %)" value={result.aroma} />
{/* Блок «Пигмент» */}
<div className="grid grid-cols-2 gap-4">
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'pigmentPrice')!}
value={formData.pigmentPrice || ''}
onChange={(value) => updateField('pigmentPrice', value)}
/>
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'pigmentWeight')!}
value={formData.pigmentWeight || ''}
onChange={(value) => updateField('pigmentWeight', value)}
/>
</div>
<CostBlock title="Себестоимость пигмента (0.5 %)" value={result.pigment} />
{/* Блок «Форма» */}
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'moldPrice')!}
value={formData.moldPrice || ''}
onChange={(value) => updateField('moldPrice', value)}
/>
<CostBlock title="Себестоимость формы" value={result.mold} />
{/* Блок «Упаковка» */}
<div className="grid grid-cols-2 gap-4">
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'box')!}
value={formData.box || ''}
onChange={(value) => updateField('box', value)}
/>
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'filler')!}
value={formData.filler || ''}
onChange={(value) => updateField('filler', value)}
/>
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'ribbon')!}
value={formData.ribbon || ''}
onChange={(value) => updateField('ribbon', value)}
/>
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'labelValue')!}
value={formData.labelValue || ''}
onChange={(value) => updateField('labelValue', value)}
/>
</div>
<CostBlock title="Стоимость упаковки" value={result.packaging} />
<CostBlock title="Операционные расходы (5 %)" value={result.operational} />
<CostBlock title="Итого себестоимость" value={result.total} highlight />
{/* Блок «Наценка и цена 100 г» */}
<div className="grid grid-cols-2 gap-4">
<FormField
field={calculator.fieldSchema.find((f) => f.name === 'markup')!}
value={formData.markup || ''}
onChange={(value) => updateField('markup', value)}
/>
</div>
<CostBlock title="Итоговая цена с наценкой" value={finalPrice} />
<CostBlock title="Цена за 100 г" value={pricePer100g} />
</>
)}
{/* Универсальная структура для других калькуляторов */}
{calculator.id !== 'soap' && (
<>
{/* Основные поля (без группы) */}
{groupedFields.general && (
<div className="space-y-4">
{groupedFields.general.map((field) => (
<FormField
key={field.id}
field={field}
value={formData[field.name] || ''}
onChange={(value) => updateField(field.name, value)}
/>
))}
</div>
)}
{/* Поля с фото */}
<label className="flex flex-col gap-1">
<span className="text-gray-300">Фото (необязательно)</span>
<input
type="file"
accept="image/*"
onChange={handlePhotoChange}
className="
bg-gray-700
text-gray-200
rounded-md
border border-gray-600
p-2
focus:outline-none focus:border-sky-500
"
/>
</label>
{photoFile && (
<img
src={URL.createObjectURL(photoFile)}
alt="Предпросмотр"
className="w-32 h-32 object-cover rounded-lg border border-sky-400"
/>
)}
{/* Группированные поля */}
{Object.entries(groupedFields)
.filter(([group]) => group !== 'general')
.map(([group, fields]) => (
<div key={group} className="space-y-4">
<h2 className="text-xl font-semibold text-gray-300 capitalize">
{group === 'packaging' ? 'Упаковка' : group}
</h2>
<div className="grid grid-cols-2 gap-4">
{fields.map((field) => (
<FormField
key={field.id}
field={field}
value={formData[field.name] || ''}
onChange={(value) => updateField(field.name, value)}
/>
))}
</div>
</div>
))}
{/* Блоки с результатами расчёта */}
<div className="space-y-2">
{Object.entries(result)
.filter(([key]) => key !== 'total')
.map(([key, value]) => (
<CostBlock
key={key}
title={formatResultLabel(key)}
value={value}
/>
))}
<CostBlock title="Итого себестоимость" value={result.total} highlight />
<CostBlock title="Итоговая цена с наценкой" value={finalPrice} />
{weight > 0 && (
<CostBlock title="Цена за 100 г" value={pricePer100g} />
)}
</div>
</>
)}
{/* Кнопка отправки */}
<button
type="submit"
className="
w-full
py-2
rounded-md
bg-sky-500 hover:bg-sky-600
text-gray-100 font-semibold
focus:outline-none focus:ring focus:ring-offset-2 focus:ring-sky-500 focus:ring-opacity-60
"
>
Отправить расчёт в Telegram
</button>
</form>
);
}