Initial commit
This commit is contained in:
commit
02de654266
119
.gitignore
vendored
Normal file
119
.gitignore
vendored
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# ====================
|
||||||
|
# Global / Root
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Lockfiles (if you choose to ignore lockfiles, otherwise you can remove these lines)
|
||||||
|
# /package-lock.json
|
||||||
|
# /yarn.lock
|
||||||
|
# /pnpm-lock.yaml
|
||||||
|
|
||||||
|
# VSCode workspace settings
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# Backend (Express + bot)
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
/backend/node_modules/
|
||||||
|
/backend/.env
|
||||||
|
/backend/.env.*
|
||||||
|
/backend/logs/
|
||||||
|
/backend/*.log
|
||||||
|
|
||||||
|
# If you compile/transpile backend to a dist/ or build/ folder, ignore it:
|
||||||
|
# /backend/dist/
|
||||||
|
/backend/build/
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# Frontend (Next.js)
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
/frontend/node_modules/
|
||||||
|
|
||||||
|
/frontend/.next/
|
||||||
|
/frontend/out/
|
||||||
|
/frontend/.vercel/
|
||||||
|
/frontend/.cache/
|
||||||
|
/frontend/.env
|
||||||
|
/frontend/.env.*
|
||||||
|
/frontend/.turbo/
|
||||||
|
/frontend/.turbo-cache/
|
||||||
|
/frontend/coverage/
|
||||||
|
|
||||||
|
# If using Swift/TypeScript build caching
|
||||||
|
/frontend/*.tsbuildinfo
|
||||||
|
|
||||||
|
# Ignore type declarations generated by Next
|
||||||
|
/frontend/next-env.d.ts
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# Miscellaneous
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
# PEM certificates, private keys
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Temporary or swap files
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# npm / yarn / pnpm files
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# Temporary folders from Next.js exports
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
coverage/
|
||||||
|
.jest/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker-compose.override.yml
|
||||||
|
docker-compose.*.yml
|
||||||
|
|
||||||
|
# Generated log files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# macOS Trash
|
||||||
|
.Trashes
|
||||||
|
|
||||||
|
# Thumbnails and metadata files
|
||||||
|
*.db
|
||||||
|
*.seed
|
||||||
|
|
||||||
|
# Editor visual state files
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# End of .gitignore
|
||||||
181
backend/bot.js
Normal file
181
backend/bot.js
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
|
||||||
|
// backend/bot.js
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 1) Устанавливаем зависимости:
|
||||||
|
// npm install express body-parser node-telegram-bot-api
|
||||||
|
//
|
||||||
|
// 2) Запуск:
|
||||||
|
// node bot.js
|
||||||
|
// (или: npx nodemon bot.js — для auto-reload при изменениях)
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const bodyParser = require('body-parser');
|
||||||
|
const TelegramBot = require('node-telegram-bot-api');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Добавляем multer и streamifier
|
||||||
|
const multer = require('multer');
|
||||||
|
const streamifier = require('streamifier');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const HTTP_PORT = 3001; // порт для BACKEND API
|
||||||
|
const BOT_TOKEN = '7801636590:AAFphqOK0Dqta7v9VCLkTPGYC1OujNIFgXA'; // ← замените на свой токен
|
||||||
|
const WEBAPP_BASE_URL = 'https://d472-46-246-24-76.ngrok-free.app/'; // ← например: https://xyz123.ngrok.io
|
||||||
|
|
||||||
|
// 1) Запускаем бота (polling)
|
||||||
|
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
||||||
|
|
||||||
|
// 2) Настраиваем multer (храним файлы в памяти)
|
||||||
|
const storage = multer.memoryStorage();
|
||||||
|
const upload = multer({ storage });
|
||||||
|
|
||||||
|
// 3) Разрешаем CORS для фронтенда
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4) JSON-парсер (не обязателен для multipart/form-data)
|
||||||
|
app.use(bodyParser.json());
|
||||||
|
|
||||||
|
// 5) Обработчик POST /api/submit (multipart/form-data)
|
||||||
|
app.post(
|
||||||
|
'/api/submit',
|
||||||
|
upload.single('photo'), // поле "photo" — это файл, если есть
|
||||||
|
(req, res) => {
|
||||||
|
try {
|
||||||
|
// Текстовые поля придут в req.body, файл — в req.file
|
||||||
|
const {
|
||||||
|
chat_id,
|
||||||
|
soapName,
|
||||||
|
weight,
|
||||||
|
basePrice,
|
||||||
|
aromaPrice,
|
||||||
|
aromaWeight,
|
||||||
|
pigmentPrice,
|
||||||
|
pigmentWeight,
|
||||||
|
moldPrice,
|
||||||
|
box,
|
||||||
|
filler,
|
||||||
|
ribbon,
|
||||||
|
labelValue,
|
||||||
|
markup,
|
||||||
|
totalCost,
|
||||||
|
finalPrice,
|
||||||
|
pricePer100g,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Проверяем обязательные поля
|
||||||
|
if (!chat_id) {
|
||||||
|
return res.status(400).send('chat_id не передан');
|
||||||
|
}
|
||||||
|
if (!soapName) {
|
||||||
|
return res.status(400).send('soapName не передан');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Соберём сообщение так, чтобы в чат пришло всё, что ввели:
|
||||||
|
// 1. Название мыла
|
||||||
|
// 2. Вес и цена основы
|
||||||
|
// 3. Отдушка
|
||||||
|
// 4. Пигмент
|
||||||
|
// 5. Форма
|
||||||
|
// 6. Упаковка
|
||||||
|
// 7. Наценка
|
||||||
|
// 8. Итоги
|
||||||
|
let text = `🧼 <b>Расчёт мыла:</b> <i>${soapName}</i>\n\n`;
|
||||||
|
|
||||||
|
text += `🔹 <b>Вес мыла:</b> ${weight} г\n`;
|
||||||
|
text += `🔹 <b>Цена за 1 кг основы:</b> ${basePrice} ₽/кг\n\n`;
|
||||||
|
|
||||||
|
text += `🔹 <b>Отдушка:</b> ${aromaWeight} г по ${aromaPrice} ₽/фасовка\n`;
|
||||||
|
text += `🔹 <b>Пигмент:</b> ${pigmentWeight} г по ${pigmentPrice} ₽/фасовка\n\n`;
|
||||||
|
|
||||||
|
text += `🔹 <b>Цена формы:</b> ${moldPrice} ₽\n\n`;
|
||||||
|
|
||||||
|
text += `🔹 <b>Упаковка:</b>\n`;
|
||||||
|
text += ` • Пакет/коробка: ${box} ₽\n`;
|
||||||
|
text += ` • Наполнитель: ${filler} ₽\n`;
|
||||||
|
text += ` • Лента: ${ribbon} ₽\n`;
|
||||||
|
text += ` • Наклейка: ${labelValue} ₽\n\n`;
|
||||||
|
|
||||||
|
text += `🔹 <b>Наценка:</b> ${markup}%\n\n`;
|
||||||
|
|
||||||
|
text += `📊 <b>Итоги расчёта:</b>\n`;
|
||||||
|
text += ` • Себестоимость: ${Number(totalCost).toFixed(1)} ₽\n`;
|
||||||
|
text += ` • Итоговая цена с наценкой: ${Number(finalPrice).toFixed(1)} ₽\n`;
|
||||||
|
text += ` • Цена за 100 г: ${Number(pricePer100g).toFixed(1)} ₽`;
|
||||||
|
|
||||||
|
// Если пользователь прикрепил фото (req.file), шлём sendPhoto
|
||||||
|
if (req.file) {
|
||||||
|
const bufferStream = streamifier.createReadStream(req.file.buffer);
|
||||||
|
|
||||||
|
return bot
|
||||||
|
.sendPhoto(
|
||||||
|
Number(chat_id),
|
||||||
|
bufferStream,
|
||||||
|
{ caption: text, parse_mode: 'HTML' }
|
||||||
|
)
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Ошибка при отправке фото:', err);
|
||||||
|
res.status(500).send('Ошибка при отправке фото ботом');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если фото не было, просто шлём текст
|
||||||
|
bot
|
||||||
|
.sendMessage(Number(chat_id), text, { parse_mode: 'HTML' })
|
||||||
|
.then(() => res.sendStatus(200))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Ошибка при отправке сообщения:', err);
|
||||||
|
res.sendStatus(500);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка в /api/submit:', err);
|
||||||
|
res.status(500).send('Внутренняя ошибка сервера');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6) Команда /menu — отправляем inline-кнопку с chat_id
|
||||||
|
bot.setMyCommands([
|
||||||
|
{ command: 'menu', description: 'Открыть калькулятор' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
bot.onText(/\/menu/, (msg) => {
|
||||||
|
try {
|
||||||
|
const chatId = msg.chat.id;
|
||||||
|
const url = `${WEBAPP_BASE_URL}/?chat_id=${chatId}`;
|
||||||
|
|
||||||
|
bot.sendMessage(
|
||||||
|
chatId,
|
||||||
|
'Нажмите кнопку ниже, чтобы открыть калькулятор:',
|
||||||
|
{
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Открыть калькулятор',
|
||||||
|
web_app: { url },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка в обработчике /menu:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7) Ловим ошибки polling-а и логируем детали
|
||||||
|
bot.on('polling_error', (err) => {
|
||||||
|
console.error('Polling error:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8) Запускаем Express-сервер на порту 3001
|
||||||
|
app.listen(HTTP_PORT, () => {
|
||||||
|
console.log(`Bot+API запущены, слушаем порт ${HTTP_PORT}`);
|
||||||
|
});
|
||||||
5
backend/nodemon.json
Normal file
5
backend/nodemon.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"watch": ["."],
|
||||||
|
"ext": "js,json",
|
||||||
|
"exec": "node bot.js"
|
||||||
|
}
|
||||||
3135
backend/package-lock.json
generated
Normal file
3135
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
backend/package.json
Normal file
20
backend/package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"multer": "^2.0.0",
|
||||||
|
"node-telegram-bot-api": "^0.66.0",
|
||||||
|
"streamifier": "^0.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
BIN
frontend/app/favicon.ico
Normal file
BIN
frontend/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
12
frontend/app/globals.css
Normal file
12
frontend/app/globals.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
34
frontend/app/layout.tsx
Normal file
34
frontend/app/layout.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Next App",
|
||||||
|
description: "Generated by create next app",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend/app/page.tsx
Normal file
7
frontend/app/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import SoapCalculator from "@/components/SoapCalculator";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<SoapCalculator />
|
||||||
|
);
|
||||||
|
}
|
||||||
336
frontend/components/SoapCalculator.tsx
Normal file
336
frontend/components/SoapCalculator.tsx
Normal file
@ -0,0 +1,336 @@
|
|||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
frontend/eslint.config.mjs
Normal file
16
frontend/eslint.config.mjs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
53
frontend/lib/calc.ts
Normal file
53
frontend/lib/calc.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// lib/calc.ts
|
||||||
|
|
||||||
|
type Input = {
|
||||||
|
weight: number;
|
||||||
|
basePrice: number;
|
||||||
|
aromaPrice: number;
|
||||||
|
aromaWeight: number;
|
||||||
|
pigmentPrice: number;
|
||||||
|
pigmentWeight: number;
|
||||||
|
moldPrice: number;
|
||||||
|
packaging: {
|
||||||
|
box: number;
|
||||||
|
filler: number;
|
||||||
|
ribbon: number;
|
||||||
|
label: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calculateTotal(data: Input) {
|
||||||
|
const {
|
||||||
|
weight,
|
||||||
|
basePrice,
|
||||||
|
aromaPrice,
|
||||||
|
aromaWeight,
|
||||||
|
pigmentPrice,
|
||||||
|
pigmentWeight,
|
||||||
|
moldPrice,
|
||||||
|
packaging,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const base = (weight / 1000) * basePrice;
|
||||||
|
const aroma = ((weight * 0.01) / aromaWeight) * aromaPrice;
|
||||||
|
const pigment = ((weight * 0.005) / pigmentWeight) * pigmentPrice;
|
||||||
|
const mold = moldPrice / 100;
|
||||||
|
const packagingCost = packaging.box + packaging.filler + packaging.ribbon + packaging.label;
|
||||||
|
const subtotal = base + aroma + pigment + mold + packagingCost;
|
||||||
|
const operational = subtotal * 0.05;
|
||||||
|
const total = subtotal + operational;
|
||||||
|
|
||||||
|
return {
|
||||||
|
base: round(base),
|
||||||
|
aroma: round(aroma),
|
||||||
|
pigment: round(pigment),
|
||||||
|
mold: round(mold),
|
||||||
|
packaging: round(packagingCost),
|
||||||
|
operational: round(operational),
|
||||||
|
total: round(total),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(val: number) {
|
||||||
|
return Math.round(val * 10) / 10;
|
||||||
|
}
|
||||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
7
frontend/next.config.ts
Normal file
7
frontend/next.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6133
frontend/package-lock.json
generated
Normal file
6133
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"next": "15.3.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "15.3.3",
|
||||||
|
"@eslint/eslintrc": "^3"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/postcss.config.mjs
Normal file
5
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: ["@tailwindcss/postcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
27
frontend/tsconfig.json
Normal file
27
frontend/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
26
frontend/types/telegram.d.ts
vendored
Normal file
26
frontend/types/telegram.d.ts
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
interface TelegramWebAppUser {
|
||||||
|
id: number;
|
||||||
|
is_bot: boolean;
|
||||||
|
first_name: string;
|
||||||
|
last_name?: string;
|
||||||
|
username?: string;
|
||||||
|
language_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TelegramWebAppInitData {
|
||||||
|
user?: TelegramWebAppUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TelegramWebApp {
|
||||||
|
initData: string;
|
||||||
|
initDataUnsafe: TelegramWebAppInitData;
|
||||||
|
ready: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TelegramNamespace {
|
||||||
|
WebApp: TelegramWebApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
Telegram?: TelegramNamespace;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user