Init monorepo: Frontend + Backend
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# --- Глобальные игноры ---
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# --- Frontend ---
|
||||||
|
survey-app/dist/
|
||||||
|
survey-app/node_modules/
|
||||||
|
survey-app/.env
|
||||||
|
survey-app/.env.local
|
||||||
|
|
||||||
|
# --- Backend ---
|
||||||
|
survey-backend/dist/
|
||||||
|
survey-backend/node_modules/
|
||||||
|
survey-backend/.env
|
||||||
24
survey-app/.gitignore
vendored
Normal file
24
survey-app/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
16
survey-app/README.md
Normal file
16
survey-app/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
29
survey-app/eslint.config.js
Normal file
29
survey-app/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
survey-app/index.html
Normal file
13
survey-app/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>survey-app</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3721
survey-app/package-lock.json
generated
Normal file
3721
survey-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
survey-app/package.json
Normal file
38
survey-app/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "survey-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.554.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.9.6",
|
||||||
|
"tailwind-merge": "^3.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
"@types/react": "^19.2.2",
|
||||||
|
"@types/react-dom": "^19.2.2",
|
||||||
|
"@vitejs/plugin-react": "^5.1.0",
|
||||||
|
"autoprefixer": "^10.4.22",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
|
"vite": "^7.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
survey-app/postcss.config.js
Normal file
6
survey-app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
survey-app/public/vite.svg
Normal file
1
survey-app/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
42
survey-app/src/App.css
Normal file
42
survey-app/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#root {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.react:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes logo-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
a:nth-of-type(2) .logo {
|
||||||
|
animation: logo-spin infinite 20s linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
39
survey-app/src/App.jsx
Normal file
39
survey-app/src/App.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
|
import { ToastProvider } from './context/ToastContext'; // <--- ИМПОРТ
|
||||||
|
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Builder from './pages/Builder';
|
||||||
|
import Runner from './pages/Runner';
|
||||||
|
import Results from './pages/Results';
|
||||||
|
|
||||||
|
const ProtectedRoute = ({ children }) => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return <div className="h-screen flex items-center justify-center text-gray-500">Загрузка...</div>;
|
||||||
|
if (!user) return <Navigate to="/login" />;
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ToastProvider> {/* <--- ОБЕРТКА ЗДЕСЬ */}
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
|
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
|
<Route path="/create" element={<ProtectedRoute><Builder /></ProtectedRoute>} />
|
||||||
|
<Route path="/edit/:id" element={<ProtectedRoute><Builder /></ProtectedRoute>} />
|
||||||
|
<Route path="/survey/:id" element={<ProtectedRoute><Runner /></ProtectedRoute>} />
|
||||||
|
<Route path="/results/:id" element={<ProtectedRoute><Results /></ProtectedRoute>} />
|
||||||
|
|
||||||
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
survey-app/src/assets/react.svg
Normal file
1
survey-app/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
34
survey-app/src/components/ConfirmModal.jsx
Normal file
34
survey-app/src/components/ConfirmModal.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from './ui'; // Импортируем твою кнопку
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
export const ConfirmModal = ({ isOpen, onClose, onConfirm, title, message, isDangerous = false }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0 ${isDangerous ? 'bg-red-100 text-red-600' : 'bg-indigo-100 text-indigo-600'}`}>
|
||||||
|
<AlertTriangle size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button variant="outline" onClick={onClose}>Отмена</Button>
|
||||||
|
<Button
|
||||||
|
variant={isDangerous ? 'destructive' : 'primary'}
|
||||||
|
onClick={() => { onConfirm(); onClose(); }}
|
||||||
|
className={isDangerous ? 'bg-red-600 hover:bg-red-700 text-white' : ''}
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
survey-app/src/components/ui.jsx
Normal file
44
survey-app/src/components/ui.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '../lib/utils';
|
||||||
|
|
||||||
|
export const Button = ({ children, variant = 'primary', className, isLoading, type = "button", ...props }) => {
|
||||||
|
const styles = {
|
||||||
|
primary: "bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm",
|
||||||
|
outline: "border border-gray-200 bg-white hover:bg-gray-100 text-gray-900 shadow-sm",
|
||||||
|
ghost: "hover:bg-gray-100 text-gray-700",
|
||||||
|
destructive: "bg-red-50 text-red-600 hover:bg-red-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium h-10 px-4 py-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
|
||||||
|
styles[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Input = ({ className, ...props }) => (
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Card = ({ children, className }) => (
|
||||||
|
<div className={cn("rounded-xl border border-gray-200 bg-white text-gray-950 shadow-sm", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
51
survey-app/src/context/AuthContext.jsx
Normal file
51
survey-app/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
|
// 1. Создаем контекст с пустым объектом, чтобы не было null error
|
||||||
|
const AuthContext = createContext({});
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 2. Вся логика внутри асинхронной функции, чтобы избежать синхронных сбоев
|
||||||
|
const initAuth = async () => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userData = await api('/auth/me');
|
||||||
|
setUser(userData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth failed", error);
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = (token, userData) => {
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
setUser(userData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, login, logout, loading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
68
survey-app/src/context/ToastContext.jsx
Normal file
68
survey-app/src/context/ToastContext.jsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
const ToastContext = createContext();
|
||||||
|
|
||||||
|
export const useToast = () => useContext(ToastContext);
|
||||||
|
|
||||||
|
export const ToastProvider = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
// Добавить уведомление
|
||||||
|
const addToast = useCallback((message, type = 'info') => {
|
||||||
|
const id = Date.now();
|
||||||
|
setToasts((prev) => [...prev, { id, message, type }]);
|
||||||
|
// Авто-удаление через 3 секунды
|
||||||
|
setTimeout(() => removeToast(id), 3000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Удалить уведомление
|
||||||
|
const removeToast = useCallback((id) => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Хелперы
|
||||||
|
const toast = {
|
||||||
|
success: (msg) => addToast(msg, 'success'),
|
||||||
|
error: (msg) => addToast(msg, 'error'),
|
||||||
|
info: (msg) => addToast(msg, 'info'),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={toast}>
|
||||||
|
{children}
|
||||||
|
{/* Контейнер для уведомлений */}
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`
|
||||||
|
pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border min-w-[300px] animate-in slide-in-from-right-full duration-300
|
||||||
|
${t.type === 'success' ? 'bg-white border-green-200 text-gray-800' : ''}
|
||||||
|
${t.type === 'error' ? 'bg-white border-red-200 text-gray-800' : ''}
|
||||||
|
${t.type === 'info' ? 'bg-white border-indigo-200 text-gray-800' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Иконка */}
|
||||||
|
<div className={`
|
||||||
|
p-1 rounded-full
|
||||||
|
${t.type === 'success' ? 'bg-green-100 text-green-600' : ''}
|
||||||
|
${t.type === 'error' ? 'bg-red-100 text-red-600' : ''}
|
||||||
|
${t.type === 'info' ? 'bg-indigo-100 text-indigo-600' : ''}
|
||||||
|
`}>
|
||||||
|
{t.type === 'success' && <CheckCircle size={16} />}
|
||||||
|
{t.type === 'error' && <AlertCircle size={16} />}
|
||||||
|
{t.type === 'info' && <Info size={16} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm font-medium flex-1">{t.message}</p>
|
||||||
|
|
||||||
|
<button onClick={() => removeToast(t.id)} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
survey-app/src/index.css
Normal file
1
survey-app/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
26
survey-app/src/lib/api.js
Normal file
26
survey-app/src/lib/api.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export const API_URL = 'http://localhost:5000/api';
|
||||||
|
|
||||||
|
export const api = async (endpoint, method = 'GET', body = null) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
|
// Мы просто выполняем запрос. Если он упадет (например, нет сети),
|
||||||
|
// ошибка сама полетит вверх к Login.jsx, где мы её и поймаем.
|
||||||
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : null
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = res.headers.get("content-type");
|
||||||
|
if (!contentType || !contentType.includes("application/json")) {
|
||||||
|
throw new Error("Ошибка сервера: получен не JSON ответ. Проверьте консоль бэкенда.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Ошибка запроса');
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
6
survey-app/src/lib/utils.js
Normal file
6
survey-app/src/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
10
survey-app/src/main.jsx
Normal file
10
survey-app/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.jsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
340
survey-app/src/pages/Builder.jsx
Normal file
340
survey-app/src/pages/Builder.jsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { useToast } from '../context/ToastContext';
|
||||||
|
import { Button, Input, Card } from '../components/ui';
|
||||||
|
import {
|
||||||
|
ArrowLeft, Save, Plus, Trash2, Circle, Square, GripVertical,
|
||||||
|
ChevronDown, Check, Trophy, Globe, Lock, Settings, FileText
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragOverlay,
|
||||||
|
defaultDropAnimationSideEffects
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
|
const restrictToVerticalAxis = ({ transform }) => {
|
||||||
|
return {
|
||||||
|
...transform,
|
||||||
|
x: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropAnimationConfig = {
|
||||||
|
sideEffects: defaultDropAnimationSideEffects({
|
||||||
|
styles: {
|
||||||
|
active: {
|
||||||
|
opacity: '0.4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 1. UI КОМПОНЕНТЫ ---
|
||||||
|
const QuestionTypeSelect = ({ value, onChange }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
const options = [{ value: 'SINGLE', label: 'Одиночный выбор', icon: Circle }, { value: 'MULTI', label: 'Множественный выбор', icon: Square }];
|
||||||
|
const currentOption = options.find(o => o.value === value);
|
||||||
|
const CurrentIcon = currentOption?.icon || Circle;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => { if (ref.current && !ref.current.contains(e.target)) setIsOpen(false); };
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative min-w-[220px]" ref={ref}>
|
||||||
|
<button type="button" onClick={() => setIsOpen(!isOpen)} className={`w-full bg-gray-50 hover:bg-gray-100 border-0 rounded-lg py-2 pl-3 pr-10 text-left text-sm font-medium transition-all flex items-center gap-2 ${isOpen ? 'ring-2 ring-indigo-100 bg-white' : ''}`}>
|
||||||
|
<CurrentIcon size={16} className="text-indigo-500"/>
|
||||||
|
<span className="block truncate text-gray-700">{currentOption?.label}</span>
|
||||||
|
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none"><ChevronDown className={`h-4 w-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} /></span>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 mt-2 w-full overflow-hidden rounded-xl bg-white border border-gray-100 shadow-xl ring-1 ring-black ring-opacity-5 animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
{options.map((opt) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
return (
|
||||||
|
<div key={opt.value} onClick={() => { onChange(opt.value); setIsOpen(false); }} className={`relative cursor-pointer select-none py-3 pl-3 pr-4 flex items-center gap-3 transition-colors ${value === opt.value ? 'bg-indigo-50 text-indigo-900' : 'text-gray-700 hover:bg-gray-50'}`}>
|
||||||
|
<Icon size={16} className={value === opt.value ? 'text-indigo-600' : 'text-gray-400'}/>
|
||||||
|
<span className={`block truncate text-sm ${value === opt.value ? 'font-medium' : 'font-normal'}`}>{opt.label}</span>
|
||||||
|
{value === opt.value && <Check className="h-4 w-4 text-indigo-600 ml-auto" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 2. ВИЗУАЛЬНАЯ КАРТОЧКА ---
|
||||||
|
const QuestionCard = ({ q, index, updateQ, removeQ, addOpt, updateOpt, toggleCorrect, dragHandleProps, isOverlay }) => {
|
||||||
|
return (
|
||||||
|
<div className={`
|
||||||
|
mb-6 bg-white rounded-xl p-6 relative transition-all duration-200
|
||||||
|
${isOverlay
|
||||||
|
? 'shadow-2xl ring-1 ring-indigo-500/50 cursor-grabbing scale-[1.02] z-50'
|
||||||
|
: 'shadow-sm border border-gray-200 hover:shadow-md'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{!isOverlay && <div className="absolute top-6 bottom-6 left-0 w-1 bg-indigo-500 rounded-r-full opacity-0 group-hover:opacity-100 transition-opacity"></div>}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute left-4 top-6 cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-600 touch-none p-2 -ml-2 outline-none"
|
||||||
|
{...dragHandleProps}
|
||||||
|
>
|
||||||
|
<GripVertical size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-8">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-5 gap-4">
|
||||||
|
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2 select-none">
|
||||||
|
Вопрос {index + 1}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 ml-auto w-full sm:w-auto">
|
||||||
|
<QuestionTypeSelect value={q.type || 'SINGLE'} onChange={(val) => updateQ && updateQ(index, 'type', val)} />
|
||||||
|
<button onClick={() => removeQ && removeQ(index)} className="w-9 h-9 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition-all" title="Удалить вопрос">
|
||||||
|
<Trash2 size={18}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<input
|
||||||
|
value={q.text}
|
||||||
|
onChange={e => updateQ && updateQ(index, 'text', e.target.value)}
|
||||||
|
placeholder="Введите текст вопроса..."
|
||||||
|
className="w-full text-lg font-medium border-b border-gray-200 pb-2 focus:border-indigo-500 focus:outline-none transition-colors bg-transparent placeholder:text-gray-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{q.options.map((opt, oIdx) => (
|
||||||
|
<div key={oIdx} className="flex items-center gap-3 group/opt">
|
||||||
|
<button onClick={() => toggleCorrect && toggleCorrect(index, oIdx)} className="transition-transform active:scale-95 focus:outline-none">
|
||||||
|
{q.type === 'MULTI'
|
||||||
|
? <Square size={22} className={opt.isCorrect ? "text-emerald-500 fill-emerald-50" : "text-gray-300"} />
|
||||||
|
: <Circle size={22} className={opt.isCorrect ? "text-emerald-500 fill-emerald-50" : "text-gray-300"} />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
value={opt.text}
|
||||||
|
onChange={e => updateOpt && updateOpt(index, oIdx, e.target.value)}
|
||||||
|
placeholder={`Вариант ${oIdx + 1}`}
|
||||||
|
className={`flex-1 h-10 bg-transparent border-b border-transparent hover:border-gray-200 focus:border-indigo-500 focus:outline-none transition-colors text-sm ${opt.isCorrect ? 'text-emerald-700 font-medium' : 'text-gray-700'}`}
|
||||||
|
/>
|
||||||
|
{q.options.length > 1 && (
|
||||||
|
<button onClick={() => {
|
||||||
|
if(!updateQ) return;
|
||||||
|
const n = [...q.options];
|
||||||
|
n.splice(oIdx, 1);
|
||||||
|
updateQ(index, 'options', n);
|
||||||
|
}} className="opacity-0 group-hover/opt:opacity-100 text-gray-300 hover:text-red-400 transition-all">
|
||||||
|
<Trash2 size={14}/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={() => addOpt && addOpt(index)} className="flex items-center gap-2 text-sm font-medium text-indigo-600 hover:text-indigo-700 mt-2 pl-1 py-1 rounded hover:bg-indigo-50 w-fit transition-colors">
|
||||||
|
<Plus size={16}/> Добавить вариант
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 3. SORTABLE WRAPPER ---
|
||||||
|
const SortableItem = ({ id, ...props }) => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
transition,
|
||||||
|
// ВОТ ОН: Возвращаем "Призрака" (полупрозрачный оригинал)
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} style={style} className="relative group">
|
||||||
|
<QuestionCard {...props} dragHandleProps={{ ...attributes, ...listeners }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- MAIN ---
|
||||||
|
export default function Builder() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
|
const { id } = useParams();
|
||||||
|
const isEditMode = Boolean(id);
|
||||||
|
const [activeId, setActiveId] = useState(null);
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('Новый опрос');
|
||||||
|
const [accessType, setAccessType] = useState('PUBLIC');
|
||||||
|
const [allowedCodes, setAllowedCodes] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [questions, setQuestions] = useState([
|
||||||
|
{ id: 'q-1', text: '', type: 'SINGLE', options: [{ text: '', isCorrect: true }] }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalMaxScore = useMemo(() => questions.reduce((t, q) => t + q.options.filter(o => o.isCorrect).length, 0), [questions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditMode) {
|
||||||
|
setLoading(true);
|
||||||
|
api(`/surveys/${id}?mode=edit`)
|
||||||
|
.then(data => {
|
||||||
|
setTitle(data.title);
|
||||||
|
setAccessType(data.accessType);
|
||||||
|
setAllowedCodes(data.allowedUsers.map(u => u.user.inviteCode).join(', '));
|
||||||
|
setQuestions(data.questions.map(q => ({ ...q, id: String(q.id), type: q.type || 'SINGLE' })));
|
||||||
|
})
|
||||||
|
.catch(() => toast.error("Ошибка загрузки"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [id, isEditMode]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragStart = (event) => setActiveId(event.active.id);
|
||||||
|
const handleDragCancel = () => setActiveId(null);
|
||||||
|
const handleDragEnd = (event) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
setQuestions((items) => {
|
||||||
|
const oldIndex = items.findIndex((i) => i.id === active.id);
|
||||||
|
const newIndex = items.findIndex((i) => i.id === over.id);
|
||||||
|
return arrayMove(items, oldIndex, newIndex);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setActiveId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateQ = (i, f, v) => { const n = [...questions]; n[i][f] = v; setQuestions(n); };
|
||||||
|
const addOpt = (qi) => { const n = [...questions]; n[qi].options.push({ text: '', isCorrect: false }); setQuestions(n); };
|
||||||
|
const updateOpt = (qi, oi, v) => { const n = [...questions]; n[qi].options[oi].text = v; setQuestions(n); };
|
||||||
|
const toggleCorrect = (qi, oi) => {
|
||||||
|
const n = [...questions];
|
||||||
|
if (n[qi].type === 'MULTI') n[qi].options[oi].isCorrect = !n[qi].options[oi].isCorrect;
|
||||||
|
else n[qi].options.forEach((o, k) => o.isCorrect = k === oi);
|
||||||
|
setQuestions(n);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuestion = () => {
|
||||||
|
const newId = `q-${Date.now()}`;
|
||||||
|
setQuestions([...questions, { id: newId, text: '', type: 'SINGLE', options: [{ text: '', isCorrect: true }] }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!title.trim()) return toast.error("Введите название опроса");
|
||||||
|
if (questions.some(q => !q.text.trim())) return toast.error("Заполните текст всех вопросов");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const cleanQuestions = questions.map(({ id, ...rest }) => rest);
|
||||||
|
const cleanCodes = allowedCodes.split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
|
||||||
|
const payload = { title, accessType, allowedUserCodes: cleanCodes, questions: cleanQuestions };
|
||||||
|
if (isEditMode) await api(`/surveys/${id}`, 'PUT', payload);
|
||||||
|
else await api('/surveys', 'POST', payload);
|
||||||
|
toast.success(isEditMode ? "Опрос обновлен" : "Опрос опубликован!");
|
||||||
|
navigate('/');
|
||||||
|
} catch (e) { toast.error(e.message); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeQuestion = useMemo(() => questions.find(q => q.id === activeId), [activeId, questions]);
|
||||||
|
const activeIndex = useMemo(() => questions.findIndex(q => q.id === activeId), [activeId, questions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F3F4F6] pb-32">
|
||||||
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-20 px-6 h-16 flex items-center justify-between shadow-sm">
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<Button variant="ghost" onClick={() => navigate('/')} className="text-gray-500 hover:text-gray-900"><ArrowLeft size={20}/></Button>
|
||||||
|
<div className="flex items-center gap-2 w-full max-w-md">
|
||||||
|
<FileText size={18} className="text-indigo-500 shrink-0"/>
|
||||||
|
<input value={title} onChange={e => setTitle(e.target.value)} className="font-bold text-lg text-gray-900 bg-transparent border-b border-transparent hover:border-gray-300 focus:border-indigo-500 focus:outline-none px-1 py-0.5 w-full transition-all" placeholder="Название опроса"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="hidden md:flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-indigo-600 bg-indigo-50 px-3 py-1.5 rounded-full border border-indigo-100 mr-2">
|
||||||
|
<Trophy size={14} /><span>Баллы: {totalMaxScore}</span>
|
||||||
|
</div>
|
||||||
|
<Button onClick={save} isLoading={loading} className="bg-indigo-600 hover:bg-indigo-700 shadow-md shadow-indigo-200"><Save size={18} className="mr-2"/> {isEditMode ? 'Сохранить' : 'Опубликовать'}</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto p-6 mt-4 grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-8 items-start">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
|
>
|
||||||
|
<SortableContext items={questions} strategy={verticalListSortingStrategy}>
|
||||||
|
{questions.map((q, i) => (
|
||||||
|
<SortableItem
|
||||||
|
key={q.id} id={q.id} q={q} index={i}
|
||||||
|
updateQ={updateQ} removeQ={(idx) => setQuestions(questions.filter((_, x) => x !== idx))}
|
||||||
|
addOpt={addOpt} updateOpt={updateOpt} toggleCorrect={toggleCorrect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
<DragOverlay dropAnimation={dropAnimationConfig}>
|
||||||
|
{activeId ? <QuestionCard q={activeQuestion} index={activeIndex} isOverlay={true} /> : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
<button onClick={addQuestion} className="w-full py-6 border-2 border-dashed border-gray-300 rounded-xl flex flex-col items-center justify-center text-gray-400 hover:border-indigo-400 hover:text-indigo-600 hover:bg-indigo-50/50 transition-all duration-200 group">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center mb-2 shadow-sm group-hover:scale-110 transition-transform"><Plus size={20} /></div>
|
||||||
|
<span className="font-medium">Добавить вопрос</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:sticky lg:top-24 space-y-6">
|
||||||
|
<div className="bg-white p-5 rounded-xl border border-gray-200 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 mb-4 text-gray-900 font-bold"><Settings size={18} className="text-gray-400"/><h3>Параметры доступа</h3></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div onClick={() => setAccessType('PUBLIC')} className={`cursor-pointer p-3 rounded-lg border-2 transition-all flex items-start gap-3 ${accessType === 'PUBLIC' ? 'border-emerald-500 bg-emerald-50/30' : 'border-gray-100 hover:border-gray-200'}`}>
|
||||||
|
<div className={`mt-0.5 w-5 h-5 rounded-full border flex items-center justify-center ${accessType === 'PUBLIC' ? 'border-emerald-500 bg-emerald-500 text-white' : 'border-gray-300'}`}>{accessType === 'PUBLIC' && <Check size={12}/>}</div>
|
||||||
|
<div><div className="text-sm font-bold text-gray-900 flex items-center gap-2"><Globe size={14}/> Публичный</div><div className="text-xs text-gray-500 mt-0.5">Доступен всем</div></div>
|
||||||
|
</div>
|
||||||
|
<div onClick={() => setAccessType('INVITE_ONLY')} className={`cursor-pointer p-3 rounded-lg border-2 transition-all flex items-start gap-3 ${accessType === 'INVITE_ONLY' ? 'border-indigo-500 bg-indigo-50/30' : 'border-gray-100 hover:border-gray-200'}`}>
|
||||||
|
<div className={`mt-0.5 w-5 h-5 rounded-full border flex items-center justify-center ${accessType === 'INVITE_ONLY' ? 'border-indigo-500 bg-indigo-500 text-white' : 'border-gray-300'}`}>{accessType === 'INVITE_ONLY' && <Check size={12}/>}</div>
|
||||||
|
<div><div className="text-sm font-bold text-gray-900 flex items-center gap-2"><Lock size={14}/> По приглашению</div><div className="text-xs text-gray-500 mt-0.5">Только по кодам</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{accessType === 'INVITE_ONLY' && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 animate-in slide-in-from-top-2 fade-in duration-200">
|
||||||
|
<label className="text-xs font-bold text-gray-500 uppercase mb-2 block">Коды доступа</label>
|
||||||
|
<textarea placeholder="#USER-1234, #USER-5678" value={allowedCodes} onChange={e => setAllowedCodes(e.target.value)} className="w-full text-sm p-3 rounded-lg border border-gray-200 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 outline-none min-h-[80px] resize-y font-mono bg-gray-50"/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
survey-app/src/pages/Dashboard.jsx
Normal file
247
survey-app/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useToast } from '../context/ToastContext';
|
||||||
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { Button, Card } from '../components/ui';
|
||||||
|
import {
|
||||||
|
Plus, Trash2, BarChart3, Play, LogOut, Share2, Copy,
|
||||||
|
Globe, Lock, Edit, LayoutGrid, List, Users, CheckCircle2
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState('my');
|
||||||
|
const [mySurveys, setMySurveys] = useState([]);
|
||||||
|
const [feedSurveys, setFeedSurveys] = useState([]);
|
||||||
|
const [deleteId, setDeleteId] = useState(null);
|
||||||
|
|
||||||
|
const loadMy = () => api('/surveys/my').then(setMySurveys);
|
||||||
|
const loadFeed = () => api('/surveys/feed').then(setFeedSurveys);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMy();
|
||||||
|
loadFeed();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- СТАТИСТИКА (Вычисляем на лету) ---
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const totalSurveys = mySurveys.length;
|
||||||
|
const totalResponses = mySurveys.reduce((acc, s) => acc + (s._count?.submissions || 0), 0);
|
||||||
|
return { totalSurveys, totalResponses };
|
||||||
|
}, [mySurveys]);
|
||||||
|
// --------------------------------------
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
try {
|
||||||
|
await api(`/surveys/${deleteId}`, 'DELETE');
|
||||||
|
toast.success('Опрос удален');
|
||||||
|
loadMy();
|
||||||
|
loadFeed();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Ошибка удаления');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyLink = (id) => {
|
||||||
|
const link = `${window.location.origin}/survey/${id}`;
|
||||||
|
navigator.clipboard.writeText(link);
|
||||||
|
toast.info('Ссылка скопирована');
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyCode = () => {
|
||||||
|
navigator.clipboard.writeText(user.inviteCode);
|
||||||
|
toast.info('Код приглашения скопирован');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- НОВЫЙ КОМПОНЕНТ КАРТОЧКИ ---
|
||||||
|
const SurveyCard = ({ s, isMy }) => (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300 flex flex-col overflow-hidden group">
|
||||||
|
{/* Тело карточки */}
|
||||||
|
<div className="p-6 flex-1">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
{s.accessType === 'PUBLIC'
|
||||||
|
? <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800 border border-emerald-200"><Globe size={12} className="mr-1"/> Публичный</span>
|
||||||
|
: <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 border border-indigo-200"><Lock size={12} className="mr-1"/> Приватный</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
{isMy && (
|
||||||
|
<button onClick={() => setDeleteId(s.id)} className="text-gray-300 hover:text-red-500 transition-colors opacity-0 group-hover:opacity-100">
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2">{s.title}</h3>
|
||||||
|
|
||||||
|
<div className="flex items-center text-sm text-gray-500 gap-4">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users size={14}/>
|
||||||
|
<span>{isMy ? s._count?.submissions : 'Автор: ' + (s.author?.name || 'Unknown')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{new Date(s.createdAt || Date.now()).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Подвал карточки (Footer) - Серый фон */}
|
||||||
|
<div className="bg-gray-50 px-6 py-4 border-t border-gray-100 flex items-center gap-3">
|
||||||
|
<Link to={`/survey/${s.id}`} className="flex-1">
|
||||||
|
<Button variant="primary" className="w-full h-9 text-xs shadow-none bg-indigo-600 hover:bg-indigo-700">
|
||||||
|
<Play size={14} className="mr-2 fill-current"/> Пройти
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{isMy ? (
|
||||||
|
<>
|
||||||
|
<Link to={`/edit/${s.id}`}>
|
||||||
|
<button className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-indigo-600 transition-colors" title="Редактировать">
|
||||||
|
<Edit size={16}/>
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/results/${s.id}`}>
|
||||||
|
<button className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-indigo-600 transition-colors" title="Результаты">
|
||||||
|
<BarChart3 size={16}/>
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{s.accessType === 'PUBLIC' && (
|
||||||
|
<button onClick={() => copyLink(s.id)} className="h-9 w-9 flex items-center justify-center rounded-md border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-indigo-600 transition-colors" title="Поделиться">
|
||||||
|
<Share2 size={16}/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50/50 pb-20">
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={!!deleteId}
|
||||||
|
onClose={() => setDeleteId(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
title="Удалить опрос?"
|
||||||
|
message="Это действие нельзя отменить."
|
||||||
|
isDangerous={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* --- 1. HERO HEADER (Градиент) --- */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-900 via-indigo-800 to-violet-900 text-white pt-10 pb-24 px-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center backdrop-blur-sm border border-white/20">
|
||||||
|
<LayoutGrid className="text-white" size={20}/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold">Survey App</h1>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={logout} className="text-white hover:bg-white/10 hover:text-white border border-white/20">
|
||||||
|
<LogOut size={16} className="mr-2"/> Выход
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold mb-2">Привет, {user?.name || 'Guest'}! 👋</h2>
|
||||||
|
<p className="text-indigo-200">Вот сводка вашей активности за сегодня.</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/create">
|
||||||
|
<Button className="bg-white text-indigo-900 hover:bg-gray-100 border-none shadow-xl">
|
||||||
|
<Plus size={18} className="mr-2"/> Создать новый опрос
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- 2. СТАТИСТИКА (Карточки поднимаются наверх) --- */}
|
||||||
|
<div className="max-w-6xl mx-auto px-6 -mt-12 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Карточка 1: Код */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-lg shadow-indigo-900/5 border border-gray-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-1">Ваш код доступа</p>
|
||||||
|
<div className="flex items-center gap-2 cursor-pointer group" onClick={copyCode}>
|
||||||
|
<code className="text-xl font-bold text-indigo-600">{user?.inviteCode}</code>
|
||||||
|
<Copy size={16} className="text-gray-300 group-hover:text-indigo-500 transition-colors"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-indigo-50 rounded-full flex items-center justify-center text-indigo-600">
|
||||||
|
<Lock size={20}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Карточка 2: Опросы */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-lg shadow-indigo-900/5 border border-gray-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-1">Создано опросов</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{stats.totalSurveys}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-emerald-50 rounded-full flex items-center justify-center text-emerald-600">
|
||||||
|
<List size={20}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Карточка 3: Ответы */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-lg shadow-indigo-900/5 border border-gray-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-1">Получено ответов</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{stats.totalResponses}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-10 h-10 bg-amber-50 rounded-full flex items-center justify-center text-amber-600">
|
||||||
|
<CheckCircle2 size={20}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- 3. КОНТЕНТ --- */}
|
||||||
|
<div className="max-w-6xl mx-auto px-6 mt-10">
|
||||||
|
{/* Табы (Pills style) */}
|
||||||
|
<div className="flex bg-gray-200/50 p-1 rounded-xl w-fit mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('my')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'my' ? 'bg-white text-indigo-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
Мои опросы
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('feed')}
|
||||||
|
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${activeTab === 'feed' ? 'bg-white text-indigo-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||||
|
>
|
||||||
|
Лента опросов
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Сетка опросов */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{(activeTab === 'my' ? mySurveys : feedSurveys).map(s => (
|
||||||
|
<SurveyCard key={s.id} s={s} isMy={activeTab === 'my'} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Пустое состояние */}
|
||||||
|
{(activeTab === 'my' ? mySurveys : feedSurveys).length === 0 && (
|
||||||
|
<div className="text-center py-20 bg-white rounded-2xl border border-dashed border-gray-300">
|
||||||
|
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
|
<List className="text-gray-300" size={32}/>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">Список пуст</h3>
|
||||||
|
<p className="text-gray-500 max-w-xs mx-auto mt-1">
|
||||||
|
{activeTab === 'my' ? 'Вы еще не создали ни одного опроса.' : 'Вам пока не доступен ни один опрос.'}
|
||||||
|
</p>
|
||||||
|
{activeTab === 'my' && (
|
||||||
|
<Link to="/create">
|
||||||
|
<Button variant="outline" className="mt-4">Создать первый опрос</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
survey-app/src/pages/Login.jsx
Normal file
146
survey-app/src/pages/Login.jsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useToast } from '../context/ToastContext'; // Используем тосты для ошибок
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { Button } from '../components/ui';
|
||||||
|
import { LayoutGrid, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const { login, user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [isReg, setIsReg] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form, setForm] = useState({ email: '', password: '', name: '' });
|
||||||
|
|
||||||
|
// Если уже вошел — редирект
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) navigate('/');
|
||||||
|
}, [user, navigate]);
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const endpoint = isReg ? '/auth/register' : '/auth/login';
|
||||||
|
const res = await api(endpoint, 'POST', form);
|
||||||
|
|
||||||
|
login(res.token, res.user);
|
||||||
|
toast.success(isReg ? 'Аккаунт создан! Добро пожаловать.' : 'С возвращением!');
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error(err.message || "Ошибка входа");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-900 via-violet-900 to-purple-900 p-4">
|
||||||
|
|
||||||
|
{/* Декоративные круги на фоне (Blur effect) */}
|
||||||
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-[20%] left-[20%] w-96 h-96 bg-indigo-500/20 rounded-full blur-3xl animate-pulse"></div>
|
||||||
|
<div className="absolute bottom-[20%] right-[20%] w-96 h-96 bg-purple-500/20 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-md bg-white/95 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 overflow-hidden relative z-10 animate-in fade-in zoom-in-95 duration-300">
|
||||||
|
|
||||||
|
{/* Верхняя часть с Логотипом */}
|
||||||
|
<div className="bg-gray-50/50 px-8 py-6 border-b border-gray-100 text-center">
|
||||||
|
<div className="w-12 h-12 bg-indigo-600 rounded-xl flex items-center justify-center mx-auto mb-4 shadow-lg shadow-indigo-500/30 text-white">
|
||||||
|
<LayoutGrid size={24}/>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">
|
||||||
|
{isReg ? 'Создать аккаунт' : 'Добро пожаловать'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">
|
||||||
|
{isReg ? 'Начните создавать опросы бесплатно' : 'Войдите, чтобы управлять опросами'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Форма */}
|
||||||
|
<div className="p-8">
|
||||||
|
<form onSubmit={submit} className="space-y-5">
|
||||||
|
|
||||||
|
{isReg && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-gray-700 uppercase ml-1">Имя</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||||
|
<input
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:bg-white focus:border-indigo-500 transition-all outline-none text-sm"
|
||||||
|
placeholder="Иван Иванов"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm({...form, name: e.target.value})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-gray-700 uppercase ml-1">Email</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:bg-white focus:border-indigo-500 transition-all outline-none text-sm"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm({...form, email: e.target.value})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-semibold text-gray-700 uppercase ml-1">Пароль</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-gray-50 border border-gray-200 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:bg-white focus:border-indigo-500 transition-all outline-none text-sm font-mono"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => setForm({...form, password: e.target.value})}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full h-11 text-base mt-2 shadow-lg shadow-indigo-500/20" isLoading={loading}>
|
||||||
|
{isReg ? 'Зарегистрироваться' : 'Войти в систему'}
|
||||||
|
{!loading && <ArrowRight size={18} className="ml-2 opacity-80"/>}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Переключатель */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-100"></div></div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase"><span className="bg-white px-2 text-gray-400">или</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setIsReg(!isReg); setForm({ email: '', password: '', name: '' }); }}
|
||||||
|
className="mt-4 text-sm font-medium text-indigo-600 hover:text-indigo-700 hover:underline transition-all"
|
||||||
|
>
|
||||||
|
{isReg ? 'У меня уже есть аккаунт' : 'Создать новый аккаунт'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Футер страницы */}
|
||||||
|
<div className="absolute bottom-4 text-center text-indigo-200/40 text-xs">
|
||||||
|
© 2025 Survey App. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
survey-app/src/pages/Results.jsx
Normal file
205
survey-app/src/pages/Results.jsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { Button, Card } from '../components/ui';
|
||||||
|
import { ArrowLeft, Trophy, Users, Calculator, Download, Calendar, Award } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Results() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [subs, setSubs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [title, setTitle] = useState('Результаты');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
// Загружаем результаты
|
||||||
|
const data = await api(`/surveys/${id}/results`);
|
||||||
|
setSubs(data);
|
||||||
|
|
||||||
|
// Загружаем название опроса (для красоты в заголовке)
|
||||||
|
const surveyInfo = await api(`/surveys/${id}?mode=edit`);
|
||||||
|
setTitle(surveyInfo.title);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// --- ВЫЧИСЛЯЕМ СТАТИСТИКУ ---
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (!subs.length) return null;
|
||||||
|
|
||||||
|
const total = subs.length;
|
||||||
|
// Средний балл
|
||||||
|
const avgScore = (subs.reduce((acc, s) => acc + s.score, 0) / total).toFixed(1);
|
||||||
|
// Максимально возможный балл (берем из первого результата, так как он у всех одинаковый для теста)
|
||||||
|
const maxPossible = subs[0].maxScore;
|
||||||
|
// Лучший результат среди участников
|
||||||
|
const bestScore = Math.max(...subs.map(s => s.score));
|
||||||
|
|
||||||
|
return { total, avgScore, maxPossible, bestScore };
|
||||||
|
}, [subs]);
|
||||||
|
|
||||||
|
// Хелпер для цвета бейджика с баллами
|
||||||
|
const getScoreColor = (score, max) => {
|
||||||
|
const percent = (score / max) * 100;
|
||||||
|
if (percent === 100) return 'bg-emerald-100 text-emerald-700 border-emerald-200';
|
||||||
|
if (percent >= 70) return 'bg-blue-100 text-blue-700 border-blue-200';
|
||||||
|
if (percent >= 40) return 'bg-amber-100 text-amber-700 border-amber-200';
|
||||||
|
return 'bg-red-100 text-red-700 border-red-200';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Хелпер для аватарок (Инициалы)
|
||||||
|
const getInitials = (name) => {
|
||||||
|
return name ? name.substring(0, 2).toUpperCase() : '??';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Заглушка для экспорта
|
||||||
|
const handleExport = () => {
|
||||||
|
alert("В реальном проекте здесь начнется скачивание .CSV файла");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="h-screen flex items-center justify-center text-gray-500">Загрузка аналитики...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50/50 p-6">
|
||||||
|
<div className="max-w-5xl mx-auto space-y-8">
|
||||||
|
|
||||||
|
{/* ХЕДЕР */}
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<Link to="/" className="inline-flex items-center text-sm text-gray-500 hover:text-indigo-600 mb-2 transition-colors">
|
||||||
|
<ArrowLeft size={16} className="mr-1" /> Вернуться назад
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
|
||||||
|
<p className="text-gray-500 text-sm mt-1">Аналитика и список ответов</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={handleExport}>
|
||||||
|
<Download size={16} className="mr-2"/> Экспорт в CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* КАРТОЧКИ СТАТИСТИКИ (KPI) */}
|
||||||
|
{stats ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{/* Карточка 1: Участники */}
|
||||||
|
<Card className="p-5 border-l-4 border-l-indigo-500 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider">Всего участников</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mt-1">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-indigo-50 rounded-full flex items-center justify-center text-indigo-600">
|
||||||
|
<Users size={24}/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Карточка 2: Средний балл */}
|
||||||
|
<Card className="p-5 border-l-4 border-l-amber-500 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider">Средний балл</p>
|
||||||
|
<div className="flex items-baseline gap-1 mt-1">
|
||||||
|
<p className="text-3xl font-bold text-gray-900">{stats.avgScore}</p>
|
||||||
|
<span className="text-gray-400 text-sm">/ {stats.maxPossible}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-amber-50 rounded-full flex items-center justify-center text-amber-600">
|
||||||
|
<Calculator size={24}/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Карточка 3: Лучший результат */}
|
||||||
|
<Card className="p-5 border-l-4 border-l-emerald-500 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider">Рекорд</p>
|
||||||
|
<div className="flex items-baseline gap-1 mt-1">
|
||||||
|
<p className="text-3xl font-bold text-gray-900">{stats.bestScore}</p>
|
||||||
|
<span className="text-gray-400 text-sm">/ {stats.maxPossible}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 bg-emerald-50 rounded-full flex items-center justify-center text-emerald-600">
|
||||||
|
<Trophy size={24}/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="p-8 text-center text-gray-500 border-dashed">
|
||||||
|
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Users className="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
Пока нет ответов на этот опрос.
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ТАБЛИЦА РЕЗУЛЬТАТОВ */}
|
||||||
|
{subs.length > 0 && (
|
||||||
|
<Card className="overflow-hidden border-gray-200 shadow-sm">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 bg-gray-50/50 flex justify-between items-center">
|
||||||
|
<h3 className="font-bold text-gray-700">Детальный список</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-500 border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 font-medium">Участник</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Результат</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Эффективность</th>
|
||||||
|
<th className="px-6 py-3 font-medium">Дата</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
|
{subs.map((s, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50/80 transition-colors">
|
||||||
|
|
||||||
|
{/* Имя + Аватар */}
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-white font-bold text-xs shadow-sm">
|
||||||
|
{getInitials(s.user?.name)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{s.user?.name || 'Аноним'}</p>
|
||||||
|
<p className="text-xs text-gray-500">{s.user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Баллы */}
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="font-mono font-bold text-gray-700">{s.score}</span>
|
||||||
|
<span className="text-gray-400 text-xs"> / {s.maxScore}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Процент успеха (Бейджик) */}
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${getScoreColor(s.score, s.maxScore)}`}>
|
||||||
|
{s.score === s.maxScore && <Award size={12} className="mr-1" />}
|
||||||
|
{Math.round((s.score / s.maxScore) * 100)}%
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Дата */}
|
||||||
|
<td className="px-6 py-4 text-gray-500">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<Calendar size={14}/>
|
||||||
|
{new Date(s.completedAt).toLocaleDateString()}
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
{new Date(s.completedAt).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
211
survey-app/src/pages/Runner.jsx
Normal file
211
survey-app/src/pages/Runner.jsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { Button, Card } from '../components/ui';
|
||||||
|
import { ConfirmModal } from '../components/ConfirmModal';
|
||||||
|
import { AlertTriangle, Check, ArrowLeft, Clock, User } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Runner() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [survey, setSurvey] = useState(null);
|
||||||
|
const [answers, setAnswers] = useState({});
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api(`/surveys/${id}`)
|
||||||
|
.then(setSurvey)
|
||||||
|
.catch(err => setError(err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// Вычисляем прогресс
|
||||||
|
const progress = useMemo(() => {
|
||||||
|
if (!survey) return 0;
|
||||||
|
const answeredCount = Object.keys(answers).length;
|
||||||
|
const totalCount = survey.questions.length;
|
||||||
|
return Math.round((answeredCount / totalCount) * 100);
|
||||||
|
}, [answers, survey]);
|
||||||
|
|
||||||
|
const handleSelect = (qId, type, optId) => {
|
||||||
|
setAnswers(prev => {
|
||||||
|
const current = prev[qId] || [];
|
||||||
|
const qType = type || 'SINGLE';
|
||||||
|
if (qType === 'SINGLE') return { ...prev, [qId]: [optId] };
|
||||||
|
else {
|
||||||
|
if (current.includes(optId)) return { ...prev, [qId]: current.filter(id => id !== optId) };
|
||||||
|
else return { ...prev, [qId]: [...current, optId] };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitClick = () => {
|
||||||
|
const unanswered = survey.questions.filter(q => !answers[q.id] || answers[q.id].length === 0);
|
||||||
|
if (unanswered.length > 0) setShowConfirm(true);
|
||||||
|
else finishSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishSubmit = async () => {
|
||||||
|
const payload = Object.entries(answers).flatMap(([qId, opts]) => opts.map(oId => ({ questionId: Number(qId), optionId: oId })));
|
||||||
|
try {
|
||||||
|
const res = await api(`/surveys/${id}/submit`, 'POST', { answers: payload });
|
||||||
|
setResult(res);
|
||||||
|
} catch(e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="min-h-screen flex items-center justify-center text-gray-500 bg-[#F3F4F6]">Загрузка опроса...</div>;
|
||||||
|
|
||||||
|
if (error) return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#F3F4F6] p-4">
|
||||||
|
<Card className="max-w-md w-full p-8 text-center border border-red-100 shadow-lg">
|
||||||
|
<div className="w-16 h-16 bg-red-50 text-red-500 rounded-full flex items-center justify-center mx-auto mb-4"><AlertTriangle size={32} /></div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-2">Опрос недоступен</h1>
|
||||||
|
<p className="text-gray-500 mb-6">Возможно, ссылка устарела или у вас нет доступа.</p>
|
||||||
|
<Button onClick={() => navigate('/')} className="w-full bg-white border border-gray-300 text-gray-700 hover:bg-gray-50">На главную</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result) return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#F3F4F6] p-4">
|
||||||
|
<Card className="p-12 text-center max-w-lg w-full animate-in fade-in zoom-in duration-300 shadow-xl border-0">
|
||||||
|
<div className="w-20 h-20 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<TrophyIcon className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold mb-2 text-gray-900">Результат</h1>
|
||||||
|
<p className="text-gray-500 mb-8">Вы успешно завершили опрос</p>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-2xl p-6 mb-8 border border-gray-100">
|
||||||
|
<div className="text-6xl font-black text-indigo-600 tracking-tighter">{result.score} <span className="text-2xl text-gray-400 font-medium">/ {result.maxScore}</span></div>
|
||||||
|
<div className="text-sm text-gray-400 uppercase tracking-widest font-bold mt-2">Баллов набрано</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => navigate('/')} size="lg" className="w-full bg-indigo-600 hover:bg-indigo-700 h-12 text-lg shadow-lg shadow-indigo-200">В меню</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#F3F4F6] pb-32">
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={showConfirm} onClose={() => setShowConfirm(false)} onConfirm={finishSubmit}
|
||||||
|
title="Завершить тест?" message="Вы ответили не на все вопросы. Вы уверены?" isDangerous={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* --- STICKY TOP BAR (PROGRESS) --- */}
|
||||||
|
<div className="sticky top-0 z-20 bg-white/80 backdrop-blur-md border-b border-gray-200 px-6 h-14 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button onClick={() => navigate('/')} className="text-gray-400 hover:text-gray-800 transition-colors"><ArrowLeft size={20}/></button>
|
||||||
|
<span className="font-semibold text-sm text-gray-600 hidden sm:block truncate max-w-[200px]">{survey.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-1 max-w-xs ml-auto">
|
||||||
|
<span className="text-xs font-bold text-indigo-600">{progress}%</span>
|
||||||
|
<div className="h-2 flex-1 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-indigo-600 transition-all duration-500 ease-out" style={{ width: `${progress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto p-6 space-y-8 mt-4">
|
||||||
|
{/* --- HEADER --- */}
|
||||||
|
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gray-200/60 text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-3 leading-tight">{survey.title}</h1>
|
||||||
|
{survey.description && <p className="text-gray-500 text-lg mb-6">{survey.description}</p>}
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-4 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
<div className="flex items-center gap-1"><User size={14}/> {survey.author?.name || 'Автор'}</div>
|
||||||
|
<div className="flex items-center gap-1"><Clock size={14}/> {new Date(survey.createdAt).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- QUESTIONS --- */}
|
||||||
|
{survey.questions.map((q, index) => {
|
||||||
|
const selected = answers[q.id] || [];
|
||||||
|
const isMulti = q.type === 'MULTI';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={q.id} className="bg-white p-6 md:p-8 rounded-2xl shadow-sm border border-gray-200/60">
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-indigo-50 text-indigo-600 flex items-center justify-center font-bold text-sm">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 leading-snug">{q.text}</h3>
|
||||||
|
{isMulti && <p className="text-xs text-gray-400 mt-1 font-medium">Выберите несколько вариантов</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 pl-0 md:pl-12">
|
||||||
|
{q.options.map(opt => {
|
||||||
|
const isSelected = selected.includes(opt.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={opt.id}
|
||||||
|
className={`
|
||||||
|
group flex items-center gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all duration-200 relative overflow-hidden
|
||||||
|
${isSelected
|
||||||
|
? 'border-indigo-600 bg-indigo-50/50 z-10'
|
||||||
|
: 'border-gray-100 bg-white hover:border-indigo-200 hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Индикатор выбора */}
|
||||||
|
<div className={`
|
||||||
|
w-6 h-6 flex items-center justify-center flex-shrink-0 border-2 transition-all duration-200
|
||||||
|
${isMulti ? 'rounded-md' : 'rounded-full'}
|
||||||
|
${isSelected
|
||||||
|
? 'border-indigo-600 bg-indigo-600 text-white scale-110 shadow-md shadow-indigo-200'
|
||||||
|
: 'border-gray-300 bg-white group-hover:border-indigo-300'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{isMulti
|
||||||
|
? <Check size={14} strokeWidth={3} className={`transition-transform ${isSelected ? 'scale-100' : 'scale-0'}`}/>
|
||||||
|
: <div className={`w-2.5 h-2.5 bg-white rounded-full transition-transform ${isSelected ? 'scale-100' : 'scale-0'}`}/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type={isMulti ? 'checkbox' : 'radio'}
|
||||||
|
name={`q-${q.id}`}
|
||||||
|
onChange={() => handleSelect(q.id, q.type, opt.id)}
|
||||||
|
className="hidden"
|
||||||
|
checked={isSelected}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={`font-medium transition-colors text-base ${isSelected ? 'text-indigo-900' : 'text-gray-700'}`}>
|
||||||
|
{opt.text}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- BOTTOM BAR (FLOATING) --- */}
|
||||||
|
<div className="fixed bottom-0 left-0 w-full bg-white border-t border-gray-200 p-4 flex justify-center md:justify-end md:px-10 z-20 shadow-[0_-4px_20px_rgba(0,0,0,0.05)]">
|
||||||
|
<div className="w-full max-w-2xl flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitClick}
|
||||||
|
className="w-full md:w-auto px-8 h-12 text-lg font-medium bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-200 rounded-xl transition-transform active:scale-95"
|
||||||
|
>
|
||||||
|
Завершить опрос
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маленькая иконка для экрана результатов
|
||||||
|
function TrophyIcon(props) {
|
||||||
|
return (
|
||||||
|
<svg {...props} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
survey-app/tailwind.config.js
Normal file
11
survey-app/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
7
survey-app/vite.config.js
Normal file
7
survey-app/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
17
survey-backend/.gitignore
vendored
Normal file
17
survey-backend/.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Зависимости
|
||||||
|
node_modules/
|
||||||
|
server/node_modules/
|
||||||
|
|
||||||
|
# Сборка
|
||||||
|
dist/
|
||||||
|
server/dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Среда и секреты (Никогда не отправляй их!)
|
||||||
|
.env
|
||||||
|
server/.env
|
||||||
|
|
||||||
|
# Логи и системные файлы
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
15
survey-backend/docker-compose.yml
Normal file
15
survey-backend/docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: admin
|
||||||
|
POSTGRES_PASSWORD: password123
|
||||||
|
POSTGRES_DB: surveydb
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
2107
survey-backend/package-lock.json
generated
Normal file
2107
survey-backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
survey-backend/package.json
Normal file
35
survey-backend/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "survey-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.19.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"prisma": "^6.19.0",
|
||||||
|
"zod": "^4.1.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.5",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"nodemon": "^3.1.11",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
survey-backend/prisma.config.ts
Normal file
12
survey-backend/prisma.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
engine: "classic",
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
100
survey-backend/prisma/schema.prisma
Normal file
100
survey-backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- МОДЕЛИ ---
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
password String // Хеш пароля
|
||||||
|
name String?
|
||||||
|
inviteCode String @unique // Уникальный код типа "#USER-1234"
|
||||||
|
|
||||||
|
surveys Survey[] @relation("CreatedSurveys") // Опросы, которые я создал
|
||||||
|
submissions Submission[] // Опросы, которые я прошел
|
||||||
|
|
||||||
|
allowedIn AllowedAccess[] // Куда меня пригласили
|
||||||
|
}
|
||||||
|
|
||||||
|
model Survey {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
isPublished Boolean @default(false)
|
||||||
|
accessType AccessType @default(PUBLIC) // PUBLIC, INVITE_ONLY
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
authorId Int
|
||||||
|
author User @relation("CreatedSurveys", fields: [authorId], references: [id])
|
||||||
|
|
||||||
|
questions Question[]
|
||||||
|
submissions Submission[]
|
||||||
|
allowedUsers AllowedAccess[]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccessType {
|
||||||
|
PUBLIC
|
||||||
|
INVITE_ONLY
|
||||||
|
}
|
||||||
|
|
||||||
|
model Question {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
surveyId Int
|
||||||
|
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
text String
|
||||||
|
type QuestionType @default(SINGLE)
|
||||||
|
points Int @default(0)
|
||||||
|
order Int @default(0)
|
||||||
|
|
||||||
|
options Option[]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum QuestionType {
|
||||||
|
SINGLE // Один выбор (Radio)
|
||||||
|
MULTI // Множественный (Checkbox)
|
||||||
|
// TEXT - можно добавить позже
|
||||||
|
}
|
||||||
|
|
||||||
|
model Option {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
questionId Int
|
||||||
|
question Question @relation(fields: [questionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
text String
|
||||||
|
isCorrect Boolean @default(false) // Правильный ли это ответ?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Белый список для приватных опросов
|
||||||
|
model AllowedAccess {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
surveyId Int
|
||||||
|
userId Int
|
||||||
|
|
||||||
|
// ДОБАВЛЕНО: onDelete: Cascade
|
||||||
|
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
@@unique([surveyId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Результаты прохождения
|
||||||
|
model Submission {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
surveyId Int
|
||||||
|
userId Int?
|
||||||
|
|
||||||
|
score Int
|
||||||
|
maxScore Int
|
||||||
|
completedAt DateTime @default(now())
|
||||||
|
|
||||||
|
// ДОБАВЛЕНО: onDelete: Cascade
|
||||||
|
survey Survey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
248
survey-backend/src/index.ts
Normal file
248
survey-backend/src/index.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
// Логируем SQL запросы для отладки (опционально)
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
log: ['error', 'warn'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = 5000;
|
||||||
|
const SECRET_KEY = process.env.JWT_SECRET || 'dev-secret';
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// --- MIDDLEWARE ---
|
||||||
|
interface AuthRequest extends Request { user?: { id: number; email: string }; }
|
||||||
|
|
||||||
|
const authMiddleware = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
if (!header) { res.status(401).json({ error: "No token" }); return; }
|
||||||
|
try {
|
||||||
|
const token = header.split(' ')[1];
|
||||||
|
req.user = jwt.verify(token, SECRET_KEY) as any;
|
||||||
|
next();
|
||||||
|
} catch (e) { res.status(401).json({ error: "Invalid token" }); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- AUTH ---
|
||||||
|
app.post('/api/auth/register', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { email, password, name } = req.body;
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// FIX 1: Генерируем код ВСЕГДА В ВЕРХНЕМ РЕГИСТРЕ
|
||||||
|
const inviteCode = `#USER-${Math.floor(10000 + Math.random() * 90000)}`.toUpperCase();
|
||||||
|
|
||||||
|
const user = await prisma.user.create({ data: { email, password: hashedPassword, name, inviteCode } });
|
||||||
|
const token = jwt.sign({ id: user.id, email: user.email }, SECRET_KEY);
|
||||||
|
|
||||||
|
console.log(`[AUTH] Registered: ${email} with code ${inviteCode}`);
|
||||||
|
res.json({ token, user: { id: user.id, name: user.name, inviteCode: user.inviteCode } });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
res.status(400).json({ error: "User exists" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!user || !await bcrypt.compare(password, user.password)) { res.status(401).json({ error: "Invalid credentials" }); return; }
|
||||||
|
const token = jwt.sign({ id: user.id, email: user.email }, SECRET_KEY);
|
||||||
|
res.json({ token, user: { id: user.id, name: user.name, inviteCode: user.inviteCode } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/me', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
|
||||||
|
res.json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- SURVEYS CRUD ---
|
||||||
|
|
||||||
|
// CREATE
|
||||||
|
app.post('/api/surveys', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
// ... (код создания оставляем как был в прошлом шаге, он рабочий)
|
||||||
|
const { title, description, questions, accessType, allowedUserCodes } = req.body;
|
||||||
|
console.log(`[CREATE] Survey "${title}"`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let allowedIds: number[] = [];
|
||||||
|
if (accessType === 'INVITE_ONLY' && allowedUserCodes?.length) {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { inviteCode: { in: allowedUserCodes, mode: 'insensitive' } }
|
||||||
|
});
|
||||||
|
allowedIds = users.map(u => u.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const survey = await prisma.survey.create({
|
||||||
|
data: {
|
||||||
|
title, description, accessType, authorId: req.user!.id, isPublished: true,
|
||||||
|
questions: {
|
||||||
|
create: questions.map((q: any) => ({
|
||||||
|
text: q.text,
|
||||||
|
type: q.type || 'SINGLE', // <--- Важно: сохраняем тип
|
||||||
|
points: q.points || 0,
|
||||||
|
options: { create: q.options.map((o: any) => ({ text: o.text, isCorrect: o.isCorrect || false })) }
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
allowedUsers: { create: allowedIds.map(uid => ({ userId: uid })) }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.json(survey);
|
||||||
|
} catch (e) { console.error(e); res.status(500).json({ error: "Create failed" }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. UPDATE (NEW: Редактирование)
|
||||||
|
app.put('/api/surveys/:id', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const { title, questions, accessType, allowedUserCodes } = req.body;
|
||||||
|
|
||||||
|
// Проверяем права
|
||||||
|
const existing = await prisma.survey.findUnique({ where: { id } });
|
||||||
|
if (!existing || existing.authorId !== req.user!.id) {
|
||||||
|
res.status(403).json({ error: "Access denied" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Транзакция: Удаляем старое -> Создаем новое (Самый надежный способ для MVP)
|
||||||
|
await prisma.$transaction([
|
||||||
|
// 1. Обновляем шапку
|
||||||
|
prisma.survey.update({
|
||||||
|
where: { id },
|
||||||
|
data: { title, accessType }
|
||||||
|
}),
|
||||||
|
// 2. Удаляем старые доступы и вопросы (каскадно удалятся варианты)
|
||||||
|
prisma.allowedAccess.deleteMany({ where: { surveyId: id } }),
|
||||||
|
prisma.question.deleteMany({ where: { surveyId: id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Создаем заново (как при CREATE)
|
||||||
|
let allowedIds: number[] = [];
|
||||||
|
if (accessType === 'INVITE_ONLY' && allowedUserCodes?.length) {
|
||||||
|
const users = await prisma.user.findMany({ where: { inviteCode: { in: allowedUserCodes, mode: 'insensitive' } } });
|
||||||
|
allowedIds = users.map(u => u.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Добавляем новые вопросы
|
||||||
|
// Мы не можем использовать nested update, поэтому делаем отдельный update
|
||||||
|
const updated = await prisma.survey.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
questions: {
|
||||||
|
create: questions.map((q: any) => ({
|
||||||
|
text: q.text, type: q.type || 'SINGLE', points: q.points || 0,
|
||||||
|
options: { create: q.options.map((o: any) => ({ text: o.text, isCorrect: o.isCorrect || false })) }
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
allowedUsers: { create: allowedIds.map(uid => ({ userId: uid })) }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
} catch (e) { console.error(e); res.status(500).json({ error: "Update failed" }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET MY, DELETE, FEED, GET SINGLE -> Оставляем без изменений (код из прошлого сообщения)
|
||||||
|
app.get('/api/surveys/my', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
const surveys = await prisma.survey.findMany({
|
||||||
|
where: { authorId: req.user!.id },
|
||||||
|
include: { _count: { select: { submissions: true } } },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
|
});
|
||||||
|
res.json(surveys);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/surveys/:id', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
try {
|
||||||
|
await prisma.survey.delete({ where: { id: Number(req.params.id), authorId: req.user!.id } });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (e) { res.status(500).json({ error: "Delete failed" }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/surveys/feed', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
// ... (Код из прошлого сообщения про Feed)
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const publicSurveys = await prisma.survey.findMany({ where: { accessType: 'PUBLIC', isPublished: true, authorId: { not: userId } }, include: { author: { select: { name: true } }, _count: { select: { submissions: true } } } });
|
||||||
|
const invitedSurveys = await prisma.survey.findMany({ where: { accessType: 'INVITE_ONLY', isPublished: true, allowedUsers: { some: { userId: userId } } }, include: { author: { select: { name: true } }, _count: { select: { submissions: true } } } });
|
||||||
|
const allSurveys = [...publicSurveys, ...invitedSurveys].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||||
|
res.json(allSurveys);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/surveys/:id', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
// ... (Код получения одного опроса из прошлого сообщения)
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
const isCreator = req.query.mode === 'edit';
|
||||||
|
const survey = await prisma.survey.findUnique({ where: { id }, include: { allowedUsers: { include: { user: true } }, questions: { include: { options: true } } } });
|
||||||
|
if (!survey) { res.status(404).json({ error: "Not found" }); return; }
|
||||||
|
if (!isCreator) {
|
||||||
|
if (survey.accessType === 'INVITE_ONLY') {
|
||||||
|
const isAllowed = survey.allowedUsers.some(u => u.userId === req.user!.id);
|
||||||
|
if (!isAllowed && survey.authorId !== req.user!.id) { res.status(403).json({ error: "Access denied" }); return; }
|
||||||
|
}
|
||||||
|
survey.questions.forEach(q => q.options.forEach(o => (o as any).isCorrect = undefined));
|
||||||
|
} else { if (survey.authorId !== req.user!.id) { res.status(403).json({ error: "Not your survey" }); return; } }
|
||||||
|
res.json(survey);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// SUBMIT (NEW LOGIC: Поддержка MULTI)
|
||||||
|
app.post('/api/surveys/:id/submit', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
const surveyId = Number(req.params.id);
|
||||||
|
const { answers } = req.body; // answers: { questionId: number, optionId: number }[]
|
||||||
|
|
||||||
|
const survey = await prisma.survey.findUnique({
|
||||||
|
where: { id: surveyId },
|
||||||
|
include: { questions: { include: { options: true } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!survey) return;
|
||||||
|
|
||||||
|
let totalScore = 0; // Сколько набрал юзер
|
||||||
|
let maxScore = 0; // Сколько можно было набрать (сумма всех правильных галочек)
|
||||||
|
|
||||||
|
survey.questions.forEach(q => {
|
||||||
|
// 1. Считаем, сколько всего правильных ответов в этом вопросе
|
||||||
|
// Если вопрос SINGLE, там будет 1 правильный. Если MULTI — может быть несколько.
|
||||||
|
const correctOptionIds = q.options.filter(o => o.isCorrect).map(o => o.id);
|
||||||
|
maxScore += correctOptionIds.length;
|
||||||
|
|
||||||
|
// 2. Смотрим, что выбрал юзер
|
||||||
|
const userSelectedOptionIds = answers
|
||||||
|
.filter((a: any) => a.questionId === q.id)
|
||||||
|
.map((a: any) => a.optionId);
|
||||||
|
|
||||||
|
// 3. Начисляем баллы: +1 за каждое совпадение
|
||||||
|
userSelectedOptionIds.forEach((selectedId: number) => {
|
||||||
|
if (correctOptionIds.includes(selectedId)) {
|
||||||
|
totalScore += 1;
|
||||||
|
} else {
|
||||||
|
// Опционально: Можно отнимать баллы за ошибки (totalScore -= 1),
|
||||||
|
// но пока оставим просто 0 (не угадал - не получил).
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sub = await prisma.submission.create({
|
||||||
|
data: { surveyId, userId: req.user!.id, score: totalScore, maxScore }
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ score: totalScore, maxScore, id: sub.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/surveys/:id/results', authMiddleware, async (req: AuthRequest, res) => {
|
||||||
|
// ... (старый код)
|
||||||
|
const surveyId = Number(req.params.id);
|
||||||
|
const survey = await prisma.survey.findUnique({ where: { id: surveyId } });
|
||||||
|
if (survey?.authorId !== req.user!.id) { res.status(403).json({ error: "Access denied" }); return; }
|
||||||
|
const submissions = await prisma.submission.findMany({ where: { surveyId }, include: { user: { select: { name: true, email: true } } }, orderBy: { completedAt: 'desc' } });
|
||||||
|
res.json(submissions);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => console.log(`Server running on ${PORT}`));
|
||||||
12
survey-backend/tsconfig.json
Normal file
12
survey-backend/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noImplicitAny": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user