DoSoapCalc/frontend/components/SoapCalculator.tsx
2025-06-04 08:22:18 +03:00

393 lines
12 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/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>
);