Initial commit: SuperGov frontend + FastAPI backend

Made-with: Cursor
This commit is contained in:
Dimash 2026-04-04 22:08:49 +05:00
commit 2e7e56b9da
101 changed files with 15693 additions and 0 deletions

38
.gitignore vendored Normal file
View 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
View 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
View 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
View 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`.

View 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

View 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()

View 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"],
},
},
]

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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
View 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")

View 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"
}
]

View 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"]
}
]

View 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
View 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
View 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,
},
}

View 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()

View 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}

View 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
}

View 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
View 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]}

View 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}

View 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)
}
}

View 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",
)

View 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": "Кластеризация по районам доступна на карте; добавляйте жалобы через форму «Сообщить о проблеме».",
},
}

View 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}

View 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]}
}

View 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}

View 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}

View 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}
}

View 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
View 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
);

View 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()

View 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()

View 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)

View 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 ""

View 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

View 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()

View 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

View 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

View 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

View 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),
}
}

View 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

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

1
src/assets/react.svg Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View 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;
}
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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";

View 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';

View 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";

View 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';

View 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' },
];

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>,
)

View 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
View 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
View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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='&copy; <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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View 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
View 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