Initial commit: SuperGov frontend + FastAPI backend
Made-with: Cursor
This commit is contained in:
commit
2e7e56b9da
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Секреты и окружения (никогда не коммитить)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Python
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
backend/venv
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# 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) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## 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 updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-kaz \
|
||||||
|
tesseract-ocr-rus \
|
||||||
|
tesseract-ocr-eng \
|
||||||
|
libgl1-mesa-glx \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
33
backend/README.md
Normal file
33
backend/README.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# SuperGov Backend Setup
|
||||||
|
|
||||||
|
## 1. Prerequisites
|
||||||
|
- Python 3.10+
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Tesseract OCR (installed globally or via Docker)
|
||||||
|
|
||||||
|
## 2. Environment Setup
|
||||||
|
Rename `.env.example` to `.env` and fill in your keys:
|
||||||
|
- Anthropic API Key
|
||||||
|
- Supabase credentials
|
||||||
|
- Stack Auth JWKS URL & Project ID
|
||||||
|
- Redis URL
|
||||||
|
|
||||||
|
## 3. Launching
|
||||||
|
|
||||||
|
**Using Docker (Recommended):**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
This starts Redis and Celery (Worker + Beat).
|
||||||
|
|
||||||
|
**Starting FastAPI:**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Supabase Setup
|
||||||
|
Run the `app/schema.sql` file in your Supabase SQL Editor to generate all tables.
|
||||||
|
|
||||||
|
## 5. API Documentation
|
||||||
|
Swagger is automatically available at `http://localhost:8000/docs`.
|
||||||
49
backend/app/agents/base_agent.py
Normal file
49
backend/app/agents/base_agent.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import os
|
||||||
|
from anthropic import Anthropic, AsyncAnthropic
|
||||||
|
from app.redis_client import redis_client
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
class BaseAgent:
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = os.getenv("ANTHROPIC_API_KEY")
|
||||||
|
self.sync_client = Anthropic(api_key=self.api_key) if self.api_key else None
|
||||||
|
self.async_client = AsyncAnthropic(api_key=self.api_key) if self.api_key else None
|
||||||
|
self.model = os.getenv(
|
||||||
|
"ANTHROPIC_MODEL",
|
||||||
|
"claude-haiku-4-5-20251001",
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_context(self, session_id: str) -> dict:
|
||||||
|
return redis_client.get_json(f"session:{session_id}") or {}
|
||||||
|
|
||||||
|
def save_context(self, session_id: str, context: dict):
|
||||||
|
redis_client.set_json(f"session:{session_id}", context, ttl=10800)
|
||||||
|
|
||||||
|
def get_user_profile(self, user_id: str) -> dict:
|
||||||
|
cache_key = f"profile:{user_id}"
|
||||||
|
cached = redis_client.get_json(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
res = db.table("users").select("*").eq("id", user_id).execute()
|
||||||
|
if res.data:
|
||||||
|
profile = res.data[0]
|
||||||
|
redis_client.set_json(cache_key, profile, ttl=3600)
|
||||||
|
return profile
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def call_claude(self, system_prompt: str, messages: list, tools=None, max_tokens=1000):
|
||||||
|
if not self.sync_client:
|
||||||
|
raise RuntimeError("ANTHROPIC_API_KEY is not configured")
|
||||||
|
kwargs = {
|
||||||
|
"model": self.model,
|
||||||
|
"max_tokens": max_tokens,
|
||||||
|
"system": system_prompt,
|
||||||
|
"messages": messages
|
||||||
|
}
|
||||||
|
if tools:
|
||||||
|
kwargs["tools"] = tools
|
||||||
|
|
||||||
|
response = self.sync_client.messages.create(**kwargs)
|
||||||
|
return response
|
||||||
34
backend/app/agents/benefits_agent.py
Normal file
34
backend/app/agents/benefits_agent.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from app.agents.base_agent import BaseAgent
|
||||||
|
import json
|
||||||
|
|
||||||
|
class BenefitsAgent(BaseAgent):
|
||||||
|
SYSTEM_PROMPT = """You are a benefits specialist for Kazakhstan.
|
||||||
|
You evaluate if a citizen matches specific benefit rules.
|
||||||
|
Citizen profile: {profile}
|
||||||
|
Benefit details & rules: {benefit}
|
||||||
|
1. Analyze ALL criteria strictly against the user profile.
|
||||||
|
2. If the user does NOT meet the criteria, set `is_eligible` to false.
|
||||||
|
3. If they DO meet the criteria, set `is_eligible` to true and generate an encouraging explanation in {language}.
|
||||||
|
Return ONLY JSON:
|
||||||
|
{
|
||||||
|
"is_eligible": true,
|
||||||
|
"explanation": "..."
|
||||||
|
}"""
|
||||||
|
|
||||||
|
def evaluate_benefit(self, profile: dict, benefit: dict, language: str = 'ru') -> dict:
|
||||||
|
system = self.SYSTEM_PROMPT.format(
|
||||||
|
profile=json.dumps(profile, ensure_ascii=False),
|
||||||
|
benefit=json.dumps(benefit, ensure_ascii=False),
|
||||||
|
language=language
|
||||||
|
)
|
||||||
|
res = self.call_claude(
|
||||||
|
system_prompt=system,
|
||||||
|
messages=[{"role": "user", "content": "Generate explanation for my benefits."}],
|
||||||
|
max_tokens=500
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(res.content[0].text)
|
||||||
|
except Exception:
|
||||||
|
return {"is_eligible": False, "explanation": ""}
|
||||||
|
|
||||||
|
benefits_agent = BenefitsAgent()
|
||||||
414
backend/app/agents/chat_tools.py
Normal file
414
backend/app/agents/chat_tools.py
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
"""
|
||||||
|
18 функций SuperGov для Claude tool_use — исполнение на бэкенде (мок/интеграции).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.halyk_connector import halyk_bank
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_iin(user_pk: str) -> str | None:
|
||||||
|
if not user_pk or user_pk in ("anonymous", "mock-user-id"):
|
||||||
|
return "870412300415"
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
r = db.table("users").select("iin").eq("id", user_pk).limit(1).execute()
|
||||||
|
if r.data:
|
||||||
|
return r.data[0].get("iin")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(payload: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_tool(name: str, tool_input: dict[str, Any], user_id: str | None) -> str:
|
||||||
|
uid = user_id or "anonymous"
|
||||||
|
|
||||||
|
handlers = {
|
||||||
|
"voice_agent": _voice_agent,
|
||||||
|
"predictive_benefits": _predictive_benefits,
|
||||||
|
"ai_lawyer": _ai_lawyer,
|
||||||
|
"smart_queue_tson": _smart_queue_tson,
|
||||||
|
"agency_rating": _agency_rating,
|
||||||
|
"digital_storage": _digital_storage,
|
||||||
|
"family_profile": _family_profile,
|
||||||
|
"gov_analytics": _gov_analytics,
|
||||||
|
"offline_mode": _offline_mode,
|
||||||
|
"sandbox_application": _sandbox_application,
|
||||||
|
"ai_chat_core": _ai_chat_core,
|
||||||
|
"form_generation": _form_generation,
|
||||||
|
"step_by_step_guide": _step_by_step_guide,
|
||||||
|
"status_dashboard": _status_dashboard,
|
||||||
|
"autofill_profile": _autofill_profile,
|
||||||
|
"refusal_explanation": _refusal_explanation,
|
||||||
|
"complaints_map": _complaints_map,
|
||||||
|
"unified_life_flow": _unified_life_flow,
|
||||||
|
# совместимость со старыми именами
|
||||||
|
"check_bank_balance": _check_bank_balance,
|
||||||
|
"pay_state_fee": _pay_state_fee,
|
||||||
|
"book_tson_appointment": _book_tson_appointment,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn = handlers.get(name)
|
||||||
|
if not fn:
|
||||||
|
return _ok({"ok": False, "error": f"unknown_tool:{name}"})
|
||||||
|
try:
|
||||||
|
return fn(tool_input, uid)
|
||||||
|
except Exception as e:
|
||||||
|
return _ok({"ok": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
def _voice_agent(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Голосовой агент",
|
||||||
|
"detail": "Полное управление голосом; распознавание через /api/voice/transcribe; ответы — Claude.",
|
||||||
|
"user": uid,
|
||||||
|
"action": inp.get("action") or "info",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _predictive_benefits(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Предиктивные льготы",
|
||||||
|
"detail": "ИИ подбирает пособия и субсидии по профилю; раздел /benefits.",
|
||||||
|
"query": inp.get("query"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_lawyer(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "AI-юрист",
|
||||||
|
"detail": "Объяснение НПА простым языком; ссылки на нормы; API /api/legal.",
|
||||||
|
"question": inp.get("question"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _smart_queue_tson(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Умная очередь ЦОН",
|
||||||
|
"detail": "Запись с прогнозом загрузки; /api/tson.",
|
||||||
|
"tson_id": inp.get("tson_id"),
|
||||||
|
"slot": inp.get("time_slot"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _agency_rating(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Рейтинг ведомств",
|
||||||
|
"detail": "Публичный рейтинг по скорости и качеству; страница /rating.",
|
||||||
|
"agency": inp.get("agency_name"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _digital_storage(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Цифровое хранилище",
|
||||||
|
"detail": "Документы в облаке; автоподгрузка в заявках; /documents.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _family_profile(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Семейный профиль",
|
||||||
|
"detail": "Один аккаунт на семью; доверенности; /family.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _gov_analytics(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Gov-аналитика",
|
||||||
|
"detail": "Дашборд узких мест и трендов для органа; /analytics.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _offline_mode(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Офлайн-режим",
|
||||||
|
"detail": "Черновики форм в IndexedDB; синхронизация при сети.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _sandbox_application(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Песочница заявки",
|
||||||
|
"detail": "Симуляция проверки заявки до отправки без риска.",
|
||||||
|
"application_type": inp.get("application_type"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_chat_core(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "AI-помощник MVP",
|
||||||
|
"detail": "Чат kk/ru/en; этот диалог — Claude API.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _form_generation(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Генерация форм",
|
||||||
|
"detail": "Автозаполнение полей из ИИН и профиля; мастер услуг /services.",
|
||||||
|
"service": inp.get("service_type"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _step_by_step_guide(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Пошаговый гайд",
|
||||||
|
"detail": "Визуальный план с прогрессом по выбранной услуге.",
|
||||||
|
"step": inp.get("step_name"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _status_dashboard(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Дашборд статусов",
|
||||||
|
"detail": "Все заявки в одном месте; /applications.",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _autofill_profile(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Автозаполнение",
|
||||||
|
"detail": "Поля формы из профиля гражданина.",
|
||||||
|
"field_group": inp.get("field_group"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _refusal_explanation(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "AI-объяснение отказов",
|
||||||
|
"detail": "Перевод юридического текста отказа и план исправления.",
|
||||||
|
"refusal_snippet": inp.get("refusal_text", "")[:500],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _complaints_map(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Карта жалоб",
|
||||||
|
"detail": "Карта + кластеризация по районам; /map.",
|
||||||
|
"region": inp.get("region"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _unified_life_flow(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"feature": "Единый поток",
|
||||||
|
"detail": "Жизненное событие → параллельные услуги в одном сценарии.",
|
||||||
|
"event": inp.get("life_event"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _check_bank_balance(inp: dict, uid: str) -> str:
|
||||||
|
iin = _resolve_iin(uid)
|
||||||
|
if not iin:
|
||||||
|
return _ok({"ok": False, "error": "Нет ИИН в профиле"})
|
||||||
|
try:
|
||||||
|
accounts = halyk_bank.get_accounts(iin)
|
||||||
|
return _ok({"ok": True, "bank": "Halyk (демо-интеграция)", "iin_tail": iin[-4:], "accounts": accounts})
|
||||||
|
except Exception as e:
|
||||||
|
return _ok({"ok": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
def _pay_state_fee(inp: dict, uid: str) -> str:
|
||||||
|
iin = _resolve_iin(uid)
|
||||||
|
if not iin:
|
||||||
|
return _ok({"ok": False, "error": "Нет ИИН в профиле"})
|
||||||
|
amt = float(inp.get("amount") or 0)
|
||||||
|
purpose = str(inp.get("purpose") or "госпошлина")
|
||||||
|
try:
|
||||||
|
result = halyk_bank.process_payment(iin, amt, purpose)
|
||||||
|
return _ok(result)
|
||||||
|
except Exception as e:
|
||||||
|
return _ok({"ok": False, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
def _book_tson_appointment(inp: dict, uid: str) -> str:
|
||||||
|
return _ok({
|
||||||
|
"ok": True,
|
||||||
|
"tson_id": inp.get("tson_id"),
|
||||||
|
"time_slot": inp.get("time_slot"),
|
||||||
|
"confirmation": "MOCK-BOOKING",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
CHAT_TOOLS = [
|
||||||
|
{
|
||||||
|
"name": "voice_agent",
|
||||||
|
"description": "Голосовой агент: полное управление голосом, доступность для пожилых и регионов.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {"type": "string", "description": "start|stop|info"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "predictive_benefits",
|
||||||
|
"description": "Предиктивные льготы: найти пособия и субсидии по праву гражданина.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ai_lawyer",
|
||||||
|
"description": "AI-юрист: объяснить законы простым языком, ссылки на НПА.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"question": {"type": "string"}},
|
||||||
|
"required": ["question"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "smart_queue_tson",
|
||||||
|
"description": "Умная очередь ЦОН: запись с предсказанием загрузки.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tson_id": {"type": "string"},
|
||||||
|
"time_slot": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "agency_rating",
|
||||||
|
"description": "Рейтинг ведомств: публичный рейтинг по скорости и качеству.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"agency_name": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "digital_storage",
|
||||||
|
"description": "Цифровое хранилище документов в облаке и автоподгрузка в заявки.",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "family_profile",
|
||||||
|
"description": "Семейный профиль: один аккаунт на семью, доверенности онлайн.",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gov_analytics",
|
||||||
|
"description": "Gov-аналитика: узкие места, аномалии, тренды для государства.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"metric": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "offline_mode",
|
||||||
|
"description": "Офлайн-режим: черновики форм без интернета, синхронизация при подключении.",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sandbox_application",
|
||||||
|
"description": "Песочница: проверить заявку до отправки без риска.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"application_type": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ai_chat_core",
|
||||||
|
"description": "AI-помощник MVP: мультиязычный чат (kk, ru, en) и голосовой ввод.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"language": {"type": "string", "description": "kk|ru|en"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "form_generation",
|
||||||
|
"description": "Генерация форм MVP: автозаполнение полей из ИИН и профиля.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"service_type": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "step_by_step_guide",
|
||||||
|
"description": "Пошаговый гайд MVP: визуальный план с прогрессом по услуге.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"step_name": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status_dashboard",
|
||||||
|
"description": "Дашборд статусов MVP: все заявки, статусы в реальном времени.",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "autofill_profile",
|
||||||
|
"description": "Автозаполнение MVP: профиль гражданина в поля формы.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"field_group": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "refusal_explanation",
|
||||||
|
"description": "AI-объяснение отказов MVP: разбор юридического текста и план исправления.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"refusal_text": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "complaints_map",
|
||||||
|
"description": "Карта жалоб MVP: интерактивная карта и кластеризация по районам.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"region": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unified_life_flow",
|
||||||
|
"description": "Единый поток MVP: жизненное событие — все связанные услуги параллельно.",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"life_event": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "check_bank_balance",
|
||||||
|
"description": "Проверить счета и баланс Halyk Bank по ИИН пользователя (демо-интеграция).",
|
||||||
|
"input_schema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pay_state_fee",
|
||||||
|
"description": "Оплатить госпошлину с привязанного счёта Halyk (демо).",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"amount": {"type": "number"},
|
||||||
|
"purpose": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["amount", "purpose"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
31
backend/app/agents/document_agent.py
Normal file
31
backend/app/agents/document_agent.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from app.agents.base_agent import BaseAgent
|
||||||
|
import json
|
||||||
|
|
||||||
|
class DocumentAgent(BaseAgent):
|
||||||
|
SYSTEM_PROMPT = """You are a document specialist for Kazakhstan government services.
|
||||||
|
Given a service type and citizen profile, you must:
|
||||||
|
1. Identify the correct government form
|
||||||
|
2. Map profile fields to form fields
|
||||||
|
3. List which fields can be auto-filled vs need manual input
|
||||||
|
4. Generate the filled form data as JSON
|
||||||
|
5. Validate all required fields are present
|
||||||
|
Citizen profile: {profile}
|
||||||
|
Available documents in vault: {documents}
|
||||||
|
Service type: {service_type}
|
||||||
|
Return JSON only: {"form_name": "...", "auto_filled": {}, "needs_input": [{"field": "...", "label": "...", "reason": "..."}], "validation_errors": []}"""
|
||||||
|
|
||||||
|
def generate_form(self, service_type: str, profile: dict, documents: list) -> dict:
|
||||||
|
system = self.SYSTEM_PROMPT.format(
|
||||||
|
profile=json.dumps(profile, ensure_ascii=False),
|
||||||
|
documents=json.dumps(documents, ensure_ascii=False),
|
||||||
|
service_type=service_type
|
||||||
|
)
|
||||||
|
|
||||||
|
res = self.call_claude(
|
||||||
|
system_prompt=system,
|
||||||
|
messages=[{"role": "user", "content": f"Generate form for {service_type}"}],
|
||||||
|
max_tokens=2000
|
||||||
|
)
|
||||||
|
return json.loads(res.content[0].text)
|
||||||
|
|
||||||
|
document_agent = DocumentAgent()
|
||||||
29
backend/app/agents/guide_agent.py
Normal file
29
backend/app/agents/guide_agent.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from app.agents.base_agent import BaseAgent
|
||||||
|
import json
|
||||||
|
|
||||||
|
class GuideAgent(BaseAgent):
|
||||||
|
SYSTEM_PROMPT = """You are a step-by-step guide for Kazakhstan government services.
|
||||||
|
Create a personalized action plan for the citizen.
|
||||||
|
Each step must have: title, description, duration_days, required_docs list, agency name, depends_on (step numbers).
|
||||||
|
Personalize: skip steps the citizen already completed (has EDS, has bank account, etc.)
|
||||||
|
Citizen profile: {profile}
|
||||||
|
Service type: {service_type}
|
||||||
|
Return ONLY a JSON array of step objects."""
|
||||||
|
|
||||||
|
def get_guide(self, profile: dict, service_type: str) -> list:
|
||||||
|
system = self.SYSTEM_PROMPT.format(
|
||||||
|
profile=json.dumps(profile, ensure_ascii=False),
|
||||||
|
service_type=service_type
|
||||||
|
)
|
||||||
|
|
||||||
|
res = self.call_claude(
|
||||||
|
system_prompt=system,
|
||||||
|
messages=[{"role": "user", "content": "Generate the personalized plan."}],
|
||||||
|
max_tokens=1500
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(res.content[0].text)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
guide_agent = GuideAgent()
|
||||||
24
backend/app/agents/lawyer_agent.py
Normal file
24
backend/app/agents/lawyer_agent.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from app.agents.base_agent import BaseAgent
|
||||||
|
import json
|
||||||
|
|
||||||
|
class LawyerAgent(BaseAgent):
|
||||||
|
SYSTEM_PROMPT = """Вы - высококвалифицированный юридический ИИ-помощник для граждан Казахстана.
|
||||||
|
Ваша задача: Объяснять законы и НПА Республики Казахстан простым, понятным языком (без юридического жаргона).
|
||||||
|
Каждый ответ должен содержать:
|
||||||
|
1. Понятное объяснение сути закона
|
||||||
|
2. Прямые ссылки на статьи НПА (например, "ст. 43 Гражданского кодекса РК")
|
||||||
|
3. Рекомендацию для гражданина, что делать.
|
||||||
|
Гражданин задал вопрос: {query}"""
|
||||||
|
|
||||||
|
def consult(self, query: str) -> dict:
|
||||||
|
system = self.SYSTEM_PROMPT.format(query=query)
|
||||||
|
res = self.call_claude(
|
||||||
|
system_prompt=system,
|
||||||
|
messages=[{"role": "user", "content": "Пожалуйста, объясни этот правовой нюанс."}],
|
||||||
|
max_tokens=1500
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"answer": res.content[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
lawyer_agent = LawyerAgent()
|
||||||
139
backend/app/agents/nlp_agent.py
Normal file
139
backend/app/agents/nlp_agent.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
from app.agents.base_agent import BaseAgent
|
||||||
|
from app.agents.chat_tools import CHAT_TOOLS, execute_tool
|
||||||
|
import json
|
||||||
|
|
||||||
|
class NLPAgent(BaseAgent):
|
||||||
|
SYSTEM_PROMPT = """You are SuperGov AI for Kazakhstan public services (eGov). You answer via the Claude API.
|
||||||
|
|
||||||
|
You can answer general questions (science, culture, everyday life) accurately and briefly, not only government topics.
|
||||||
|
|
||||||
|
When the user needs platform data or actions (benefits, ЦОН, ratings, complaints map, family, documents, analytics, bank, fees, forms, voice agent, lawyer, queue, sandbox, offline, unified flow, etc.), call the matching tool from your tool list (voice_agent, predictive_benefits, ai_lawyer, smart_queue_tson, agency_rating, digital_storage, family_profile, gov_analytics, offline_mode, sandbox_application, form_generation, step_by_step_guide, status_dashboard, autofill_profile, refusal_explanation, complaints_map, unified_life_flow, …) instead of guessing.
|
||||||
|
|
||||||
|
Languages: Kazakh (қазақша), Russian, English — reply in the user's language.
|
||||||
|
|
||||||
|
Address the citizen by their preferred name when greeting or when it feels natural: {display_name}
|
||||||
|
|
||||||
|
When a tool returns JSON, summarize clearly for a non-technical citizen. Never claim a payment or booking succeeded unless the tool result says ok.
|
||||||
|
|
||||||
|
Citizen profile (may be empty): {profile}
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def stream_chat(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
session_id: str,
|
||||||
|
user: dict | None = None,
|
||||||
|
):
|
||||||
|
user_id = (user or {}).get("id")
|
||||||
|
|
||||||
|
display_name = ""
|
||||||
|
if user:
|
||||||
|
display_name = (
|
||||||
|
(user.get("full_name") or "").strip()
|
||||||
|
or (user.get("displayName") or "").strip()
|
||||||
|
or (user.get("email") or "").split("@")[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
context = self.load_context(session_id)
|
||||||
|
api_messages: list = context.get("api_messages") or []
|
||||||
|
|
||||||
|
if user and user.get("id") == "mock-user-id":
|
||||||
|
profile = {k: v for k, v in user.items() if v is not None}
|
||||||
|
elif user_id:
|
||||||
|
profile = self.get_user_profile(user_id)
|
||||||
|
else:
|
||||||
|
profile = {}
|
||||||
|
system_prompt = self.SYSTEM_PROMPT.format(
|
||||||
|
profile=json.dumps(profile, ensure_ascii=False),
|
||||||
|
display_name=display_name or "(не указано — поздоровайтесь нейтрально)",
|
||||||
|
)
|
||||||
|
|
||||||
|
api_messages.append({"role": "user", "content": message})
|
||||||
|
|
||||||
|
if not self.api_key or not self.async_client:
|
||||||
|
api_messages.pop()
|
||||||
|
err = "Сервер: не задан ANTHROPIC_API_KEY."
|
||||||
|
yield f'data: {json.dumps({"token": err, "done": True, "error": True})}\n\n'
|
||||||
|
return
|
||||||
|
|
||||||
|
final_text = ""
|
||||||
|
try:
|
||||||
|
final_text = await self._run_tool_loop(
|
||||||
|
system_prompt, api_messages, user_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if api_messages and api_messages[-1].get("role") == "user":
|
||||||
|
api_messages.pop()
|
||||||
|
err = f"Ошибка AI: {e!s}"
|
||||||
|
yield f'data: {json.dumps({"token": err, "done": True, "error": True})}\n\n'
|
||||||
|
return
|
||||||
|
|
||||||
|
# сохраняем историю для следующего сообщения (ограничение размера)
|
||||||
|
context["api_messages"] = api_messages[-48:]
|
||||||
|
self.save_context(session_id, context)
|
||||||
|
|
||||||
|
# потоковая отдача текста клиенту
|
||||||
|
chunk = 48
|
||||||
|
for i in range(0, len(final_text), chunk):
|
||||||
|
part = final_text[i : i + chunk]
|
||||||
|
yield f'data: {json.dumps({"token": part, "done": False})}\n\n'
|
||||||
|
|
||||||
|
yield f'data: {json.dumps({"token": "", "done": True, "intent": "chat"})}\n\n'
|
||||||
|
|
||||||
|
async def _run_tool_loop(
|
||||||
|
self, system_prompt: str, messages: list, user_id: str | None
|
||||||
|
) -> str:
|
||||||
|
max_turns = 10
|
||||||
|
for _ in range(max_turns):
|
||||||
|
resp = await self.async_client.messages.create(
|
||||||
|
model=self.model,
|
||||||
|
max_tokens=4096,
|
||||||
|
system=system_prompt,
|
||||||
|
messages=messages,
|
||||||
|
tools=CHAT_TOOLS,
|
||||||
|
)
|
||||||
|
|
||||||
|
assistant_blocks = []
|
||||||
|
for block in resp.content:
|
||||||
|
if block.type == "text":
|
||||||
|
assistant_blocks.append({"type": "text", "text": block.text})
|
||||||
|
elif block.type == "tool_use":
|
||||||
|
assistant_blocks.append(
|
||||||
|
{
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": block.id,
|
||||||
|
"name": block.name,
|
||||||
|
"input": block.input,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.append({"role": "assistant", "content": assistant_blocks})
|
||||||
|
|
||||||
|
if resp.stop_reason == "end_turn":
|
||||||
|
return "".join(
|
||||||
|
b.text for b in resp.content if b.type == "text"
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.stop_reason == "tool_use":
|
||||||
|
tool_results = []
|
||||||
|
for block in resp.content:
|
||||||
|
if block.type != "tool_use":
|
||||||
|
continue
|
||||||
|
out = execute_tool(block.name, dict(block.input or {}), user_id)
|
||||||
|
tool_results.append(
|
||||||
|
{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": block.id,
|
||||||
|
"content": out,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not tool_results:
|
||||||
|
return "Не удалось выполнить инструменты."
|
||||||
|
messages.append({"role": "user", "content": tool_results})
|
||||||
|
continue
|
||||||
|
|
||||||
|
return "Запрос прерван (лимит токенов или другая причина). Повторите вопрос."
|
||||||
|
|
||||||
|
return "Слишком много шагов. Упростите запрос."
|
||||||
|
|
||||||
|
nlp_agent = NLPAgent()
|
||||||
28
backend/app/agents/oneflow_agent.py
Normal file
28
backend/app/agents/oneflow_agent.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from app.agents.base_agent import BaseAgent
|
||||||
|
import json
|
||||||
|
|
||||||
|
class OneFlowAgent(BaseAgent):
|
||||||
|
SYSTEM_PROMPT = """You are a Master Planner for Kazakhstan Government Services.
|
||||||
|
Given any unique life event or user input (e.g. "my wife had a baby", "I want to open a cafe with an expat partner"),
|
||||||
|
you must deduce the exact government services required.
|
||||||
|
Produce a dependency graph and an estimated days to completion.
|
||||||
|
Return ONLY JSON data matching:
|
||||||
|
{
|
||||||
|
"services": ["service_code_1", "service_code_2"],
|
||||||
|
"dependency_graph": {"service_code_2": ["service_code_1"]},
|
||||||
|
"estimated_days": 14
|
||||||
|
}"""
|
||||||
|
|
||||||
|
def generate_flow(self, life_event: str) -> dict:
|
||||||
|
messages = [{"role": "user", "content": f"Life Event: {life_event}"}]
|
||||||
|
res = self.call_claude(
|
||||||
|
system_prompt=self.SYSTEM_PROMPT,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=1500
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(res.content[0].text)
|
||||||
|
except Exception:
|
||||||
|
return {"error": "Failed to generate dynamic flow"}
|
||||||
|
|
||||||
|
oneflow_agent = OneFlowAgent()
|
||||||
30
backend/app/agents/orchestrator.py
Normal file
30
backend/app/agents/orchestrator.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import json
|
||||||
|
from app.agents.base_agent import BaseAgent
|
||||||
|
|
||||||
|
class OrchestratorAgent(BaseAgent):
|
||||||
|
SYSTEM_PROMPT = """You are the orchestrator of SuperGov, a Kazakhstan government services platform.
|
||||||
|
Classify the user's intent into one of: [register_ip, child_benefit, passport, property_registration,
|
||||||
|
car_registration, no_criminal, housing_subsidy, utility_subsidy, education_grant, pension,
|
||||||
|
medical_policy, business_license, land_registration, marriage_cert, divorce_cert,
|
||||||
|
death_cert, disability_benefit, general_question].
|
||||||
|
Extract key entities. Detect language (kk/ru/en).
|
||||||
|
Return JSON only: {"intent": "...", "entities": {}, "language": "ru", "confidence": 0.9}"""
|
||||||
|
|
||||||
|
def classify(self, message: str, session_id: str) -> dict:
|
||||||
|
context = self.load_context(session_id)
|
||||||
|
|
||||||
|
messages = [{"role": "user", "content": message}]
|
||||||
|
|
||||||
|
# Tool forcing JSON could be used, but instruction prompting works for Claude
|
||||||
|
response = self.call_claude(
|
||||||
|
system_prompt=self.SYSTEM_PROMPT,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=500
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(response.content[0].text)
|
||||||
|
except:
|
||||||
|
return {"intent": "general_question", "entities": {}, "language": "ru", "confidence": 0.0}
|
||||||
|
|
||||||
|
orchestrator = OrchestratorAgent()
|
||||||
28
backend/app/agents/queue_agent.py
Normal file
28
backend/app/agents/queue_agent.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from app.agents.base_agent import BaseAgent
|
||||||
|
import json
|
||||||
|
|
||||||
|
class QueueAgent(BaseAgent):
|
||||||
|
SYSTEM_PROMPT = """You are an AI analyzing TsON (Public Service Center) loads.
|
||||||
|
Given a TsON region code and time context, realistically predict the crowd load using your internal model of crowd behavior.
|
||||||
|
Return ONLY JSON:
|
||||||
|
{
|
||||||
|
"tson_id": "...",
|
||||||
|
"current_load_percent": 85,
|
||||||
|
"estimated_wait_minutes": 45,
|
||||||
|
"recommendation": "Come after 4 PM",
|
||||||
|
"available_slots": ["16:00", "16:30"]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
def predict_queue(self, tson_id: str) -> dict:
|
||||||
|
messages = [{"role": "user", "content": f"Predict load for TsON: {tson_id}"}]
|
||||||
|
res = self.call_claude(
|
||||||
|
system_prompt=self.SYSTEM_PROMPT,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=800
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(res.content[0].text)
|
||||||
|
except Exception:
|
||||||
|
return {"error": "Failed to predict queue"}
|
||||||
|
|
||||||
|
queue_agent = QueueAgent()
|
||||||
25
backend/app/agents/refusal_agent.py
Normal file
25
backend/app/agents/refusal_agent.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from app.agents.base_agent import BaseAgent
|
||||||
|
import json
|
||||||
|
|
||||||
|
class RefusalAgent(BaseAgent):
|
||||||
|
SYSTEM_PROMPT = """You are a legal interpreter for Kazakhstan government services.
|
||||||
|
A citizen received an official rejection. Parse it and:
|
||||||
|
1. Find the exact reason for rejection
|
||||||
|
2. Translate to plain human language (in citizen's language)
|
||||||
|
3. List specific corrective actions with exact documents needed
|
||||||
|
4. Reference the specific law/regulation
|
||||||
|
Return JSON only: {"reason_raw": "...", "reason_plain": "...", "actions": [{"step": 1, "description": "...", "required_doc": "..."}], "legal_reference": "..."}"""
|
||||||
|
|
||||||
|
def analyze_refusal(self, ocr_text: str, language: str = "ru") -> dict:
|
||||||
|
messages = [{"role": "user", "content": f"Language preference: {language}\n\nRefusal Document Text:\n{ocr_text}"}]
|
||||||
|
res = self.call_claude(
|
||||||
|
system_prompt=self.SYSTEM_PROMPT,
|
||||||
|
messages=messages,
|
||||||
|
max_tokens=2000
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(res.content[0].text)
|
||||||
|
except Exception:
|
||||||
|
return {"error": "Failed to parse API response"}
|
||||||
|
|
||||||
|
refusal_agent = RefusalAgent()
|
||||||
116
backend/app/auth.py
Normal file
116
backend/app/auth.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import HTTPException, Security
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from jose import jwt, JWTError
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
_env_root = Path(__file__).resolve().parents[2] / ".env"
|
||||||
|
load_dotenv(_env_root)
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
STACK_AUTH_JWKS_URL = os.getenv("STACK_AUTH_JWKS_URL")
|
||||||
|
SUPERGOV_JWT_SECRET = os.getenv(
|
||||||
|
"SUPERGOV_JWT_SECRET",
|
||||||
|
"dev-supergov-jwt-change-in-production",
|
||||||
|
)
|
||||||
|
|
||||||
|
_jwks = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_jwks():
|
||||||
|
global _jwks
|
||||||
|
if not _jwks:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(STACK_AUTH_JWKS_URL)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
_jwks = resp.json()
|
||||||
|
return _jwks
|
||||||
|
|
||||||
|
|
||||||
|
def _user_from_supergov_token(token: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
SUPERGOV_JWT_SECRET,
|
||||||
|
algorithms=["HS256"],
|
||||||
|
)
|
||||||
|
if payload.get("typ") != "supergov_otp":
|
||||||
|
return None
|
||||||
|
uid = payload.get("sub")
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
db = get_db()
|
||||||
|
user_res = db.table("users").select("*").eq("id", uid).execute()
|
||||||
|
if user_res.data:
|
||||||
|
return user_res.data[0]
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Security(security)):
|
||||||
|
if not STACK_AUTH_JWKS_URL:
|
||||||
|
return {
|
||||||
|
"id": "mock-user-id",
|
||||||
|
"stack_user_id": "mock-stack",
|
||||||
|
"full_name": "Демо пользователь",
|
||||||
|
"iin": "870412300415",
|
||||||
|
"email": "demo@supergov.kz",
|
||||||
|
"phone": "+77001234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing authorization header")
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
|
||||||
|
otp_user = _user_from_supergov_token(token)
|
||||||
|
if otp_user:
|
||||||
|
return otp_user
|
||||||
|
|
||||||
|
try:
|
||||||
|
jwks = await get_jwks()
|
||||||
|
unverified_header = jwt.get_unverified_header(token)
|
||||||
|
rsa_key = {}
|
||||||
|
for key in jwks["keys"]:
|
||||||
|
if key["kid"] == unverified_header["kid"]:
|
||||||
|
rsa_key = {
|
||||||
|
"kty": key["kty"],
|
||||||
|
"kid": key["kid"],
|
||||||
|
"use": key["use"],
|
||||||
|
"n": key["n"],
|
||||||
|
"e": key["e"],
|
||||||
|
}
|
||||||
|
if rsa_key:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
rsa_key,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
audience=os.getenv("STACK_PROJECT_ID"),
|
||||||
|
issuer=f"https://api.stack-auth.com/api/v1/projects/{os.getenv('STACK_PROJECT_ID')}",
|
||||||
|
)
|
||||||
|
stack_user_id = payload.get("sub")
|
||||||
|
db = get_db()
|
||||||
|
user_res = db.table("users").select("*").eq("stack_user_id", stack_user_id).execute()
|
||||||
|
if not user_res.data:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Профиль не найден в базе — сначала завершите регистрацию на странице «Создать аккаунт».",
|
||||||
|
)
|
||||||
|
return user_res.data[0]
|
||||||
|
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid authentication token")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=401, detail=str(e))
|
||||||
|
|
||||||
|
raise HTTPException(status_code=401, detail="Unable to parse authentication token")
|
||||||
12
backend/app/data/benefits.json
Normal file
12
backend/app/data/benefits.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Пособие по рождению ребенка",
|
||||||
|
"description": "Единовременная выплата на рождение первого, второго и третьего ребенка",
|
||||||
|
"amount_min": 140000,
|
||||||
|
"amount_max": 230000,
|
||||||
|
"frequency": "one_time",
|
||||||
|
"criteria": {"has_children": true, "max_child_age": 1},
|
||||||
|
"agency": "Министерство труда РК",
|
||||||
|
"service_type": "child_benefit"
|
||||||
|
}
|
||||||
|
]
|
||||||
16
backend/app/data/citizens.json
Normal file
16
backend/app/data/citizens.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"iin": "870412300415",
|
||||||
|
"full_name": "Смаилов Арлан Бекович",
|
||||||
|
"birth_date": "1987-04-12",
|
||||||
|
"gender": "M",
|
||||||
|
"address": "г. Астана, ул. Кабанбай батыра, 11",
|
||||||
|
"has_eds": true,
|
||||||
|
"eds_expires": "2026-12-01",
|
||||||
|
"tax_debt": 0,
|
||||||
|
"tax_regime": "simplified",
|
||||||
|
"family": [{"relation": "child", "age": 3, "name": "Смаилова Айша"}],
|
||||||
|
"income_monthly": 250000,
|
||||||
|
"bank_accounts": ["Kaspi Bank"]
|
||||||
|
}
|
||||||
|
]
|
||||||
16
backend/app/data/services.json
Normal file
16
backend/app/data/services.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "sv-001",
|
||||||
|
"name": "Открытие ИП онлайн",
|
||||||
|
"type": "register_ip",
|
||||||
|
"agency": "КГД МФ РК",
|
||||||
|
"avg_days": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sv-002",
|
||||||
|
"name": "Пособие по рождению",
|
||||||
|
"type": "child_benefit",
|
||||||
|
"agency": "Министерство труда РК",
|
||||||
|
"avg_days": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
22
backend/app/database.py
Normal file
22
backend/app/database.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from supabase import Client, create_client
|
||||||
|
|
||||||
|
# При запуске uvicorn из папки backend cwd ≠ корень репозитория — грузим .env из gogo/
|
||||||
|
_env_root = Path(__file__).resolve().parents[2] / ".env"
|
||||||
|
load_dotenv(_env_root)
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
||||||
|
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY")
|
||||||
|
|
||||||
|
if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
|
||||||
|
print("Warning: Supabase credentials not found. DB operations will fail.")
|
||||||
|
|
||||||
|
# Use service key for backend admin operations bypass RLS
|
||||||
|
supabase: Client = create_client(SUPABASE_URL or "http://localhost", SUPABASE_SERVICE_KEY or "dummy")
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
return supabase
|
||||||
126
backend/app/main.py
Normal file
126
backend/app/main.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from urllib.error import URLError
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.routers import (
|
||||||
|
agencies,
|
||||||
|
analytics,
|
||||||
|
applications,
|
||||||
|
auth,
|
||||||
|
bank,
|
||||||
|
benefits,
|
||||||
|
chat,
|
||||||
|
complaints,
|
||||||
|
documents,
|
||||||
|
family,
|
||||||
|
forms,
|
||||||
|
legal,
|
||||||
|
tson,
|
||||||
|
voice,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(title="SuperGov API")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # In prod, restrict to frontend URL
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(chat.router)
|
||||||
|
app.include_router(forms.router)
|
||||||
|
app.include_router(applications.router)
|
||||||
|
app.include_router(benefits.router)
|
||||||
|
app.include_router(complaints.router)
|
||||||
|
app.include_router(documents.router)
|
||||||
|
app.include_router(agencies.router)
|
||||||
|
app.include_router(voice.router)
|
||||||
|
app.include_router(analytics.router)
|
||||||
|
app.include_router(family.router)
|
||||||
|
app.include_router(bank.router)
|
||||||
|
app.include_router(tson.router)
|
||||||
|
app.include_router(legal.router)
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health_check():
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"version": "1.0",
|
||||||
|
"agents": [
|
||||||
|
"OrchestratorAgent",
|
||||||
|
"NLPAgent",
|
||||||
|
"DocumentAgent",
|
||||||
|
"GuideAgent",
|
||||||
|
"RefusalAgent",
|
||||||
|
"BenefitsAgent",
|
||||||
|
"LawyerAgent"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/integrations")
|
||||||
|
def health_integrations():
|
||||||
|
"""Локальная сводка: какие переменные заданы и отвечают ли внешние URL (без секретов)."""
|
||||||
|
stack_jwks_url = os.getenv("STACK_AUTH_JWKS_URL")
|
||||||
|
stack_jwks_ok = False
|
||||||
|
if stack_jwks_url:
|
||||||
|
try:
|
||||||
|
req = Request(stack_jwks_url, headers={"User-Agent": "SuperGov-health/1"})
|
||||||
|
with urlopen(req, timeout=8.0) as resp:
|
||||||
|
body = json.loads(resp.read().decode())
|
||||||
|
stack_jwks_ok = bool(body.get("keys"))
|
||||||
|
except (URLError, TimeoutError, OSError, ValueError, TypeError):
|
||||||
|
stack_jwks_ok = False
|
||||||
|
|
||||||
|
supabase_rest = "skipped"
|
||||||
|
if os.getenv("SUPABASE_URL") and os.getenv("SUPABASE_SERVICE_KEY"):
|
||||||
|
try:
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
db.table("users").select("id").limit(1).execute()
|
||||||
|
supabase_rest = "ok"
|
||||||
|
except Exception as e:
|
||||||
|
supabase_rest = f"error:{type(e).__name__}"
|
||||||
|
|
||||||
|
sendgrid_ready = bool(os.getenv("SENDGRID_API_KEY") and os.getenv("SENDGRID_FROM_EMAIL"))
|
||||||
|
twilio_ready = bool(
|
||||||
|
os.getenv("TWILIO_ACCOUNT_SID")
|
||||||
|
and os.getenv("TWILIO_AUTH_TOKEN")
|
||||||
|
and os.getenv("TWILIO_FROM_NUMBER")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"supabase": {
|
||||||
|
"env_url": bool(os.getenv("SUPABASE_URL")),
|
||||||
|
"env_service_key": bool(os.getenv("SUPABASE_SERVICE_KEY")),
|
||||||
|
"rest_probe": supabase_rest,
|
||||||
|
},
|
||||||
|
"stack_auth": {
|
||||||
|
"env_project_id": bool(os.getenv("STACK_PROJECT_ID")),
|
||||||
|
"env_jwks_url": bool(stack_jwks_url),
|
||||||
|
"jwks_reachable": stack_jwks_ok,
|
||||||
|
},
|
||||||
|
"sendgrid": {
|
||||||
|
"env_api_key": bool(os.getenv("SENDGRID_API_KEY")),
|
||||||
|
"env_from_email": bool(os.getenv("SENDGRID_FROM_EMAIL")),
|
||||||
|
"otp_email_ready": sendgrid_ready,
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"env_bot_token": bool(os.getenv("TELEGRAM_BOT_TOKEN")),
|
||||||
|
"note": "Call NotificationService.send_telegram(chat_id, message) from your code; no route uses it yet.",
|
||||||
|
},
|
||||||
|
"twilio": {
|
||||||
|
"env_account_sid": bool(os.getenv("TWILIO_ACCOUNT_SID")),
|
||||||
|
"env_auth_token": bool(os.getenv("TWILIO_AUTH_TOKEN")),
|
||||||
|
"env_from_number": bool(os.getenv("TWILIO_FROM_NUMBER")),
|
||||||
|
"sms_ready": twilio_ready,
|
||||||
|
},
|
||||||
|
}
|
||||||
26
backend/app/redis_client.py
Normal file
26
backend/app/redis_client.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import redis
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
|
class RedisClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
def get_json(self, key: str):
|
||||||
|
val = self.client.get(key)
|
||||||
|
if val:
|
||||||
|
return json.loads(val)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_json(self, key: str, value: dict, ttl: int = 10800):
|
||||||
|
self.client.setex(key, ttl, json.dumps(value))
|
||||||
|
|
||||||
|
def delete(self, key: str):
|
||||||
|
self.client.delete(key)
|
||||||
|
|
||||||
|
redis_client = RedisClient()
|
||||||
12
backend/app/routers/agencies.py
Normal file
12
backend/app/routers/agencies.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/agencies", tags=["agencies"])
|
||||||
|
|
||||||
|
@router.get("/rating")
|
||||||
|
async def get_agency_rating(user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 13
|
||||||
|
db = get_db()
|
||||||
|
res = db.table("agencies").select("*").order("composite_score", desc=True).execute()
|
||||||
|
return {"success": True, "data": res.data}
|
||||||
25
backend/app/routers/analytics.py
Normal file
25
backend/app/routers/analytics.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
|
||||||
|
|
||||||
|
@router.get("/agencies")
|
||||||
|
async def get_analytics(user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 14
|
||||||
|
# Real world: ensure user role is admin
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {"insight": "Rejection rate anomaly in Mgd."}
|
||||||
|
}
|
||||||
|
|
||||||
|
from app.agents.oneflow_agent import oneflow_agent
|
||||||
|
|
||||||
|
@router.get("/oneflow/{life_event}")
|
||||||
|
async def get_oneflow(life_event: str, user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 8 - Pure Claude API Generation
|
||||||
|
flow_data = oneflow_agent.generate_flow(life_event)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": flow_data
|
||||||
|
}
|
||||||
31
backend/app/routers/applications.py
Normal file
31
backend/app/routers/applications.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.agents.guide_agent import guide_agent
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["applications"])
|
||||||
|
|
||||||
|
@router.get("/applications")
|
||||||
|
async def get_applications(user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 4
|
||||||
|
db = get_db()
|
||||||
|
res = db.table("applications").select("*").eq("user_id", user["id"]).execute()
|
||||||
|
return {"success": True, "data": res.data}
|
||||||
|
|
||||||
|
@router.get("/guide/{service_type}")
|
||||||
|
async def get_guide(service_type: str, user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 3
|
||||||
|
plan = guide_agent.get_guide(user, service_type)
|
||||||
|
return {"success": True, "data": {"steps": plan}}
|
||||||
|
|
||||||
|
@router.post("/applications/simulate")
|
||||||
|
async def simulate_app(req: dict, user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 16
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"rejection_risk": "low",
|
||||||
|
"missing_docs": [],
|
||||||
|
"suggestions": ["Form looks perfect!"]
|
||||||
|
}
|
||||||
|
}
|
||||||
151
backend/app/routers/auth.py
Normal file
151
backend/app/routers/auth.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from jose import jwt
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from app.auth import SUPERGOV_JWT_SECRET, get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.notifications import NotificationService
|
||||||
|
from app.services.otp_store import generate_code, save_code, verify_and_consume
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
JWT_EXP_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
stack_user_id: str
|
||||||
|
iin: str = Field(..., min_length=12, max_length=12)
|
||||||
|
email: str
|
||||||
|
phone: str
|
||||||
|
full_name: str = Field(..., min_length=2, max_length=300)
|
||||||
|
|
||||||
|
@model_validator(mode="before")
|
||||||
|
@classmethod
|
||||||
|
def map_name_to_full_name(cls, data: object):
|
||||||
|
if isinstance(data, dict) and not data.get("full_name") and data.get("name"):
|
||||||
|
return {**data, "full_name": data["name"]}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class OtpSendRequest(BaseModel):
|
||||||
|
email: str = Field(..., min_length=3, max_length=320)
|
||||||
|
|
||||||
|
|
||||||
|
class OtpVerifyRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
code: str = Field(..., min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdateRequest(BaseModel):
|
||||||
|
full_name: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
birth_date: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register")
|
||||||
|
async def register_user(req: RegisterRequest):
|
||||||
|
if not req.iin.isdigit():
|
||||||
|
raise HTTPException(400, "IIN must be 12 digits")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
existing = db.table("users").select("id").eq("iin", req.iin).execute()
|
||||||
|
if existing.data:
|
||||||
|
raise HTTPException(400, "IIN already registered")
|
||||||
|
|
||||||
|
user_data = {
|
||||||
|
"stack_user_id": req.stack_user_id,
|
||||||
|
"iin": req.iin,
|
||||||
|
"email": req.email,
|
||||||
|
"phone": req.phone,
|
||||||
|
"full_name": req.full_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
res = db.table("users").insert(user_data).execute()
|
||||||
|
if not res.data:
|
||||||
|
raise HTTPException(500, "Database insertion failed")
|
||||||
|
|
||||||
|
NotificationService.send_email(
|
||||||
|
req.email,
|
||||||
|
"Добро пожаловать в SuperGov",
|
||||||
|
f"<p>Здравствуйте, {req.full_name}!</p><p>Регистрация завершена. Вход по коду на почту доступен на странице входа.</p>",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True, "data": {"user_id": res.data[0]["id"], "message": "OK"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/otp/send")
|
||||||
|
async def otp_send(body: OtpSendRequest):
|
||||||
|
db = get_db()
|
||||||
|
found = db.table("users").select("id,email,full_name").eq("email", body.email.strip()).execute()
|
||||||
|
if not found.data:
|
||||||
|
raise HTTPException(404, "Пользователь с таким email не найден. Сначала зарегистрируйтесь.")
|
||||||
|
|
||||||
|
code = generate_code()
|
||||||
|
save_code(body.email, code)
|
||||||
|
ok = NotificationService.send_email(
|
||||||
|
body.email.strip(),
|
||||||
|
"SuperGov — код входа",
|
||||||
|
f"<p>Ваш код для входа: <strong style='font-size:24px'>{code}</strong></p><p>Код действителен 10 минут.</p>",
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(503, "Не удалось отправить письмо (проверьте SENDGRID_API_KEY и SENDGRID_FROM_EMAIL)")
|
||||||
|
return {"success": True, "data": {"message": "Код отправлен на email"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/otp/verify")
|
||||||
|
async def otp_verify(body: OtpVerifyRequest):
|
||||||
|
if not verify_and_consume(body.email, body.code.strip()):
|
||||||
|
raise HTTPException(400, "Неверный или просроченный код")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
user_res = db.table("users").select("*").eq("email", body.email.strip()).execute()
|
||||||
|
if not user_res.data:
|
||||||
|
raise HTTPException(404, "Пользователь не найден")
|
||||||
|
|
||||||
|
user = user_res.data[0]
|
||||||
|
exp = datetime.now(timezone.utc) + timedelta(days=JWT_EXP_DAYS)
|
||||||
|
token = jwt.encode(
|
||||||
|
{
|
||||||
|
"sub": str(user["id"]),
|
||||||
|
"typ": "supergov_otp",
|
||||||
|
"email": user["email"],
|
||||||
|
"exp": int(exp.timestamp()),
|
||||||
|
},
|
||||||
|
SUPERGOV_JWT_SECRET,
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user": {"id": user["id"], "full_name": user.get("full_name"), "email": user["email"]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def get_me(user: dict = Depends(get_current_user)):
|
||||||
|
return {"success": True, "data": user}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me")
|
||||||
|
async def patch_me(body: ProfileUpdateRequest, user: dict = Depends(get_current_user)):
|
||||||
|
uid = user.get("id")
|
||||||
|
if not uid or uid == "mock-user-id":
|
||||||
|
return {"success": True, "data": user}
|
||||||
|
|
||||||
|
updates = {k: v for k, v in body.model_dump(exclude_none=True).items()}
|
||||||
|
if not updates:
|
||||||
|
return {"success": True, "data": user}
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
res = db.table("users").update(updates).eq("id", uid).execute()
|
||||||
|
if not res.data:
|
||||||
|
raise HTTPException(500, "Не удалось обновить профиль")
|
||||||
|
return {"success": True, "data": res.data[0]}
|
||||||
26
backend/app/routers/bank.py
Normal file
26
backend/app/routers/bank.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.services.halyk_connector import halyk_bank
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/bank", tags=["bank"])
|
||||||
|
|
||||||
|
@router.get("/accounts")
|
||||||
|
async def get_accounts(user: dict = Depends(get_current_user)):
|
||||||
|
iin = user.get("iin")
|
||||||
|
if not iin:
|
||||||
|
return {"success": False, "error": "User has no IIN configured"}
|
||||||
|
accounts = halyk_bank.get_accounts(iin)
|
||||||
|
return {"success": True, "data": accounts}
|
||||||
|
|
||||||
|
class PaymentReq(BaseModel):
|
||||||
|
amount: float
|
||||||
|
purpose: str
|
||||||
|
|
||||||
|
@router.post("/pay")
|
||||||
|
async def process_payment(req: PaymentReq, user: dict = Depends(get_current_user)):
|
||||||
|
iin = user.get("iin")
|
||||||
|
result = halyk_bank.process_payment(iin, req.amount, req.purpose)
|
||||||
|
if not result["success"]:
|
||||||
|
return {"success": False, "error": result["error"]}
|
||||||
|
return {"success": True, "data": result}
|
||||||
37
backend/app/routers/benefits.py
Normal file
37
backend/app/routers/benefits.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.agents.benefits_agent import benefits_agent
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/benefits", tags=["benefits"])
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_benefits(user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 10
|
||||||
|
db = get_db()
|
||||||
|
all_benefits = db.table("benefits").select("*").execute().data
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
total_min = 0
|
||||||
|
total_max = 0
|
||||||
|
|
||||||
|
for b in all_benefits:
|
||||||
|
# PURE API: Claude evaluates eligibility entirely based on intelligence and given rules
|
||||||
|
evaluation = benefits_agent.evaluate_benefit(user, b, user.get("language", "ru"))
|
||||||
|
if evaluation.get("is_eligible", False):
|
||||||
|
matches.append({
|
||||||
|
"benefit": b,
|
||||||
|
"explanation": evaluation.get("explanation", "")
|
||||||
|
})
|
||||||
|
total_min += b.get("amount_min", 0)
|
||||||
|
total_max += b.get("amount_max", 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"benefits": matches,
|
||||||
|
"total_amount_min": total_min,
|
||||||
|
"total_amount_max": total_max,
|
||||||
|
"count": len(matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/app/routers/chat.py
Normal file
65
backend/app/routers/chat.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.agents.nlp_agent import nlp_agent
|
||||||
|
from app.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||||
|
|
||||||
|
CHAT_QUEUE_KEY = "supergov:chat_queue"
|
||||||
|
|
||||||
|
|
||||||
|
def _enqueue_chat_turn(user: dict, session_id: str, message: str) -> None:
|
||||||
|
"""Сохраняет обращение в Redis-очередь (аудит / последующая обработка). Без Redis не падает."""
|
||||||
|
try:
|
||||||
|
from app.redis_client import redis_client
|
||||||
|
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"ts": int(time.time()),
|
||||||
|
"user_id": str(user.get("id", "")),
|
||||||
|
"session_id": session_id,
|
||||||
|
"preview": (message or "")[:800],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
redis_client.client.lpush(CHAT_QUEUE_KEY, payload)
|
||||||
|
redis_client.client.ltrim(CHAT_QUEUE_KEY, 0, 499)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMessageBody(BaseModel):
|
||||||
|
message: str = Field(..., min_length=1, max_length=32000)
|
||||||
|
session_id: str = Field(..., min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/message")
|
||||||
|
async def chat_message(
|
||||||
|
body: ChatMessageBody,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Потоковый ответ Claude (SSE). Все инструменты исполняются на сервере после tool_use."""
|
||||||
|
_enqueue_chat_turn(user, body.session_id, body.message)
|
||||||
|
return StreamingResponse(
|
||||||
|
nlp_agent.stream_chat(body.message, body.session_id, user),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream")
|
||||||
|
async def chat_stream(
|
||||||
|
message: str = Query(...),
|
||||||
|
session_id: str = Query(...),
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Обратная совместимость: GET + query."""
|
||||||
|
_enqueue_chat_turn(user, session_id, message)
|
||||||
|
return StreamingResponse(
|
||||||
|
nlp_agent.stream_chat(message, session_id, user),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
)
|
||||||
71
backend/app/routers/complaints.py
Normal file
71
backend/app/routers/complaints.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/complaints", tags=["complaints"])
|
||||||
|
|
||||||
|
|
||||||
|
class ComplaintCreate(BaseModel):
|
||||||
|
category: str = Field(..., min_length=1, max_length=120)
|
||||||
|
description: str = Field(..., min_length=3, max_length=4000)
|
||||||
|
lat: float = Field(..., ge=-90, le=90)
|
||||||
|
lng: float = Field(..., ge=-180, le=180)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_complaints(user: dict = Depends(get_current_user)):
|
||||||
|
db = get_db()
|
||||||
|
res = db.table("complaints").select("*").order("created_at", desc=True).limit(500).execute()
|
||||||
|
return {"success": True, "data": res.data or []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def create_complaint(body: ComplaintCreate, user: dict = Depends(get_current_user)):
|
||||||
|
uid = user.get("id")
|
||||||
|
row = {
|
||||||
|
"category": body.category,
|
||||||
|
"description": body.description,
|
||||||
|
"lat": body.lat,
|
||||||
|
"lng": body.lng,
|
||||||
|
"status": "new",
|
||||||
|
"votes": 0,
|
||||||
|
}
|
||||||
|
if not uid or uid == "mock-user-id":
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
**row,
|
||||||
|
"user_id": None,
|
||||||
|
"demo": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
db = get_db()
|
||||||
|
row["user_id"] = uid
|
||||||
|
res = db.table("complaints").insert(row).execute()
|
||||||
|
return {"success": True, "data": res.data[0] if res.data else row}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{complaint_id}/vote")
|
||||||
|
async def vote_complaint(complaint_id: str, user: dict = Depends(get_current_user)):
|
||||||
|
db = get_db()
|
||||||
|
cur = db.table("complaints").select("votes").eq("id", complaint_id).execute()
|
||||||
|
if not cur.data:
|
||||||
|
raise HTTPException(status_code=404, detail="not_found")
|
||||||
|
v = (cur.data[0].get("votes") or 0) + 1
|
||||||
|
db.table("complaints").update({"votes": v}).eq("id", complaint_id).execute()
|
||||||
|
return {"success": True, "data": {"votes": v}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/clusters")
|
||||||
|
async def get_clusters(user: dict = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"insight": "Кластеризация по районам доступна на карте; добавляйте жалобы через форму «Сообщить о проблеме».",
|
||||||
|
},
|
||||||
|
}
|
||||||
49
backend/app/routers/documents.py
Normal file
49
backend/app/routers/documents.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
from fastapi import APIRouter, Depends, UploadFile, File
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
from app.services.ocr_service import OCRService
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/documents", tags=["documents"])
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_documents(user: dict = Depends(get_current_user)):
|
||||||
|
db = get_db()
|
||||||
|
res = db.table("documents").select("*").eq("user_id", user["id"]).execute()
|
||||||
|
return {"success": True, "data": res.data}
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_document(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user: dict = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
# Feature 12: Vault & OCR
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
# Supabase Storage
|
||||||
|
db = get_db()
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
path = f"{user['id']}/{file_id}_{file.filename}"
|
||||||
|
|
||||||
|
# db.storage.from_("vault").upload(path, content) # Need mocked or valid supabase client
|
||||||
|
|
||||||
|
ocr_text = OCRService.extract_text(content)
|
||||||
|
|
||||||
|
# In reality Call DocumentAgent for classification of ocr_text here
|
||||||
|
classified = {
|
||||||
|
"doc_type": "passport",
|
||||||
|
"fields": {"iin": user.get("iin")}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc_record = {
|
||||||
|
"user_id": user["id"],
|
||||||
|
"doc_type": classified["doc_type"],
|
||||||
|
"file_name": file.filename,
|
||||||
|
"storage_path": path,
|
||||||
|
"ocr_text": ocr_text,
|
||||||
|
"extracted_fields": classified["fields"]
|
||||||
|
}
|
||||||
|
|
||||||
|
res = db.table("documents").insert(doc_record).execute()
|
||||||
|
|
||||||
|
return {"success": True, "data": res.data}
|
||||||
37
backend/app/routers/family.py
Normal file
37
backend/app/routers/family.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.database import get_db
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/family", tags=["family"])
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def get_family(user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 15
|
||||||
|
# From eGov mock or user relations DB table
|
||||||
|
return {"success": True, "data": []}
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def add_family_member(req: dict, user: dict = Depends(get_current_user)):
|
||||||
|
return {"success": True, "data": {"message": "Pending validation"}}
|
||||||
|
|
||||||
|
@router.post("/verify/iin")
|
||||||
|
async def verify_iin(req: dict, user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 17
|
||||||
|
iin = req.get("iin", "")
|
||||||
|
if len(iin) != 12 or not iin.isdigit():
|
||||||
|
return {"success": True, "data": {"valid": False}}
|
||||||
|
|
||||||
|
weights1 = [1,2,3,4,5,6,7,8,9,10,11]
|
||||||
|
weights2 = [3,4,5,6,7,8,9,10,11,1,2]
|
||||||
|
sum1 = sum(int(iin[i]) * weights1[i] for i in range(11)) % 11
|
||||||
|
|
||||||
|
if sum1 == 10:
|
||||||
|
control = sum(int(iin[i]) * weights2[i] for i in range(11)) % 11
|
||||||
|
else:
|
||||||
|
control = sum1
|
||||||
|
|
||||||
|
valid = control == int(iin[11])
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {"valid": valid, "birth_date": "20" + iin[0:2] + "-" + iin[2:4] + "-" + iin[4:6]}
|
||||||
|
}
|
||||||
40
backend/app/routers/forms.py
Normal file
40
backend/app/routers/forms.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.agents.document_agent import document_agent
|
||||||
|
from app.services.egov_mock import egov_mock
|
||||||
|
from app.agents.refusal_agent import refusal_agent
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/forms", tags=["forms"])
|
||||||
|
|
||||||
|
class GenerateFormReq(BaseModel):
|
||||||
|
service_type: str
|
||||||
|
session_id: str
|
||||||
|
|
||||||
|
@router.post("/generate")
|
||||||
|
async def generate_form(req: GenerateFormReq, user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 2
|
||||||
|
# Mocking vault
|
||||||
|
vault = [{"doc_type": "passport", "file_name": "pass.pdf"}]
|
||||||
|
form_data = document_agent.generate_form(req.service_type, user, vault)
|
||||||
|
return {"success": True, "data": form_data}
|
||||||
|
|
||||||
|
class AutoFillReq(BaseModel):
|
||||||
|
service_type: str
|
||||||
|
|
||||||
|
@router.post("/autofill")
|
||||||
|
async def autofill_form(req: AutoFillReq, user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 5
|
||||||
|
profile = egov_mock.get_citizen_by_iin(user.get("iin", ""))
|
||||||
|
if not profile:
|
||||||
|
profile = user
|
||||||
|
return {"success": True, "data": {"pre_filled": profile, "sources": {"iin": "ГБД ФЛ"}}}
|
||||||
|
|
||||||
|
class RefusalReq(BaseModel):
|
||||||
|
text: str
|
||||||
|
|
||||||
|
@router.post("/refusal/analyze")
|
||||||
|
async def analyze_refusal(req: RefusalReq, user: dict = Depends(get_current_user)):
|
||||||
|
# Feature 6
|
||||||
|
analysis = refusal_agent.analyze_refusal(req.text, user.get("language", "ru"))
|
||||||
|
return {"success": True, "data": analysis}
|
||||||
15
backend/app/routers/legal.py
Normal file
15
backend/app/routers/legal.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.agents.lawyer_agent import lawyer_agent
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/legal", tags=["legal"])
|
||||||
|
|
||||||
|
class ConsultReq(BaseModel):
|
||||||
|
query: str
|
||||||
|
|
||||||
|
@router.post("/consult")
|
||||||
|
async def legal_consultation(req: ConsultReq, user: dict = Depends(get_current_user)):
|
||||||
|
# AI Юрист
|
||||||
|
consultation = lawyer_agent.consult(req.query)
|
||||||
|
return {"success": True, "data": consultation}
|
||||||
26
backend/app/routers/tson.py
Normal file
26
backend/app/routers/tson.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from app.auth import get_current_user
|
||||||
|
from app.agents.queue_agent import queue_agent
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/tson", tags=["tson"])
|
||||||
|
|
||||||
|
@router.get("/queue/{tson_id}")
|
||||||
|
async def get_queue_load(tson_id: str, user: dict = Depends(get_current_user)):
|
||||||
|
# Умная очередь: предсказание от AI
|
||||||
|
prediction = queue_agent.predict_queue(tson_id)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": prediction
|
||||||
|
}
|
||||||
|
|
||||||
|
class BookSlotReq(BaseModel):
|
||||||
|
tson_id: str
|
||||||
|
time: str
|
||||||
|
|
||||||
|
@router.post("/book")
|
||||||
|
async def book_slot(req: BookSlotReq, user: dict = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {"ticket_number": f"T-{random.randint(100, 999)}", "time": req.time}
|
||||||
|
}
|
||||||
18
backend/app/routers/voice.py
Normal file
18
backend/app/routers/voice.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from fastapi import APIRouter, Depends, UploadFile, File
|
||||||
|
from app.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/voice", tags=["voice"])
|
||||||
|
|
||||||
|
@router.post("/transcribe")
|
||||||
|
async def transcribe(file: UploadFile = File(...), user: dict = Depends(get_current_user)):
|
||||||
|
"""Заглушка: в браузере используйте встроенное распознавание (кнопка микрофона в чате). Здесь — мок для демо."""
|
||||||
|
await file.read()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"transcript": "Запишитесь в ЦОН на завтра",
|
||||||
|
"language_detected": "ru",
|
||||||
|
"confidence": 0.85,
|
||||||
|
"note": "Для живого голоса в Chrome/Edge включите микрофон в чате — текст пойдёт в Claude без этой загрузки файла.",
|
||||||
|
},
|
||||||
|
}
|
||||||
114
backend/app/schema.sql
Normal file
114
backend/app/schema.sql
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
-- Supabase Schema for SuperGov
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
stack_user_id text UNIQUE NOT NULL,
|
||||||
|
iin text UNIQUE NOT NULL,
|
||||||
|
email text UNIQUE NOT NULL,
|
||||||
|
phone text,
|
||||||
|
full_name text,
|
||||||
|
address text,
|
||||||
|
birth_date date,
|
||||||
|
has_eds boolean DEFAULT false,
|
||||||
|
eds_expires_at timestamptz,
|
||||||
|
subscription_tier text DEFAULT 'free',
|
||||||
|
language text DEFAULT 'ru',
|
||||||
|
notification_telegram boolean DEFAULT true,
|
||||||
|
notification_email boolean DEFAULT true,
|
||||||
|
notification_sms boolean DEFAULT false,
|
||||||
|
telegram_chat_id text,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS applications (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id uuid REFERENCES users(id),
|
||||||
|
service_type text NOT NULL,
|
||||||
|
egov_application_id text,
|
||||||
|
status text DEFAULT 'submitted',
|
||||||
|
status_label text,
|
||||||
|
form_data jsonb DEFAULT '{}',
|
||||||
|
agency text,
|
||||||
|
predicted_completion_date date,
|
||||||
|
citizen_rating integer,
|
||||||
|
created_at timestamptz DEFAULT now(),
|
||||||
|
updated_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS application_steps (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
application_id uuid REFERENCES applications(id),
|
||||||
|
step_number integer,
|
||||||
|
title text,
|
||||||
|
status text DEFAULT 'pending',
|
||||||
|
completed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS complaints (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id uuid REFERENCES users(id),
|
||||||
|
category text,
|
||||||
|
description text,
|
||||||
|
lat double precision,
|
||||||
|
lng double precision,
|
||||||
|
status text DEFAULT 'new',
|
||||||
|
agency_assigned text,
|
||||||
|
votes integer DEFAULT 0,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id uuid REFERENCES users(id),
|
||||||
|
doc_type text,
|
||||||
|
file_name text,
|
||||||
|
storage_path text,
|
||||||
|
ocr_text text,
|
||||||
|
extracted_fields jsonb DEFAULT '{}',
|
||||||
|
expires_at date,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS benefits (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name text,
|
||||||
|
description text,
|
||||||
|
amount_min integer,
|
||||||
|
amount_max integer,
|
||||||
|
frequency text,
|
||||||
|
criteria jsonb,
|
||||||
|
agency text,
|
||||||
|
service_type text
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_benefits (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id uuid REFERENCES users(id),
|
||||||
|
benefit_id uuid REFERENCES benefits(id),
|
||||||
|
is_eligible boolean,
|
||||||
|
explanation text,
|
||||||
|
discovered_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id uuid REFERENCES users(id),
|
||||||
|
title text,
|
||||||
|
body text,
|
||||||
|
type text,
|
||||||
|
is_read boolean DEFAULT false,
|
||||||
|
created_at timestamptz DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agencies (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name text,
|
||||||
|
code text UNIQUE,
|
||||||
|
avg_processing_days numeric,
|
||||||
|
approval_rate numeric,
|
||||||
|
avg_citizen_rating numeric,
|
||||||
|
composite_score numeric,
|
||||||
|
score_trend numeric
|
||||||
|
);
|
||||||
25
backend/app/services/egov_mock.py
Normal file
25
backend/app/services/egov_mock.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
DATA_DIR = os.path.join(os.path.dirname(__file__), "../data")
|
||||||
|
|
||||||
|
class EGovMock:
|
||||||
|
def __init__(self):
|
||||||
|
self.mock_enabled = os.getenv("EGOV_MOCK", "true").lower() == "true"
|
||||||
|
self._citizens = []
|
||||||
|
if self.mock_enabled:
|
||||||
|
_path = os.path.join(DATA_DIR, "citizens.json")
|
||||||
|
if os.path.exists(_path):
|
||||||
|
with open(_path, "r", encoding="utf-8") as f:
|
||||||
|
self._citizens = json.load(f)
|
||||||
|
|
||||||
|
def get_citizen_by_iin(self, iin: str) -> Optional[dict]:
|
||||||
|
if not self.mock_enabled:
|
||||||
|
raise Exception("Mock disabled. Actual eGov integration required.")
|
||||||
|
for citizen in self._citizens:
|
||||||
|
if citizen.get("iin") == iin:
|
||||||
|
return citizen
|
||||||
|
return None
|
||||||
|
|
||||||
|
egov_mock = EGovMock()
|
||||||
50
backend/app/services/halyk_connector.py
Normal file
50
backend/app/services/halyk_connector.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class HalykConnector:
|
||||||
|
"""
|
||||||
|
Mock Halyk Bank OpenBanking Integration
|
||||||
|
Provides checking accounts, balance, and mock payment gateway.
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
self.mock = os.getenv("EGOV_MOCK", "true").lower() == "true"
|
||||||
|
# Seed mock bank data per iin
|
||||||
|
self._accounts = {
|
||||||
|
"870412300415": [
|
||||||
|
{"account_id": "KZ1234567890", "type": "debit", "balance": 450000.0, "currency": "KZT"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_accounts(self, iin: str) -> list:
|
||||||
|
if not self.mock:
|
||||||
|
raise Exception("Halyk prod bank integration not configured")
|
||||||
|
if iin not in self._accounts:
|
||||||
|
self._accounts[iin] = [
|
||||||
|
{
|
||||||
|
"account_id": f"KZ{iin[-6:]}{'0' * 4}",
|
||||||
|
"type": "debit",
|
||||||
|
"balance": 250_000.0,
|
||||||
|
"currency": "KZT",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return self._accounts[iin]
|
||||||
|
|
||||||
|
def process_payment(self, iin: str, amount: float, purpose: str) -> dict:
|
||||||
|
accounts = self.get_accounts(iin)
|
||||||
|
if not accounts:
|
||||||
|
return {"success": False, "error": "No accounts found for IIN"}
|
||||||
|
|
||||||
|
main_account = accounts[0]
|
||||||
|
if main_account["balance"] < amount:
|
||||||
|
return {"success": False, "error": "Insufficient funds"}
|
||||||
|
|
||||||
|
main_account["balance"] -= amount
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"transaction_id": str(uuid.uuid4()),
|
||||||
|
"old_balance": main_account["balance"] + amount,
|
||||||
|
"new_balance": main_account["balance"],
|
||||||
|
"purpose": purpose
|
||||||
|
}
|
||||||
|
|
||||||
|
halyk_bank = HalykConnector()
|
||||||
53
backend/app/services/notifications.py
Normal file
53
backend/app/services/notifications.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import os
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_telegram(chat_id: str, message: str):
|
||||||
|
bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||||
|
if not bot_token:
|
||||||
|
print("Telegram skipping: no token")
|
||||||
|
return
|
||||||
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||||
|
httpx.post(url, json={"chat_id": chat_id, "text": message}, timeout=15.0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_email(to_email: str, subject: str, html_body: str) -> bool:
|
||||||
|
api_key = os.getenv("SENDGRID_API_KEY")
|
||||||
|
from_email = os.getenv("SENDGRID_FROM_EMAIL", "noreply@supergov.local")
|
||||||
|
if not api_key:
|
||||||
|
print("SendGrid skipping: no SENDGRID_API_KEY")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
r = httpx.post(
|
||||||
|
"https://api.sendgrid.com/v3/mail/send",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"personalizations": [{"to": [{"email": to_email}]}],
|
||||||
|
"from": {"email": from_email},
|
||||||
|
"subject": subject,
|
||||||
|
"content": [{"type": "text/html", "value": html_body}],
|
||||||
|
},
|
||||||
|
timeout=20.0,
|
||||||
|
)
|
||||||
|
return r.status_code in (200, 202)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"SendGrid error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_sms(to_phone: str, message: str):
|
||||||
|
sid = os.getenv("TWILIO_ACCOUNT_SID")
|
||||||
|
token = os.getenv("TWILIO_AUTH_TOKEN")
|
||||||
|
_from = os.getenv("TWILIO_FROM_NUMBER")
|
||||||
|
if not sid or not token or not _from:
|
||||||
|
print("Twilio skipping: missing creds")
|
||||||
|
return
|
||||||
|
url = f"https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json"
|
||||||
|
data = {"To": to_phone, "From": _from, "Body": message}
|
||||||
|
httpx.post(url, data=data, auth=(sid, token), timeout=20.0)
|
||||||
15
backend/app/services/ocr_service.py
Normal file
15
backend/app/services/ocr_service.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import pytesseract
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
class OCRService:
|
||||||
|
@staticmethod
|
||||||
|
def extract_text(file_bytes: bytes) -> str:
|
||||||
|
try:
|
||||||
|
image = Image.open(io.BytesIO(file_bytes))
|
||||||
|
# Require both RU and KZ, ENG fallback
|
||||||
|
text = pytesseract.image_to_string(image, lang="rus+kaz+eng")
|
||||||
|
return text
|
||||||
|
except Exception as e:
|
||||||
|
print(f"OCR Error: {e}")
|
||||||
|
return ""
|
||||||
40
backend/app/services/otp_store.py
Normal file
40
backend/app/services/otp_store.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
from app.redis_client import redis_client
|
||||||
|
|
||||||
|
_MEMORY: dict[str, tuple[str, float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _key(email: str) -> str:
|
||||||
|
return f"supergov:otp:{email.lower().strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_code() -> str:
|
||||||
|
return "".join(random.choices(string.digits, k=6))
|
||||||
|
|
||||||
|
|
||||||
|
def save_code(email: str, code: str, ttl_sec: int = 600) -> None:
|
||||||
|
try:
|
||||||
|
redis_client.client.setex(_key(email), ttl_sec, code)
|
||||||
|
except Exception:
|
||||||
|
_MEMORY[email.lower().strip()] = (code, time.time() + ttl_sec)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_and_consume(email: str, code: str) -> bool:
|
||||||
|
k = email.lower().strip()
|
||||||
|
try:
|
||||||
|
stored = redis_client.client.get(_key(email))
|
||||||
|
if not stored or stored != code:
|
||||||
|
return False
|
||||||
|
redis_client.client.delete(_key(email))
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
if k not in _MEMORY:
|
||||||
|
return False
|
||||||
|
c, exp = _MEMORY[k]
|
||||||
|
if time.time() > exp or c != code:
|
||||||
|
del _MEMORY[k]
|
||||||
|
return False
|
||||||
|
del _MEMORY[k]
|
||||||
|
return True
|
||||||
25
backend/app/services/pdf_generator.py
Normal file
25
backend/app/services/pdf_generator.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
import io
|
||||||
|
|
||||||
|
class PdfGenerator:
|
||||||
|
@staticmethod
|
||||||
|
def generate_form(data: dict) -> bytes:
|
||||||
|
packet = io.BytesIO()
|
||||||
|
c = canvas.Canvas(packet, pagesize=A4)
|
||||||
|
c.drawString(100, 800, "SuperGov - Form Generation")
|
||||||
|
|
||||||
|
y = 750
|
||||||
|
for key, val in data.items():
|
||||||
|
if isinstance(val, dict):
|
||||||
|
c.drawString(100, y, f"{key}:")
|
||||||
|
y -= 20
|
||||||
|
for k, v in val.items():
|
||||||
|
c.drawString(120, y, f"{k}: {v}")
|
||||||
|
y -= 20
|
||||||
|
else:
|
||||||
|
c.drawString(100, y, f"{key}: {val}")
|
||||||
|
y -= 20
|
||||||
|
c.save()
|
||||||
|
packet.seek(0)
|
||||||
|
return packet.read()
|
||||||
26
backend/app/services/rule_engine.py
Normal file
26
backend/app/services/rule_engine.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
class RuleEngine:
|
||||||
|
@staticmethod
|
||||||
|
def check_eligibility(profile: dict, benefit_criteria: dict) -> bool:
|
||||||
|
"""
|
||||||
|
Runs simple rules. Custom parsing mapped from JSON.
|
||||||
|
E.g. {"has_children": true, "max_child_age": 1}
|
||||||
|
"""
|
||||||
|
# Children check
|
||||||
|
if benefit_criteria.get("has_children"):
|
||||||
|
family = profile.get("family", [])
|
||||||
|
children = [f for f in family if f.get("relation") == "child"]
|
||||||
|
if not children:
|
||||||
|
return False
|
||||||
|
max_age = benefit_criteria.get("max_child_age")
|
||||||
|
if max_age:
|
||||||
|
if not any(c.get("age", 99) <= max_age for c in children):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Income check
|
||||||
|
max_income = benefit_criteria.get("max_income")
|
||||||
|
if max_income:
|
||||||
|
if profile.get("income_monthly", 9999999) > max_income:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Add more mappings as rules expand
|
||||||
|
return True
|
||||||
7
backend/app/tasks/analytics_calc.py
Normal file
7
backend/app/tasks/analytics_calc.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from app.tasks.celery_app import celery_app
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def calc_agency_scores():
|
||||||
|
print("Recalculating composite scores for all agencies...")
|
||||||
|
# Add score aggregation logic here
|
||||||
|
pass
|
||||||
7
backend/app/tasks/benefits_calc.py
Normal file
7
backend/app/tasks/benefits_calc.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from app.tasks.celery_app import celery_app
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def calc_all():
|
||||||
|
print("Recalculating benefits matrix for all citizens...")
|
||||||
|
# Add eligibility generation logic here
|
||||||
|
pass
|
||||||
30
backend/app/tasks/celery_app.py
Normal file
30
backend/app/tasks/celery_app.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from celery import Celery
|
||||||
|
from celery.schedules import crontab
|
||||||
|
import os
|
||||||
|
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||||
|
|
||||||
|
celery_app = Celery("supergov", broker=REDIS_URL, backend=REDIS_URL)
|
||||||
|
|
||||||
|
celery_app.conf.update(
|
||||||
|
task_serializer="json",
|
||||||
|
accept_content=["json"],
|
||||||
|
result_serializer="json",
|
||||||
|
timezone="Asia/Almaty",
|
||||||
|
enable_utc=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
celery_app.conf.beat_schedule = {
|
||||||
|
"poll_application_statuses": {
|
||||||
|
"task": "app.tasks.status_poller.poll_statuses",
|
||||||
|
"schedule": 900.0, # Every 15 min
|
||||||
|
},
|
||||||
|
"recalc_benefits_daily": {
|
||||||
|
"task": "app.tasks.benefits_calc.calc_all",
|
||||||
|
"schedule": crontab(hour=3, minute=0), # 3am daily
|
||||||
|
},
|
||||||
|
"recalc_analytics_weekly": {
|
||||||
|
"task": "app.tasks.analytics_calc.calc_agency_scores",
|
||||||
|
"schedule": crontab(day_of_week='sun', hour=4, minute=0),
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/app/tasks/status_poller.py
Normal file
16
backend/app/tasks/status_poller.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
from app.tasks.celery_app import celery_app
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def poll_statuses():
|
||||||
|
print("Polling external eGov statuses for all active applications...")
|
||||||
|
pass
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def calc_all():
|
||||||
|
print("Recalculating benefits matrix for all citizens...")
|
||||||
|
pass
|
||||||
|
|
||||||
|
@celery_app.task
|
||||||
|
def calc_agency_scores():
|
||||||
|
print("Recalculating composite scores for all agencies...")
|
||||||
|
pass
|
||||||
36
backend/docker-compose.yml
Normal file
36
backend/docker-compose.yml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: supergov-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
celery_worker:
|
||||||
|
build: .
|
||||||
|
command: celery -A app.tasks.celery_app worker --loglevel=info
|
||||||
|
container_name: supergov-celery-worker
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
celery_beat:
|
||||||
|
build: .
|
||||||
|
command: celery -A app.tasks.celery_app beat --loglevel=info
|
||||||
|
container_name: supergov-celery-beat
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
15
backend/requirements.txt
Normal file
15
backend/requirements.txt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
anthropic
|
||||||
|
supabase
|
||||||
|
redis
|
||||||
|
celery
|
||||||
|
python-multipart
|
||||||
|
httpx
|
||||||
|
pydantic
|
||||||
|
python-jose[cryptography]
|
||||||
|
python-dotenv
|
||||||
|
reportlab
|
||||||
|
pytesseract
|
||||||
|
Pillow
|
||||||
|
pyproj
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
index.html
Normal file
13
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="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>gogo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10314
package-lock.json
generated
Normal file
10314
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
package.json
Normal file
55
package.json
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "gogo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"dev:api": "node scripts/run-api.mjs",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@stackframe/stack": "^2.8.80",
|
||||||
|
"@supabase/supabase-js": "^2.101.1",
|
||||||
|
"@tanstack/react-query": "^5.96.2",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.72.1",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
|
"vite": "^8.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
31
scripts/run-api.mjs
Normal file
31
scripts/run-api.mjs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const root = path.join(__dirname, '..');
|
||||||
|
const backend = path.join(root, 'backend');
|
||||||
|
const win = process.platform === 'win32';
|
||||||
|
const pyName = win ? 'python.exe' : 'python';
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
path.join(root, '.venv', win ? 'Scripts' : 'bin', pyName),
|
||||||
|
path.join(root, 'backend', 'venv', win ? 'Scripts' : 'bin', pyName),
|
||||||
|
];
|
||||||
|
|
||||||
|
const python = candidates.find((p) => existsSync(p));
|
||||||
|
if (!python) {
|
||||||
|
console.error(
|
||||||
|
'Не найден Python в .venv или backend\\venv. Создайте venv и: pip install -r backend/requirements.txt',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(
|
||||||
|
python,
|
||||||
|
['-m', 'uvicorn', 'app.main:app', '--reload', '--host', '127.0.0.1', '--port', '8000'],
|
||||||
|
{ cwd: backend, stdio: 'inherit', shell: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
child.on('exit', (code) => process.exit(code ?? 0));
|
||||||
184
src/App.css
Normal file
184
src/App.css
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/App.tsx
Normal file
98
src/App.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { Suspense, type ComponentProps } from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { StackProvider, useUser } from '@stackframe/stack';
|
||||||
|
import { stack, stackInitError } from './lib/stack';
|
||||||
|
|
||||||
|
import { AuthLayout } from './components/layout/AuthLayout';
|
||||||
|
import { DashboardLayout } from './components/layout/DashboardLayout';
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
import { Login } from './pages/auth/Login';
|
||||||
|
import { Register } from './pages/auth/Register';
|
||||||
|
|
||||||
|
import { Dashboard } from './pages/dashboard/Dashboard';
|
||||||
|
import { Chat } from './pages/chat/Chat';
|
||||||
|
import { Services } from './pages/services/Services';
|
||||||
|
import { ApplicationWizard } from './pages/services/ApplicationWizard';
|
||||||
|
import { Applications } from './pages/applications/Applications';
|
||||||
|
import { Tracker } from './pages/tracker/Tracker';
|
||||||
|
import { Benefits } from './pages/benefits/Benefits';
|
||||||
|
import { ComplaintsMap } from './pages/map/ComplaintsMap';
|
||||||
|
import { AgenciesRating } from './pages/rating/AgenciesRating';
|
||||||
|
import { Profile } from './pages/profile/Profile';
|
||||||
|
import { UserProfileBootstrap } from './components/UserProfileBootstrap';
|
||||||
|
import { getOtpToken } from './lib/apiHeaders';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const stackUser = useUser();
|
||||||
|
const otp = typeof localStorage !== 'undefined' ? getOtpToken() : null;
|
||||||
|
if (!stackUser && !otp) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UserProfileBootstrap />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
if (stackInitError || !stack) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-100 p-6">
|
||||||
|
<div className="max-w-lg rounded-2xl border border-amber-200 bg-white p-8 shadow-lg">
|
||||||
|
<h1 className="text-xl font-bold text-navy mb-3">Не настроен Stack Auth</h1>
|
||||||
|
<p className="text-slate-600 text-sm leading-relaxed mb-4">{stackInitError}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Файл <code className="rounded bg-slate-100 px-1">.env</code> должен лежать в папке{' '}
|
||||||
|
<code className="rounded bg-slate-100 px-1">gogo</code> (рядом с{' '}
|
||||||
|
<code className="rounded bg-slate-100 px-1">package.json</code>), не только в{' '}
|
||||||
|
<code className="rounded bg-slate-100 px-1">backend</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<StackProvider app={stack as ComponentProps<typeof StackProvider>['app']}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Suspense fallback={<div className="h-screen w-full flex items-center justify-center bg-slate-50"><div className="w-10 h-10 border-4 border-cyan border-t-transparent rounded-full animate-spin"></div></div>}>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AuthLayout />}>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Dashboard Routes (Protected) */}
|
||||||
|
<Route element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/chat" element={<Chat />} />
|
||||||
|
<Route path="/services" element={<Services />} />
|
||||||
|
<Route path="/application/new/:serviceType" element={<ApplicationWizard />} />
|
||||||
|
<Route path="/applications" element={<Applications />} />
|
||||||
|
<Route path="/tracker" element={<Tracker />} />
|
||||||
|
<Route path="/benefits" element={<Benefits />} />
|
||||||
|
<Route path="/map" element={<ComplaintsMap />} />
|
||||||
|
<Route path="/rating" element={<AgenciesRating />} />
|
||||||
|
<Route path="/profile" element={<Profile />} />
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</Suspense>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StackProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
src/assets/react.svg
Normal file
1
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 |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
47
src/components/ErrorBoundary.tsx
Normal file
47
src/components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-4">
|
||||||
|
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-lg w-full border border-red-100">
|
||||||
|
<h1 className="text-2xl font-bold text-red-600 mb-2">Ошибка Инициализации</h1>
|
||||||
|
<p className="text-slate-600 text-sm mb-4">
|
||||||
|
Приложение не смогло запуститься. Скорее всего, это связано с отсутствием или недействительностью ключей авторизации Stack Auth.
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-600 text-sm mb-6">
|
||||||
|
Пожалуйста, добавьте <strong>VITE_STACK_PUBLISHABLE_KEY</strong> в ваш файл <code>.env</code> в корне проекта.
|
||||||
|
</p>
|
||||||
|
<div className="bg-red-50 p-4 rounded-xl text-xs font-mono text-red-800 break-words border border-red-100">
|
||||||
|
{this.state.error?.message || "Unknown error occurred"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/components/UserProfileBootstrap.tsx
Normal file
53
src/components/UserProfileBootstrap.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useUser } from '@stackframe/stack';
|
||||||
|
import { useStore } from '../store/useStore';
|
||||||
|
import { getApiBase } from '../lib/apiBase';
|
||||||
|
import { buildAuthHeaders } from '../lib/apiHeaders';
|
||||||
|
|
||||||
|
const API_BASE = getApiBase();
|
||||||
|
|
||||||
|
/** Подтягивает профиль из API (Stack JWT или OTP JWT) в Zustand. */
|
||||||
|
export function UserProfileBootstrap() {
|
||||||
|
const stackUser = useUser();
|
||||||
|
const setUser = useStore((s) => s.setUser);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const headers = await buildAuthHeaders(stackUser as Parameters<typeof buildAuthHeaders>[0]);
|
||||||
|
if (!headers.Authorization) {
|
||||||
|
if (stackUser?.displayName) {
|
||||||
|
setUser({
|
||||||
|
name: stackUser.displayName || 'Пользователь',
|
||||||
|
email: stackUser.primaryEmail || '',
|
||||||
|
phone: '',
|
||||||
|
iin: '',
|
||||||
|
stackUserId: stackUser.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/me`, { headers });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const json = (await res.json()) as { data?: Record<string, string> };
|
||||||
|
const u = json.data;
|
||||||
|
if (cancelled || !u) return;
|
||||||
|
setUser({
|
||||||
|
name: (u.full_name as string) || (u.email as string) || 'Пользователь',
|
||||||
|
email: (u.email as string) || '',
|
||||||
|
phone: (u.phone as string) || '',
|
||||||
|
iin: (u.iin as string) || '',
|
||||||
|
stackUserId: (u.stack_user_id as string) || '',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [stackUser, setUser]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
23
src/components/layout/AuthLayout.tsx
Normal file
23
src/components/layout/AuthLayout.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function AuthLayout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex flex-col justify-center items-center p-4 relative overflow-hidden">
|
||||||
|
{/* Abstract Background Elements */}
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-96 h-96 bg-cyan/20 blur-3xl rounded-full pointer-events-none" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-96 h-96 bg-gold/10 blur-3xl rounded-full pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="w-full max-w-md z-10">
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<h1 className="text-4xl font-extrabold text-navy flex items-center justify-center tracking-tight">
|
||||||
|
<span className="text-cyan">super</span>gov
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 mt-3 text-sm font-medium">Государство в твоем смартфоне</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/80 backdrop-blur-xl rounded-3xl shadow-xl shadow-navy/5 p-8 border border-white/40">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/layout/DashboardLayout.tsx
Normal file
19
src/components/layout/DashboardLayout.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { TopBar } from './TopBar';
|
||||||
|
import { MobileNav } from './MobileNav';
|
||||||
|
|
||||||
|
export function DashboardLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-[#f8fafc] overflow-hidden w-full">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden w-full">
|
||||||
|
<TopBar />
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 md:p-6 pb-24 md:pb-6 w-full max-w-7xl mx-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<MobileNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/layout/MobileNav.tsx
Normal file
38
src/components/layout/MobileNav.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { LayoutDashboard, MessageSquare, Briefcase, Map as MapIcon, User } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export function MobileNav() {
|
||||||
|
const items = [
|
||||||
|
{ to: '/dashboard', icon: LayoutDashboard, label: 'Главная' },
|
||||||
|
{ to: '/chat', icon: MessageSquare, label: 'Чат' },
|
||||||
|
{ to: '/applications', icon: Briefcase, label: 'Заявки' },
|
||||||
|
{ to: '/map', icon: MapIcon, label: 'Карта' },
|
||||||
|
{ to: '/profile', icon: User, label: 'Профиль' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white/90 backdrop-blur-md border-t border-slate-200 pb-safe z-50 shadow-[0_-4px_10px_rgba(0,0,0,0.02)]">
|
||||||
|
<ul className="flex items-center justify-around h-16 px-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.to} className="w-full">
|
||||||
|
<NavLink
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) => cn(
|
||||||
|
"flex flex-col items-center justify-center h-full gap-1 text-[10px] font-medium transition-colors",
|
||||||
|
isActive ? "text-cyan" : "text-slate-400 hover:text-slate-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<item.icon className={cn("w-5 h-5", isActive ? "text-cyan" : "")} />
|
||||||
|
<span className={isActive ? "font-bold" : ""}>{item.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/components/layout/Sidebar.tsx
Normal file
63
src/components/layout/Sidebar.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { useStore } from '../../store/useStore';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { LayoutDashboard, MessageSquare, Briefcase, Map as MapIcon, Star, User } from 'lucide-react';
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const { user } = useStore();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/dashboard', icon: LayoutDashboard, label: 'Главная' },
|
||||||
|
{ to: '/chat', icon: MessageSquare, label: 'AI Чат' },
|
||||||
|
{ to: '/services', icon: Briefcase, label: 'Услуги', badge: 18 },
|
||||||
|
{ to: '/applications', icon: Briefcase, label: 'Заявки' },
|
||||||
|
{ to: '/map', icon: MapIcon, label: 'Карта' },
|
||||||
|
{ to: '/rating', icon: Star, label: 'Рейтинг' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="hidden md:flex flex-col w-64 h-screen border-r border-slate-200 bg-white sticky top-0 shrink-0">
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-navy flex items-center">
|
||||||
|
<span className="text-cyan">super</span>gov
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 px-4 space-y-2">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) => cn(
|
||||||
|
"flex items-center gap-3 px-3 py-2.5 rounded-xl font-medium transition-colors",
|
||||||
|
isActive ? "bg-cyan/10 text-cyan font-semibold" : "text-slate-600 hover:bg-slate-50 hover:text-navy"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className={cn("w-5 h-5", item.to === '/chat' && "text-cyan")} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
{item.badge && (
|
||||||
|
<span className="ml-auto bg-navy text-white text-[10px] font-bold px-2 py-0.5 rounded-full">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 mt-auto border-t border-slate-100">
|
||||||
|
<NavLink
|
||||||
|
to="/profile"
|
||||||
|
className="flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full bg-cyan/20 flex items-center justify-center text-cyan font-bold shrink-0">
|
||||||
|
{user?.name?.charAt(0) || <User className="w-5 h-5" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-navy truncate">{user?.name || "Профиль"}</p>
|
||||||
|
{user?.iin && <p className="text-xs text-slate-500 truncate">IIN: ****{user.iin.slice(-4)}</p>}
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/layout/TopBar.tsx
Normal file
110
src/components/layout/TopBar.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Search, Bell, LogOut } from 'lucide-react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useStackApp } from '@stackframe/stack';
|
||||||
|
import { useStore } from '../../store/useStore';
|
||||||
|
import { clearOtpToken } from '../../lib/apiHeaders';
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const stackApp = useStackApp();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const { language, setLanguage, user, setUser } = useStore();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setLoggingOut(true);
|
||||||
|
try {
|
||||||
|
clearOtpToken();
|
||||||
|
setUser(null);
|
||||||
|
try {
|
||||||
|
await stackApp.signOut();
|
||||||
|
} catch {
|
||||||
|
/* нет сессии Stack — только OTP */
|
||||||
|
}
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
} finally {
|
||||||
|
setLoggingOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageTitle = () => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'/dashboard': 'Главная',
|
||||||
|
'/chat': 'AI Ассистент',
|
||||||
|
'/services': 'Услуги',
|
||||||
|
'/applications': 'Мои заявки',
|
||||||
|
'/map': 'Карта проблем',
|
||||||
|
'/rating': 'Рейтинг ведомств',
|
||||||
|
'/profile': 'Профиль',
|
||||||
|
};
|
||||||
|
return map[location.pathname] || 'SuperGov';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (search.trim()) {
|
||||||
|
navigate(`/chat?q=${encodeURIComponent(search)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="h-16 border-b border-slate-200 bg-white/80 backdrop-blur-md flex items-center justify-between px-6 sticky top-0 z-10">
|
||||||
|
<h2 className="text-xl font-semibold text-navy hidden md:block">{getPageTitle()}</h2>
|
||||||
|
|
||||||
|
{/* Mobile Title */}
|
||||||
|
<h2 className="text-lg font-bold text-navy md:hidden flex items-center">
|
||||||
|
<span className="text-cyan">super</span>gov
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 ml-auto">
|
||||||
|
{user?.name && (
|
||||||
|
<span className="hidden lg:inline text-sm text-slate-600 max-w-[140px] truncate" title={user.name}>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSearch} className="hidden md:flex relative">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Задать вопрос AI..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9 pr-4 py-2 w-64 bg-slate-100 border-none rounded-full text-sm focus:ring-2 focus:ring-cyan outline-none transition-all focus:bg-white focus:w-80 shadow-sm focus:shadow-md"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button className="relative p-2 text-slate-600 hover:bg-slate-100 rounded-full transition-colors">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
<span className="absolute top-1 right-1 w-2.5 h-2.5 bg-red-500 rounded-full border-2 border-white"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center bg-slate-100 rounded-full p-1 border border-slate-200 shadow-inner hidden md:flex">
|
||||||
|
{['kz', 'ru', 'en'].map((lang) => (
|
||||||
|
<button
|
||||||
|
key={lang}
|
||||||
|
onClick={() => setLanguage(lang as 'kz' | 'ru' | 'en')}
|
||||||
|
className={`px-3 py-1 text-[10px] font-bold rounded-full transition-colors uppercase tracking-wider ${
|
||||||
|
language === lang ? 'bg-white text-navy shadow-sm' : 'text-slate-500 hover:text-navy'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lang}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleLogout()}
|
||||||
|
disabled={loggingOut}
|
||||||
|
className="flex items-center gap-1.5 rounded-full border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 hover:text-navy disabled:opacity-60"
|
||||||
|
title="Выйти"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">{loggingOut ? 'Выход…' : 'Выйти'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/ui/Badge.tsx
Normal file
31
src/components/ui/Badge.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { HTMLAttributes, forwardRef } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: 'default' | 'success' | 'warning' | 'danger' | 'outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Badge = forwardRef<HTMLDivElement, BadgeProps>(
|
||||||
|
({ className, variant = 'default', ...props }, ref) => {
|
||||||
|
const variants = {
|
||||||
|
default: 'bg-navy/10 text-navy',
|
||||||
|
success: 'bg-emerald-100 text-emerald-800',
|
||||||
|
warning: 'bg-gold/20 text-yellow-800',
|
||||||
|
danger: 'bg-red-100 text-red-800',
|
||||||
|
outline: 'border border-slate-200 text-slate-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold tracking-wide transition-colors",
|
||||||
|
variants[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Badge.displayName = "Badge";
|
||||||
41
src/components/ui/Button.tsx
Normal file
41
src/components/ui/Button.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { ButtonHTMLAttributes, forwardRef } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { motion, HTMLMotionProps } from 'framer-motion';
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersect standard button props with motion props
|
||||||
|
type MotionButtonProps = Omit<ButtonProps, keyof HTMLMotionProps<"button">> & HTMLMotionProps<"button">;
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, MotionButtonProps>(
|
||||||
|
({ className, variant = 'primary', size = 'md', ...props }, ref) => {
|
||||||
|
const baseStyles = 'inline-flex items-center justify-center rounded-xl font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan disabled:pointer-events-none disabled:opacity-50';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-navy text-white hover:bg-navy/90 shadow-lg shadow-navy/20',
|
||||||
|
secondary: 'bg-cyan text-white hover:bg-cyan/90 shadow-lg shadow-cyan/20',
|
||||||
|
outline: 'border-2 border-slate-200 text-slate-700 hover:bg-slate-50',
|
||||||
|
ghost: 'hover:bg-slate-100 text-slate-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-9 px-4 text-sm',
|
||||||
|
md: 'h-11 px-6 text-base',
|
||||||
|
lg: 'h-14 px-8 text-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
ref={ref}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
35
src/components/ui/Card.tsx
Normal file
35
src/components/ui/Card.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { HTMLAttributes, forwardRef } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { motion, HTMLMotionProps } from 'framer-motion';
|
||||||
|
|
||||||
|
export const Card = forwardRef<HTMLDivElement, HTMLMotionProps<"div">>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("rounded-2xl border border-slate-100 bg-white text-slate-900 shadow-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
export const CardTitle = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3 ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight text-navy", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
29
src/components/ui/Input.tsx
Normal file
29
src/components/ui/Input.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { InputHTMLAttributes, forwardRef } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, label, error, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 w-full">
|
||||||
|
{label && <label className="text-sm font-medium text-slate-700">{label}</label>}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 w-full rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm transition-all shadow-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan focus-visible:border-cyan disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
error && "border-red-500 focus-visible:ring-red-500",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <span className="text-sm text-red-500">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
28
src/data/aiCapabilities.ts
Normal file
28
src/data/aiCapabilities.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/** 18 функций SuperGov — быстрые запросы к Claude + инструментам */
|
||||||
|
export type AiCapability = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
prompt: string;
|
||||||
|
tag: 'innovation' | 'mvp';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AI_CAPABILITIES: AiCapability[] = [
|
||||||
|
{ id: 'voice', label: 'Голосовой агент', prompt: 'Что умеет голосовой агент SuperGov для пожилых и регионов?', tag: 'innovation' },
|
||||||
|
{ id: 'benefits', label: 'Предиктивные льготы', prompt: 'Какие пособия и субсидии мне могут подойти? Проверь предиктивные льготы.', tag: 'innovation' },
|
||||||
|
{ id: 'lawyer', label: 'AI-юрист', prompt: 'Объясни простым языком мои права при обращении в госорган (со ссылками на НПА).', tag: 'innovation' },
|
||||||
|
{ id: 'queue', label: 'Умная очередь ЦОН', prompt: 'Как записаться в ЦОН с умной очередью и прогнозом загрузки?', tag: 'innovation' },
|
||||||
|
{ id: 'rating', label: 'Рейтинг ведомств', prompt: 'Покажи, как работает публичный рейтинг ведомств по скорости и качеству.', tag: 'innovation' },
|
||||||
|
{ id: 'storage', label: 'Цифровое хранилище', prompt: 'Как хранить документы в облаке и автоподгружать их в заявках?', tag: 'innovation' },
|
||||||
|
{ id: 'family', label: 'Семейный профиль', prompt: 'Как настроить семейный профиль и доверенности онлайн?', tag: 'innovation' },
|
||||||
|
{ id: 'gov', label: 'Gov-аналитика', prompt: 'Что показывает Gov-аналитика для государства: узкие места и тренды?', tag: 'innovation' },
|
||||||
|
{ id: 'offline', label: 'Офлайн-режим', prompt: 'Как заполнять формы офлайн и синхронизировать при появлении интернета?', tag: 'innovation' },
|
||||||
|
{ id: 'sandbox', label: 'Песочница', prompt: 'Как проверить заявку в песочнице до реальной отправки?', tag: 'innovation' },
|
||||||
|
{ id: 'chat', label: 'AI-помощник', prompt: 'Привет! Ответь на казахском: как подать заявку на справку?', tag: 'mvp' },
|
||||||
|
{ id: 'forms', label: 'Генерация форм', prompt: 'Как работает автозаполнение формы из ИИН и профиля?', tag: 'mvp' },
|
||||||
|
{ id: 'guide', label: 'Пошаговый гайд', prompt: 'Дай пошаговый гайд с прогрессом для выбранной госуслуги.', tag: 'mvp' },
|
||||||
|
{ id: 'status', label: 'Дашборд статусов', prompt: 'Где посмотреть все мои заявки и статусы в реальном времени?', tag: 'mvp' },
|
||||||
|
{ id: 'autofill', label: 'Автозаполнение', prompt: 'Как подставить данные профиля в поля формы без ручного ввода?', tag: 'mvp' },
|
||||||
|
{ id: 'refusal', label: 'AI отказов', prompt: 'Как объяснить отказ в заявке простым языком и что исправить?', tag: 'mvp' },
|
||||||
|
{ id: 'map', label: 'Карта жалоб', prompt: 'Как работает карта жалоб с кластерами по районам?', tag: 'mvp' },
|
||||||
|
{ id: 'flow', label: 'Единый поток', prompt: 'Как оформить жизненное событие и запустить связанные услуги параллельно?', tag: 'mvp' },
|
||||||
|
];
|
||||||
54
src/data/superGovServices.ts
Normal file
54
src/data/superGovServices.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Mic,
|
||||||
|
Sparkles,
|
||||||
|
Scale,
|
||||||
|
CalendarClock,
|
||||||
|
Star,
|
||||||
|
FolderOpen,
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
WifiOff,
|
||||||
|
FlaskConical,
|
||||||
|
MessageSquare,
|
||||||
|
FileInput,
|
||||||
|
ListOrdered,
|
||||||
|
LayoutDashboard,
|
||||||
|
FormInput,
|
||||||
|
FileWarning,
|
||||||
|
MapPin,
|
||||||
|
GitBranch,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export type SuperGovService = {
|
||||||
|
id: string;
|
||||||
|
tier: 'innovation' | 'mvp';
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
/** маршрут или deep-link в чат */
|
||||||
|
href: string;
|
||||||
|
chatPrompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 18 услуг/функций продукта */
|
||||||
|
export const SUPERGOV_SERVICES: SuperGovService[] = [
|
||||||
|
{ id: 'voice-agent', tier: 'innovation', title: 'Голосовой агент', subtitle: 'Полное управление голосом. Для пожилых и регионов', icon: Mic, href: '/chat', chatPrompt: 'Расскажи про голосовой агент SuperGov' },
|
||||||
|
{ id: 'predict-benefits', tier: 'innovation', title: 'Предиктивные льготы', subtitle: 'ИИ находит пособия и субсидии по праву', icon: Sparkles, href: '/benefits', chatPrompt: 'Какие льготы мне доступны? Предиктивный подбор.' },
|
||||||
|
{ id: 'ai-lawyer', tier: 'innovation', title: 'AI-юрист', subtitle: 'Законы простым языком, ссылки на НПА', icon: Scale, href: '/chat', chatPrompt: 'Объясни мои права при обращении в госорган простым языком' },
|
||||||
|
{ id: 'smart-queue', tier: 'innovation', title: 'Умная очередь ЦОН', subtitle: 'Запись с прогнозом загрузки', icon: CalendarClock, href: '/chat', chatPrompt: 'Как записаться в ЦОН с умной очередью?' },
|
||||||
|
{ id: 'agency-rating', tier: 'innovation', title: 'Рейтинг ведомств', subtitle: 'Публичный рейтинг скорости и качества', icon: Star, href: '/rating', chatPrompt: 'Как устроен рейтинг ведомств?' },
|
||||||
|
{ id: 'digital-vault', tier: 'innovation', title: 'Цифровое хранилище', subtitle: 'Документы в облаке, автоподгрузка в заявках', icon: FolderOpen, href: '/chat', chatPrompt: 'Как работает цифровое хранилище документов?' },
|
||||||
|
{ id: 'family', tier: 'innovation', title: 'Семейный профиль', subtitle: 'Один аккаунт на семью, доверенности онлайн', icon: Users, href: '/chat', chatPrompt: 'Как настроить семейный профиль?' },
|
||||||
|
{ id: 'gov-analytics', tier: 'innovation', title: 'Gov-аналитика', subtitle: 'Узкие места, аномалии, тренды для государства', icon: BarChart3, href: '/chat', chatPrompt: 'Что показывает Gov-аналитика?' },
|
||||||
|
{ id: 'offline', tier: 'innovation', title: 'Офлайн-режим', subtitle: 'Формы без сети, синхронизация при подключении', icon: WifiOff, href: '/chat', chatPrompt: 'Как работает офлайн-режим заполнения форм?' },
|
||||||
|
{ id: 'sandbox', tier: 'innovation', title: 'Песочница', subtitle: 'Проверка заявки до отправки без риска', icon: FlaskConical, href: '/chat', chatPrompt: 'Как проверить заявку в песочнице до отправки?' },
|
||||||
|
{ id: 'ai-chat', tier: 'mvp', title: 'AI-помощник', subtitle: 'Чат kk / ru / en, голосовой ввод', icon: MessageSquare, href: '/chat' },
|
||||||
|
{ id: 'form-gen', tier: 'mvp', title: 'Генерация форм', subtitle: 'До 95% полей из ИИН и профиля', icon: FileInput, href: '/services', chatPrompt: 'Как работает автозаполнение форм из ИИН?' },
|
||||||
|
{ id: 'guide', tier: 'mvp', title: 'Пошаговый гайд', subtitle: 'План с прогрессом, объяснение шагов', icon: ListOrdered, href: '/services', chatPrompt: 'Дай пошаговый гайд по выбранной услуге' },
|
||||||
|
{ id: 'status-dash', tier: 'mvp', title: 'Дашборд статусов', subtitle: 'Все заявки, статусы в реальном времени', icon: LayoutDashboard, href: '/applications' },
|
||||||
|
{ id: 'autofill', tier: 'mvp', title: 'Автозаполнение', subtitle: 'Профиль → поля формы без ручного ввода', icon: FormInput, href: '/services', chatPrompt: 'Как включить автозаполнение из профиля?' },
|
||||||
|
{ id: 'refusal-ai', tier: 'mvp', title: 'AI-объяснение отказов', subtitle: 'Юридический текст → план исправления', icon: FileWarning, href: '/chat', chatPrompt: 'Объясни отказ в заявке простым языком' },
|
||||||
|
{ id: 'complaints-map', tier: 'mvp', title: 'Карта жалоб', subtitle: 'Карта и кластеризация по районам', icon: MapPin, href: '/map' },
|
||||||
|
{ id: 'unified-flow', tier: 'mvp', title: 'Единый поток', subtitle: 'Жизненное событие → услуги параллельно', icon: GitBranch, href: '/chat', chatPrompt: 'Как оформить жизненное событие и все услуги сразу?' },
|
||||||
|
];
|
||||||
32
src/index.css
Normal file
32
src/index.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-navy: #1A2B6B;
|
||||||
|
--color-cyan: #00B4D8;
|
||||||
|
--color-gold: #FFB703;
|
||||||
|
--font-sans: "Inter", "Plus Jakarta Sans", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: #f8fafc;
|
||||||
|
--foreground: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.glass {
|
||||||
|
@apply bg-white/70 backdrop-blur-md border border-white/20;
|
||||||
|
}
|
||||||
|
.glass-dark {
|
||||||
|
@apply bg-navy/80 backdrop-blur-md border border-white/10;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/lib/apiBase.ts
Normal file
9
src/lib/apiBase.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Базовый URL API. В dev без VITE_API_URL используется относительный путь `/api` → Vite proxy на бэкенд.
|
||||||
|
*/
|
||||||
|
export function getApiBase(): string {
|
||||||
|
const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim();
|
||||||
|
if (raw) return raw.replace(/\/$/, '');
|
||||||
|
if (import.meta.env.DEV) return '';
|
||||||
|
return 'http://127.0.0.1:8000';
|
||||||
|
}
|
||||||
42
src/lib/apiHeaders.ts
Normal file
42
src/lib/apiHeaders.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
const OTP_KEY = 'supergov_otp_token';
|
||||||
|
|
||||||
|
export function getOtpToken(): string | null {
|
||||||
|
return localStorage.getItem(OTP_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setOtpToken(token: string) {
|
||||||
|
localStorage.setItem(OTP_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOtpToken() {
|
||||||
|
localStorage.removeItem(OTP_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сессия Stack (@stackframe/stack): токен через getAccessToken (актуально), getTokens — запасной вариант. */
|
||||||
|
type StackSessionUser = {
|
||||||
|
getAccessToken?: () => Promise<string | null>;
|
||||||
|
getTokens?: () => Promise<{ accessToken: string | null }>;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
export async function buildAuthHeaders(stackUser: StackSessionUser): Promise<Record<string, string>> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const otp = getOtpToken();
|
||||||
|
if (otp) {
|
||||||
|
headers.Authorization = `Bearer ${otp}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
if (!stackUser) return headers;
|
||||||
|
|
||||||
|
if (typeof stackUser.getAccessToken === 'function') {
|
||||||
|
const access = await stackUser.getAccessToken();
|
||||||
|
if (access) {
|
||||||
|
headers.Authorization = `Bearer ${access}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof stackUser.getTokens === 'function') {
|
||||||
|
const t = await stackUser.getTokens();
|
||||||
|
if (t?.accessToken) headers.Authorization = `Bearer ${t.accessToken}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
13
src/lib/axios.ts
Normal file
13
src/lib/axios.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { getApiBase } from './apiBase';
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: getApiBase(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: Token injection will be handled dynamically in components
|
||||||
|
// using Stack Auth's useUser or useStackApp to retrieve the access token,
|
||||||
|
// or we can set it here if we store it globally.
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
return config;
|
||||||
|
});
|
||||||
45
src/lib/stack.ts
Normal file
45
src/lib/stack.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { StackClientApp } from "@stackframe/stack";
|
||||||
|
|
||||||
|
const publishableClientKey = (
|
||||||
|
import.meta.env.VITE_STACK_PUBLISHABLE_KEY ||
|
||||||
|
import.meta.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY ||
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const projectId = (
|
||||||
|
import.meta.env.VITE_STACK_PROJECT_ID ||
|
||||||
|
import.meta.env.NEXT_PUBLIC_STACK_PROJECT_ID ||
|
||||||
|
""
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
let stackInitError: string | null = null;
|
||||||
|
let _stack: StackClientApp | null = null;
|
||||||
|
|
||||||
|
if (!projectId || !publishableClientKey) {
|
||||||
|
stackInitError =
|
||||||
|
"В gogo/.env нужны UUID проекта и publishable key: VITE_STACK_PROJECT_ID + VITE_STACK_PUBLISHABLE_KEY, либо как в backend: STACK_PROJECT_ID + STACK_PUBLISHABLE_CLIENT_KEY / NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY. Перезапустите npm run dev.";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
_stack = new StackClientApp({
|
||||||
|
projectId,
|
||||||
|
publishableClientKey,
|
||||||
|
tokenStore: "cookie",
|
||||||
|
// Без этого в @stackframe/stack для браузера остаётся redirectMethod "nextjs".
|
||||||
|
redirectMethod: "window",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
stackInitError =
|
||||||
|
e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { stackInitError };
|
||||||
|
export const stack = _stack;
|
||||||
|
|
||||||
|
/** Для страниц внутри StackProvider — stack всегда задан. */
|
||||||
|
export function requireStack(): StackClientApp {
|
||||||
|
if (!_stack) {
|
||||||
|
throw new Error(stackInitError || "Stack не инициализирован");
|
||||||
|
}
|
||||||
|
return _stack;
|
||||||
|
}
|
||||||
6
src/lib/supabase.ts
Normal file
6
src/lib/supabase.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { createClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'https://placeholder.supabase.co';
|
||||||
|
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY || 'placeholder-key';
|
||||||
|
|
||||||
|
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
54
src/pages/applications/Applications.tsx
Normal file
54
src/pages/applications/Applications.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { Card, CardContent } from '../../components/ui/Card';
|
||||||
|
import { Clock, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function Applications() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const apps = [
|
||||||
|
{ id: '182-391-233', name: 'Открытие ИП онлайн', date: '12 апреля 2026', status: 'В обработке', progress: 40, color: 'text-blue-500', bg: 'bg-blue-100', icon: Clock },
|
||||||
|
{ id: '182-391-230', name: 'Выдача паспорта гражданина РК', date: '10 апреля 2026', status: 'Готово к выдаче', progress: 80, color: 'text-emerald-500', bg: 'bg-emerald-100', icon: CheckCircle2 },
|
||||||
|
{ id: '182-391-225', name: 'Пособие по рождению', date: '01 апреля 2026', status: 'Исполнено', progress: 100, color: 'text-emerald-500', bg: 'bg-emerald-100', icon: CheckCircle2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-navy">Мои заявки</h1>
|
||||||
|
<p className="text-slate-500 mt-1 text-sm font-medium">История оказания государственных услуг.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{apps.map(app => (
|
||||||
|
<Card key={app.id} className="hover:border-cyan transition-colors cursor-pointer group shadow-sm border-slate-200/60" onClick={() => navigate('/tracker')}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start md:items-center gap-5">
|
||||||
|
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center shrink-0 shadow-inner ${app.bg} ${app.color}`}>
|
||||||
|
<app.icon className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-navy text-lg leading-tight mb-1 group-hover:text-cyan transition-colors">{app.name}</h3>
|
||||||
|
<p className="text-xs text-slate-500 font-semibold tracking-wide">ЗАЯВКА № {app.id} • {app.date}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:items-end w-full md:w-auto">
|
||||||
|
<span className={`inline-block px-3 py-1 rounded-md text-[10px] font-black uppercase tracking-widest mb-3 ${app.progress >= 80 ? 'bg-emerald-100 text-emerald-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||||
|
{app.status}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-4 w-full md:w-56">
|
||||||
|
<div className="w-full h-2.5 bg-slate-100 rounded-full overflow-hidden shadow-inner">
|
||||||
|
<div className={`h-full rounded-full transition-all duration-1000 ${app.progress >= 80 ? 'bg-emerald-500' : 'bg-cyan'}`} style={{ width: `${app.progress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-slate-400 w-8">{app.progress}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
src/pages/auth/Login.tsx
Normal file
160
src/pages/auth/Login.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate, Navigate } from 'react-router-dom';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { Input } from '../../components/ui/Input';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useUser } from '@stackframe/stack';
|
||||||
|
import { requireStack } from '../../lib/stack';
|
||||||
|
import { getApiBase } from '../../lib/apiBase';
|
||||||
|
import { clearOtpToken, setOtpToken } from '../../lib/apiHeaders';
|
||||||
|
|
||||||
|
const API_BASE = getApiBase();
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const user = useUser();
|
||||||
|
const [mode, setMode] = useState<'password' | 'otp'>('password');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [otpSent, setOtpSent] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
clearOtpToken();
|
||||||
|
const result = await requireStack().signInWithCredential({ email, password });
|
||||||
|
if (result.status === 'error') {
|
||||||
|
toast.error('Ошибка входа. Проверьте email и пароль.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Успешный вход!');
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch {
|
||||||
|
toast.error('Ошибка входа.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendOtp = async () => {
|
||||||
|
if (!email.trim()) {
|
||||||
|
toast.error('Введите email');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/otp/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
throw new Error(t || res.statusText);
|
||||||
|
}
|
||||||
|
setOtpSent(true);
|
||||||
|
toast.success('Код отправлен на почту');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось отправить код');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyOtp = async (_e: React.FormEvent) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/otp/verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email: email.trim(), code: code.trim() }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const json = (await res.json()) as { data?: { access_token?: string } };
|
||||||
|
const tok = json.data?.access_token;
|
||||||
|
if (!tok) throw new Error('Нет токена');
|
||||||
|
setOtpToken(tok);
|
||||||
|
toast.success('Вход по коду выполнен');
|
||||||
|
navigate('/dashboard');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Неверный код');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-navy mb-2">Вход в систему</h2>
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMode('password');
|
||||||
|
setOtpSent(false);
|
||||||
|
}}
|
||||||
|
className={`flex-1 py-2 rounded-xl text-xs font-bold transition-colors ${
|
||||||
|
mode === 'password' ? 'bg-navy text-white' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Пароль
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('otp')}
|
||||||
|
className={`flex-1 py-2 rounded-xl text-xs font-bold transition-colors ${
|
||||||
|
mode === 'otp' ? 'bg-navy text-white' : 'bg-slate-100 text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Код на email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'password' ? (
|
||||||
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
|
<Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required placeholder="ivanov@example.com" />
|
||||||
|
<Input label="Пароль" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required placeholder="••••••••" />
|
||||||
|
<Button type="submit" className="w-full mt-2" size="lg" disabled={loading}>
|
||||||
|
{loading ? 'Загрузка...' : 'Войти'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (otpSent) void verifyOtp(e);
|
||||||
|
else void sendOtp();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required placeholder="ivanov@example.com" disabled={otpSent} />
|
||||||
|
{otpSent && (
|
||||||
|
<Input label="Код из письма" value={code} onChange={(e) => setCode(e.target.value)} required placeholder="000000" maxLength={6} />
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full mt-2" size="lg" disabled={loading}>
|
||||||
|
{loading ? '...' : otpSent ? 'Войти с кодом' : 'Отправить код'}
|
||||||
|
</Button>
|
||||||
|
{otpSent && (
|
||||||
|
<button type="button" className="w-full text-xs text-cyan font-semibold" onClick={() => { setOtpSent(false); setCode(''); }}>
|
||||||
|
Изменить email
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center mt-6 text-sm text-slate-500">
|
||||||
|
Нет аккаунта?{' '}
|
||||||
|
<Link to="/register" className="text-cyan font-semibold hover:underline">
|
||||||
|
Создать аккаунт
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/pages/auth/Register.tsx
Normal file
127
src/pages/auth/Register.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { Input } from '../../components/ui/Input';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { requireStack } from '../../lib/stack';
|
||||||
|
import { clearOtpToken } from '../../lib/apiHeaders';
|
||||||
|
import { api } from '../../lib/axios';
|
||||||
|
import { MailCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
iin: z.string().length(12, 'ИИН должен содержать ровно 12 цифр').regex(/^\d+$/, 'Только цифры'),
|
||||||
|
name: z.string().min(2, 'Введите полное имя'),
|
||||||
|
email: z.string().email('Неверный формат email'),
|
||||||
|
phone: z.string().min(10, 'Введите номер телефона'),
|
||||||
|
password: z.string().min(6, 'Пароль минимум 6 символов'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterForm = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
|
export function Register() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [successMode, setSuccessMode] = useState(false);
|
||||||
|
const { register, handleSubmit, formState: { errors } } = useForm<RegisterForm>({
|
||||||
|
resolver: zodResolver(registerSchema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterForm) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
clearOtpToken();
|
||||||
|
const app = requireStack();
|
||||||
|
const signUp = await app.signUpWithCredential({
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
noVerificationCallback: true,
|
||||||
|
});
|
||||||
|
if (signUp.status === 'error') {
|
||||||
|
throw signUp.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await app.getUser();
|
||||||
|
const stackUserId = user?.id ?? 'mock_id';
|
||||||
|
|
||||||
|
await api.post('/api/auth/register', {
|
||||||
|
iin: data.iin,
|
||||||
|
email: data.email,
|
||||||
|
phone: data.phone,
|
||||||
|
full_name: data.name,
|
||||||
|
stack_user_id: stackUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSuccessMode(true);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Ошибка при регистрации. Возможно, пользователь уже существует.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (successMode) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-6 animate-in fade-in zoom-in duration-500">
|
||||||
|
<div className="w-20 h-20 bg-emerald-100 text-emerald-500 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<MailCheck className="w-10 h-10" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-navy mb-2">Проверьте вашу почту</h2>
|
||||||
|
<p className="text-slate-500 text-sm mb-6 max-w-sm mx-auto leading-relaxed">
|
||||||
|
Мы отправили письмо на <span className="font-semibold">{errors.email ? '' : ''}</span> с инструкциями для подтверждения аккаунта.
|
||||||
|
</p>
|
||||||
|
<Link to="/login">
|
||||||
|
<Button variant="outline" className="w-full">Вернуться ко входу</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-navy mb-6">Создать аккаунт</h2>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="ИИН"
|
||||||
|
placeholder="12 цифр"
|
||||||
|
{...register('iin')}
|
||||||
|
error={errors.iin?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="ФИО"
|
||||||
|
placeholder="Иванов Иван Иванович"
|
||||||
|
{...register('name')}
|
||||||
|
error={errors.name?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="ivan@example.com"
|
||||||
|
{...register('email')}
|
||||||
|
error={errors.email?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Телефон"
|
||||||
|
type="tel"
|
||||||
|
placeholder="+7 (XXX) XXX-XX-XX"
|
||||||
|
{...register('phone')}
|
||||||
|
error={errors.phone?.message}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...register('password')}
|
||||||
|
error={errors.password?.message}
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full mt-4" size="lg" disabled={loading}>
|
||||||
|
{loading ? 'Создание...' : 'Зарегистрироваться'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center mt-6 text-sm text-slate-500">
|
||||||
|
Уже есть аккаунт? <Link to="/login" className="text-cyan font-semibold hover:underline">Войти</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/pages/benefits/Benefits.tsx
Normal file
53
src/pages/benefits/Benefits.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Card, CardContent } from '../../components/ui/Card';
|
||||||
|
import { Gift, Sparkles } from 'lucide-react';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
|
||||||
|
export function Benefits() {
|
||||||
|
const benefits = [
|
||||||
|
{ id: 1, name: 'Пособие по уходу за ребенком', amount: '350 000 ₸', tags: ['Семья', 'Дети'], desc: 'Доступно на основе состава вашей семьи по данным ГБД ФЛ.' },
|
||||||
|
{ id: 2, name: 'Скидка на транспортный налог', amount: '45 000 ₸', tags: ['Авто'], desc: 'Рассчитано на основе вашего автомобиля 2.4L и статуса льготника.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-gradient-to-r from-emerald-500 to-teal-500 p-8 md:p-10 rounded-[2rem] text-white shadow-xl shadow-emerald-500/20 relative overflow-hidden">
|
||||||
|
<Sparkles className="absolute -right-4 top-1/2 -translate-y-1/2 w-64 h-64 text-white/10 rotate-12" />
|
||||||
|
<div className="relative z-10 w-full md:w-2/3">
|
||||||
|
<span className="bg-white/20 backdrop-blur-md px-3 py-1.5 text-[10px] uppercase font-bold tracking-widest rounded-full mb-4 inline-block">AI Smart Discovery</span>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-black mb-3 leading-tight">Найдено 2 льготы</h1>
|
||||||
|
<p className="text-emerald-50 text-lg opacity-90 max-w-lg leading-relaxed">Мы проанализировали ваш профиль и нашли государственные выплаты на общую сумму <span className="font-bold underline decoration-white/30 decoration-2 underline-offset-4">395 000 ₸ в год</span>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-5 pt-2">
|
||||||
|
{benefits.map(b => (
|
||||||
|
<Card key={b.id} className="border-emerald-100 hover:border-emerald-300 hover:shadow-lg transition-all duration-300">
|
||||||
|
<CardContent className="p-6 md:p-8">
|
||||||
|
<div className="flex flex-col h-full gap-6">
|
||||||
|
<div className="flex gap-4 items-start">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0">
|
||||||
|
<Gift className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-navy text-xl leading-tight mb-2">{b.name}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{b.tags.map(t => (
|
||||||
|
<span key={t} className="text-[10px] px-2 py-1 bg-slate-100 text-slate-500 font-bold uppercase rounded-md tracking-wider">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto pt-4 border-t border-slate-100">
|
||||||
|
<p className="text-3xl font-black text-emerald-500 mb-3">{b.amount}<span className="text-sm font-semibold text-slate-400 ml-1">/ год</span></p>
|
||||||
|
<p className="text-sm text-slate-500 mb-6 leading-relaxed">{b.desc}</p>
|
||||||
|
<Button className="w-full bg-emerald-50 text-emerald-700 hover:bg-emerald-600 hover:text-white transition-colors h-12 text-sm">Оформить в 1 клик</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
345
src/pages/chat/Chat.tsx
Normal file
345
src/pages/chat/Chat.tsx
Normal file
@ -0,0 +1,345 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { Send, Mic, User } from 'lucide-react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useUser } from '@stackframe/stack';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { AI_CAPABILITIES } from '../../data/aiCapabilities';
|
||||||
|
import { getApiBase } from '../../lib/apiBase';
|
||||||
|
import { buildAuthHeaders } from '../../lib/apiHeaders';
|
||||||
|
import { useStore } from '../../store/useStore';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'ai';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = getApiBase();
|
||||||
|
|
||||||
|
function buildGreeting(displayName: string) {
|
||||||
|
const trimmed = displayName.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
return `Здравствуйте, ${trimmed}! Я AI-ассистент SuperGov . Задайте вопрос или выберите одну из 18 функций ниже — ответы и действия выполняются через ИИ и серверные инструменты.`;
|
||||||
|
}
|
||||||
|
return `Здравствуйте! Я AI-ассистент SuperGov . Задайте вопрос или выберите одну из 18 функций ниже — ответы и действия выполняются через ИИ и серверные инструменты.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chat() {
|
||||||
|
const stackUser = useUser();
|
||||||
|
const profileName = useStore((s) => s.user?.name);
|
||||||
|
const emailLocal = stackUser?.primaryEmail?.split('@')[0]?.trim() || '';
|
||||||
|
const displayName = (
|
||||||
|
profileName ||
|
||||||
|
stackUser?.displayName ||
|
||||||
|
emailLocal ||
|
||||||
|
''
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const greetText = useMemo(() => buildGreeting(displayName), [displayName]);
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState<Message[]>([
|
||||||
|
{ id: '1', role: 'ai', content: buildGreeting('') },
|
||||||
|
]);
|
||||||
|
useEffect(() => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.length === 1 && prev[0].id === '1' && prev[0].role === 'ai') {
|
||||||
|
return [{ ...prev[0], content: greetText }];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [greetText]);
|
||||||
|
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [sessionId] = useState(() => crypto.randomUUID());
|
||||||
|
const recRef = useRef<{ stop: () => void; start: () => void } | null>(null);
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialQuery = searchParams.get('q');
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages, isTyping]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
const userMsg = text.trim();
|
||||||
|
if (!userMsg) return;
|
||||||
|
|
||||||
|
const newMessage: Message = { id: Date.now().toString(), role: 'user', content: userMsg };
|
||||||
|
setMessages((prev) => [...prev, newMessage]);
|
||||||
|
setInput('');
|
||||||
|
setIsTyping(true);
|
||||||
|
|
||||||
|
const responseId = (Date.now() + 1).toString();
|
||||||
|
setMessages((prev) => [...prev, { id: responseId, role: 'ai', content: '' }]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = await buildAuthHeaders(stackUser as Parameters<typeof buildAuthHeaders>[0]);
|
||||||
|
if (!headers.Authorization) {
|
||||||
|
toast.error('Войдите в систему (пароль Stack или код на email)');
|
||||||
|
setIsTyping(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/chat/message`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: userMsg, session_id: sessionId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errText = await res.text();
|
||||||
|
throw new Error(errText || res.statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.body) throw new Error('Пустой ответ сервера');
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let full = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const parts = buffer.split('\n\n');
|
||||||
|
buffer = parts.pop() || '';
|
||||||
|
for (const block of parts) {
|
||||||
|
const line = block.trim();
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const jsonStr = line.slice(5).trim();
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr) as {
|
||||||
|
token?: string;
|
||||||
|
done?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
};
|
||||||
|
if (data.error) {
|
||||||
|
full += data.token || '';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (data.token) {
|
||||||
|
full += data.token;
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((msg) => (msg.id === responseId ? { ...msg, content: full } : msg))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
const msg =
|
||||||
|
e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: 'Не удалось связаться с AI. Проверьте backend и ANTHROPIC_API_KEY.';
|
||||||
|
toast.error(msg);
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => (m.id === responseId ? { ...m, content: `Ошибка: ${msg}` } : m))
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsTyping(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stackUser, sessionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialQuery) {
|
||||||
|
void handleSend(initialQuery);
|
||||||
|
}
|
||||||
|
}, [initialQuery, handleSend]);
|
||||||
|
|
||||||
|
const handleVoice = () => {
|
||||||
|
const w = window as unknown as { webkitSpeechRecognition?: new () => Record<string, unknown>; SpeechRecognition?: new () => Record<string, unknown> };
|
||||||
|
const SR = w.webkitSpeechRecognition || w.SpeechRecognition;
|
||||||
|
|
||||||
|
if (SR) {
|
||||||
|
if (isRecording && recRef.current) {
|
||||||
|
try {
|
||||||
|
recRef.current.stop();
|
||||||
|
} catch {
|
||||||
|
/* */
|
||||||
|
}
|
||||||
|
setIsRecording(false);
|
||||||
|
recRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rec = new SR() as {
|
||||||
|
lang: string;
|
||||||
|
interimResults: boolean;
|
||||||
|
maxAlternatives: number;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
onresult: ((ev: { results: { [k: number]: { [k: number]: { transcript: string } } } }) => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
onend: (() => void) | null;
|
||||||
|
};
|
||||||
|
rec.lang = 'ru-RU';
|
||||||
|
rec.interimResults = false;
|
||||||
|
rec.maxAlternatives = 1;
|
||||||
|
rec.onresult = (ev: { results: { [k: number]: { [k: number]: { transcript: string } } } }) => {
|
||||||
|
const t = ev.results[0]?.[0]?.transcript;
|
||||||
|
if (t) setInput(t);
|
||||||
|
setIsRecording(false);
|
||||||
|
recRef.current = null;
|
||||||
|
};
|
||||||
|
rec.onerror = () => {
|
||||||
|
setIsRecording(false);
|
||||||
|
recRef.current = null;
|
||||||
|
toast.error('Ошибка распознавания речи');
|
||||||
|
};
|
||||||
|
rec.onend = () => {
|
||||||
|
setIsRecording(false);
|
||||||
|
recRef.current = null;
|
||||||
|
};
|
||||||
|
recRef.current = rec;
|
||||||
|
rec.start();
|
||||||
|
setIsRecording(true);
|
||||||
|
} catch {
|
||||||
|
toast.error('Голосовой ввод недоступен в этом браузере');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecording(true);
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
const mockBlob = new Blob(['mock'], { type: 'audio/webm' });
|
||||||
|
formData.append('file', mockBlob, 'voice.webm');
|
||||||
|
const headers = await buildAuthHeaders(stackUser as Parameters<typeof buildAuthHeaders>[0]);
|
||||||
|
const res = await fetch(`${API_BASE}/api/voice/transcribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const json = (await res.json()) as { success?: boolean; data?: { transcript?: string } };
|
||||||
|
if (json.success && json.data?.transcript) setInput(json.data.transcript);
|
||||||
|
else setInput('Как подать заявку на справку?');
|
||||||
|
} catch {
|
||||||
|
setInput('Как подать заявку на справку?');
|
||||||
|
} finally {
|
||||||
|
setIsRecording(false);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-8rem)] md:h-[calc(100vh-6rem)] bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden relative">
|
||||||
|
<div className="px-4 pt-3 pb-1 border-b border-slate-100 flex flex-wrap gap-2 items-center shrink-0">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-400">18 функций</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 font-semibold">новое ×10</span>
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded-full bg-cyan/15 text-navy font-semibold">MVP ×8</span>
|
||||||
|
<span className="text-[10px] text-slate-400 ml-auto">Claude Haiku · любые вопросы</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className={cn('flex gap-4 w-full', msg.role === 'user' ? 'justify-end' : 'justify-start')}>
|
||||||
|
{msg.role === 'ai' && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-cyan flex items-center justify-center shrink-0 shadow-sm shadow-cyan/20">
|
||||||
|
<span className="text-white text-[10px] font-bold tracking-wider">AI</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-4 rounded-2xl max-w-[85%] md:max-w-[70%]',
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-navy text-white rounded-tr-none shadow-md shadow-navy/10'
|
||||||
|
: 'bg-slate-50 border border-slate-100 text-slate-800 rounded-tl-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{msg.role === 'user' && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center shrink-0">
|
||||||
|
<User className="w-4 h-4 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isTyping && (
|
||||||
|
<div className="flex gap-4 w-full justify-start">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-cyan flex items-center justify-center shrink-0 shadow-sm">
|
||||||
|
<span className="text-white text-[10px] font-bold">AI</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 border border-slate-100 p-4 rounded-2xl rounded-tl-none flex items-center gap-1.5 h-12">
|
||||||
|
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce [animation-delay:-0.3s]" />
|
||||||
|
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce [animation-delay:-0.15s]" />
|
||||||
|
<span className="w-1.5 h-1.5 bg-slate-400 rounded-full animate-bounce" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={endRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-white border-t border-slate-100">
|
||||||
|
<p className="text-[10px] text-slate-500 mb-2 font-medium">Быстрые темы (18)</p>
|
||||||
|
<div className="flex gap-2 mb-3 overflow-x-auto pb-2 max-h-32 flex-wrap">
|
||||||
|
{AI_CAPABILITIES.map((cap) => (
|
||||||
|
<button
|
||||||
|
key={cap.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSend(cap.prompt)}
|
||||||
|
title={cap.prompt}
|
||||||
|
className={cn(
|
||||||
|
'whitespace-nowrap px-3 py-1.5 rounded-full text-[11px] font-semibold transition-all border',
|
||||||
|
cap.tag === 'innovation'
|
||||||
|
? 'bg-emerald-50 hover:bg-emerald-100 text-emerald-900 border-emerald-200'
|
||||||
|
: 'bg-slate-50 hover:bg-cyan hover:text-white text-slate-700 border-slate-200'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cap.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSend(input);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 relative"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder="Любой вопрос или задача…"
|
||||||
|
className="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 pr-12 text-sm focus:outline-none focus:border-cyan focus:ring-1 focus:ring-cyan transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVoice()}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-14 p-2 rounded-lg transition-colors',
|
||||||
|
isRecording ? 'text-red-500 bg-red-50 animate-pulse' : 'text-slate-400 hover:text-cyan hover:bg-cyan/10'
|
||||||
|
)}
|
||||||
|
title="Голосовой ввод"
|
||||||
|
>
|
||||||
|
<Mic className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<Button type="submit" size="sm" className="h-11 w-11 rounded-lg shrink-0 p-0" disabled={!input.trim() || isTyping}>
|
||||||
|
<Send className="w-4 h-4 ml-0.5" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/pages/dashboard/Dashboard.tsx
Normal file
151
src/pages/dashboard/Dashboard.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { Card, CardContent } from '../../components/ui/Card';
|
||||||
|
import { Briefcase, CheckCircle2, Gift, FileText, ArrowRight } from 'lucide-react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ title: 'Активные заявки', value: '2', icon: Briefcase, color: 'text-blue-500', bg: 'bg-blue-100' },
|
||||||
|
{ title: 'Завершено', value: '14', icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-100' },
|
||||||
|
{ title: 'Доступные льготы', value: '3', icon: Gift, color: 'text-yellow-600', bg: 'bg-yellow-100' },
|
||||||
|
{ title: 'Документы', value: '7', icon: FileText, color: 'text-purple-500', bg: 'bg-purple-100' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const quickServices = [
|
||||||
|
{ name: 'Открыть ИП', path: '/application/new/ip' },
|
||||||
|
{ name: 'Пособие на ребёнка', path: '/application/new/child' },
|
||||||
|
{ name: 'Паспорт', path: '/application/new/passport' },
|
||||||
|
{ name: 'Недвижимость', path: '/application/new/property' },
|
||||||
|
{ name: 'Авто и транспорт', path: '/application/new/car' },
|
||||||
|
{ name: 'Справка о несудимости', path: '/application/new/criminal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-navy">Добрый день, Алихан!</h1>
|
||||||
|
<p className="text-slate-500 mt-1">Здесь сводка ваших государственных услуг и важных уведомлений.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-gradient-to-r from-cyan to-blue-500 rounded-2xl p-6 text-white shadow-lg shadow-cyan/20 flex flex-col md:flex-row md:items-center justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg mb-1 flex items-center gap-2">
|
||||||
|
<span className="bg-white text-cyan text-xs px-2 py-1 rounded-full font-black tracking-wider">AI FOUND</span>
|
||||||
|
Найдены доступные льготы
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/90 text-sm">AI проанализировал ваш профиль и нашел 3 льготы на сумму <span className="font-bold">450,000 ₸/год</span>.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/benefits')}
|
||||||
|
className="bg-white text-cyan px-5 py-2.5 rounded-xl font-semibold text-sm hover:bg-slate-50 transition-colors shrink-0 shadow-sm"
|
||||||
|
>
|
||||||
|
Подробнее
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<Card key={i} className="border-none shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-5 flex flex-col items-center text-center">
|
||||||
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3 ${s.bg} ${s.color}`}>
|
||||||
|
<s.icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-navy">{s.value}</p>
|
||||||
|
<p className="text-xs text-slate-500 font-medium">{s.title}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-6 mt-8">
|
||||||
|
<div className="md:col-span-2 space-y-6">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-navy">Популярные услуги</h2>
|
||||||
|
<Link to="/services" className="text-sm text-cyan hover:underline font-medium flex items-center">
|
||||||
|
Все услуги <ArrowRight className="w-4 h-4 ml-1" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{quickServices.map((service, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => navigate(service.path)}
|
||||||
|
className="bg-white p-4 rounded-xl border border-slate-100 hover:border-cyan hover:shadow-sm text-left transition-all group flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-slate-700 group-hover:text-cyan transition-colors text-sm">{service.name}</span>
|
||||||
|
<div className="w-6 h-6 rounded-full bg-slate-50 flex items-center justify-center group-hover:bg-cyan/10 transition-colors">
|
||||||
|
<ArrowRight className="w-3 h-3 text-slate-400 group-hover:text-cyan" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-navy mb-4">Активные заявки</h2>
|
||||||
|
<Card className="border border-slate-100 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-50 flex justify-between items-center bg-slate-50/50">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-sm">Свидетельство о рождении</h4>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">№ 182-391-233 • Сегодня</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="inline-block px-2.5 py-1 bg-blue-100 text-blue-700 rounded-lg text-xs font-semibold">В обработке</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="w-full bg-slate-100 rounded-full h-1.5 mb-2 overflow-hidden">
|
||||||
|
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: '40%' }}></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-slate-500">
|
||||||
|
<span>Проверка документов</span>
|
||||||
|
<span>Ожидается: 12 апреля 2026</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-navy mb-4">Уведомления</h2>
|
||||||
|
<Card className="border border-slate-100 shadow-sm h-[420px] overflow-hidden">
|
||||||
|
<CardContent className="p-0 flex flex-col h-full overflow-y-auto">
|
||||||
|
<div className="p-4 border-b border-slate-100 hover:bg-slate-50 transition-colors cursor-pointer">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center shrink-0">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-800">Статус обновлен</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">Ваша заявка "Открытие ИП" успешно одобрена.</p>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-2">10 минут назад</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-b border-slate-100 hover:bg-slate-50 transition-colors cursor-pointer bg-blue-50/30">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-600 flex items-center justify-center shrink-0 relative">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-400 rounded-full border-2 border-white"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-800">Требуется действие</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">Прикрепите копию паспорта для заявки №192.</p>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-2">Вчера</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/pages/map/ComplaintsMap.tsx
Normal file
218
src/pages/map/ComplaintsMap.tsx
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet';
|
||||||
|
import { useUser } from '@stackframe/stack';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||||
|
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet.markercluster';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { Input } from '../../components/ui/Input';
|
||||||
|
import { MapPin } from 'lucide-react';
|
||||||
|
import { getApiBase } from '../../lib/apiBase';
|
||||||
|
import { buildAuthHeaders } from '../../lib/apiHeaders';
|
||||||
|
|
||||||
|
import icon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import iconShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
const DefaultIcon = L.icon({
|
||||||
|
iconUrl: icon,
|
||||||
|
shadowUrl: iconShadow,
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
});
|
||||||
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
|
||||||
|
const API_BASE = getApiBase();
|
||||||
|
|
||||||
|
type Complaint = {
|
||||||
|
id: string;
|
||||||
|
category?: string;
|
||||||
|
description?: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
votes?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MapClickHandler({
|
||||||
|
enabled,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
onPick: (lat: number, lng: number) => void;
|
||||||
|
}) {
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
if (enabled) onPick(e.latlng.lat, e.latlng.lng);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClusteredMarkers({ items }: { items: Complaint[] }) {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
const mcg = (L as unknown as { markerClusterGroup: (o?: object) => L.LayerGroup }).markerClusterGroup({
|
||||||
|
chunkedLoading: true,
|
||||||
|
});
|
||||||
|
items.forEach((c) => {
|
||||||
|
if (typeof c.lat !== 'number' || typeof c.lng !== 'number') return;
|
||||||
|
const m = L.marker([c.lat, c.lng]);
|
||||||
|
m.bindPopup(
|
||||||
|
`<div class="text-xs"><strong>${c.category || 'Жалоба'}</strong><br/>${(c.description || '').slice(0, 220)}<br/><span class="text-slate-500">👍 ${c.votes ?? 0}</span></div>`
|
||||||
|
);
|
||||||
|
mcg.addLayer(m);
|
||||||
|
});
|
||||||
|
map.addLayer(mcg);
|
||||||
|
return () => {
|
||||||
|
map.removeLayer(mcg);
|
||||||
|
};
|
||||||
|
}, [map, items]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComplaintsMap() {
|
||||||
|
const stackUser = useUser();
|
||||||
|
const center: [number, number] = [51.1801, 71.446];
|
||||||
|
const [complaints, setComplaints] = useState<Complaint[]>([]);
|
||||||
|
const [pickMode, setPickMode] = useState(false);
|
||||||
|
const [category, setCategory] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [picked, setPicked] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formSuccess, setFormSuccess] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const headers = await buildAuthHeaders(stackUser as Parameters<typeof buildAuthHeaders>[0]);
|
||||||
|
const res = await fetch(`${API_BASE}/api/complaints/`, { headers });
|
||||||
|
const json = (await res.json()) as { data?: Complaint[] };
|
||||||
|
const rows = json.data || [];
|
||||||
|
setComplaints(rows.filter((r) => typeof r.lat === 'number' && typeof r.lng === 'number'));
|
||||||
|
} catch {
|
||||||
|
toast.error('Не удалось загрузить жалобы');
|
||||||
|
}
|
||||||
|
}, [stackUser]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!picked) {
|
||||||
|
toast.error('Включите «Указать на карте» и кликните по месту');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!category.trim() || description.trim().length < 3) {
|
||||||
|
toast.error('Укажите категорию и описание');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const headers = await buildAuthHeaders(stackUser as Parameters<typeof buildAuthHeaders>[0]);
|
||||||
|
const res = await fetch(`${API_BASE}/api/complaints/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
category: category.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
lat: picked.lat,
|
||||||
|
lng: picked.lng,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
toast.success('Жалоба добавлена');
|
||||||
|
setFormSuccess(true);
|
||||||
|
setCategory('');
|
||||||
|
setDescription('');
|
||||||
|
setPicked(null);
|
||||||
|
setPickMode(false);
|
||||||
|
await load();
|
||||||
|
window.setTimeout(() => setFormSuccess(false), 8000);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Ошибка отправки');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-10rem)] md:h-[calc(100vh-6rem)] rounded-[2rem] overflow-hidden border border-slate-200 shadow-sm relative z-0">
|
||||||
|
<div className="absolute top-4 left-4 z-[500] bg-white/95 backdrop-blur-md p-5 rounded-2xl shadow-xl border border-white/50 w-[min(100%-2rem,22rem)] max-h-[calc(100%-2rem)] overflow-y-auto">
|
||||||
|
<h3 className="font-bold text-navy text-lg mb-1">Карта жалоб</h3>
|
||||||
|
<p className="text-xs text-slate-500 mb-4 leading-relaxed">
|
||||||
|
Кластеры по районам. Укажите точку на карте, затем заполните форму.
|
||||||
|
</p>
|
||||||
|
{formSuccess && (
|
||||||
|
<div
|
||||||
|
className="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-sm font-medium text-emerald-900"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Форма успешно отправлена. Жалоба появится на карте.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ml-2 text-xs text-emerald-700 underline underline-offset-2"
|
||||||
|
onClick={() => setFormSuccess(false)}
|
||||||
|
>
|
||||||
|
Скрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`mb-3 rounded-xl px-3 py-2 text-[11px] font-medium ${
|
||||||
|
pickMode ? 'bg-cyan/15 text-navy border border-cyan/30' : 'bg-slate-50 text-slate-600 border border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{picked
|
||||||
|
? `Точка: ${picked.lat.toFixed(5)}, ${picked.lng.toFixed(5)}`
|
||||||
|
: 'Точка не выбрана'}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={pickMode ? 'primary' : 'outline'}
|
||||||
|
className="w-full text-xs h-10 mb-4"
|
||||||
|
onClick={() => {
|
||||||
|
setPickMode((v) => !v);
|
||||||
|
if (pickMode) toast('Режим выбора выключен');
|
||||||
|
else toast('Кликните по карте, чтобы поставить метку');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MapPin className="w-4 h-4 mr-1" />
|
||||||
|
{pickMode ? 'Готово (выкл. выбор)' : 'Указать на карте'}
|
||||||
|
</Button>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input label="Категория" value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Дорога, двор, освещение…" />
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1">Описание</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm min-h-[72px]"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Опишите проблему"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="button" className="w-full mt-4" onClick={() => void submit()} disabled={loading}>
|
||||||
|
{loading ? 'Отправка…' : 'Отправить жалобу'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapContainer center={center} zoom={13} style={{ height: '100%', width: '100%', zIndex: 0 }}>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://carto.com/">Carto</a>'
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png"
|
||||||
|
/>
|
||||||
|
<MapClickHandler
|
||||||
|
enabled={pickMode}
|
||||||
|
onPick={(lat, lng) => {
|
||||||
|
setPicked({ lat, lng });
|
||||||
|
setPickMode(false);
|
||||||
|
toast.success('Точка сохранена');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ClusteredMarkers items={complaints} />
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/pages/profile/Profile.tsx
Normal file
152
src/pages/profile/Profile.tsx
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useUser } from '@stackframe/stack';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { Input } from '../../components/ui/Input';
|
||||||
|
import { getApiBase } from '../../lib/apiBase';
|
||||||
|
import { buildAuthHeaders } from '../../lib/apiHeaders';
|
||||||
|
import { useStore } from '../../store/useStore';
|
||||||
|
|
||||||
|
const API_BASE = getApiBase();
|
||||||
|
|
||||||
|
type Me = {
|
||||||
|
full_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
iin?: string;
|
||||||
|
address?: string;
|
||||||
|
birth_date?: string;
|
||||||
|
language?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Profile() {
|
||||||
|
const stackUser = useUser();
|
||||||
|
const setStoreUser = useStore((s) => s.setUser);
|
||||||
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
full_name: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
birth_date: '',
|
||||||
|
language: 'ru',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const headers = await buildAuthHeaders(stackUser as Parameters<typeof buildAuthHeaders>[0]);
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/me`, { headers });
|
||||||
|
if (!res.ok) throw new Error('Не удалось загрузить профиль');
|
||||||
|
const json = (await res.json()) as { data: Me };
|
||||||
|
const u = json.data;
|
||||||
|
setMe(u);
|
||||||
|
setForm({
|
||||||
|
full_name: u.full_name || stackUser?.displayName || '',
|
||||||
|
phone: u.phone || '',
|
||||||
|
address: (u as { address?: string }).address || '',
|
||||||
|
birth_date: u.birth_date || '',
|
||||||
|
language: u.language || 'ru',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error('Профиль недоступен без авторизации');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [stackUser]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const headers = await buildAuthHeaders(stackUser as Parameters<typeof buildAuthHeaders>[0]);
|
||||||
|
const res = await fetch(`${API_BASE}/api/auth/me`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
full_name: form.full_name || undefined,
|
||||||
|
phone: form.phone || undefined,
|
||||||
|
address: form.address || undefined,
|
||||||
|
birth_date: form.birth_date || undefined,
|
||||||
|
language: form.language || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const json = (await res.json()) as { data: Me };
|
||||||
|
setMe(json.data);
|
||||||
|
setStoreUser({
|
||||||
|
name: form.full_name || json.data?.email || 'Пользователь',
|
||||||
|
email: json.data?.email || me?.email || '',
|
||||||
|
phone: form.phone || '',
|
||||||
|
iin: me?.iin || '',
|
||||||
|
stackUserId: '',
|
||||||
|
});
|
||||||
|
toast.success('Сохранено');
|
||||||
|
setSaveSuccess(true);
|
||||||
|
window.setTimeout(() => setSaveSuccess(false), 6000);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <p className="text-slate-500 text-sm p-6">Загрузка профиля…</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl space-y-6 p-2 md:p-0">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-navy">Личные данные</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Имя из регистрации используется в AI-ассистенте и интерфейсе.</p>
|
||||||
|
</div>
|
||||||
|
{saveSuccess && (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-900"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Форма успешно отправлена — данные профиля сохранены.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm space-y-4">
|
||||||
|
<Input label="ФИО" value={form.full_name} onChange={(e) => setForm((f) => ({ ...f, full_name: e.target.value }))} />
|
||||||
|
<Input label="Email" value={me?.email || ''} disabled className="opacity-70" />
|
||||||
|
<Input label="ИИН" value={me?.iin || ''} disabled className="opacity-70" />
|
||||||
|
<Input label="Телефон" value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1">Адрес</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm min-h-[72px]"
|
||||||
|
value={form.address}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Дата рождения"
|
||||||
|
type="date"
|
||||||
|
value={form.birth_date}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, birth_date: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1">Язык интерфейса</label>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm"
|
||||||
|
value={form.language}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, language: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="kz">Қазақша</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button type="button" className="w-full" onClick={() => void save()} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/pages/rating/AgenciesRating.tsx
Normal file
82
src/pages/rating/AgenciesRating.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { Card, CardContent } from '../../components/ui/Card';
|
||||||
|
import { ArrowUpRight, ArrowDownRight, Activity, Star } from 'lucide-react';
|
||||||
|
import { ResponsiveContainer, LineChart, Line, Tooltip } from 'recharts';
|
||||||
|
|
||||||
|
export function AgenciesRating() {
|
||||||
|
const data = [
|
||||||
|
{ name: 'МВД Республики Казахстан', score: 98, trend: 'up', val: '+2%' },
|
||||||
|
{ name: 'Правительство для граждан (ЦОН)', score: 92, trend: 'up', val: '+5%' },
|
||||||
|
{ name: 'Министерство Здравоохранения', score: 85, trend: 'down', val: '-1%' },
|
||||||
|
{ name: 'Налоговый комитет РК', score: 76, trend: 'up', val: '+10%' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const chartData = [ { name: 'Jan', uv: 80 }, { name: 'Feb', uv: 85 }, { name: 'Mar', uv: 92 }, { name: 'Apr', uv: 98 } ];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-navy">Рейтинг ведомств</h1>
|
||||||
|
<p className="text-slate-500 mt-1 text-sm">Оценка скорости и качества государственных услуг.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{data.map((agency, i) => (
|
||||||
|
<Card key={i} className="hover:border-cyan transition-colors cursor-pointer group shadow-sm">
|
||||||
|
<CardContent className="p-5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-slate-50 flex items-center justify-center font-black text-slate-300 text-2xl group-hover:bg-cyan group-hover:text-white transition-all shadow-inner">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-navy text-lg">{agency.name}</h3>
|
||||||
|
<div className="flex items-center gap-4 mt-2">
|
||||||
|
<span className="text-xs font-semibold text-slate-400">Индекс CSI</span>
|
||||||
|
<div className="w-32 h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${agency.score > 90 ? 'bg-emerald-400' : agency.score > 80 ? 'bg-cyan' : 'bg-gold'}`} style={{ width: `${agency.score}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex flex-col items-end gap-1">
|
||||||
|
<span className="text-3xl font-black text-navy">{agency.score}</span>
|
||||||
|
<span className={`text-xs font-bold flex items-center bg-slate-50 px-2 py-0.5 rounded-md ${agency.trend === 'up' ? 'text-emerald-500' : 'text-red-500'}`}>
|
||||||
|
{agency.trend === 'up' ? <ArrowUpRight className="w-3 h-3 mr-0.5" /> : <ArrowDownRight className="w-3 h-3 mr-0.5" />}
|
||||||
|
{agency.val}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sticky top-24">
|
||||||
|
<Card className="border-slate-200 shadow-xl shadow-slate-200/20 bg-gradient-to-b from-white to-slate-50/50">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-cyan/10 flex items-center justify-center">
|
||||||
|
<Activity className="w-5 h-5 text-cyan" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-navy">Динамика МВД РК</h3>
|
||||||
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Последние 4 месяца</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-56 w-full -ml-4">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: '16px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)' }}
|
||||||
|
itemStyle={{ color: '#1A2B6B', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="uv" stroke="#00B4D8" strokeWidth={4} dot={{ r: 5, fill: '#fff', strokeWidth: 3, stroke: '#00B4D8' }} activeDot={{ r: 8, fill: '#00B4D8', strokeWidth: 0 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/pages/services/ApplicationWizard.tsx
Normal file
133
src/pages/services/ApplicationWizard.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { Input } from '../../components/ui/Input';
|
||||||
|
import { Card, CardContent } from '../../components/ui/Card';
|
||||||
|
import { CheckCircle2, UploadCloud, ChevronRight, Check } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export function ApplicationWizard() {
|
||||||
|
const { serviceType } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (step < 5) setStep(step + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step > 1) setStep(step - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
toast.success('Заявка успешно подана!');
|
||||||
|
navigate('/tracker');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-navy">Оформление услуги</h1>
|
||||||
|
<p className="text-slate-500 mt-1 text-sm font-medium">Следуйте инструкциям для заполнения заявки.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center relative mb-12">
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-1.5 bg-slate-100 -z-10 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-cyan transition-all duration-500" style={{ width: `${((step - 1) / 4) * 100}%` }}></div>
|
||||||
|
</div>
|
||||||
|
{[1, 2, 3, 4, 5].map((s) => (
|
||||||
|
<div key={s} className={`w-10 h-10 rounded-full border-[3px] flex items-center justify-center font-bold text-sm bg-white transition-colors duration-300 ${step >= s ? 'border-cyan text-cyan' : 'border-slate-200 text-slate-400'} ${step > s ? 'bg-cyan text-white border-cyan' : ''}`}>
|
||||||
|
{step > s ? <Check className="w-5 h-5" /> : s}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="min-h-[350px] shadow-sm border-slate-200/60">
|
||||||
|
<CardContent className="p-8">
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||||
|
<h2 className="font-bold text-navy text-lg">Шаг 1: Основные данные</h2>
|
||||||
|
<div className="p-4 bg-[aliceblue] border border-blue-100 rounded-xl flex items-start gap-3">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-blue-800 leading-relaxed font-medium">Данные автоматически заполнены из вашего цифрового профиля.</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<Input label="ИИН" value="950412300456" disabled className="bg-slate-50 text-slate-500" />
|
||||||
|
<Input label="ФИО" value="Иванов Иван Иванович" disabled className="bg-slate-50 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<Input label="Адрес регистрации" value="г. Астана, пр. Мангилик Ел, д. 23" disabled className="bg-slate-50 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-5 animate-in fade-in slide-in-from-right-4 duration-500">
|
||||||
|
<h2 className="font-bold text-navy text-lg">Шаг 2: Детали заявки</h2>
|
||||||
|
<Input label="Название организации" placeholder="Например: SuperCompany" />
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-slate-700">Вид деятельности (ОКЭД)</label>
|
||||||
|
<select className="flex h-12 w-full rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan transition-shadow">
|
||||||
|
<option>Выберите вид деятельности...</option>
|
||||||
|
<option>Разработка программного обеспечения (62.01.1)</option>
|
||||||
|
<option>Розничная торговля (47.19.1)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Steps 3,4,5... */}
|
||||||
|
{/* Using concise implementation for brevity */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4 animate-in fade-in slide-in-from-right-4">
|
||||||
|
<h2 className="font-bold text-navy text-lg mb-4">Шаг 3: Документы</h2>
|
||||||
|
<div className="border-2 border-dashed border-slate-200 rounded-2xl p-10 flex flex-col items-center justify-center text-center hover:border-cyan hover:bg-cyan/5 transition-colors cursor-pointer group">
|
||||||
|
<UploadCloud className="w-12 h-12 text-slate-300 group-hover:text-cyan mb-3 transition-colors" />
|
||||||
|
<p className="font-semibold text-slate-700">Перетащите файлы сюда</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1.5">или нажмите чтобы выбрать (PDF, JPG до 5 МБ)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<div className="space-y-4 animate-in fade-in slide-in-from-right-4">
|
||||||
|
<h2 className="font-bold text-navy text-lg mb-4">Шаг 4: Проверка данных</h2>
|
||||||
|
<div className="space-y-0 border border-slate-100 rounded-2xl bg-slate-50 overflow-hidden">
|
||||||
|
<div className="flex justify-between border-b border-slate-200 p-4">
|
||||||
|
<span className="text-sm text-slate-500">Услуга</span>
|
||||||
|
<span className="text-sm font-semibold text-right">Открытие ИП</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-b border-slate-200 p-4">
|
||||||
|
<span className="text-sm text-slate-500">Госпошлина</span>
|
||||||
|
<span className="text-sm font-bold text-emerald-600">0 ₸ (Бесплатно)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<div className="space-y-4 animate-in fade-in slide-in-from-right-4 text-center py-8">
|
||||||
|
<div className="w-24 h-24 mx-auto bg-gradient-to-tr from-navy to-blue-800 text-white rounded-full flex items-center justify-center font-bold shadow-xl shadow-navy/20 mb-6 border-4 border-white">
|
||||||
|
<span className="text-sm tracking-widest">EDS</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-bold text-navy text-2xl">Подписание ЭЦП</h2>
|
||||||
|
<p className="text-sm text-slate-500 max-w-sm mx-auto leading-relaxed">Нажмите кнопку ниже для старта процесса подписания через NCALayer или eGov Mobile.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
|
<Button variant="outline" onClick={handleBack} disabled={step === 1 || loading} className="w-32">Назад</Button>
|
||||||
|
{step < 5 ? (
|
||||||
|
<Button onClick={handleNext} className="w-40">Далее <ChevronRight className="w-4 h-4 ml-1" /></Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleSubmit} disabled={loading} className="w-auto px-8 bg-emerald-500 hover:bg-emerald-600 text-white shadow-lg shadow-emerald-500/20">
|
||||||
|
{loading ? 'Обработка...' : 'Подписать и отправить'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/pages/services/Services.tsx
Normal file
92
src/pages/services/Services.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Card, CardContent } from '../../components/ui/Card';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowRight, MessageCircle } from 'lucide-react';
|
||||||
|
import { SUPERGOV_SERVICES } from '../../data/superGovServices';
|
||||||
|
|
||||||
|
export function Services() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [filter, setFilter] = useState<'Все' | 'Новое' | 'MVP'>('Все');
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (filter === 'Все') return SUPERGOV_SERVICES;
|
||||||
|
if (filter === 'Новое') return SUPERGOV_SERVICES.filter((s) => s.tier === 'innovation');
|
||||||
|
return SUPERGOV_SERVICES.filter((s) => s.tier === 'mvp');
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
const openService = (s: (typeof SUPERGOV_SERVICES)[0]) => {
|
||||||
|
if (s.chatPrompt && s.href === '/chat') {
|
||||||
|
navigate(`/chat?q=${encodeURIComponent(s.chatPrompt)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s.chatPrompt && s.href !== '/chat') {
|
||||||
|
navigate(`${s.href}?from=services`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(s.href);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-navy">18 функций SuperGov</h1>
|
||||||
|
<p className="text-slate-500 mt-1 text-sm">
|
||||||
|
Инновации и MVP-ядро: услуги, AI, карта, банк, документы — всё из одного каталога.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-2 hide-scrollbar">
|
||||||
|
{(['Все', 'Новое', 'MVP'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(t)}
|
||||||
|
className={`px-4 py-2 rounded-full text-xs font-semibold whitespace-nowrap transition-colors tracking-wide ${
|
||||||
|
filter === t
|
||||||
|
? 'bg-navy text-white shadow-md shadow-navy/20'
|
||||||
|
: 'bg-white text-slate-600 hover:bg-slate-50 border border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<Card
|
||||||
|
key={s.id}
|
||||||
|
className="hover:border-cyan hover:shadow-lg transition-all duration-300 group cursor-pointer border-slate-200/60"
|
||||||
|
onClick={() => openService(s)}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-3">
|
||||||
|
<span
|
||||||
|
className={`text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded-full ${
|
||||||
|
s.tier === 'innovation' ? 'bg-emerald-100 text-emerald-800' : 'bg-cyan/15 text-navy'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.tier === 'innovation' ? 'Новое' : 'MVP'}
|
||||||
|
</span>
|
||||||
|
{s.chatPrompt && <MessageCircle className="w-4 h-4 text-cyan shrink-0" />}
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-cyan/10 text-cyan flex items-center justify-center mb-4 group-hover:bg-cyan group-hover:text-white transition-colors duration-300">
|
||||||
|
<s.icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-bold text-navy mb-1.5">{s.title}</h3>
|
||||||
|
<p className="text-xs text-slate-500 line-clamp-3 leading-relaxed min-h-[3rem]">{s.subtitle}</p>
|
||||||
|
<div className="mt-5 pt-4 border-t border-slate-100 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] uppercase font-bold text-slate-400 tracking-wider">
|
||||||
|
{s.href === '/services' ? 'Каталог' : s.href.replace('/', '')}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center text-cyan text-sm font-semibold opacity-0 group-hover:opacity-100 transition-all duration-300 -translate-x-2 group-hover:translate-x-0">
|
||||||
|
Открыть <ArrowRight className="w-4 h-4 ml-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/pages/tracker/Tracker.tsx
Normal file
62
src/pages/tracker/Tracker.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Card, CardContent } from '../../components/ui/Card';
|
||||||
|
import { Check, Clock, PackageCheck } from 'lucide-react';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
|
||||||
|
export function Tracker() {
|
||||||
|
const steps = [
|
||||||
|
{ title: 'Принято в обработку', desc: 'Заявка успешно зарегистрирована.', date: '12 Апр, 10:00', done: true },
|
||||||
|
{ title: 'Проверка документов', desc: 'Автоматическая сверка с ГБД.', date: '12 Апр, 10:05', done: true },
|
||||||
|
{ title: 'Проверка ГО', desc: 'Рассмотрение в КГД МФ РК.', date: 'В процессе', done: false, active: true },
|
||||||
|
{ title: 'Готово к подписи', desc: 'Ожидает вашей подписи.', date: '—', done: false },
|
||||||
|
{ title: 'Исполнено', desc: 'Услуга оказана успешно.', date: '—', done: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
<div className="flex items-start justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-navy">Открытие ИП онлайн</h1>
|
||||||
|
<p className="text-slate-500 mt-1 text-sm font-semibold tracking-wide uppercase">ЗАЯВКА № 182-391-233</p>
|
||||||
|
</div>
|
||||||
|
<span className="px-3 py-1 bg-blue-100 text-blue-700 font-bold text-xs rounded-full">В работе</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="shadow-lg shadow-slate-200/40 border-slate-200/50">
|
||||||
|
<CardContent className="p-8 md:p-10">
|
||||||
|
<div className="relative border-l-2 border-slate-100 ml-5 md:ml-6 space-y-10">
|
||||||
|
{steps.map((s, i) => (
|
||||||
|
<div key={i} className="relative pl-8 md:pl-10">
|
||||||
|
<div className={`absolute -left-[18px] md:-left-[20px] top-0 w-9 h-9 md:w-10 md:h-10 rounded-full flex items-center justify-center border-[3px] bg-white transition-colors duration-500 ${
|
||||||
|
s.done ? 'border-emerald-500' : s.active ? 'border-cyan shadow-lg shadow-cyan/20' : 'border-slate-200'
|
||||||
|
}`}>
|
||||||
|
{s.done ? <Check className="w-5 h-5 text-emerald-500" /> : s.active ? <Clock className="w-5 h-5 text-cyan animate-pulse" /> : <div className="w-2.5 h-2.5 rounded-full bg-slate-200" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className={`font-bold text-lg mb-1 ${s.done || s.active ? 'text-navy' : 'text-slate-400'}`}>{s.title}</h3>
|
||||||
|
<p className={`text-sm mb-2 ${s.done || s.active ? 'text-slate-500' : 'text-slate-300'}`}>{s.desc}</p>
|
||||||
|
<span className={`text-[11px] font-bold tracking-wider uppercase px-2 py-0.5 rounded-md ${s.done ? 'bg-emerald-50 text-emerald-600' : s.active ? 'bg-cyan/10 text-cyan' : 'text-slate-300'}`}>
|
||||||
|
{s.date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="bg-blue-50/50 border border-blue-100 rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-white rounded-xl shadow-sm text-blue-500 flex items-center justify-center shrink-0">
|
||||||
|
<PackageCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-navy">Ожидаемая дата готовности</h4>
|
||||||
|
<p className="text-slate-500 text-sm mt-0.5">12 Апр 2026, до 18:00</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" className="bg-white" disabled>Отозвать заявку</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/store/useStore.ts
Normal file
30
src/store/useStore.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
iin: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
stackUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
user: UserProfile | null;
|
||||||
|
language: 'kz' | 'ru' | 'en';
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
setUser: (user: UserProfile | null) => void;
|
||||||
|
setLanguage: (lang: 'kz' | 'ru' | 'en') => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<AppState>((set) => ({
|
||||||
|
user: null,
|
||||||
|
language: (localStorage.getItem('lang') as 'kz' | 'ru' | 'en') || 'ru',
|
||||||
|
sidebarOpen: false,
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
setLanguage: (language) => {
|
||||||
|
localStorage.setItem('lang', language);
|
||||||
|
set({ language });
|
||||||
|
},
|
||||||
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
|
}));
|
||||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
38
types.txt
Normal file
38
types.txt
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { AccountSettings } from "./components-page/account-settings.js";
|
||||||
|
import { GetCurrentUserOptions, HandlerUrlOptions, HandlerUrls, OAuthScopesOnSignIn, ResolvedHandlerUrls, stackAppInternalsSymbol } from "./lib/stack-app/common.js";
|
||||||
|
import { AdminEmailOutbox, AdminEmailOutboxRecipient, AdminEmailOutboxSimpleStatus, AdminEmailOutboxStatus, AdminSendAttemptError, AdminSentEmail } from "./lib/stack-app/email/index.js";
|
||||||
|
import { InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView } from "./lib/stack-app/internal-api-keys/index.js";
|
||||||
|
import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions } from "./lib/stack-app/permissions/index.js";
|
||||||
|
import { Connection, OAuthConnection } from "./lib/stack-app/connected-accounts/index.js";
|
||||||
|
import { ContactChannel, ServerContactChannel } from "./lib/stack-app/contact-channels/index.js";
|
||||||
|
import { Auth, CurrentInternalServerUser, CurrentInternalUser, CurrentServerUser, CurrentUser, OAuthProvider, ServerOAuthProvider, ServerUser, User } from "./lib/stack-app/users/index.js";
|
||||||
|
import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions, ServerTeamMemberProfile, ServerTeamUpdateOptions, ServerTeamUser, Team, TeamCreateOptions, TeamInvitation, TeamMemberProfile, TeamUpdateOptions, TeamUser } from "./lib/stack-app/teams/index.js";
|
||||||
|
import { StackServerApp, StackServerAppConstructor, StackServerAppConstructorOptions } from "./lib/stack-app/apps/interfaces/server-app.js";
|
||||||
|
import { EmailOutboxListOptions, EmailOutboxListResult, EmailOutboxUpdateOptions, StackAdminApp, StackAdminAppConstructor, StackAdminAppConstructorOptions } from "./lib/stack-app/apps/interfaces/admin-app.js";
|
||||||
|
import { AdminDomainConfig, AdminEmailConfig, AdminOAuthProviderConfig, AdminProjectConfig, AdminProjectConfigUpdateOptions, OAuthProviderConfig, ProjectConfig } from "./lib/stack-app/project-configs/index.js";
|
||||||
|
import { AdminOwnedProject, AdminProject, AdminProjectCreateOptions, AdminProjectUpdateOptions, Project, PushedConfigSource } from "./lib/stack-app/projects/index.js";
|
||||||
|
import { AnalyticsOptions, AnalyticsReplayOptions } from "./lib/stack-app/apps/implementations/session-replay.js";
|
||||||
|
import { StackClientApp, StackClientAppConstructor, StackClientAppConstructorOptions, StackClientAppJson } from "./lib/stack-app/apps/interfaces/client-app.js";
|
||||||
|
import { getConvexProvidersConfig } from "./integrations/convex.js";
|
||||||
|
import { CliAuthConfirmation } from "./components-page/cli-auth-confirm.js";
|
||||||
|
import { EmailVerification } from "./components-page/email-verification.js";
|
||||||
|
import { ForgotPassword } from "./components-page/forgot-password.js";
|
||||||
|
import { PasswordReset } from "./components-page/password-reset.js";
|
||||||
|
import StackHandler from "./components-page/stack-handler.js";
|
||||||
|
import { useStackApp, useUser } from "./lib/hooks.js";
|
||||||
|
import NextStackProvider from "./providers/stack-provider.js";
|
||||||
|
import { StackTheme } from "./providers/theme-provider.js";
|
||||||
|
import { AuthPage } from "./components-page/auth-page.js";
|
||||||
|
import { SignIn } from "./components-page/sign-in.js";
|
||||||
|
import { SignUp } from "./components-page/sign-up.js";
|
||||||
|
import { CredentialSignIn } from "./components/credential-sign-in.js";
|
||||||
|
import { CredentialSignUp } from "./components/credential-sign-up.js";
|
||||||
|
import { UserAvatar } from "./components/elements/user-avatar.js";
|
||||||
|
import { MagicLinkSignIn } from "./components/magic-link-sign-in.js";
|
||||||
|
import { MessageCard } from "./components/message-cards/message-card.js";
|
||||||
|
import { OAuthButton } from "./components/oauth-button.js";
|
||||||
|
import { OAuthButtonGroup } from "./components/oauth-button-group.js";
|
||||||
|
import { SelectedTeamSwitcher } from "./components/selected-team-switcher.js";
|
||||||
|
import { TeamSwitcher } from "./components/team-switcher.js";
|
||||||
|
import { UserButton } from "./components/user-button.js";
|
||||||
|
export { AccountSettings, AdminDomainConfig, AdminEmailConfig, AdminEmailOutbox, AdminEmailOutboxRecipient, AdminEmailOutboxSimpleStatus, AdminEmailOutboxStatus, AdminOAuthProviderConfig, AdminOwnedProject, AdminProject, AdminProjectConfig, AdminProjectConfigUpdateOptions, AdminProjectCreateOptions, AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectPermissionDefinitionCreateOptions, AdminProjectPermissionDefinitionUpdateOptions, AdminProjectUpdateOptions, AdminSendAttemptError, AdminSentEmail, AdminTeamPermission, AdminTeamPermissionDefinition, AdminTeamPermissionDefinitionCreateOptions, AdminTeamPermissionDefinitionUpdateOptions, type AnalyticsOptions, type AnalyticsReplayOptions, Auth, AuthPage, CliAuthConfirmation, Connection, ContactChannel, CredentialSignIn, CredentialSignUp, CurrentInternalServerUser, CurrentInternalUser, CurrentServerUser, CurrentUser, EditableTeamMemberProfile, EmailOutboxListOptions, EmailOutboxListResult, EmailOutboxUpdateOptions, EmailVerification, ForgotPassword, GetCurrentUserOptions, GetCurrentUserOptions as GetUserOptions, HandlerUrlOptions, HandlerUrls, InternalApiKey, InternalApiKeyBase, InternalApiKeyBaseCrudRead, InternalApiKeyCreateOptions, InternalApiKeyFirstView, MagicLinkSignIn, MessageCard, OAuthButton, OAuthButtonGroup, OAuthConnection, OAuthProvider, OAuthProviderConfig, OAuthScopesOnSignIn, PasswordReset, Project, ProjectConfig, PushedConfigSource, ReceivedTeamInvitation, ResolvedHandlerUrls, SelectedTeamSwitcher, SentTeamInvitation, ServerContactChannel, ServerListUsersOptions, ServerOAuthProvider, ServerTeam, ServerTeamCreateOptions, ServerTeamMemberProfile, ServerTeamUpdateOptions, ServerTeamUser, ServerUser, SignIn, SignUp, StackAdminApp, StackAdminAppConstructor, StackAdminAppConstructorOptions, StackClientApp, StackClientAppConstructor, StackClientAppConstructorOptions, StackClientAppJson, StackHandler, NextStackProvider as StackProvider, StackServerApp, StackServerAppConstructor, StackServerAppConstructorOptions, StackTheme, Team, TeamCreateOptions, TeamInvitation, TeamMemberProfile, TeamSwitcher, TeamUpdateOptions, TeamUser, User, UserAvatar, UserButton, getConvexProvidersConfig, stackAppInternalsSymbol, useStackApp, useUser };
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user