Compare commits
9 Commits
master
...
ab10918dc4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab10918dc4 | ||
|
|
7538faa699 | ||
|
|
b5b2439bd8 | ||
|
|
35ed595edc | ||
|
|
9c6272db25 | ||
|
|
83af205e7e | ||
|
|
4a5b0a9532 | ||
|
|
48ec88df74 | ||
|
|
7555757ca3 |
1107
package-lock.json
generated
1107
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,146 +1,122 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {DEBUG_MODE} from "../utils/common";
|
||||
import {ApiResponse, PageResponse} from "../types/api.ts";
|
||||
import {PageControl} from "../types/common.ts";
|
||||
|
||||
export function hostUrl(url: string){
|
||||
return DEBUG_MODE? "http://localhost:8000"+url: url;
|
||||
}
|
||||
|
||||
export function apiUrl(url: string) {
|
||||
return hostUrl("/api/v1"+url);
|
||||
}
|
||||
import {apiUrl, getTokensFromCookies} from "../utils/common.ts";
|
||||
import {ApiResponse} from "../types/api.ts";
|
||||
|
||||
|
||||
export async function getFetch(path:string) {
|
||||
export async function getFetch(path: string): Promise<ApiResponse> {
|
||||
const finalTokens = getTokensFromCookies();
|
||||
|
||||
try{
|
||||
const response = await fetch(
|
||||
apiUrl(path), {
|
||||
method: "GET",
|
||||
credentials: "include"
|
||||
}
|
||||
);
|
||||
try {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }),
|
||||
},
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (!response.ok){
|
||||
if (!response.ok) {
|
||||
const body = await response.json();
|
||||
|
||||
console.error('Error fetching:', 'Network response was not ok', body);
|
||||
|
||||
return {
|
||||
body: body,
|
||||
success: false
|
||||
} as ApiResponse;
|
||||
|
||||
return { body, success: false };
|
||||
}
|
||||
return {
|
||||
body: await response.json(),
|
||||
success: true,
|
||||
} as ApiResponse
|
||||
|
||||
return { body: await response.json(), success: true };
|
||||
} catch (error) {
|
||||
console.error('Error fetching:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
catch {
|
||||
return {
|
||||
success: false
|
||||
} as ApiResponse;
|
||||
export async function postFetch(
|
||||
path: string,
|
||||
body: object | FormData
|
||||
): Promise<ApiResponse> {
|
||||
const finalTokens = getTokensFromCookies();
|
||||
const isFormData = body instanceof FormData;
|
||||
|
||||
try {
|
||||
|
||||
const headers = new Headers();
|
||||
|
||||
if (finalTokens?.access){
|
||||
headers.append("Authorization", `Bearer ${finalTokens.access}`)
|
||||
}
|
||||
if (!isFormData){
|
||||
headers.append('Content-Type','application/json')
|
||||
}
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: isFormData ? body as FormData : JSON.stringify(body),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.json();
|
||||
console.error('Error posting:', 'Network response was not ok', responseBody);
|
||||
return { body: responseBody, success: false };
|
||||
}
|
||||
|
||||
return { body: await response.json(), success: true };
|
||||
} catch (error) {
|
||||
console.error('Error posting:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchFetch(
|
||||
path: string,
|
||||
body: object | FormData
|
||||
): Promise<ApiResponse> {
|
||||
const finalTokens = getTokensFromCookies();
|
||||
const isFormData = body instanceof FormData;
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }),
|
||||
...(!isFormData && { 'Content-Type': 'application/json' }),
|
||||
},
|
||||
body: isFormData ? body as FormData : JSON.stringify(body),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.json();
|
||||
console.error('Error patching:', 'Network response was not ok', responseBody);
|
||||
return { body: responseBody, success: false };
|
||||
}
|
||||
|
||||
return { body: await response.json(), success: true };
|
||||
} catch (error) {
|
||||
console.error('Error patching:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const createQueryString = (params: Record<string, string | number | boolean | undefined>): string => {
|
||||
const filteredParams = Object.entries(params)
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
return filteredParams.join('&');
|
||||
};
|
||||
export async function deleteFetch(path: string): Promise<ApiResponse> {
|
||||
const finalTokens = getTokensFromCookies();
|
||||
|
||||
export function useDataPage<T>(application:string, endpoint: string, params:{[key:string]:string|number}={}) {
|
||||
const [data, setData] = useState<T[]|null>(null);
|
||||
// const [previousUrl, setPreviousUrl] = useState<string|undefined>(undefined)
|
||||
// const [nextUrl, setNextUrl] = useState<string|undefined>(undefined)
|
||||
try {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...(finalTokens?.access && { Authorization: `Bearer ${finalTokens.access}` }),
|
||||
},
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.json();
|
||||
console.error('Error deleting:', 'Network response was not ok', responseBody);
|
||||
return { body: responseBody, success: false };
|
||||
}
|
||||
|
||||
|
||||
|
||||
useEffect(
|
||||
()=>{
|
||||
if (data === null){
|
||||
getFetch(`/${application}/${endpoint}/?${createQueryString(params)}`).then((r)=>{
|
||||
if (r.success){
|
||||
const pageData = r.body as PageResponse<T>;
|
||||
|
||||
setData(pageData.results);
|
||||
|
||||
|
||||
}
|
||||
else {
|
||||
setData([]);
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
[data, application, endpoint]
|
||||
)
|
||||
|
||||
// function changePage(pageUrl: string|undefined) {
|
||||
// if (pageUrl){ //TODO CHANGING PAGES
|
||||
// getFetch(`/${application}/${endpoint}/`).then((r)=>{
|
||||
// if (r.success){
|
||||
// const pageData = r.body as PageResponse<T>;
|
||||
//
|
||||
// setData(pageData.results);
|
||||
//
|
||||
//
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
function nextPage() {
|
||||
// changePage(nextUrl)
|
||||
return { body: await response.json(), success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting:', error);
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
// changePage(previousUrl)
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
items: data,
|
||||
hasPrevious: undefined,//previousUrl !== undefined,
|
||||
hasNext: undefined,//nextUrl !== undefined,
|
||||
nextPage,
|
||||
previousPage
|
||||
} as PageControl<T>
|
||||
|
||||
|
||||
}
|
||||
|
||||
export function useData<T>(application:string, endpoint: string, id: string|number) {
|
||||
|
||||
const [data, setData] = useState<T | null | undefined>(null);
|
||||
|
||||
useEffect(
|
||||
()=>{
|
||||
if (data === null){
|
||||
getFetch(`/${application}/${endpoint}/${id}`).then((r)=>{
|
||||
if (r.success){
|
||||
const pageData = r.body as T;
|
||||
setData(pageData);
|
||||
}
|
||||
else {
|
||||
setData(null);
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
[data, application, endpoint, id]
|
||||
)
|
||||
|
||||
return data;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
139
src/API/hooks.ts
Normal file
139
src/API/hooks.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import {PageControl} from "../types/common.ts";
|
||||
import {useEffect, useState} from "react";
|
||||
import {ApiResponse, PageResponse} from "../types/api.ts";
|
||||
import {getFetch} from "./common.ts";
|
||||
import {createQueryString} from "../utils/common.ts";
|
||||
|
||||
export function useDataPage<T>(application:string, endpoint: string, params:{[key:string]:string|number}={}, needLoad:boolean=true):PageControl<T> {
|
||||
const [data, setData] = useState<T[]|null>(null);
|
||||
// const [previousUrl, setPreviousUrl] = useState<string|undefined>(undefined)
|
||||
// const [nextUrl, setNextUrl] = useState<string|undefined>(undefined)
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(
|
||||
()=>{
|
||||
if (data === null && needLoad){
|
||||
getFetch(`/${application}/${endpoint}/?${createQueryString(params)}`).then((r)=>{
|
||||
if (r.success){
|
||||
const pageData = (r as ApiResponse).body as PageResponse<T>;
|
||||
|
||||
setData(pageData.results);
|
||||
|
||||
|
||||
}
|
||||
else {
|
||||
setData([]);
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
[data, application, endpoint, needLoad]
|
||||
)
|
||||
|
||||
// function changePage(pageUrl: string|undefined) {
|
||||
// if (pageUrl){ //TODO CHANGING PAGES
|
||||
// getFetch(`/${application}/${endpoint}/`).then((r)=>{
|
||||
// if (r.success){
|
||||
// const pageData = r.body as PageResponse<T>;
|
||||
//
|
||||
// setData(pageData.results);
|
||||
//
|
||||
//
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
function nextPage() {
|
||||
// changePage(nextUrl)
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
// changePage(previousUrl)
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
items: data,
|
||||
hasPrevious: false,//previousUrl !== undefined,
|
||||
hasNext: false,//nextUrl !== undefined,
|
||||
nextPage,
|
||||
previousPage
|
||||
} as PageControl<T>
|
||||
|
||||
|
||||
}
|
||||
|
||||
export function useData<T>(application:string, endpoint: string, id: string|number, needLoad:boolean=true): T|null|undefined {
|
||||
|
||||
const [data, setData] = useState<T | null | undefined>(null);
|
||||
|
||||
useEffect(
|
||||
()=>{
|
||||
if (data === null && needLoad){
|
||||
getFetch(`/${application}/${endpoint}/${id}`).then((r)=>{
|
||||
if (r.success){
|
||||
const pageData = r.body as T;
|
||||
setData(pageData);
|
||||
}
|
||||
else {
|
||||
setData(undefined);
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
[data, application, endpoint, id, needLoad]
|
||||
)
|
||||
|
||||
return data;
|
||||
|
||||
}
|
||||
|
||||
interface CachedData<T>{
|
||||
get(id: string | number, needLoad: boolean): T|undefined|null
|
||||
}
|
||||
|
||||
export function useCachedData<T>(application: string, endpoint: string): CachedData<T> {
|
||||
const [cache, setCache] = useState<Map<string | number, T | undefined>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
// Очистка кеша или другие побочные эффекты при необходимости
|
||||
return () => {
|
||||
setCache(new Map());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
get: (id: string | number, needLoad: boolean = true): T|undefined|null => {
|
||||
const cachedData = cache.has(id)?cache.get(id):null;
|
||||
|
||||
if (cachedData !== null) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
if (needLoad) {
|
||||
getFetch(`/${application}/${endpoint}/${id}`).then((r) => {
|
||||
if (r.success) {
|
||||
const data = r.body as T;
|
||||
setCache((prevCache) => {
|
||||
const newCache = new Map(prevCache);
|
||||
newCache.set(id, data);
|
||||
return newCache;
|
||||
});
|
||||
} else {
|
||||
setCache((prevCache) => {
|
||||
const newCache = new Map(prevCache);
|
||||
newCache.set(id, undefined);
|
||||
return newCache;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
src/API/users.ts
Normal file
46
src/API/users.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
import { TokenObtainPair, TokenRefresh, TokenVerify, JwtTokenResponse } from '../types/users';
|
||||
import {apiUrl} from "../utils/common.ts";
|
||||
|
||||
|
||||
export async function login(data: TokenObtainPair): Promise<JwtTokenResponse | null> {
|
||||
const response = await fetch(apiUrl('/users/auth/jwt/create/'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function refreshToken(data: TokenRefresh): Promise<JwtTokenResponse | null> {
|
||||
const response = await fetch(apiUrl('/users/auth/jwt/refresh/'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function verifyToken(data: TokenVerify): Promise<boolean> {
|
||||
const response = await fetch(apiUrl('/users/auth/jwt/verify/'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
55
src/App.tsx
55
src/App.tsx
@@ -1,25 +1,48 @@
|
||||
// App.tsx
|
||||
import {BrowserRouter as Router, Routes, Route, Navigate, Link} from 'react-router-dom';
|
||||
import React from "react";
|
||||
import {BrowserRouter as Router, Routes, Route, Navigate} from 'react-router-dom';
|
||||
import {MainPage} from "./pages/MainPage.tsx";
|
||||
import {SurveyPage} from "./pages/SurveyPage.tsx";
|
||||
import {DashboardPage} from "./pages/DashboardPage.tsx";
|
||||
|
||||
import { SurveyResultPage } from './pages/SurveyResultPage.tsx';
|
||||
import { ProfessionPage } from './pages/ProfessionPage.tsx';
|
||||
import React from "react";
|
||||
import {LoginPage} from "./pages/LoginPage.tsx";
|
||||
import {UserProvider} from "./utils/users/UserProvider.tsx";
|
||||
import {DashboardLayout} from "./layouts/DashboardLayout.tsx";
|
||||
import {MainLayout} from "./layouts/MainLayout.tsx";
|
||||
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="header">
|
||||
<Link to="/dashboard" className="menu-button">☰</Link>
|
||||
<h1 className="logo">Pro-Fi Test</h1>
|
||||
</div>
|
||||
<Routes>
|
||||
<Route path="/" element={<MainPage/>}/>
|
||||
<Route path="/surveys/:surveyId/" element={<SurveyPage/>}/>
|
||||
<Route path="/dashboard/" element={<DashboardPage/>}/>
|
||||
<Route path="*" element={<Navigate to="/"/>}/>
|
||||
</Routes>
|
||||
</Router>
|
||||
<UserProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
|
||||
<Route path="/login/" element={<LoginPage />} />
|
||||
|
||||
|
||||
<Route path="/dashboard/*" element={<DashboardLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
<Route path="/" element={<MainLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path="/surveys/results/:scoreVariableId/" element={<SurveyResultPage />} />
|
||||
<Route path="/surveys/professions/:professionId/" element={<ProfessionPage />} />
|
||||
<Route path="/surveys/:surveyId/" element={<SurveyPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</UserProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {ReactElement} from "react";
|
||||
import {Link} from "react-router-dom";
|
||||
|
||||
export function LoadingData({data, children}: {data: object|null|undefined, children: ReactElement}) {
|
||||
export function LoadingData({data, children}: {data: object|null|undefined, children: (ReactElement | null | undefined | boolean)[]}) {
|
||||
|
||||
if (data === null){
|
||||
return <div className={"loading"}>Идёт загрузка...</div>
|
||||
}
|
||||
if (data === undefined){
|
||||
return <Link className={"not-found"} to={"/"}>404 НИЧЕГО НЕ НАЙДЕНО</Link>
|
||||
return <Link className={"not-found"} to={"/"}>404 НЕ НАЙДЕНО</Link>
|
||||
}
|
||||
return children;
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {ReactElement} from "react";
|
||||
import {UniqueItem} from "../types/common.ts";
|
||||
|
||||
export function LoadingList({data, listElement}: {data: UniqueItem[]|null, listElement(e:UniqueItem, i:number):ReactElement}) {
|
||||
export function LoadingList({data, listElement}: {data: UniqueItem[]|null, listElement(e:UniqueItem, i:number):unknown}) {
|
||||
if (data === null){
|
||||
return <div className={"loading"}>Идёт загрузка...</div>
|
||||
}
|
||||
|
||||
29
src/layouts/DashboardLayout.tsx
Normal file
29
src/layouts/DashboardLayout.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import {useUser} from "../utils/users/UseUser.ts";
|
||||
import {Outlet, useNavigate} from "react-router-dom";
|
||||
import React, {useEffect} from "react";
|
||||
|
||||
export function DashboardLayout() {
|
||||
const { user } = useUser();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate("/login/")
|
||||
}
|
||||
}, [navigate, user]);
|
||||
|
||||
if (!user){
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="header">
|
||||
<h1 className="logo">Pro-Fi</h1>
|
||||
</div>
|
||||
<div className="content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/layouts/MainLayout.tsx
Normal file
16
src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import {Link, Outlet} from "react-router-dom";
|
||||
import React from "react";
|
||||
|
||||
export function MainLayout() {
|
||||
return (
|
||||
<>
|
||||
<div className="header">
|
||||
<Link to="/dashboard" className="menu-button">☰</Link>
|
||||
<h1 className="logo">Pro-Fi Test</h1>
|
||||
</div>
|
||||
<div className="content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
src/pages/LoginPage.tsx
Normal file
66
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useUser} from "../utils/users/UseUser.ts";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
|
||||
export function LoginPage() {
|
||||
const userContext = useUser();
|
||||
const user = userContext?.user;
|
||||
const login = userContext?.login;
|
||||
// Локальные стейты для формы
|
||||
const [loginInput, setLoginInput] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (user != undefined){
|
||||
navigate("/dashboard/")
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
// Обработчик отправки формы
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (login && loginInput && password) {
|
||||
try {
|
||||
await login({ login: loginInput, password });
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Ошибка входа');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-form" onSubmit={handleSubmit}>
|
||||
<h1 className="login-title">Войти в систему</h1>
|
||||
<div className="form-group">
|
||||
<label htmlFor="login" className="form-label">Логин</label>
|
||||
<input
|
||||
id="login"
|
||||
className="form-input"
|
||||
type="text"
|
||||
value={loginInput}
|
||||
onChange={(e) => setLoginInput(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="form-label">Пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
className="form-input"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<button type="submit" className="submit-button">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import '../css/MainPage.css';
|
||||
import {useDataPage} from "../API/common.ts";
|
||||
import {Survey} from "../types/survey.ts";
|
||||
import {LoadingList} from "../components/LoadingList.tsx";
|
||||
import {useDataPage} from "../API/hooks.ts";
|
||||
|
||||
export function MainPage() {
|
||||
|
||||
|
||||
57
src/pages/ProfessionPage.tsx
Normal file
57
src/pages/ProfessionPage.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import { Institution, Profession, Specialty } from '../types/survey';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { LoadingData } from '../components/LoadingData';
|
||||
import { LoadingList } from '../components/LoadingList';
|
||||
import {useCachedData, useData, useDataPage} from "../API/hooks.ts";
|
||||
|
||||
|
||||
|
||||
export function ProfessionPage() {
|
||||
const { professionId } = useParams<{ professionId: string }>();
|
||||
const profession = useData<Profession>('surveys', 'professions', professionId || 0);
|
||||
const specialties = useDataPage<Specialty>('surveys', 'specialties', { profession: professionId || 0 }, !!profession);
|
||||
|
||||
const institutions = useCachedData<Institution>("surveys", "institutions");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LoadingData data={profession}>
|
||||
<h1>{profession?.name}</h1>
|
||||
<div className="description">
|
||||
<ReactMarkdown>{profession?.description || ''}</ReactMarkdown>
|
||||
</div>
|
||||
<div className="specialties-block">
|
||||
{specialties.items != null ? <h2>Где можно обучиться</h2>:null}
|
||||
<LoadingList
|
||||
data={specialties.items}
|
||||
listElement={specialty => (
|
||||
<SpecialtyBlock
|
||||
key={specialty.id}
|
||||
specialty={specialty as Specialty}
|
||||
institution={institutions.get((specialty as Specialty).institution)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</LoadingData>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpecialtyBlock({
|
||||
specialty,
|
||||
institution,
|
||||
}: {
|
||||
specialty: Specialty;
|
||||
institution: Institution | undefined | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="specialty-block">
|
||||
<h3>Учебное заведение: {institution?.name || 'Загрузка...'}</h3>
|
||||
<p className={"specialty-name"}>{specialty.name}</p>
|
||||
{specialty.link?<Link to={specialty.link} className={"site"}>Открыть сайт</Link>:null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,167 @@
|
||||
import {LoadingList} from "../components/LoadingList.tsx";
|
||||
import {Survey} from "../types/survey.ts";
|
||||
import {Link, useParams} from "react-router-dom";
|
||||
import React from "react";
|
||||
import {useData, useDataPage} from "../API/common.ts";
|
||||
import {LoadingData} from "../components/LoadingData.tsx";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Survey,
|
||||
Question,
|
||||
QuestionPair,
|
||||
AgreementQuestion,
|
||||
QuestionType,
|
||||
|
||||
} from "../types/survey";
|
||||
import { LoadingData } from "../components/LoadingData";
|
||||
import { LoadingList } from "../components/LoadingList";
|
||||
import {useData, useDataPage} from "../API/hooks.ts";
|
||||
|
||||
export function SurveyPage() {
|
||||
const { surveyId } = useParams<{ surveyId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {surveyId} = useParams();
|
||||
// Загрузка теста
|
||||
const survey = useData<Survey>("surveys", "surveys", surveyId || 0);
|
||||
|
||||
const survey = useData<Survey>("surveys", "surveys", surveyId);
|
||||
// Загрузка вопросов
|
||||
const [questions, setQuestions] = useState<Question[] | null>(null);
|
||||
const questionsPairs = useDataPage<QuestionPair>("surveys", "question-pairs", { survey: surveyId || 0 });
|
||||
const questionsAgreement = useDataPage<AgreementQuestion>("surveys", "agreement-questions", { survey: surveyId || 0 });
|
||||
|
||||
|
||||
// Состояние для ответов
|
||||
const [answers, setAnswers] = useState<Record<string, number>>({});
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
|
||||
// Объединение вопросов после загрузки
|
||||
useEffect(() => {
|
||||
if (
|
||||
questions === null &&
|
||||
questionsPairs.items !== null &&
|
||||
questionsAgreement.items !== null
|
||||
) {
|
||||
const mergedQuestions = [
|
||||
...questionsPairs.items.map((q:Question) => ({ ...q, questionType: QuestionType.QuestionPair })),
|
||||
...questionsAgreement.items.map((q:Question) => ({ ...q, questionType: QuestionType.AgreementQuestion })),
|
||||
];
|
||||
mergedQuestions.sort(() => Math.random() - 0.5); // Перемешивание
|
||||
setQuestions(mergedQuestions);
|
||||
}
|
||||
}, [questions, questionsPairs.items, questionsAgreement.items]);
|
||||
|
||||
// Проверка завершения теста
|
||||
useEffect(() => {
|
||||
if (questions && Object.keys(answers).length === questions.length) {
|
||||
setIsCompleted(true);
|
||||
} else {
|
||||
setIsCompleted(false);
|
||||
}
|
||||
}, [questions, answers]);
|
||||
|
||||
// Обработка ответов
|
||||
const handleAnswer = (questionKey: string, answerValue: number) => {
|
||||
setAnswers((prev) => ({ ...prev, [questionKey]: answerValue }));
|
||||
};
|
||||
|
||||
// Вычисление итогового score_variable
|
||||
const calculateResults = () => {
|
||||
if (!questions) return;
|
||||
|
||||
const scoreCounts: Record<string, number> = {};
|
||||
|
||||
questions.forEach((question) => {
|
||||
const questionKey = `${question.questionType}_${question.id}`;
|
||||
const answer = answers[questionKey];
|
||||
if (answer !== undefined) {
|
||||
if (question.questionType === QuestionType.QuestionPair) {
|
||||
const selectedStatement = (question as QuestionPair).statements.find(
|
||||
(s) => s.id === answer
|
||||
);
|
||||
if (selectedStatement) {
|
||||
scoreCounts[selectedStatement.score_variable] =
|
||||
(scoreCounts[selectedStatement.score_variable] || 0) + 1;
|
||||
}
|
||||
} else if (question.questionType === QuestionType.AgreementQuestion) {
|
||||
const scoreVariable = (question as AgreementQuestion).score_variable;
|
||||
scoreCounts[scoreVariable] = (scoreCounts[scoreVariable] || 0) + answer;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const maxScoreVariable = Object.keys(scoreCounts).reduce((a, b) =>
|
||||
scoreCounts[a] > scoreCounts[b] ? a : b
|
||||
);
|
||||
|
||||
navigate(`/surveys/results/${maxScoreVariable}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="survey-page">
|
||||
<LoadingData data={survey}>
|
||||
<h1>
|
||||
{survey?.title}
|
||||
</h1>
|
||||
<div>
|
||||
{survey?.description}
|
||||
</div>
|
||||
<h1>{survey?.title}</h1>
|
||||
<div>{survey?.description}</div>
|
||||
|
||||
<SurveyQuestions survey={survey}/>
|
||||
<LoadingList data={questions} listElement={(question: Question) => (
|
||||
<QuestionBlock
|
||||
key={`${question.questionType}_${question.id}`} // Используем тип вопроса и id для уникальности
|
||||
question={question}
|
||||
onAnswer={handleAnswer}
|
||||
answered={answers[`${question.questionType}_${question.id}`]} // Учитываем тип вопроса в ключе
|
||||
/>
|
||||
)} />
|
||||
|
||||
<>
|
||||
{isCompleted && (
|
||||
<button onClick={calculateResults}>Завершить тест</button>
|
||||
)}
|
||||
</>
|
||||
</LoadingData>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionBlock({
|
||||
question,
|
||||
onAnswer,
|
||||
answered,
|
||||
}: {
|
||||
question: Question;
|
||||
onAnswer: (questionId: string, answerValue: number) => void;
|
||||
answered?: number;
|
||||
}) {
|
||||
switch (question.questionType) {
|
||||
case QuestionType.AgreementQuestion:
|
||||
{ const agreementQuestion = question as AgreementQuestion;
|
||||
return (
|
||||
<div className={`question agreement ${answered !== undefined ? "answered" : "unanswered"}`}>
|
||||
{agreementQuestion.question}
|
||||
<button
|
||||
className={`answer ${answered === 1 ? "selected" : "idle"}`}
|
||||
disabled={answered===1}
|
||||
onClick={() => onAnswer(`${question.questionType}_${question.id}`, 1)}>Согласен</button>
|
||||
<button
|
||||
className={`answer ${answered === 0 ? "selected" : "idle"}`}
|
||||
disabled={answered === 0}
|
||||
onClick={() => onAnswer(`${question.questionType}_${question.id}`, 0)}>Не согласен</button>
|
||||
|
||||
function SurveyQuestions({survey}: { survey: Survey | null | undefined }) {
|
||||
</div>
|
||||
); }
|
||||
|
||||
const _survey = survey as Survey;
|
||||
case QuestionType.QuestionPair:
|
||||
{ const pairQuestion = question as QuestionPair;
|
||||
return (
|
||||
<div className={`question pair ${answered !== undefined ? "answered" : "unanswered"}`}>
|
||||
{pairQuestion.statements.map((statement) => (
|
||||
<button
|
||||
className={`answer ${answered === statement.id ? "selected" : "idle"}`}
|
||||
disabled={answered === statement.id}
|
||||
key={statement.id}
|
||||
onClick={() => onAnswer(`${question.questionType}_${question.id}`, statement.id)}
|
||||
>
|
||||
{statement.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
); }
|
||||
|
||||
const questionsPairs = useDataPage("surveys", "question-pairs", {survey:_survey.id});
|
||||
const questionsAgreement = useDataPage("surveys", "question-pairs", {survey:_survey.id});
|
||||
|
||||
//const questions =
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
45
src/pages/SurveyResultPage.tsx
Normal file
45
src/pages/SurveyResultPage.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Profession, ScoreVariable, Survey } from '../types/survey';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { LoadingData } from '../components/LoadingData';
|
||||
import { LoadingList } from '../components/LoadingList';
|
||||
import {useData, useDataPage} from "../API/hooks.ts";
|
||||
|
||||
export function SurveyResultPage() {
|
||||
const { scoreVariableId } = useParams<{ scoreVariableId: string }>();
|
||||
const scoreVariable = useData<ScoreVariable>('surveys', 'score-variables', scoreVariableId || 0);
|
||||
const survey = useData<Survey>('surveys', 'surveys', scoreVariable?.survey || 0, !!scoreVariable);
|
||||
|
||||
const professions = useDataPage<Profession>(
|
||||
'surveys',
|
||||
'professions',
|
||||
{ score_variable: scoreVariableId || 0 },
|
||||
!!scoreVariable
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LoadingData data={survey && scoreVariable?survey:scoreVariable}>
|
||||
<h1>{survey?.title}</h1>
|
||||
<div className={"result"}>
|
||||
<h2>Ваш результат</h2>
|
||||
<p>{scoreVariable?.title}</p>
|
||||
</div>
|
||||
<div className={"description"}>
|
||||
<ReactMarkdown>{scoreVariable?.description}</ReactMarkdown>
|
||||
</div>
|
||||
<div className={"profession-list"}>
|
||||
<h3>Подходящие профессии</h3>
|
||||
<LoadingList
|
||||
data={professions.items}
|
||||
listElement={(profession)=>(
|
||||
<Link key={profession.id} to={`/surveys/professions/${profession.id}/`} className={"profession"}>
|
||||
{(profession as Profession).name}
|
||||
</Link>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</LoadingData>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,4 +9,5 @@ export interface PageControl<T>{
|
||||
hasPrevious: boolean;
|
||||
nextPage(): void;
|
||||
previousPage(): void;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,47 @@ interface Statement extends UniqueItem{
|
||||
score_variable: number;
|
||||
}
|
||||
|
||||
export interface QuestionPair extends UniqueItem{
|
||||
|
||||
export enum QuestionType{
|
||||
QuestionPair,
|
||||
AgreementQuestion
|
||||
}
|
||||
|
||||
export interface Question extends UniqueItem{
|
||||
questionType: QuestionType
|
||||
}
|
||||
|
||||
|
||||
export interface QuestionPair extends Question{
|
||||
statements: Statement[]
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface AgreementQuestion extends Question{
|
||||
question: string;
|
||||
score_variable: number;
|
||||
}
|
||||
|
||||
|
||||
export interface ScoreVariable {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
survey: number;
|
||||
}
|
||||
|
||||
export interface Profession extends UniqueItem {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Specialty extends UniqueItem {
|
||||
name: string;
|
||||
institution: number;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface Institution extends UniqueItem {
|
||||
name: string;
|
||||
}
|
||||
46
src/types/users.ts
Normal file
46
src/types/users.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {UniqueItem} from "./common.ts";
|
||||
|
||||
export type UserContextType = {
|
||||
user: Account | undefined;
|
||||
login: (credentials: TokenObtainPair) => Promise<void>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
export interface ContactData{
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
middle_name?: string;
|
||||
phone?: string;
|
||||
email?:string;
|
||||
}
|
||||
|
||||
interface Role extends UniqueItem{
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface Account extends UniqueItem, ContactData{
|
||||
login: string;
|
||||
role?: Role;
|
||||
is_staff: boolean;
|
||||
is_active: boolean;
|
||||
|
||||
}
|
||||
|
||||
|
||||
export interface TokenObtainPair {
|
||||
login: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface TokenRefresh {
|
||||
refresh: string;
|
||||
}
|
||||
|
||||
export interface TokenVerify {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface JwtTokenResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
}
|
||||
5
src/utils/checkdebug.ts
Normal file
5
src/utils/checkdebug.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
`true` if debug mode enabled and `false` otherwise
|
||||
*/
|
||||
|
||||
export const DEBUG_MODE: boolean = process.env.NODE_ENV === "development";
|
||||
@@ -1,4 +1,36 @@
|
||||
/*
|
||||
`true` if debug mode enabled and `false` otherwise
|
||||
*/
|
||||
export const DEBUG_MODE: boolean = process.env.NODE_ENV === "development";
|
||||
|
||||
|
||||
import {JwtTokenResponse} from "../types/users.ts";
|
||||
import {DEBUG_MODE} from "./checkdebug.ts";
|
||||
|
||||
export function getTokensFromCookies(): JwtTokenResponse | undefined {
|
||||
const cookies = document.cookie.split('; ').reduce((acc, cookie) => {
|
||||
const [key, value] = cookie.split('=');
|
||||
acc[key] = decodeURIComponent(value);
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const access = cookies['access_token'];
|
||||
const refresh = cookies['refresh_token'];
|
||||
|
||||
if (access && refresh) {
|
||||
return { access, refresh };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const createQueryString = (params: Record<string, string | number | boolean | undefined>): string => {
|
||||
const filteredParams = Object.entries(params)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
return filteredParams.join('&');
|
||||
};
|
||||
|
||||
export function hostUrl(url: string){
|
||||
return DEBUG_MODE? "http://localhost:8000"+url: url;
|
||||
}
|
||||
|
||||
export function apiUrl(url: string) {
|
||||
return hostUrl("/api/v1"+url);
|
||||
}
|
||||
16
src/utils/cookies.ts
Normal file
16
src/utils/cookies.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function setCookie(name: string, value: string, days: number) {
|
||||
const expires = new Date(Date.now() + days * 864e5).toUTCString();
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
|
||||
}
|
||||
|
||||
export function getCookie(name: string) {
|
||||
return document.cookie.split('; ')
|
||||
.reduce((prev: string | null, current: string) => {
|
||||
const [key, value] = current.split('=');
|
||||
return key === name ? decodeURIComponent(value) : prev;
|
||||
}, null);
|
||||
}
|
||||
|
||||
export function deleteCookie(name: string) {
|
||||
setCookie(name, '', -1);
|
||||
}
|
||||
15
src/utils/users/UseUser.ts
Normal file
15
src/utils/users/UseUser.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
import {UserContext} from "./UserContext.ts";
|
||||
import {useContext} from "react";
|
||||
import {UserContextType} from "../../types/users.ts";
|
||||
|
||||
|
||||
|
||||
export function useUser(): undefined| UserContextType {
|
||||
const context = useContext(UserContext);
|
||||
if (!context) {
|
||||
console.error('useUser must be used within a UserProvider');
|
||||
return undefined;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
5
src/utils/users/UserContext.ts
Normal file
5
src/utils/users/UserContext.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import {UserContextType} from "../../types/users.ts";
|
||||
import {createContext} from "react";
|
||||
|
||||
|
||||
export const UserContext = createContext<UserContextType | undefined>(undefined);
|
||||
98
src/utils/users/UserProvider.tsx
Normal file
98
src/utils/users/UserProvider.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, {ReactNode, useEffect, useState} from 'react';
|
||||
import {Account, JwtTokenResponse, TokenObtainPair} from '../../types/users.ts';
|
||||
import {UserContext} from "./UserContext.ts";
|
||||
import {refreshToken, verifyToken, login as apiLogin} from "../../API/users.ts";
|
||||
import {getFetch} from "../../API/common.ts";
|
||||
import {deleteCookie, getCookie, setCookie} from "../cookies.ts";
|
||||
|
||||
|
||||
export function UserProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<Account | undefined>(() => {
|
||||
const storedUser = localStorage.getItem('user');
|
||||
return storedUser ? JSON.parse(storedUser) : undefined;
|
||||
});
|
||||
const [tokens, setTokens] = useState<JwtTokenResponse | undefined>(() => {
|
||||
const accessToken = getCookie('access_token');
|
||||
const refreshToken = getCookie('refresh_token');
|
||||
return accessToken && refreshToken ? { access: accessToken, refresh: refreshToken } : undefined;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (tokens?.access) {
|
||||
// Устанавливаем куки для токенов
|
||||
setCookie('access_token', tokens.access, 7);
|
||||
setCookie('refresh_token', tokens.refresh, 7);
|
||||
fetchCurrentUser().then(userData => setUser(userData));
|
||||
} else {
|
||||
// Удаляем куки, если токены отсутствуют
|
||||
deleteCookie('access_token');
|
||||
deleteCookie('refresh_token');
|
||||
}
|
||||
}, [tokens]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(user)
|
||||
if (user) {
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
} else {
|
||||
localStorage.removeItem('user');
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
|
||||
const fetchCurrentUser = async (): Promise<Account | undefined> => {
|
||||
let response = await getFetch(`/users/auth/users/me/`);
|
||||
if (!response.success){
|
||||
return undefined;
|
||||
}
|
||||
|
||||
response = await getFetch(`/users/auth/users/${(response.body as Account).id}/`);
|
||||
|
||||
if (response.success){
|
||||
return response.body as Account
|
||||
}
|
||||
// if (response?.success && response.body) {
|
||||
// return response.body as Account; // Возвращаем данные о пользователе
|
||||
// }
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleLogin = async (credentials: TokenObtainPair) => {
|
||||
const tokens = await apiLogin(credentials);
|
||||
if (tokens) {
|
||||
setTokens(tokens);
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setUser(undefined);
|
||||
setTokens(undefined);
|
||||
};
|
||||
|
||||
const validateToken = async () => {
|
||||
if (tokens?.access) {
|
||||
const isValid = await verifyToken({ token: tokens.access });
|
||||
if (!isValid && tokens.refresh) {
|
||||
const newTokens = await refreshToken({ refresh: tokens.refresh });
|
||||
if (newTokens) {
|
||||
setTokens(newTokens);
|
||||
} else {
|
||||
handleLogout();
|
||||
}
|
||||
} else if (!isValid) {
|
||||
handleLogout();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
validateToken().then();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ user, login: handleLogin, logout: handleLogout }}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user