feat: mm-ui v1 + API CORS + client auth middleware
React + Vite + Tabler UI. Sayfalar: anasayfa (issue listesi), giriş, kayıt, issue detay, sorun bildir. Axios interceptor ile token refresh. API: CORS origin whitelist, CORSMiddleware eklendi.
3
.gitignore
vendored
|
|
@ -7,3 +7,6 @@ __pycache__/
|
||||||
*.sqlite
|
*.sqlite
|
||||||
/uploads/
|
/uploads/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
mm_ui/node_modules/
|
||||||
|
mm_ui/dist/
|
||||||
|
mm_ui/.env
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
app = FastAPI(title="Memleketmeselesi API", lifespan=lifespan)
|
app = FastAPI(title="Memleketmeselesi API", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
"https://memleketmeselesi.org.tr",
|
||||||
|
"https://www.memleketmeselesi.org.tr",
|
||||||
|
"https://memleketmeselesi.net.tr",
|
||||||
|
],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
app.middleware("http")(client_auth_middleware)
|
app.middleware("http")(client_auth_middleware)
|
||||||
|
|
||||||
app.include_router(locations.router)
|
app.include_router(locations.router)
|
||||||
|
|
|
||||||
2
mm_ui/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
VITE_API_URL=https://api.memleketmeselesi.org.tr
|
||||||
|
VITE_API_KEY=your-api-key-here
|
||||||
18
mm_ui/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
24
mm_ui/.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?
|
||||||
27
mm_ui/README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# React + TypeScript + 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/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||||
|
|
||||||
|
- Configure the top-level `parserOptions` property like this:
|
||||||
|
|
||||||
|
```js
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
|
||||||
|
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
|
||||||
|
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
|
||||||
16
mm_ui/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="tr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Memleketmeselesi</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3269
mm_ui/package-lock.json
generated
Normal file
33
mm_ui/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "mm_ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tabler/core": "^1.4.0",
|
||||||
|
"@tabler/icons-react": "^3.41.1",
|
||||||
|
"axios": "^1.15.2",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^7.14.2",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.15",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
|
"eslint": "^8.45.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.3",
|
||||||
|
"typescript": "^5.0.2",
|
||||||
|
"vite": "^4.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
mm_ui/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
mm_ui/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
mm_ui/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
mm_ui/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 659 B |
BIN
mm_ui/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
mm_ui/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
38
mm_ui/src/App.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import Home from './pages/Home'
|
||||||
|
import Login from './pages/Login'
|
||||||
|
import Register from './pages/Register'
|
||||||
|
import IssueDetail from './pages/IssueDetail'
|
||||||
|
import CreateIssue from './pages/CreateIssue'
|
||||||
|
import { useAuth } from './store/auth'
|
||||||
|
import { me } from './api/auth'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { setUser } = useAuth()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) {
|
||||||
|
me().then(setUser).catch(() => {
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Home />} />
|
||||||
|
<Route path="/giris" element={<Login />} />
|
||||||
|
<Route path="/kayit" element={<Register />} />
|
||||||
|
<Route path="/sorunlar/:id" element={<IssueDetail />} />
|
||||||
|
<Route path="/sorun-bildir" element={<CreateIssue />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
mm_ui/src/api/auth.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export const register = (email: string, password: string) =>
|
||||||
|
api.post('/auth/register', { email, password }).then(r => r.data)
|
||||||
|
|
||||||
|
export const login = (email: string, password: string) =>
|
||||||
|
api.post('/auth/login', { email, password }).then(r => r.data)
|
||||||
|
|
||||||
|
export const logout = () =>
|
||||||
|
api.post('/auth/logout').then(r => r.data)
|
||||||
|
|
||||||
|
export const me = () =>
|
||||||
|
api.get('/auth/me').then(r => r.data)
|
||||||
41
mm_ui/src/api/client.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? 'https://api.memleketmeselesi.org.tr'
|
||||||
|
const API_KEY = import.meta.env.VITE_API_KEY ?? ''
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
headers: { 'X-Api-Key': API_KEY },
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
async (err) => {
|
||||||
|
const original = err.config
|
||||||
|
if (err.response?.status === 401 && !original._retry) {
|
||||||
|
original._retry = true
|
||||||
|
const refresh = localStorage.getItem('refresh_token')
|
||||||
|
if (refresh) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post(`${API_BASE}/auth/refresh`, { refresh_token: refresh }, {
|
||||||
|
headers: { 'X-Api-Key': API_KEY },
|
||||||
|
})
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token)
|
||||||
|
original.headers.Authorization = `Bearer ${data.access_token}`
|
||||||
|
return api(original)
|
||||||
|
} catch {
|
||||||
|
localStorage.clear()
|
||||||
|
window.location.href = '/giris'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
25
mm_ui/src/api/issues.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export const listIssues = (params?: Record<string, unknown>) =>
|
||||||
|
api.get('/issues', { params }).then(r => r.data)
|
||||||
|
|
||||||
|
export const getIssue = (id: number) =>
|
||||||
|
api.get(`/issues/${id}`).then(r => r.data)
|
||||||
|
|
||||||
|
export const createIssue = (data: { title: string; body: string; category_id: number; location_id: number }) =>
|
||||||
|
api.post('/issues', data).then(r => r.data)
|
||||||
|
|
||||||
|
export const updateIssue = (id: number, data: { title?: string; body?: string }) =>
|
||||||
|
api.patch(`/issues/${id}`, data).then(r => r.data)
|
||||||
|
|
||||||
|
export const voteIssue = (id: number, vote: 'resolved' | 'ongoing') =>
|
||||||
|
api.post(`/issues/${id}/vote`, { vote }).then(r => r.data)
|
||||||
|
|
||||||
|
export const listComments = (id: number) =>
|
||||||
|
api.get(`/issues/${id}/comments`).then(r => r.data)
|
||||||
|
|
||||||
|
export const addComment = (id: number, body: string, parent_id?: number) =>
|
||||||
|
api.post(`/issues/${id}/comments`, { body, parent_id }).then(r => r.data)
|
||||||
|
|
||||||
|
export const listCategories = (parent_id?: number) =>
|
||||||
|
api.get('/issues/categories', { params: parent_id != null ? { parent_id } : {} }).then(r => r.data)
|
||||||
7
mm_ui/src/api/locations.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export const listLocations = (parent_id?: number) =>
|
||||||
|
api.get('/locations', { params: parent_id != null ? { parent_id } : {} }).then(r => r.data)
|
||||||
|
|
||||||
|
export const searchLocations = (q: string) =>
|
||||||
|
api.get('/locations/search', { params: { q } }).then(r => r.data)
|
||||||
27
mm_ui/src/assets/logo.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<!-- Creator: CorelDRAW X7 -->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="277.896mm" height="60mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||||
|
viewBox="0 0 27790 6000"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<defs>
|
||||||
|
<style type="text/css">
|
||||||
|
<![CDATA[
|
||||||
|
.fil1 {fill:#4D4D4D}
|
||||||
|
.fil0 {fill:#990000}
|
||||||
|
.fil2 {fill:white}
|
||||||
|
]]>
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Layer_x0020_1">
|
||||||
|
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||||
|
<g id="_2303552305504">
|
||||||
|
<path class="fil0" d="M8281 5500l-693 -1690 0 1690 -623 0 0 -2547 909 0 742 1819 763 -1819 895 0 0 2547 -624 0 0 -1683 -700 1683 -669 0zm4811 -1028l-1661 0 0 478 1675 0 0 550 -2358 0 0 -2547 2351 0 0 551 -1668 0 0 477 1661 0 0 491zm1084 4c-144,0 -265,-16 -361,-47 -96,-32 -174,-79 -232,-143 -58,-64 -99,-144 -123,-240 -25,-97 -37,-210 -37,-340 0,-125 12,-235 35,-329 23,-94 63,-173 120,-235 57,-63 134,-110 230,-142 96,-31 219,-47 368,-47l1626 0 0 572 -1487 0c-79,0 -134,17 -166,52 -31,35 -47,93 -47,174 0,82 16,139 47,173 32,33 87,50 166,50l822 0c144,0 264,15 361,45 96,31 173,77 230,140 56,63 97,141 121,237 25,95 37,206 37,334 0,128 -12,240 -35,336 -23,97 -64,177 -122,241 -58,64 -134,112 -230,144 -95,33 -216,49 -362,49l-1689 0 0 -568 1553 0c77,0 131,-18 164,-54 32,-36 49,-96 49,-179 0,-88 -17,-148 -51,-178 -33,-30 -87,-45 -162,-45l-825 0zm4427 -4l-1661 0 0 478 1675 0 0 550 -2358 0 0 -2547 2351 0 0 551 -1668 0 0 477 1661 0 0 491zm1122 478l1575 0 0 550 -2258 0 0 -2547 683 0 0 1997zm4215 -478l-1662 0 0 478 1676 0 0 550 -2358 0 0 -2547 2351 0 0 551 -1669 0 0 477 1662 0 0 491zm1084 4c-144,0 -265,-16 -361,-47 -96,-32 -174,-79 -232,-143 -58,-64 -99,-144 -123,-240 -25,-97 -37,-210 -37,-340 0,-125 12,-235 35,-329 23,-94 63,-173 120,-235 57,-63 134,-110 230,-142 96,-31 219,-47 368,-47l1626 0 0 572 -1487 0c-79,0 -134,17 -166,52 -31,35 -47,93 -47,174 0,82 16,139 47,173 32,33 87,50 166,50l822 0c144,0 264,15 361,45 96,31 173,77 230,140 56,63 97,141 121,237 25,95 37,206 37,334 0,128 -12,240 -35,336 -23,97 -64,177 -122,241 -58,64 -134,112 -230,144 -95,33 -216,49 -362,49l-1689 0 0 -568 1553 0c77,0 131,-18 164,-54 32,-36 49,-96 49,-179 0,-88 -17,-148 -51,-178 -33,-30 -87,-45 -162,-45l-825 0zm2766 -1523l0 2547 -683 0 0 -2547 683 0zm-627 -724l574 0 0 512 -574 0 0 -512z"/>
|
||||||
|
<path class="fil1" d="M8123 2741l-610 -1487 0 1487 -549 0 0 -2241 801 0 653 1600 671 -1600 788 0 0 2241 -549 0 0 -1481 -616 1481 -589 0zm4234 -904l-1462 0 0 420 1474 0 0 484 -2075 0 0 -2241 2069 0 0 484 -1468 0 0 420 1462 0 0 433zm1545 904l-610 -1487 0 1487 -549 0 0 -2241 800 0 653 1600 672 -1600 788 0 0 2241 -549 0 0 -1481 -616 1481 -589 0zm2771 -484l1386 0 0 484 -1986 0 0 -2241 600 0 0 1757zm3710 -420l-1462 0 0 420 1474 0 0 484 -2075 0 0 -2241 2069 0 0 484 -1468 0 0 420 1462 0 0 433zm1895 -258l778 1162 -668 0 -632 -914 -386 0 0 914 -601 0 0 -2241 601 0 0 907 386 0 641 -907 635 0 -754 1079zm3093 258l-1463 0 0 420 1475 0 0 484 -2075 0 0 -2241 2069 0 0 484 -1469 0 0 420 1463 0 0 433zm1024 -853l-794 0 0 -484 2189 0 0 484 -794 0 0 1757 -601 0 0 -1757z"/>
|
||||||
|
<path class="fil0" d="M1925 0c527,0 1005,212 1352,555 348,-343 826,-555 1353,-555 1063,0 1925,862 1925,1925 0,532 -216,1013 -564,1361l0 0 -2714 2714 -2713 -2714 0 0c-348,-348 -564,-829 -564,-1361 0,-1063 862,-1925 1925,-1925z"/>
|
||||||
|
<path class="fil2" d="M5947 3253c-471,471 -1130,638 -1735,502 410,22 827,-123 1140,-436 584,-584 584,-1532 0,-2116 -585,-584 -1532,-584 -2116,0 -313,313 -458,730 -436,1139 -136,-604 31,-1263 502,-1734 730,-731 1914,-731 2645,0 730,730 730,1915 0,2645z"/>
|
||||||
|
<path class="fil2" d="M608 3253c471,471 1130,638 1734,502 -409,22 -826,-123 -1139,-436 -584,-584 -584,-1532 0,-2116 584,-584 1532,-584 2116,0 313,313 458,730 436,1139 136,-604 -31,-1263 -502,-1734 -730,-731 -1915,-731 -2645,0 -731,730 -731,1915 0,2645z"/>
|
||||||
|
<polygon id="polygon2209" class="fil2" points="3827,3895 3617,3248 4167,2849 3487,2849 3277,2203 3067,2849 2388,2849 2938,3248 2728,3895 3277,3495 "/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
74
mm_ui/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../store/auth'
|
||||||
|
import { logout } from '../api/auth'
|
||||||
|
import logoSvg from '../assets/logo.svg'
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, setUser } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try { await logout() } catch { /* ignore */ }
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
localStorage.removeItem('refresh_token')
|
||||||
|
setUser(null)
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wrapper">
|
||||||
|
<header className="navbar navbar-expand-md navbar-light d-print-none">
|
||||||
|
<div className="container-xl">
|
||||||
|
<Link to="/" className="navbar-brand">
|
||||||
|
<img src={logoSvg} alt="Memleketmeselesi" height="32" />
|
||||||
|
</Link>
|
||||||
|
<div className="navbar-nav ms-auto">
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Link to="/sorun-bildir" className="nav-link btn btn-primary me-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="icon" width="24" height="24" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor" fill="none">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Sorun Bildir
|
||||||
|
</Link>
|
||||||
|
<div className="nav-item dropdown">
|
||||||
|
<a href="#" className="nav-link dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
{user.email}
|
||||||
|
{user.kyc_status === 'verified' && (
|
||||||
|
<span className="badge bg-success ms-1">Doğrulandı</span>
|
||||||
|
)}
|
||||||
|
{user.kyc_status === 'pending' && (
|
||||||
|
<span className="badge bg-warning ms-1">Beklemede</span>
|
||||||
|
)}
|
||||||
|
{user.kyc_status === 'none' && (
|
||||||
|
<span className="badge bg-danger ms-1">Doğrulanmadı</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
<div className="dropdown-menu dropdown-menu-end">
|
||||||
|
<Link to="/profil" className="dropdown-item">Profil</Link>
|
||||||
|
{user.kyc_status === 'none' && (
|
||||||
|
<Link to="/kimlik-dogrulama" className="dropdown-item">Kimlik Doğrula</Link>
|
||||||
|
)}
|
||||||
|
<div className="dropdown-divider"/>
|
||||||
|
<button className="dropdown-item text-danger" onClick={handleLogout}>Çıkış</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link to="/giris" className="nav-link">Giriş</Link>
|
||||||
|
<Link to="/kayit" className="btn btn-primary ms-2">Kayıt Ol</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="page-wrapper">
|
||||||
|
<div className="container-xl py-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
mm_ui/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import '@tabler/core/dist/css/tabler.min.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
169
mm_ui/src/pages/CreateIssue.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { createIssue, listCategories } from '../api/issues'
|
||||||
|
import { listLocations } from '../api/locations'
|
||||||
|
import { useAuth } from '../store/auth'
|
||||||
|
|
||||||
|
export default function CreateIssue() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, isVerified } = useAuth()
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [categoryId, setCategoryId] = useState<number | ''>('')
|
||||||
|
const [parentCategoryId, setParentCategoryId] = useState<number | ''>('')
|
||||||
|
const [locationId, setLocationId] = useState<number | ''>('')
|
||||||
|
const [il, setIl] = useState<number | ''>('')
|
||||||
|
const [ilce, setIlce] = useState<number | ''>('')
|
||||||
|
const [categories, setCategories] = useState<any[]>([])
|
||||||
|
const [subCategories, setSubCategories] = useState<any[]>([])
|
||||||
|
const [iller, setIller] = useState<any[]>([])
|
||||||
|
const [ilceler, setIlceler] = useState<any[]>([])
|
||||||
|
const [mahalleler, setMahalleler] = useState<any[]>([])
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) { navigate('/giris'); return }
|
||||||
|
if (!isVerified()) { navigate('/kimlik-dogrulama'); return }
|
||||||
|
listCategories().then(setCategories)
|
||||||
|
listLocations().then(setIller) // il seviyesi
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (parentCategoryId !== '') {
|
||||||
|
listCategories(parentCategoryId).then(data => {
|
||||||
|
setSubCategories(data)
|
||||||
|
setCategoryId('')
|
||||||
|
if (data.length === 0) setCategoryId(parentCategoryId)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSubCategories([])
|
||||||
|
}
|
||||||
|
}, [parentCategoryId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (il !== '') {
|
||||||
|
listLocations(il).then(setIlceler)
|
||||||
|
setIlce(''); setLocationId(''); setMahalleler([])
|
||||||
|
}
|
||||||
|
}, [il])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ilce !== '') {
|
||||||
|
listLocations(ilce).then(data => {
|
||||||
|
setMahalleler(data)
|
||||||
|
setLocationId(ilce)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [ilce])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!categoryId || !locationId) { setError('Kategori ve konum seçimi zorunludur'); return }
|
||||||
|
setError(''); setLoading(true)
|
||||||
|
try {
|
||||||
|
const issue = await createIssue({ title, body, category_id: Number(categoryId), location_id: Number(locationId) })
|
||||||
|
navigate(`/sorunlar/${issue.id}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail ?? 'Sorun bildirilemedi')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<div className="page-header mb-4">
|
||||||
|
<h2 className="page-title">Sorun Bildir</h2>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body">
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Başlık</label>
|
||||||
|
<input type="text" className="form-control" value={title}
|
||||||
|
onChange={e => setTitle(e.target.value)} required minLength={5} maxLength={300} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Açıklama</label>
|
||||||
|
<textarea className="form-control" rows={5} value={body}
|
||||||
|
onChange={e => setBody(e.target.value)} required minLength={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col">
|
||||||
|
<label className="form-label">Ana Kategori</label>
|
||||||
|
<select className="form-select" value={parentCategoryId}
|
||||||
|
onChange={e => setParentCategoryId(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">Seçin...</option>
|
||||||
|
{categories.filter(c => !c.parent_id).map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{subCategories.length > 0 && (
|
||||||
|
<div className="col">
|
||||||
|
<label className="form-label">Alt Kategori</label>
|
||||||
|
<select className="form-select" value={categoryId}
|
||||||
|
onChange={e => setCategoryId(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">Seçin...</option>
|
||||||
|
{subCategories.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row mb-3">
|
||||||
|
<div className="col">
|
||||||
|
<label className="form-label">İl</label>
|
||||||
|
<select className="form-select" value={il}
|
||||||
|
onChange={e => setIl(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">Seçin...</option>
|
||||||
|
{iller.map((l: any) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{ilceler.length > 0 && (
|
||||||
|
<div className="col">
|
||||||
|
<label className="form-label">İlçe</label>
|
||||||
|
<select className="form-select" value={ilce}
|
||||||
|
onChange={e => setIlce(e.target.value ? Number(e.target.value) : '')}>
|
||||||
|
<option value="">Seçin...</option>
|
||||||
|
{ilceler.map((l: any) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mahalleler.length > 0 && (
|
||||||
|
<div className="col">
|
||||||
|
<label className="form-label">Mahalle (opsiyonel)</label>
|
||||||
|
<select className="form-select" value={locationId}
|
||||||
|
onChange={e => setLocationId(e.target.value ? Number(e.target.value) : ilce)}>
|
||||||
|
<option value={ilce}>İlçe geneli</option>
|
||||||
|
{mahalleler.map((l: any) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-footer">
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||||
|
{loading ? <span className="spinner-border spinner-border-sm me-2" /> : null}
|
||||||
|
Sorun Bildir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
mm_ui/src/pages/Home.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { listIssues } from '../api/issues'
|
||||||
|
|
||||||
|
interface Issue {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
category_name: string
|
||||||
|
location_name: string
|
||||||
|
vote_count: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
open: { label: 'Açık', color: 'danger' },
|
||||||
|
in_progress: { label: 'İşlemde', color: 'warning' },
|
||||||
|
resolved: { label: 'Çözüldü', color: 'success' },
|
||||||
|
rejected: { label: 'Reddedildi', color: 'secondary' },
|
||||||
|
duplicate: { label: 'Yinelenen', color: 'secondary' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [issues, setIssues] = useState<Issue[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listIssues({ limit: 20 })
|
||||||
|
.then(setIssues)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header mb-4">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col">
|
||||||
|
<h2 className="page-title">Son Sorunlar</h2>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<Link to="/sorun-bildir" className="btn btn-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="icon" width="24" height="24" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor" fill="none">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
Sorun Bildir
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-5">
|
||||||
|
<div className="spinner-border text-primary" />
|
||||||
|
</div>
|
||||||
|
) : issues.length === 0 ? (
|
||||||
|
<div className="empty">
|
||||||
|
<p className="empty-title">Henüz sorun bildirimi yok</p>
|
||||||
|
<p className="empty-subtitle text-muted">İlk sorun bildirimini sen yap!</p>
|
||||||
|
<div className="empty-action">
|
||||||
|
<Link to="/sorun-bildir" className="btn btn-primary">Sorun Bildir</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Başlık</th>
|
||||||
|
<th>Kategori</th>
|
||||||
|
<th>Konum</th>
|
||||||
|
<th>Durum</th>
|
||||||
|
<th>Oy</th>
|
||||||
|
<th>Tarih</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{issues.map(issue => {
|
||||||
|
const s = STATUS_LABELS[issue.status] ?? { label: issue.status, color: 'secondary' }
|
||||||
|
return (
|
||||||
|
<tr key={issue.id}>
|
||||||
|
<td>
|
||||||
|
<Link to={`/sorunlar/${issue.id}`} className="text-reset">
|
||||||
|
{issue.title}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="text-muted">{issue.category_name}</td>
|
||||||
|
<td className="text-muted">{issue.location_name}</td>
|
||||||
|
<td><span className={`badge bg-${s.color}-lt`}>{s.label}</span></td>
|
||||||
|
<td>{issue.vote_count}</td>
|
||||||
|
<td className="text-muted">{new Date(issue.created_at).toLocaleDateString('tr-TR')}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
mm_ui/src/pages/IssueDetail.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
|
import { getIssue, listComments, addComment, voteIssue } from '../api/issues'
|
||||||
|
import { useAuth } from '../store/auth'
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
open: { label: 'Açık', color: 'danger' },
|
||||||
|
in_progress: { label: 'İşlemde', color: 'warning' },
|
||||||
|
resolved: { label: 'Çözüldü', color: 'success' },
|
||||||
|
rejected: { label: 'Reddedildi', color: 'secondary' },
|
||||||
|
duplicate: { label: 'Yinelenen', color: 'secondary' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IssueDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, isVerified } = useAuth()
|
||||||
|
const [issue, setIssue] = useState<any>(null)
|
||||||
|
const [comments, setComments] = useState<any[]>([])
|
||||||
|
const [commentBody, setBody] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) { navigate('/giris'); return }
|
||||||
|
Promise.all([getIssue(Number(id)), listComments(Number(id))])
|
||||||
|
.then(([iss, cmts]) => { setIssue(iss); setComments(cmts) })
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
const handleVote = async (vote: 'resolved' | 'ongoing') => {
|
||||||
|
if (!isVerified()) return
|
||||||
|
await voteIssue(Number(id), vote)
|
||||||
|
const iss = await getIssue(Number(id))
|
||||||
|
setIssue(iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComment = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!commentBody.trim()) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await addComment(Number(id), commentBody)
|
||||||
|
setBody('')
|
||||||
|
setComments(await listComments(Number(id)))
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail ?? 'Yorum gönderilemedi')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-5"><div className="spinner-border text-primary" /></div>
|
||||||
|
if (!issue) return <div className="alert alert-danger">Sorun bulunamadı</div>
|
||||||
|
|
||||||
|
const s = STATUS_LABELS[issue.status] ?? { label: issue.status, color: 'secondary' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-header mb-4">
|
||||||
|
<div className="row align-items-center">
|
||||||
|
<div className="col">
|
||||||
|
<h2 className="page-title">{issue.title}</h2>
|
||||||
|
<div className="text-muted mt-1">
|
||||||
|
{issue.category_name} · {issue.location_name} ·{' '}
|
||||||
|
{new Date(issue.created_at).toLocaleDateString('tr-TR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-auto">
|
||||||
|
<span className={`badge bg-${s.color}-lt fs-4`}>{s.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<div className="card mb-4">
|
||||||
|
<div className="card-body">
|
||||||
|
<p style={{ whiteSpace: 'pre-wrap' }}>{issue.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="mb-3">Yorumlar ({issue.comment_count})</h4>
|
||||||
|
{comments.map((c: any) => (
|
||||||
|
<div key={c.id} className="card mb-2">
|
||||||
|
<div className="card-body py-2">
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<small className="text-muted">{new Date(c.created_at).toLocaleString('tr-TR')}</small>
|
||||||
|
</div>
|
||||||
|
<p className="mb-0 mt-1">{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isVerified() ? (
|
||||||
|
<div className="card mt-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Yorum Yap</h5>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
<form onSubmit={handleComment}>
|
||||||
|
<textarea className="form-control mb-2" rows={3} value={commentBody}
|
||||||
|
onChange={e => setBody(e.target.value)} placeholder="Yorumunuzu yazın..." />
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||||
|
{submitting ? <span className="spinner-border spinner-border-sm me-2" /> : null}
|
||||||
|
Gönder
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="alert alert-info mt-3">
|
||||||
|
Yorum yapabilmek için <a href="/kimlik-dogrulama">kimlik doğrulaması</a> gerekiyor.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="card mb-3">
|
||||||
|
<div className="card-body">
|
||||||
|
<h5 className="card-title">Oy Kullan</h5>
|
||||||
|
<p className="text-muted small">Bu sorun çözüldü mü?</p>
|
||||||
|
<div className="d-grid gap-2">
|
||||||
|
<button className="btn btn-success" onClick={() => handleVote('resolved')}
|
||||||
|
disabled={!isVerified()}>
|
||||||
|
✓ Çözüldü ({issue.votes.resolved})
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline-danger" onClick={() => handleVote('ongoing')}
|
||||||
|
disabled={!isVerified()}>
|
||||||
|
✗ Hâlâ Devam Ediyor ({issue.votes.ongoing})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{!isVerified() && (
|
||||||
|
<small className="text-muted d-block mt-2">Oy kullanmak için kimlik doğrulaması gerekli</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
65
mm_ui/src/pages/Login.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { login, me } from '../api/auth'
|
||||||
|
import { useAuth } from '../store/auth'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { setUser } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await login(email, password)
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token)
|
||||||
|
const user = await me()
|
||||||
|
setUser(user)
|
||||||
|
navigate('/')
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail ?? 'Giriş başarısız')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-5">
|
||||||
|
<div className="card card-md">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-center mb-4">Giriş Yap</h2>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">E-posta</label>
|
||||||
|
<input type="email" className="form-control" value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Şifre</label>
|
||||||
|
<input type="password" className="form-control" value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-footer">
|
||||||
|
<button type="submit" className="btn btn-primary w-100" disabled={loading}>
|
||||||
|
{loading ? <span className="spinner-border spinner-border-sm me-2" /> : null}
|
||||||
|
Giriş Yap
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-muted mt-3">
|
||||||
|
Hesabın yok mu? <Link to="/kayit">Kayıt Ol</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
mm_ui/src/pages/Register.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { register, login, me } from '../api/auth'
|
||||||
|
import { useAuth } from '../store/auth'
|
||||||
|
|
||||||
|
export default function Register() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { setUser } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (password !== confirm) { setError('Şifreler eşleşmiyor'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await register(email, password)
|
||||||
|
const data = await login(email, password)
|
||||||
|
localStorage.setItem('access_token', data.access_token)
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token)
|
||||||
|
const user = await me()
|
||||||
|
setUser(user)
|
||||||
|
navigate('/kimlik-dogrulama')
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.detail ?? 'Kayıt başarısız')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-5">
|
||||||
|
<div className="card card-md">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-center mb-4">Kayıt Ol</h2>
|
||||||
|
{error && <div className="alert alert-danger">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">E-posta</label>
|
||||||
|
<input type="email" className="form-control" value={email}
|
||||||
|
onChange={e => setEmail(e.target.value)} required autoFocus />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Şifre</label>
|
||||||
|
<input type="password" className="form-control" value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)} required minLength={8} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">Şifre Tekrar</label>
|
||||||
|
<input type="password" className="form-control" value={confirm}
|
||||||
|
onChange={e => setConfirm(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="form-footer">
|
||||||
|
<button type="submit" className="btn btn-primary w-100" disabled={loading}>
|
||||||
|
{loading ? <span className="spinner-border spinner-border-sm me-2" /> : null}
|
||||||
|
Kayıt Ol
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-muted mt-3">
|
||||||
|
Hesabın var mı? <Link to="/giris">Giriş Yap</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
mm_ui/src/store/auth.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
kyc_status: 'none' | 'pending' | 'verified' | 'rejected'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthStore {
|
||||||
|
user: User | null
|
||||||
|
setUser: (user: User | null) => void
|
||||||
|
isLoggedIn: () => boolean
|
||||||
|
isVerified: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = create<AuthStore>((set, get) => ({
|
||||||
|
user: null,
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
isLoggedIn: () => get().user !== null,
|
||||||
|
isVerified: () => get().user?.kyc_status === 'verified',
|
||||||
|
}))
|
||||||
1
mm_ui/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
25
mm_ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
mm_ui/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
mm_ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||