- Восстановлена структура формы мыла с промежуточными стоимостями под каждым блоком - Добавлен компонент CostBlock для отображения промежуточных стоимостей - Улучшена обработка chat_id при отправке (проверка URL и sessionStorage) - Улучшена навигация между калькуляторами с более понятным интерфейсом - Добавлена подсказка для пользователя о переключении калькуляторов
520 lines
19 KiB
TypeScript
520 lines
19 KiB
TypeScript
// 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">
|
||
Калькулятор "{calculatorType}" не найден
|
||
</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>
|
||
);
|
||
}
|
||
|