DoSoapCalc/frontend/components/SoapCalculator.tsx

399 lines
13 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 Image from 'next/image';
import { calculateTotal } from '@/lib/calc';
import { API_BASE_URL } from '@/lib/config';
type InputNumberProps = {
label: string;
value: string;
onChange: (v: string) => void;
};
const InputNumber = ({ label, value, onChange }: InputNumberProps) => {
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
transition-all
/* активное состояние: адаптивный размер шрифта */
text-xs sm:text-sm md:text-base lg:text-lg
/* когда поле пустое: слегка больше (помещается внутри) */
peer-placeholder-shown:top-2
peer-placeholder-shown:text-gray-500
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
/* при фокусе или если есть содержимое: сжатый текст */
peer-focus:-top-5
peer-focus:text-gray-200
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
`}
>
{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);
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);
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(`${API_BASE_URL}/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
"
>
{/* Центрированный адаптивный логотип */}
<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>
{chatId === null && (
<div className="bg-yellow-800 p-2 text-yellow-200 font-semibold rounded">
Не найден chat_id. Откройте калькулятор через Telegram-бота.
</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
transition-all
/* активное состояние: адаптивный размер шрифта */
text-xs sm:text-sm md:text-base lg:text-lg
/* когда поле пустое: немного крупнее, помещается внутри */
peer-placeholder-shown:top-2
peer-placeholder-shown:text-gray-500
peer-placeholder-shown:text-sm sm:peer-placeholder-shown:text-base md:peer-placeholder-shown:text-lg
/* при фокусе/заполненном поле: адаптивный размер */
peer-focus:-top-5
peer-focus:text-gray-200
peer-focus:text-xs sm:peer-focus:text-sm md:peer-focus:text-base lg:peer-focus:text-lg
`}
>
Название мыла
</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="Цена основы, руб"
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-2 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>
);