393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
// components/SoapCalculator.tsx
|
||
'use client';
|
||
|
||
import { useState, useEffect, ChangeEvent, FormEvent } from 'react';
|
||
import { calculateTotal } from '@/lib/calc';
|
||
import Image from 'next/image';
|
||
|
||
type InputNumberProps = {
|
||
label: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
};
|
||
|
||
const InputNumber = ({ label, value, onChange }: InputNumberProps) => {
|
||
// Генерируем id на основе текста лейбла (без пробелов)
|
||
const id = label.toLowerCase().replace(/\s+/g, '-');
|
||
|
||
return (
|
||
<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 text-sm
|
||
transition-all
|
||
peer-placeholder-shown:top-2
|
||
peer-placeholder-shown:text-base
|
||
peer-placeholder-shown:text-gray-500
|
||
peer-focus:-top-5
|
||
peer-focus:text-gray-200
|
||
peer-focus:text-sm
|
||
`}
|
||
>
|
||
{label}
|
||
</label>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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<File | null>(null);
|
||
const [chatId, setChatId] = useState<string | null>(null);
|
||
|
||
// При монтировании достаём chat_id из URL
|
||
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);
|
||
|
||
// Считаем все базовые значения через 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,
|
||
},
|
||
});
|
||
|
||
const finalPrice = result.total * (1 + markupNum / 100);
|
||
const pricePer100g = weightNum > 0 ? (finalPrice / weightNum) * 100 : 0;
|
||
|
||
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('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 (
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="
|
||
max-w-xl mx-auto p-6 space-y-6
|
||
bg-gray-800 text-gray-200
|
||
rounded-lg shadow-lg
|
||
"
|
||
>
|
||
{chatId === null && (
|
||
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
|
||
❗ Не найден chat_id. Откройте калькулятор через Telegram-бота.
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-center">
|
||
<Image
|
||
src="/logo.svg"
|
||
alt="Logo"
|
||
width={250}
|
||
height={100}
|
||
className="mx-auto w-64 md:w-96 lg:w-112 h-auto"
|
||
/>
|
||
</div>
|
||
|
||
{/* Название мыла */}
|
||
<div className="relative mt-6">
|
||
<input
|
||
id="soap-name"
|
||
type="text"
|
||
value={soapName}
|
||
onChange={(e) => setSoapName(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="soap-name"
|
||
className={`
|
||
absolute left-3
|
||
-top-5
|
||
text-gray-400 text-sm
|
||
transition-all
|
||
peer-placeholder-shown:top-2
|
||
peer-placeholder-shown:text-base
|
||
peer-placeholder-shown:text-gray-500
|
||
peer-focus:-top-5
|
||
peer-focus:text-gray-200
|
||
peer-focus:text-sm
|
||
`}
|
||
>
|
||
Название мыла
|
||
</label>
|
||
</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"
|
||
/>
|
||
)}
|
||
|
||
{/* Блок «Основа» */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<InputNumber label="Вес мыла, г" value={weight} onChange={setWeight} />
|
||
<InputNumber
|
||
label="Цена за 1 кг основы, руб"
|
||
value={basePrice}
|
||
onChange={setBasePrice}
|
||
/>
|
||
</div>
|
||
<CostBlock title="Себестоимость основы" value={result.base} />
|
||
|
||
{/* Блок «Отдушка» */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<InputNumber
|
||
label="Цена отдушки, руб"
|
||
value={aromaPrice}
|
||
onChange={setAromaPrice}
|
||
/>
|
||
<InputNumber
|
||
label="Фасовка отдушки, г"
|
||
value={aromaWeight}
|
||
onChange={setAromaWeight}
|
||
/>
|
||
</div>
|
||
<CostBlock title="Себестоимость отдушки (1 %)" value={result.aroma} />
|
||
|
||
{/* Блок «Пигмент» */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<InputNumber
|
||
label="Цена пигмента, руб"
|
||
value={pigmentPrice}
|
||
onChange={setPigmentPrice}
|
||
/>
|
||
<InputNumber
|
||
label="Фасовка пигмента, г"
|
||
value={pigmentWeight}
|
||
onChange={setPigmentWeight}
|
||
/>
|
||
</div>
|
||
<CostBlock title="Себестоимость пигмента (0.5 %)" value={result.pigment} />
|
||
|
||
{/* Блок «Форма» */}
|
||
<InputNumber
|
||
label="Цена формы, руб"
|
||
value={moldPrice}
|
||
onChange={setMoldPrice}
|
||
/>
|
||
<CostBlock title="Себестоимость формы" value={result.mold} />
|
||
|
||
{/* Блок «Упаковка» */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<InputNumber label="Пакет/коробка, руб" value={box} onChange={setBox} />
|
||
<InputNumber label="Наполнитель, руб" value={filler} onChange={setFiller} />
|
||
<InputNumber label="Лента, руб" value={ribbon} onChange={setRibbon} />
|
||
<InputNumber
|
||
label="Наклейка, руб"
|
||
value={labelValue}
|
||
onChange={setLabelValue}
|
||
/>
|
||
</div>
|
||
<CostBlock title="Стоимость упаковки" value={result.packaging} />
|
||
<CostBlock title="Операционные расходы (5 %)" value={result.operational} />
|
||
<CostBlock title="Итого себестоимость" value={result.total} highlight />
|
||
|
||
{/* Блок «Наценка и цена 100 г» */}
|
||
<div className="grid grid-cols-1 gap-4">
|
||
<InputNumber label="Наценка, %" value={markup} onChange={setMarkup} />
|
||
</div>
|
||
<CostBlock title="Итоговая цена с наценкой" value={finalPrice} />
|
||
<CostBlock title="Цена за 100 г" value={pricePer100g} />
|
||
|
||
{/* Кнопка «Отправить расчёт» */}
|
||
<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>
|
||
);
|
||
}
|
||
|
||
type CostBlockProps = {
|
||
title: string;
|
||
value: number;
|
||
highlight?: boolean;
|
||
};
|
||
|
||
const CostBlock = ({ title, value, highlight = false }: CostBlockProps) => (
|
||
<div
|
||
className={`
|
||
p-2
|
||
${highlight ? 'bg-gray-600 font-semibold' : 'bg-gray-700'}
|
||
text-gray-200
|
||
rounded
|
||
`}
|
||
>
|
||
{title}: {value.toFixed(1)} руб
|
||
</div>
|
||
);
|