337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
// components/SoapCalculator.tsx
|
||
'use client';
|
||
|
||
import { useState, useEffect, ChangeEvent } from 'react';
|
||
import { calculateTotal } from '@/lib/calc';
|
||
|
||
type InputNumberProps = {
|
||
label: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
};
|
||
|
||
const InputNumber = ({ label, value, onChange, placeholder }: InputNumberProps) => (
|
||
<label className="flex flex-col gap-1">
|
||
<span>{label}</span>
|
||
<input
|
||
type="number"
|
||
className="border px-2 py-1 rounded text-black"
|
||
value={value}
|
||
placeholder={placeholder}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
/>
|
||
</label>
|
||
);
|
||
|
||
const CostBlock = ({
|
||
title,
|
||
value,
|
||
highlight = false,
|
||
}: {
|
||
title: string;
|
||
value: number;
|
||
highlight?: boolean;
|
||
}) => (
|
||
<div
|
||
className={`p-2 ${highlight ? 'bg-red-200 font-semibold' : 'bg-lime-100'}`}
|
||
>
|
||
{title}: {value.toFixed(1)} руб
|
||
</div>
|
||
);
|
||
|
||
export default function SoapCalculator() {
|
||
// 1) Поля ввода как строки (по умолчанию пустые)
|
||
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(''); // %
|
||
|
||
// Файл фотографии и chat_id
|
||
const [photoFile, setPhotoFile] = useState<File | null>(null);
|
||
const [chatId, setChatId] = useState<string | null>(null);
|
||
|
||
// 2) Извлекаем chat_id из URL при монтировании
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const id = params.get('chat_id');
|
||
if (id) {
|
||
setChatId(id);
|
||
}
|
||
}, []);
|
||
|
||
// 3) Преобразуем строковые поля в числа (или 0, если не число)
|
||
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);
|
||
|
||
// 4) Считаем все базовые значения через calculateTotal
|
||
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,
|
||
},
|
||
});
|
||
|
||
// 5) Итоговые значения
|
||
const finalPrice = result.total * (1 + markupNum / 100);
|
||
const pricePer100g = weightNum > 0 ? (finalPrice / weightNum) * 100 : 0;
|
||
|
||
// 6) Обработчик изменения фото
|
||
const handlePhotoChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files && e.target.files[0]) {
|
||
setPhotoFile(e.target.files[0]);
|
||
} else {
|
||
setPhotoFile(null);
|
||
}
|
||
};
|
||
|
||
// 7) Обработчик отправки формы на backend
|
||
const handleSubmit = async () => {
|
||
if (!chatId) {
|
||
alert('❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.');
|
||
return;
|
||
}
|
||
|
||
// Собираем FormData
|
||
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 {
|
||
// Замените URL на публичный адрес вашего backend (Express/ngrok/другой хостинг)
|
||
// Например: 'https://abcd1234.ngrok.io/api/submit'
|
||
const res = await fetch('http://91.122.47.159:3001/api/submit', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
|
||
if (res.ok) {
|
||
alert('✅ Расчёт успешно отправлен в Telegram!');
|
||
|
||
// Сбрасываем все поля на пустые строки / null
|
||
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 (
|
||
<div className="max-w-xl mx-auto p-4 space-y-6 text-sm">
|
||
{chatId === null && (
|
||
<div className="bg-yellow-100 p-2 text-yellow-800 font-semibold">
|
||
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
||
</div>
|
||
)}
|
||
|
||
{/* Название мыла */}
|
||
<label className="flex flex-col gap-1">
|
||
<span>Название мыла</span>
|
||
<input
|
||
type="text"
|
||
className="border px-2 py-1 rounded text-black"
|
||
value={soapName}
|
||
onChange={(e) => setSoapName(e.target.value)}
|
||
placeholder="Например: Лавандовое"
|
||
/>
|
||
</label>
|
||
|
||
{/* Фото мыла */}
|
||
<label className="flex flex-col gap-1">
|
||
<span>Фото мыла (необязательно)</span>
|
||
<input type="file" accept="image/*" onChange={handlePhotoChange} />
|
||
</label>
|
||
{photoFile && (
|
||
<img
|
||
src={URL.createObjectURL(photoFile)}
|
||
alt="Предпросмотр мыла"
|
||
className="w-32 h-32 object-cover rounded"
|
||
/>
|
||
)}
|
||
|
||
{/* Блок «Основа» */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<InputNumber
|
||
label="Вес мыла, г"
|
||
value={weight}
|
||
onChange={setWeight}
|
||
placeholder="Введите, например 60"
|
||
/>
|
||
<InputNumber
|
||
label="Цена за 1 кг основы, руб"
|
||
value={basePrice}
|
||
onChange={setBasePrice}
|
||
placeholder="Введите, например 1000"
|
||
/>
|
||
</div>
|
||
<CostBlock title="Себестоимость основы" value={result.base} />
|
||
|
||
{/* Блок «Отдушка» */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<InputNumber
|
||
label="Цена отдушки, руб"
|
||
value={aromaPrice}
|
||
onChange={setAromaPrice}
|
||
placeholder="Введите, например 300"
|
||
/>
|
||
<InputNumber
|
||
label="Фасовка отдушки, г"
|
||
value={aromaWeight}
|
||
onChange={setAromaWeight}
|
||
placeholder="Введите, например 50"
|
||
/>
|
||
</div>
|
||
<CostBlock title="Себестоимость отдушки (1 %)" value={result.aroma} />
|
||
|
||
{/* Блок «Пигмент» */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<InputNumber
|
||
label="Цена пигмента, руб"
|
||
value={pigmentPrice}
|
||
onChange={setPigmentPrice}
|
||
placeholder="Введите, например 20"
|
||
/>
|
||
<InputNumber
|
||
label="Фасовка пигмента, г"
|
||
value={pigmentWeight}
|
||
onChange={setPigmentWeight}
|
||
placeholder="Введите, например 10"
|
||
/>
|
||
</div>
|
||
<CostBlock title="Себестоимость пигмента (0.5 %)" value={result.pigment} />
|
||
|
||
{/* Блок «Форма» */}
|
||
<InputNumber
|
||
label="Цена формы, руб"
|
||
value={moldPrice}
|
||
onChange={setMoldPrice}
|
||
placeholder="Введите, например 500"
|
||
/>
|
||
<CostBlock title="Себестоимость формы" value={result.mold} />
|
||
|
||
{/* Блок «Упаковка» */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<InputNumber
|
||
label="Пакет/коробка, руб"
|
||
value={box}
|
||
onChange={setBox}
|
||
placeholder="Введите, например 20"
|
||
/>
|
||
<InputNumber
|
||
label="Наполнитель, руб"
|
||
value={filler}
|
||
onChange={setFiller}
|
||
placeholder="Введите, например 5"
|
||
/>
|
||
<InputNumber
|
||
label="Лента, руб"
|
||
value={ribbon}
|
||
onChange={setRibbon}
|
||
placeholder="Введите, например 1"
|
||
/>
|
||
<InputNumber
|
||
label="Наклейка, руб"
|
||
value={labelValue}
|
||
onChange={setLabelValue}
|
||
placeholder="Введите, например 1"
|
||
/>
|
||
</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">
|
||
<InputNumber
|
||
label="Наценка, %"
|
||
value={markup}
|
||
onChange={setMarkup}
|
||
placeholder="Введите, например 30"
|
||
/>
|
||
</div>
|
||
<CostBlock title="Итоговая цена с наценкой" value={finalPrice} />
|
||
<CostBlock title="Цена за 100 г" value={pricePer100g} />
|
||
|
||
{/* Кнопка «Отправить расчёт» */}
|
||
<button
|
||
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded"
|
||
onClick={handleSubmit}
|
||
>
|
||
Отправить расчёт в Telegram
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|