Mini-app updates: UI TG MAX session nav logs

This commit is contained in:
Fedor
2026-02-23 11:31:52 +03:00
parent 4536210284
commit 6350f9015b
19 changed files with 1221 additions and 322 deletions

29
CHANGELOG_MINIAPP.md Normal file
View File

@@ -0,0 +1,29 @@
# Доработки мини-приложения Clientright (TG/MAX и веб)
## UI и навигация
- **«Мои обращения»**: дашборд с плитками по статусам (На рассмотрении, В работе, Решённые, Отклонённые, Все), заголовок переименован с «Жалобы потребителей».
- Убрана внешняя рамка у дашборда; карточки с hover-эффектом (подъём, тень), единая высота плиток, прозрачный фон у иконок.
- Список обращений по категориям в виде карточек с hover; фильтр по выбранной категории.
- Кнопка **«Назад»** перенесена в нижний бар; убраны дублирующие кнопки «Назад» из контента (описание, документы).
## Telegram и MAX
- **Выход**: корректное закрытие приложения — в TG вызывается `Telegram.WebApp.close()`, в MAX — `window.WebApp.close()` / `postEvent('web_app_close')`. Определение платформы по initData/URL.
- Подключение скриптов по платформе: при наличии `tgWebAppData`/`tgWebAppVersion` в URL грузится только `telegram-web-app.js`, иначе — только `max-web-app.js` (устранены ошибки UnsupportedEvent в MAX).
- В TG/MAX **не показывается экран ввода телефона** — шаг «Вход» только для обычного веба; раннее определение платформы (опрос `WebApp.initData`), флаг `platformChecked` чтобы не мелькал телефон до определения.
## Сессия и авторизация
- Сессию не сбрасывать при сетевых/временных ошибках `session/verify` — удалять `session_token` только при явном ответе `valid: false`.
- При нажатии «Назад» с авторизованного пользователя не вести на шаг «Вход» — переход на дашборд «Мои обращения» или на `/hello`.
- Переход на «Подать обращение» через роут `/new` и `pushState` для стабильного флоу без возврата на телефон.
## Исправления
- **TDZ-ошибка** (пустой экран после перехода с /hello): `useEffect` для `miniapp:goBack` перенесён после объявления `prevStep` (useCallback).
- Тостер **«Добро пожаловать!»** показывается только в вебе (не в TG/MAX), проверка по `Telegram.WebApp.initData` и `WebApp.initData`.
## Отладка и логи
- Клиентский логгер `miniappLogger`: сбор событий, ошибок, отправка на `POST /api/v1/utils/client-log`; идентификация бандла (build/moduleUrl); очистка логов при смене сборки.
- Бэкенд: приём логов в `main.py`, запись в `logs/cursor-debug-*.log` (NDJSON), без PII.
## Файлы
- Новые: `StepComplaintsDashboard.tsx/.css`, `StepDraftSelection.css`, `miniappLogger.ts`.
- Правки: `ClaimForm.tsx`, `HelloAuth.tsx`, `BottomBar.tsx`, `StepDescription.tsx`, `StepWizardPlan.tsx`, `StepDraftSelection.tsx`, `App.tsx`, `main.tsx`, `index.html`, `main.py`, `api/claims.py`, `ClaimForm.css`, `BottomBar.css`, `Dockerfile.prod`.

View File

@@ -383,8 +383,13 @@ async def list_drafts(
if facts_short and len(facts_short) > 200:
facts_short = facts_short[:200].rstrip() + ''
# Подробное описание (для превью)
problem_text = payload.get('problem_description', '')
# Подробное описание (для превью); n8n может сохранять в description/chatInput
problem_text = (
payload.get('problem_description')
or payload.get('description')
or payload.get('chatInput')
or ''
)
# Считаем документы
documents_meta = payload.get('documents_meta') or []

View File

@@ -2,9 +2,13 @@
Ticket Form Intake Platform - FastAPI Backend
"""
from fastapi import FastAPI, Request
import json
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
import logging
import time
import uuid
from typing import Any, Dict, Optional, Tuple
from .config import settings, get_cors_origins_live, get_settings
from .services.database import db
@@ -23,6 +27,82 @@ logging.basicConfig(
)
logger = logging.getLogger(__name__)
DEBUG_SESSION_ID = "2a4d38"
# В прод-контейнере гарантированно доступен /app/logs (volume ./backend/logs:/app/logs)
DEBUG_LOG_PATH = "/app/logs/cursor-debug-2a4d38.log"
def _debug_write(
*,
hypothesis_id: str,
run_id: str,
location: str,
message: str,
data: Dict[str, Any],
) -> None:
"""
NDJSON debug log for Cursor Debug Mode.
IMPORTANT: do not log secrets/PII (tokens, tg hash, full init_data, phone, etc).
"""
try:
ts = int(time.time() * 1000)
entry = {
"sessionId": DEBUG_SESSION_ID,
"id": f"log_{ts}_{uuid.uuid4().hex[:8]}",
"timestamp": ts,
"location": location,
"message": message,
"data": data,
"runId": run_id,
"hypothesisId": hypothesis_id,
}
with open(DEBUG_LOG_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
# Never break prod request handling due to debug logging
return
def _extract_client_bundle_info(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
Returns (moduleUrl, scriptSrc, build) from the last 'boot' entry if present.
"""
logs = payload.get("logs") or []
if not isinstance(logs, list):
return (None, None, None)
for entry in reversed(logs):
if not isinstance(entry, dict):
continue
if entry.get("event") != "boot":
continue
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
module_url = data.get("moduleUrl") if isinstance(data.get("moduleUrl"), str) else None
script_src = data.get("scriptSrc") if isinstance(data.get("scriptSrc"), str) else None
build = data.get("build") if isinstance(data.get("build"), str) else None
return (module_url, script_src, build)
return (None, None, None)
def _extract_last_window_error(payload: Dict[str, Any]) -> Dict[str, Any]:
logs = payload.get("logs") or []
if not isinstance(logs, list):
return {}
for entry in reversed(logs):
if not isinstance(entry, dict):
continue
if entry.get("event") != "window_error":
continue
data = entry.get("data") if isinstance(entry.get("data"), dict) else {}
# Keep only safe fields
return {
"message": data.get("message"),
"filename": data.get("filename"),
"lineno": data.get("lineno"),
"colno": data.get("colno"),
"hasStack": bool(data.get("stack")),
}
return {}
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -246,6 +326,71 @@ async def get_client_ip(request: Request):
}
@app.post("/api/v1/utils/client-log")
async def client_log(request: Request):
"""
Принимает клиентские логи (для отладки webview/miniapp) и пишет в backend-логи.
Формат: { reason, client: {...}, logs: [...] }
"""
client_host = request.client.host if request.client else None
ua = request.headers.get("user-agent", "")
try:
payload = await request.json()
except Exception:
payload = {"error": "invalid_json"}
# Cursor debug-mode evidence (sanitized)
try:
if isinstance(payload, dict):
reason = payload.get("reason")
client = payload.get("client") if isinstance(payload.get("client"), dict) else {}
pathname = client.get("pathname") if isinstance(client.get("pathname"), str) else None
origin = client.get("origin") if isinstance(client.get("origin"), str) else None
logs = payload.get("logs") if isinstance(payload.get("logs"), list) else []
module_url, script_src, build = _extract_client_bundle_info(payload)
last_err = _extract_last_window_error(payload)
first_err_file = None
last_err_file = None
if isinstance(logs, list):
for e in logs:
if isinstance(e, dict) and e.get("event") == "window_error":
d = e.get("data") if isinstance(e.get("data"), dict) else {}
fn = d.get("filename")
if isinstance(fn, str):
if first_err_file is None:
first_err_file = fn
last_err_file = fn
_debug_write(
hypothesis_id="H1",
run_id="pre-fix",
location="backend/app/main.py:client_log",
message="client_log_received",
data={
"ip": client_host,
"uaPrefix": ua[:80] if isinstance(ua, str) else "",
"reason": reason,
"origin": origin,
"pathname": pathname,
"logsCount": len(logs) if isinstance(logs, list) else None,
"boot": {"moduleUrl": module_url, "scriptSrc": script_src, "build": build},
"windowErrorLast": last_err,
"windowErrorFiles": {"first": first_err_file, "last": last_err_file},
},
)
except Exception:
pass
# Ограничим размер вывода, но оставим самое важное
try:
s = json.dumps(payload, ensure_ascii=False)[:20000]
except Exception:
s = str(payload)[:20000]
logger.warning(f"📱 CLIENT_LOG ip={client_host} ua={ua} payload={s}")
return {"success": True}
@app.get("/api/v1/info")
async def info():
"""Информация о платформе"""

View File

@@ -1,36 +1,16 @@
# React Frontend Dockerfile (PRODUCTION BUILD)
# Продакшен: сборка + отдача dist (без dev-сервера).
# После правок в коде: docker compose build frontend && docker compose up -d frontend
FROM node:18-alpine AS builder
# Устанавливаем рабочую директорию
WORKDIR /app
# Копируем package.json
COPY package*.json ./
# Устанавливаем зависимости
COPY package.json package-lock.json* ./
RUN npm ci
# Копируем исходный код
COPY . .
RUN node -r ./scripts/crypto-polyfill.cjs ./node_modules/vite/bin/vite.js build
# Собираем production build
RUN npm run build
# Production stage
FROM node:18-alpine
# Устанавливаем serve глобально
RUN npm install -g serve
# Копируем собранное приложение из builder stage
COPY --from=builder /app/dist /app/dist
# Устанавливаем рабочую директорию
WORKDIR /app
# Открываем порт
RUN npm install -g serve
COPY --from=builder /app/dist ./dist
EXPOSE 3000
# Запускаем serve для раздачи статических файлов
CMD ["serve", "-s", "dist", "-l", "3000"]

View File

@@ -5,9 +5,17 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clientright — защита прав потребителей</title>
<!-- MAX Bridge: нужен для window.WebApp и initData при заходе из MAX -->
<script src="https://st.max.ru/js/max-web-app.js"></script>
<!-- Telegram SDK загружается динамически только при заходе из Telegram -->
<!-- Подключаем только скрипт текущей платформы, иначе в MAX приходят события Telegram → UnsupportedEvent -->
<script>
(function() {
var u = window.location.href || '';
if (u.indexOf('tgWebAppData') !== -1 || u.indexOf('tgWebAppVersion') !== -1) {
var s = document.createElement('script'); s.src = 'https://telegram.org/js/telegram-web-app.js'; document.head.appendChild(s);
} else {
var s = document.createElement('script'); s.src = 'https://st.max.ru/js/max-web-app.js'; document.head.appendChild(s);
}
})();
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -1,12 +1,15 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import ClaimForm from './pages/ClaimForm';
import HelloAuth from './pages/HelloAuth';
import BottomBar from './components/BottomBar';
import './App.css';
import { miniappLog, miniappSendLogs } from './utils/miniappLogger';
function App() {
const [pathname, setPathname] = useState<string>(() => window.location.pathname || '');
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
const lastRouteTsRef = useRef<number>(Date.now());
const lastPathRef = useRef<string>(pathname);
useEffect(() => {
const onPopState = () => setPathname(window.location.pathname || '');
@@ -14,6 +17,41 @@ function App() {
return () => window.removeEventListener('popstate', onPopState);
}, []);
// Логируем смену маршрута + ловим быстрый возврат на /hello (симптом бага)
useEffect(() => {
const now = Date.now();
const prev = lastPathRef.current;
lastPathRef.current = pathname;
lastRouteTsRef.current = now;
miniappLog('route', { prev, next: pathname });
if (pathname.startsWith('/hello') && !prev.startsWith('/hello')) {
// Вернулись на /hello: отправим дамп, чтобы поймать “ложится”
void miniappSendLogs('returned_to_hello');
}
}, [pathname]);
// Ловим клики в первые 2с после смены маршрута (ghost click / попадание в бар)
useEffect(() => {
const onClickCapture = (e: MouseEvent) => {
const dt = Date.now() - lastRouteTsRef.current;
if (dt > 2000) return;
const t = e.target as HTMLElement | null;
const inBar = !!t?.closest?.('.app-bottom-bar');
miniappLog('click_capture', {
dtFromRouteMs: dt,
inBottomBar: inBar,
tag: t?.tagName,
id: t?.id,
class: t?.className,
x: (e as MouseEvent).clientX,
y: (e as MouseEvent).clientY,
});
};
window.addEventListener('click', onClickCapture, true);
return () => window.removeEventListener('click', onClickCapture, true);
}, []);
useEffect(() => {
setAvatarUrl(localStorage.getItem('user_avatar_url') || '');
}, [pathname]);

View File

@@ -43,6 +43,15 @@
color: #111827;
}
.app-bar-item:disabled {
cursor: default;
opacity: 0.45;
}
.app-bar-item:disabled:hover {
color: #6b7280;
}
.app-bar-item--active {
color: #2563EB;
font-weight: 600;

View File

@@ -1,5 +1,7 @@
import { Home, Headphones, User, LogOut } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Home, Headphones, User, LogOut, ArrowLeft } from 'lucide-react';
import './BottomBar.css';
import { miniappLog } from '../utils/miniappLogger';
interface BottomBarProps {
currentPath: string;
@@ -8,32 +10,100 @@ interface BottomBarProps {
export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
const isHome = currentPath.startsWith('/hello');
const [backEnabled, setBackEnabled] = useState(false);
// В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться
useEffect(() => {
if (isHome) {
setBackEnabled(false);
return;
}
setBackEnabled(false);
const t = window.setTimeout(() => setBackEnabled(true), 1200);
return () => window.clearTimeout(t);
}, [isHome, currentPath]);
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
miniappLog('bottom_bar_back_click', { backEnabled, currentPath });
if (!backEnabled) return;
window.dispatchEvent(new CustomEvent('miniapp:goBack'));
};
const handleExit = (e: React.MouseEvent) => {
e.preventDefault();
// Telegram Mini App
try {
const tg = (window as any).Telegram;
const webApp = tg?.WebApp;
if (webApp && typeof webApp.close === 'function') {
webApp.close();
return;
}
} catch (_) {}
// MAX Mini App
try {
const maxWebApp = (window as any).WebApp;
if (maxWebApp && typeof maxWebApp.close === 'function') {
maxWebApp.close();
return;
}
} catch (_) {}
const tgWebApp = (window as any).Telegram?.WebApp;
const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : '';
const isTg =
tgInitData.length > 0 ||
window.location.href.includes('tgWebAppData') ||
navigator.userAgent.includes('Telegram');
const maxWebApp = (window as any).WebApp;
const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : '';
const maxStartParam = maxWebApp?.initDataUnsafe?.start_param;
const isMax =
maxInitData.length > 0 ||
(typeof maxStartParam === 'string' && maxStartParam.length > 0);
miniappLog('bottom_bar_exit_click', {
currentPath,
isTg,
isMax,
tgInitDataLen: tgInitData.length,
maxInitDataLen: maxInitData.length,
hasTgClose: typeof tgWebApp?.close === 'function',
hasMaxClose: typeof maxWebApp?.close === 'function',
hasMaxPostEvent: typeof maxWebApp?.postEvent === 'function',
});
// ВАЖНО: telegram-web-app.js может объявлять Telegram.WebApp.close() вне Telegram.
// Поэтому выбираем платформу по реальному initData, иначе в MAX будем вызывать TG close и рано выходить.
if (isTg) {
try {
if (typeof tgWebApp?.close === 'function') {
miniappLog('bottom_bar_exit_close', { platform: 'tg' });
tgWebApp.close();
return;
}
} catch (_) {}
}
if (isMax) {
try {
if (typeof maxWebApp?.close === 'function') {
miniappLog('bottom_bar_exit_close', { platform: 'max' });
maxWebApp.close();
return;
}
if (typeof maxWebApp?.postEvent === 'function') {
miniappLog('bottom_bar_exit_close', { platform: 'max', method: 'postEvent' });
maxWebApp.postEvent('web_app_close');
return;
}
} catch (_) {}
}
// Fallback: переход на главную
miniappLog('bottom_bar_exit_fallback', {});
window.location.href = '/hello';
};
return (
<nav className="app-bottom-bar" aria-label="Навигация">
{!isHome && (
<button
type="button"
className="app-bar-item"
onClick={handleBack}
disabled={!backEnabled}
aria-label="Назад"
>
<ArrowLeft size={24} strokeWidth={1.8} />
<span>Назад</span>
</button>
)}
<a href="/hello" className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}>
<Home size={24} strokeWidth={1.8} />
<span>Домой</span>

View File

@@ -0,0 +1,37 @@
/* Карточки дашборда — в стиле экрана hello: тень и подъём при наведении, одинаковая высота */
.dashboard-tile {
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
min-height: 88px;
height: 100%;
}
.dashboard-tile:hover {
transform: translateY(-6px);
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
}
.dashboard-tile .ant-card-body {
padding: 14px;
}
/* чтобы все плитки в ряду были одной высоты */
.dashboard-tile-row .ant-col {
display: flex;
}
.dashboard-tile-row .ant-col .dashboard-tile {
width: 100%;
}
/* заголовок плитки — фиксированная высота под 2 строки, чтобы «Приняты к работе» не делал карточку выше */
.dashboard-tile-title {
min-height: 2.5em;
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View File

@@ -0,0 +1,249 @@
/**
* StepComplaintsDashboard.tsx
*
* Экран «Мои обращения»: плитки по статусам + кнопка «Подать жалобу».
* Показывается после нажатия «Мои обращения» на приветственном экране.
*/
import { useEffect, useState } from 'react';
import { Button, Card, Row, Col, Typography, Spin } from 'antd';
import { Clock, Briefcase, CheckCircle, XCircle, FileSearch, PlusCircle } from 'lucide-react';
import './StepComplaintsDashboard.css';
const { Title, Text } = Typography;
// Статусы для плиток (маппинг status_code → категория дашборда)
const PENDING_CODES = ['draft', 'draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready', 'awaiting_sms'];
const IN_WORK_CODE = 'in_work';
const RESOLVED_CODES = ['completed', 'submitted'];
const REJECTED_CODE = 'rejected';
interface DraftItem {
claim_id?: string;
id?: string;
status_code?: string;
}
interface Counts {
pending: number;
inWork: number;
resolved: number;
rejected: number;
total: number;
}
function countByStatus(drafts: DraftItem[]): Counts {
let pending = 0;
let inWork = 0;
let resolved = 0;
let rejected = 0;
for (const d of drafts) {
const code = (d.status_code || '').toLowerCase();
if (code === IN_WORK_CODE) inWork += 1;
else if (code === REJECTED_CODE) rejected += 1;
else if (RESOLVED_CODES.includes(code)) resolved += 1;
else if (PENDING_CODES.includes(code) || code === 'draft') pending += 1;
else pending += 1; // неизвестный — в «ожидании»
}
return {
pending,
inWork,
resolved,
rejected,
total: drafts.length,
};
}
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
interface StepComplaintsDashboardProps {
unified_id?: string;
phone?: string;
session_id?: string;
onGoToList: (filter: DraftsListFilter) => void;
onNewClaim: () => void;
}
export default function StepComplaintsDashboard({
unified_id,
phone,
session_id,
onGoToList,
onNewClaim,
}: StepComplaintsDashboardProps) {
const [loading, setLoading] = useState(true);
const [counts, setCounts] = useState<Counts>({ pending: 0, inWork: 0, resolved: 0, rejected: 0, total: 0 });
useEffect(() => {
let cancelled = false;
const params = new URLSearchParams();
if (unified_id) params.append('unified_id', unified_id);
else if (phone) params.append('phone', phone);
else if (session_id) params.append('session_id', session_id);
if (!unified_id && !phone && !session_id) {
setLoading(false);
return;
}
fetch(`/api/v1/claims/drafts/list?${params.toString()}`)
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить список'))))
.then((data) => {
if (cancelled) return;
const drafts = data.drafts || [];
setCounts(countByStatus(drafts));
})
.catch(() => {
if (!cancelled) setCounts((c) => ({ ...c, total: 0 }));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [unified_id, phone, session_id]);
const tiles = [
{
key: 'pending' as const,
title: 'В ожидании',
count: counts.pending,
label: counts.pending === 1 ? '1 дело' : counts.pending < 5 ? `${counts.pending} дела` : `${counts.pending} дел`,
color: '#3B82F6',
bg: '#EFF6FF',
icon: Clock,
},
{
key: 'in_work' as const,
title: 'Приняты к работе',
count: counts.inWork,
label: counts.inWork === 1 ? '1 дело' : counts.inWork < 5 ? `${counts.inWork} дела` : `${counts.inWork} дел`,
color: '#EA580C',
bg: '#FFF7ED',
icon: Briefcase,
},
{
key: 'resolved' as const,
title: 'Решены',
count: counts.resolved,
label: counts.resolved === 1 ? '1 дело' : counts.resolved < 5 ? `${counts.resolved} дела` : `${counts.resolved} дел`,
color: '#16A34A',
bg: '#F0FDF4',
icon: CheckCircle,
},
{
key: 'rejected' as const,
title: 'Отклонены',
count: counts.rejected,
label: counts.rejected === 1 ? '1 дело' : counts.rejected < 5 ? `${counts.rejected} дела` : `${counts.rejected} дел`,
color: '#DC2626',
bg: '#FEF2F2',
icon: XCircle,
},
];
const handleTileClick = (key: DraftsListFilter) => {
onGoToList(key);
};
return (
<div style={{ padding: '16px', paddingBottom: 24 }}>
<Title level={2} style={{ marginBottom: 4, color: '#111827', fontSize: 22 }}>
Мои обращения
</Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 20 }}>
Выберите категорию
</Text>
{loading ? (
<div style={{ textAlign: 'center', padding: '48px 0' }}>
<Spin size="large" />
</div>
) : (
<>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }} className="dashboard-tile-row">
{tiles.map((t) => {
const Icon = t.icon;
return (
<Col xs={12} key={t.key}>
<Card
size="small"
className="dashboard-tile"
style={{ background: t.bg }}
onClick={() => handleTileClick(t.key)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: 10,
background: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: t.color,
}}
>
<Icon size={24} strokeWidth={1.8} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }} className="dashboard-tile-title">
{t.title}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{t.label}
</Text>
</div>
</div>
</Card>
</Col>
);
})}
</Row>
<Card
size="small"
className="dashboard-tile"
style={{ background: '#F9FAFB', marginBottom: 20 }}
onClick={() => handleTileClick('all' as const)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: 10,
background: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#6366F1',
}}
>
<FileSearch size={24} strokeWidth={1.8} />
</div>
<div style={{ flex: 1 }}>
<Text strong style={{ display: 'block', color: '#111827', fontSize: 14 }}>
Все обращения
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{counts.total === 1 ? '1 дело всего' : counts.total < 5 ? `${counts.total} дела всего` : `${counts.total} дел всего`}
</Text>
</div>
</div>
</Card>
<Button
type="primary"
size="large"
block
icon={<PlusCircle size={20} style={{ verticalAlign: 'middle', marginRight: 8 }} />}
onClick={onNewClaim}
style={{ height: 48, fontSize: 16, borderRadius: 12 }}
>
Подать жалобу
</Button>
</>
)}
</div>
);
}

View File

@@ -1,5 +1,4 @@
import { Form, Input, Button, Typography, message, Checkbox } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { useEffect, useState } from 'react';
import wizardPlanSample from '../../mocks/wizardPlanSample';
@@ -16,7 +15,7 @@ interface Props {
export default function StepDescription({
formData,
updateFormData,
onPrev,
onPrev: _onPrev,
onNext,
}: Props) {
const [form] = Form.useForm();
@@ -210,10 +209,7 @@ export default function StepDescription({
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginTop: 16 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={onPrev}>
Назад
</Button>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12, marginTop: 16 }}>
<Button type="primary" size="large" onClick={handleContinue} loading={submitting}>
Продолжить
</Button>

View File

@@ -0,0 +1,12 @@
/* Карточки списка обращений — как на hello: тень и подъём при наведении */
.draft-list-card {
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.draft-list-card:hover {
transform: translateY(-6px);
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
}

View File

@@ -14,7 +14,7 @@
*/
import { useEffect, useState } from 'react';
import { Button, Card, Row, Col, Typography, Space, Empty, Popconfirm, message, Spin, Tooltip } from 'antd';
import { Button, Card, Typography, Space, Empty, message, Spin, Tooltip } from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
@@ -26,9 +26,9 @@ import {
FileSearchOutlined,
MobileOutlined,
ExclamationCircleOutlined,
ArrowLeftOutlined,
FolderOpenOutlined
} from '@ant-design/icons';
import './StepDraftSelection.css';
import {
Package,
Wrench,
@@ -90,6 +90,41 @@ const formatDate = (dateStr: string) => {
}
};
// Короткая дата для карточек списка: "12 апреля 2024"
const formatDateShort = (dateStr: string) => {
try {
const date = new Date(dateStr);
const day = date.getDate();
const month = date.toLocaleDateString('ru-RU', { month: 'long' });
const year = date.getFullYear();
return `${day} ${month} ${year}`;
} catch {
return dateStr;
}
};
// Маппинг status_code → категория дашборда (как в StepComplaintsDashboard)
const PENDING_CODES = ['draft', 'draft_new', 'draft_docs_progress', 'draft_docs_complete', 'draft_claim_ready', 'awaiting_sms'];
const IN_WORK_CODE = 'in_work';
const RESOLVED_CODES = ['completed', 'submitted'];
const REJECTED_CODE = 'rejected';
function getDraftCategory(statusCode: string): 'pending' | 'in_work' | 'resolved' | 'rejected' {
const code = (statusCode || '').toLowerCase();
if (code === IN_WORK_CODE) return 'in_work';
if (code === REJECTED_CODE) return 'rejected';
if (RESOLVED_CODES.includes(code)) return 'resolved';
return 'pending';
}
const CATEGORY_LABELS: Record<'all' | 'pending' | 'in_work' | 'resolved' | 'rejected', string> = {
all: 'Все обращения',
pending: 'В ожидании',
in_work: 'Приняты к работе',
resolved: 'Решены',
rejected: 'Отклонены',
};
// Относительное время
const getRelativeTime = (dateStr: string) => {
try {
@@ -142,14 +177,23 @@ interface Draft {
is_legacy?: boolean; // Старый формат без documents_required
}
/** Фильтр списка по категории (с дашборда) */
export type DraftsListFilter = 'all' | 'pending' | 'in_work' | 'resolved' | 'rejected';
interface Props {
phone?: string;
session_id?: string;
unified_id?: string;
isTelegramMiniApp?: boolean; // ✅ Флаг Telegram Mini App
isTelegramMiniApp?: boolean;
/** ID черновика, открытого для просмотра описания (управляется из ClaimForm, чтобы не терять при пересчёте steps) */
draftDetailClaimId?: string | null;
/** Показывать только обращения этой категории (с дашборда) */
categoryFilter?: DraftsListFilter;
onOpenDraftDetail?: (claimId: string) => void;
onCloseDraftDetail?: () => void;
onSelectDraft: (claimId: string) => void;
onNewClaim: () => void;
onRestartDraft?: (claimId: string, description: string) => void; // Для legacy черновиков
onRestartDraft?: (claimId: string, description: string) => void;
}
// === Конфиг статусов ===
@@ -223,15 +267,31 @@ export default function StepDraftSelection({
session_id,
unified_id,
isTelegramMiniApp,
draftDetailClaimId = null,
categoryFilter = 'all',
onOpenDraftDetail,
onCloseDraftDetail,
onSelectDraft,
onNewClaim,
onRestartDraft,
}: Props) {
const [drafts, setDrafts] = useState<Draft[]>([]);
/** Список отфильтрован по категории с дашборда */
const filteredDrafts =
categoryFilter === 'all'
? drafts
: drafts.filter((d) => getDraftCategory(d.status_code) === categoryFilter);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
/** Черновик, открытый для просмотра полного описания (по клику на карточку) */
const [selectedDraft, setSelectedDraft] = useState<Draft | null>(null);
/** Полный payload черновика с API GET /drafts/{claim_id} для экрана описания */
const [detailDraftPayload, setDetailDraftPayload] = useState<{ claimId: string; payload: Record<string, unknown> } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
/** Черновик для экрана описания: из пропа draftDetailClaimId + список drafts */
const selectedDraft = draftDetailClaimId
? (drafts.find((d) => (d.claim_id || d.id) === draftDetailClaimId) ?? null)
: null;
const loadDrafts = async () => {
try {
@@ -332,6 +392,38 @@ export default function StepDraftSelection({
return { uploaded, skipped, total, percent };
};
// Открыть экран полного описания (загрузка payload — в useEffect по draftDetailClaimId)
const openDraftDetail = (draft: Draft) => {
const draftId = draft.claim_id || draft.id;
onOpenDraftDetail?.(draftId);
setDetailDraftPayload(null);
setDetailLoading(true);
};
const closeDraftDetail = () => {
onCloseDraftDetail?.();
setDetailDraftPayload(null);
};
// Загрузка payload при открытии по draftDetailClaimId (клик по карточке или восстановление после пересчёта steps)
useEffect(() => {
if (!draftDetailClaimId) return;
if (detailDraftPayload?.claimId === draftDetailClaimId) return;
setDetailLoading(true);
setDetailDraftPayload(null);
const claimId = draftDetailClaimId;
fetch(`/api/v1/claims/drafts/${claimId}`)
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('Не удалось загрузить черновик'))))
.then((data) => {
const payload = data?.claim?.payload;
if (payload && typeof payload === 'object') {
setDetailDraftPayload({ claimId, payload });
}
})
.catch(() => {})
.finally(() => setDetailLoading(false));
}, [draftDetailClaimId]);
// Обработка клика на черновик
const handleDraftAction = (draft: Draft) => {
const draftId = draft.claim_id || draft.id;
@@ -381,25 +473,28 @@ export default function StepDraftSelection({
);
};
// Экран полного описания черновика (по клику на карточку)
if (selectedDraft) {
const fullText = selectedDraft.problem_description || selectedDraft.facts_short || selectedDraft.problem_title || '—';
const draftId = selectedDraft.claim_id || selectedDraft.id;
// Экран полного описания черновика (draftDetailClaimId открыт; selectedDraft может быть null пока список не подгрузился)
if (draftDetailClaimId) {
const draftId = draftDetailClaimId;
const payload = detailDraftPayload?.claimId === draftId ? detailDraftPayload.payload : null;
const fromPayload =
(payload && (payload.problem_description ?? payload.description ?? payload.chatInput)) ?? '';
const fromDraft = selectedDraft
? (selectedDraft.problem_description ||
selectedDraft.facts_short ||
selectedDraft.problem_title ||
'')
: '';
const fullText = String(fromPayload || fromDraft || '').trim();
const displayText = fullText || 'Описание не сохранено';
return (
<div style={{ padding: '12px 16px' }}>
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0 }}>
<Card
bodyStyle={{ padding: '16px 20px' }}
style={{ borderRadius: 8, border: '1px solid #d9d9d9', background: '#fff' }}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => setSelectedDraft(null)}
style={{ paddingLeft: 0 }}
>
Назад
</Button>
<Title level={4} style={{ marginBottom: 8, color: '#111827' }}>
Обращение
</Title>
@@ -416,17 +511,17 @@ export default function StepDraftSelection({
overflow: 'auto',
}}
>
{fullText}
{detailLoading && !fromDraft ? <Spin size="small" /> : displayText}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{selectedDraft.is_legacy && onRestartDraft ? (
{selectedDraft?.is_legacy && onRestartDraft ? (
<Button
type="primary"
size="large"
icon={<ReloadOutlined />}
onClick={() => {
onRestartDraft(draftId, selectedDraft.problem_description || '');
setSelectedDraft(null);
closeDraftDetail();
}}
>
Начать заново
@@ -438,7 +533,7 @@ export default function StepDraftSelection({
icon={<FolderOpenOutlined />}
onClick={() => {
onSelectDraft(draftId);
setSelectedDraft(null);
closeDraftDetail();
}}
>
К документам
@@ -451,166 +546,95 @@ export default function StepDraftSelection({
);
}
// Цвет точки статуса по категории (как на макете — зелёный для «Приняты к работе»)
const statusDotColor: Record<string, string> = {
pending: '#1890ff',
in_work: '#52c41a',
resolved: '#52c41a',
rejected: '#ff4d4f',
};
return (
<div style={{ padding: '12px 16px' }}>
<Card
bodyStyle={{ padding: '16px 0' }}
style={{
borderRadius: 8,
border: '1px solid #d9d9d9',
background: '#fff',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={2} style={{ marginBottom: 16, color: '#1890ff' }}>
📋 Мои обращения
</Title>
<div style={{ padding: '12px 16px', overflowY: 'auto', minHeight: 0 }}>
{/* Шапка: заголовок + подзаголовок категории */}
<div style={{ marginBottom: 16, padding: '16px 0 8px' }}>
<Title level={3} style={{ margin: 0, color: '#111827', fontWeight: 700 }}>
Мои обращения
</Title>
<Text type="secondary" style={{ fontSize: 14, marginTop: 4, display: 'block' }}>
{CATEGORY_LABELS[categoryFilter]}
</Text>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
</div>
) : filteredDrafts.length === 0 ? (
<Empty
description={categoryFilter === 'all' ? 'У вас пока нет обращений' : `Нет обращений в категории «${CATEGORY_LABELS[categoryFilter]}»`}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{filteredDrafts.map((draft) => {
const config = getStatusConfig(draft);
const tileTitle = draft.facts_short
|| draft.problem_title
|| (draft.problem_description
? (draft.problem_description.length > 60 ? draft.problem_description.slice(0, 60).trim() + '…' : draft.problem_description)
: 'Обращение');
const category = getDraftCategory(draft.status_code);
const dotColor = statusDotColor[category] || '#8c8c8c';
return (
<Card
key={draft.claim_id || draft.id}
className="draft-list-card"
hoverable
style={{ background: '#fff', cursor: 'pointer' }}
bodyStyle={{ padding: '14px 16px' }}
onClick={() => openDraftDetail(draft)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Text strong style={{ fontSize: 15, color: '#111827', lineHeight: 1.35 }}>
{tileTitle}
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: dotColor,
flexShrink: 0,
}}
/>
<Text style={{ fontSize: 13, color: dotColor }}>{config.label}</Text>
</div>
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1.4 }}>
{config.description}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDateShort(draft.updated_at)}
</Text>
</div>
</Card>
);
})}
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Button
type="link"
icon={<ReloadOutlined />}
onClick={loadDrafts}
loading={loading}
>
Обновить список
</Button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" />
</div>
) : drafts.length === 0 ? (
<Empty
description="У вас пока нет незавершенных заявок"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
) : (
<>
<Row gutter={[16, 16]}>
{drafts.map((draft) => {
const config = getStatusConfig(draft);
const directionOrCategory = draft.direction || draft.category;
const DirectionIcon = getDirectionIcon(directionOrCategory);
const tileTitle = draft.facts_short
|| draft.problem_title
|| (draft.problem_description
? (draft.problem_description.length > 60 ? draft.problem_description.slice(0, 60).trim() + '…' : draft.problem_description)
: 'Обращение');
const borderColor = draft.is_legacy ? '#faad14' : '#e8e8e8';
const bgColor = draft.is_legacy ? '#fffbe6' : '#fff';
const iconBg = draft.is_legacy ? '#fff7e6' : '#f8fafc';
const iconColor = draft.is_legacy ? '#faad14' : '#6366f1';
return (
<Col xs={12} sm={8} md={6} key={draft.claim_id || draft.id}>
<Card
hoverable
bordered
style={{
borderRadius: 18,
border: `1px solid ${borderColor}`,
background: bgColor,
boxShadow: '0 2px 12px rgba(0,0,0,0.06)',
height: '100%',
}}
bodyStyle={{
padding: 16,
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: 10,
}}
onClick={() => setSelectedDraft(draft)}
>
<div style={{
width: 52,
height: 52,
borderRadius: 14,
background: iconBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: iconColor,
flexShrink: 0,
}}>
{DirectionIcon ? (
<DirectionIcon size={28} strokeWidth={1.8} />
) : (
<span style={{ fontSize: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{config.icon}
</span>
)}
</div>
<Text
strong
style={{
fontSize: 14,
lineHeight: 1.3,
minHeight: 40,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
color: '#111827',
width: '100%',
wordBreak: 'break-word',
} as React.CSSProperties}
>
{tileTitle}
</Text>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{config.label}
{(draft.documents_total != null && draft.documents_total > 0) && (
<span style={{ marginLeft: 4, color: '#1890ff' }}>
{draft.documents_uploaded ?? 0}/{draft.documents_total}
</span>
)}
</Text>
<Tooltip title={formatDate(draft.updated_at)}>
<Text type="secondary" style={{ fontSize: 11 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{getRelativeTime(draft.updated_at)}
</Text>
</Tooltip>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%', marginTop: 4 }} onClick={(e) => e.stopPropagation()}>
{getActionButton(draft)}
{draft.status_code !== 'in_work' && (
<Popconfirm
title="Удалить заявку?"
description="Это действие нельзя отменить"
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
okText="Да, удалить"
cancelText="Отмена"
>
<Button
danger
size="small"
icon={<DeleteOutlined />}
loading={deletingId === (draft.claim_id || draft.id)}
disabled={deletingId === (draft.claim_id || draft.id)}
>
Удалить
</Button>
</Popconfirm>
)}
</div>
</Card>
</Col>
);
})}
</Row>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<Button
type="link"
icon={<ReloadOutlined />}
onClick={loadDrafts}
loading={loading}
>
Обновить список
</Button>
</div>
</>
)}
</Space>
</Card>
)}
</div>
);
}

View File

@@ -1456,7 +1456,6 @@ export default function StepWizardPlan({
status="warning"
title="Нет session_id"
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
extra={<Button onClick={onPrev}>Вернуться</Button>}
/>
);
}
@@ -2706,9 +2705,6 @@ export default function StepWizardPlan({
)}
<div style={{ marginTop: 24 }}>
<Button onClick={onPrev} style={{ marginRight: 12 }}>
Изменить описание
</Button>
<Button type="primary" onClick={() => {
// Сбрасываем состояние и возвращаемся на первый экран
updateFormData({

View File

@@ -2,6 +2,71 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { miniappLog, miniappSendLogs } from './utils/miniappLogger'
// #region agent log (build tag)
// В прод-сборке это будет URL текущего JS-бандла (/assets/index-XXXX.js)
(window as any).__MINIAPP_BUILD__ = (import.meta as any).url;
// #endregion agent log
// Логирование при загрузке — по нему видно, какой фронт отдаётся и куда идут запросы
const bootLog = {
ts: new Date().toISOString(),
href: window.location.href,
origin: window.location.origin,
pathname: window.location.pathname,
host: window.location.host,
search: window.location.search,
hash: window.location.hash,
marker: 'MINIAPP_AIFORM_PROD',
// #region agent log (bundle identity)
moduleUrl: (import.meta as any).url,
scriptSrc:
document
.querySelector('script[type="module"][src*="/assets/index-"]')
?.getAttribute('src') || undefined,
build: (window as any).__MINIAPP_BUILD__,
// #endregion agent log
};
console.log('[MINIAPP] Boot', bootLog);
miniappLog('boot', bootLog);
// Логирование всех запросов к /api — куда реально уходят запросы (относительный URL = текущий origin)
const _fetch = window.fetch;
window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api') || url.startsWith('/api')) {
const full = url.startsWith('http') ? url : window.location.origin + (url.startsWith('/') ? url : '/' + url);
console.log('[MINIAPP] API request', { url, full, method: init?.method || 'GET' });
miniappLog('api_request', { url, full, method: init?.method || 'GET' });
}
return _fetch.apply(this, arguments as any);
};
// Ловим JS-ошибки и отправляем дамп на бэк
window.addEventListener('error', (e) => {
const ev = e as ErrorEvent;
miniappLog('window_error', {
message: ev.message,
filename: ev.filename,
lineno: ev.lineno,
colno: ev.colno,
name: (ev.error && (ev.error as any).name) || undefined,
stack: (ev.error && (ev.error as any).stack) || undefined,
});
void miniappSendLogs('window_error');
});
window.addEventListener('unhandledrejection', (e) => {
const ev = e as PromiseRejectionEvent;
const reason = ev.reason;
miniappLog('unhandledrejection', {
reason: reason ? String(reason) : '(empty)',
name: reason && (reason as any).name ? String((reason as any).name) : undefined,
stack: reason && (reason as any).stack ? String((reason as any).stack) : undefined,
});
void miniappSendLogs('unhandledrejection');
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>

View File

@@ -11,9 +11,9 @@
.claim-form-card {
max-width: 100%;
width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 8px;
border: 1px solid #d9d9d9;
box-shadow: none;
border-radius: 0;
border: none;
}
.claim-form-card .ant-card-body {
@@ -40,6 +40,7 @@
.steps-content {
min-height: 400px;
padding: 20px 0;
overflow-y: auto;
}
@media (max-width: 768px) {
@@ -68,8 +69,8 @@
.claim-form-container.telegram-mini-app .claim-form-card {
max-width: 100%;
box-shadow: none;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0;
border: none;
}
.claim-form-container.telegram-mini-app .claim-form-card .ant-card-head {

View File

@@ -1,9 +1,9 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Card, message, Row, Col, Spin, Button } from 'antd';
import { ArrowLeftOutlined } from '@ant-design/icons';
import Step1Phone from '../components/form/Step1Phone';
import StepDescription from '../components/form/StepDescription';
// Step1Policy убран - старый ERV флоу
import StepComplaintsDashboard from '../components/form/StepComplaintsDashboard';
import StepDraftSelection from '../components/form/StepDraftSelection';
import StepWizardPlan from '../components/form/StepWizardPlan';
import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
@@ -12,6 +12,7 @@ import StepClaimConfirmation from '../components/form/StepClaimConfirmation';
import DebugPanel from '../components/DebugPanel';
// getDocumentsForEventType убран - старый ERV флоу
import './ClaimForm.css';
import { miniappLog, miniappSendLogs } from '../utils/miniappLogger';
// Используем относительные пути - Vite proxy перенаправит на backend
@@ -95,6 +96,8 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
const autoLoadedClaimIdRef = useRef<string | null>(null);
const claimPlanEventSourceRef = useRef<EventSource | null>(null);
const claimPlanTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Защита от «ghost click» после навигации с /hello: игнорируем back первые ~1500мс
const barBackIgnoreUntilRef = useRef<number>(Date.now() + 1500);
const [currentStep, setCurrentStep] = useState(0);
const [sessionRestored, setSessionRestored] = useState(false); // Флаг: пытались восстановить сессию
@@ -109,6 +112,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
const [isSubmitted, setIsSubmitted] = useState(false);
const [showDraftSelection, setShowDraftSelection] = useState(false);
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
/** ID черновика, открытого для просмотра описания (состояние в родителе, чтобы не терять при пересчёте steps) */
const [draftDetailClaimId, setDraftDetailClaimId] = useState<string | null>(null);
/** Фильтр списка обращений при переходе с дашборда: по какой категории показывать (all = все) */
const [draftsListFilter, setDraftsListFilter] = useState<'all' | 'pending' | 'in_work' | 'resolved' | 'rejected'>('all');
const [hasDrafts, setHasDrafts] = useState(false);
const [telegramAuthChecked, setTelegramAuthChecked] = useState(false);
/** Статус Telegram auth — показываем на странице, т.к. консоль Mini App отдельная */
@@ -117,8 +124,41 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
/** Заход через MAX Mini App. */
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
/** Платформа определена (TG/MAX/веб) — до этого шаг «Вход» не показываем, чтобы в MAX не мелькал экран телефона. */
const [platformChecked, setPlatformChecked] = useState(false);
const forceNewClaimRef = useRef(false);
// Раннее определение TG/MAX, чтобы не показывать экран телефона в мини-приложении (иначе до 2.5 с оба флага false)
useEffect(() => {
const detect = () => {
const tg = (window as any).Telegram?.WebApp?.initData;
const max = (window as any).WebApp?.initData;
if (tg && typeof tg === 'string' && tg.length > 0) {
setIsTelegramMiniApp(true);
setPlatformChecked(true);
return true;
}
if (max && typeof max === 'string' && max.length > 0) {
setIsMaxMiniApp(true);
setPlatformChecked(true);
return true;
}
return false;
};
if (detect()) return;
const interval = setInterval(() => {
if (detect()) clearInterval(interval);
}, 50);
const timeout = setTimeout(() => {
clearInterval(interval);
setPlatformChecked(true);
}, 3000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, []);
// Отдельная страница /new или ?new=1 — сразу форма новой жалобы, без экрана черновиков
useEffect(() => {
const isNewPage = forceNewClaim || window.location.pathname === '/new' || new URLSearchParams(window.location.search).get('new') === '1';
@@ -246,13 +286,9 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
session_id: maxData.session_token,
}));
setIsPhoneVerified(true);
if (maxData.has_drafts) {
setShowDraftSelection(true);
setHasDrafts(true);
setCurrentStep(0);
} else {
setCurrentStep(1);
}
setShowDraftSelection(!!maxData.has_drafts);
setHasDrafts(!!maxData.has_drafts);
setCurrentStep(0); // дашборд «Мои обращения» при заходе из MAX
} else {
console.error('[MAX] max/auth ответ', maxRes.status, maxData);
}
@@ -321,23 +357,16 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
// Помечаем телефон как уже "подтверждённый" для Telegram-флоу
setIsPhoneVerified(true);
// Если n8n сразу сообщил о наличии черновиков — показываем экран выбора
if (data.has_drafts) {
console.log('🤖 Telegram auth: has_drafts=true, переходим на экран черновиков');
setShowDraftSelection(true);
setHasDrafts(true);
setCurrentStep(0);
} else {
// Иначе переходим сразу к описанию проблемы
console.log('🤖 Telegram auth: черновиков нет, переходим к описанию проблемы');
setCurrentStep(1);
}
setShowDraftSelection(!!data.has_drafts);
setHasDrafts(!!data.has_drafts);
setCurrentStep(0); // дашборд «Мои обращения» при заходе из TG
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
setTgDebug(`TG: ошибка: ${msg}`);
console.error('[TG] Ошибка при tg/auth (сеть или парсинг):', error);
} finally {
setTelegramAuthChecked(true);
setPlatformChecked(true);
}
};
@@ -375,14 +404,15 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
body: JSON.stringify({ session_token: savedSessionToken })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
let data: any = null;
try {
data = await response.json();
} catch (_) {
data = null;
}
console.log('🔑 Session verify response:', { ok: response.ok, status: response.status, data });
const data = await response.json();
console.log('🔑 Session verify response:', data);
if (data.success && data.valid) {
if (response.ok && data?.success && data?.valid) {
// Сессия валидна! Восстанавливаем состояние
console.log('✅ Session valid! Restoring user data:', {
unified_id: data.unified_id,
@@ -406,8 +436,11 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
// На странице /new («Подать жалобу») не показываем черновики
if (forceNewClaimRef.current) {
setCurrentStep(1); // сразу к описанию
message.success('Добро пожаловать!');
// Если сессия валидна — не возвращаем на экран телефона
setCurrentStep(1); // сразу к описанию (индекс зависит от step-структуры; ниже goBack не даст попасть на «Вход»)
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
message.success('Добро пожаловать!');
}
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
return;
}
@@ -415,36 +448,32 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
// Проверяем черновики
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
if (hasDraftsResult) {
// Есть черновики - показываем список
setShowDraftSelection(true);
setHasDrafts(true);
// Переходим к шагу выбора черновика
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setCurrentStep(0);
});
});
setShowDraftSelection(!!hasDraftsResult);
setHasDrafts(!!hasDraftsResult);
setCurrentStep(0); // дашборд «Мои обращения»
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
message.success('Добро пожаловать!');
addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики');
} else {
// Нет черновиков - переходим к описанию
setCurrentStep(1);
message.success('Добро пожаловать!');
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
}
} else {
// Сессия невалидна - удаляем из localStorage
addDebugEvent('session', 'success', hasDraftsResult ? '✅ Сессия восстановлена, найдены черновики' : '✅ Сессия восстановлена');
}
// Сессию удаляем только если сервер ЯВНО сказал “invalid”.
if (response.ok && data?.success && data?.valid === false) {
console.log('❌ Session invalid or expired, removing from localStorage');
localStorage.removeItem('session_token');
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
return;
}
// Сетевые/серверные проблемы — токен не трогаем (иначе “порой разлогин”).
if (!response.ok || !data?.success) {
console.warn('⚠️ Session verify failed (token kept)', { ok: response.ok, status: response.status });
addDebugEvent('session', 'warning', '⚠️ Не удалось проверить сессию (токен сохранён)');
}
} catch (error) {
console.error('❌ Error verifying session:', error);
localStorage.removeItem('session_token');
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии');
// Не удаляем session_token на сетевых ошибках — это вызывает “рандомный разлогин”
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии (токен сохранён)');
} finally {
setSessionRestored(true);
}
@@ -1463,10 +1492,25 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
const steps = useMemo(() => {
const stepsArray: any[] = [];
// Шаг 0: Выбор черновика (показывается только если есть черновики)
// ✅ unified_id уже означает, что телефон верифицирован
// Не показываем черновики на странице «Подать жалобу» (/new)
if (!forceNewClaimRef.current && (showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
// Шаги «Мои обращения»: дашборд с плитками + список черновиков — для любого авторизованного (unified_id) на главной
// Не показываем на странице «Подать жалобу» (/new)
if (!forceNewClaimRef.current && formData.unified_id && !selectedDraftId) {
stepsArray.push({
title: 'Мои обращения',
description: 'Ваши обращения',
content: (
<StepComplaintsDashboard
unified_id={formData.unified_id}
phone={formData.phone || ''}
session_id={sessionIdRef.current}
onGoToList={(filter) => {
setDraftsListFilter(filter);
nextStep();
}}
onNewClaim={handleNewClaim}
/>
),
});
stepsArray.push({
title: 'Черновики',
description: 'Выбор заявки',
@@ -1474,8 +1518,12 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
<StepDraftSelection
phone={formData.phone || ''}
session_id={sessionIdRef.current}
unified_id={formData.unified_id} // ✅ Передаём unified_id
isTelegramMiniApp={isTelegramMiniApp} // ✅ Передаём флаг Telegram
unified_id={formData.unified_id}
isTelegramMiniApp={isTelegramMiniApp}
draftDetailClaimId={draftDetailClaimId}
categoryFilter={draftsListFilter}
onOpenDraftDetail={setDraftDetailClaimId}
onCloseDraftDetail={() => setDraftDetailClaimId(null)}
onSelectDraft={handleSelectDraft}
onNewClaim={handleNewClaim}
/>
@@ -1483,12 +1531,13 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
});
}
// Шаг 1: Phone (телефон + SMS верификация)
stepsArray.push({
title: 'Вход',
description: 'Подтверждение телефона',
content: (
<Step1Phone
// Шаг «Вход» (телефон + SMS) только для обычного веба и только после определения платформы (в MAX/TG не показываем, и пока не проверили — тоже не показываем).
if (platformChecked && !isTelegramMiniApp && !isMaxMiniApp) {
stepsArray.push({
title: 'Вход',
description: 'Подтверждение телефона',
content: (
<Step1Phone
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
updateFormData={(data: any) => {
updateFormData(data);
@@ -1596,6 +1645,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
/>
),
});
}
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
// ✅ НОВЫЙ ФЛОУ: StepClaimConfirmation с SMS подтверждением
@@ -1617,7 +1667,66 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
// Step3Payment убран - не используется
return stepsArray;
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, draftDetailClaimId, draftsListFilter, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts, isTelegramMiniApp, isMaxMiniApp, platformChecked]);
// Кнопка «Назад» в нижнем баре: обработка через событие (вместо кнопок в контенте)
// ВАЖНО: держим effect ниже prevStep и steps (иначе TDZ/стейл шаги).
useEffect(() => {
const onGoBack = () => {
const now = Date.now();
const currentTitle = steps[currentStep]?.title;
const prevTitle = currentStep > 0 ? steps[currentStep - 1]?.title : null;
const isAuthed = !!formData.unified_id || isPhoneVerified || !!localStorage.getItem('session_token');
miniappLog('claim_form_go_back_event', {
currentStep,
currentTitle,
prevTitle,
isAuthed,
hasUnifiedId: !!formData.unified_id,
isPhoneVerified,
forceNewClaim: forceNewClaimRef.current,
ignoreUntil: barBackIgnoreUntilRef.current,
now,
});
if (now < barBackIgnoreUntilRef.current) return;
// Если открыта деталка черновика — закрываем её
if (draftDetailClaimId) {
miniappLog('claim_form_go_back_action', { action: 'close_draft_detail' });
setDraftDetailClaimId(null);
return;
}
// Если “назад” ведёт на шаг «Вход», но мы уже авторизованы — не показываем телефон (это выглядит как разлогин)
if (isAuthed && prevTitle === 'Вход') {
const hasDashboard = steps[0]?.title === 'Мои обращения';
if (hasDashboard) {
miniappLog('claim_form_go_back_action', { action: 'skip_phone_to_dashboard' });
setCurrentStep(0);
return;
}
miniappLog('claim_form_go_back_action', { action: 'skip_phone_to_hello' });
void miniappSendLogs('go_back_skip_phone');
window.history.pushState({}, '', '/hello');
window.dispatchEvent(new PopStateEvent('popstate'));
return;
}
if (currentStep === 0) {
miniappLog('claim_form_go_back_action', { action: 'to_hello' });
void miniappSendLogs('go_back_to_hello');
window.history.pushState({}, '', '/hello');
window.dispatchEvent(new PopStateEvent('popstate'));
return;
}
miniappLog('claim_form_go_back_action', { action: 'prev_step' });
prevStep();
};
window.addEventListener('miniapp:goBack', onGoBack);
return () => window.removeEventListener('miniapp:goBack', onGoBack);
}, [currentStep, draftDetailClaimId, formData.unified_id, isPhoneVerified, prevStep, steps]);
const handleReset = () => {
console.log('🔄 Начать заново - возврат к списку черновиков');
@@ -1757,26 +1866,12 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
<Col xs={24} lg={process.env.NODE_ENV === 'development' ? 14 : 24}>
{isDocumentsStep ? (
<div className="steps-content" style={{ marginTop: 0 }}>
{currentStep > 0 && (
<div style={{ marginBottom: 12 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={prevStep}>
Назад
</Button>
</div>
)}
{steps[currentStep] ? steps[currentStep].content : (
<div style={{ padding: '40px 0', textAlign: 'center' }}><p>Загрузка шага...</p></div>
)}
</div>
) : (
<Card title={null} className="claim-form-card" bordered={false}>
{!isSubmitted && currentStep > 0 && (
<div style={{ marginBottom: 8 }}>
<Button type="text" icon={<ArrowLeftOutlined />} onClick={prevStep}>
Назад
</Button>
</div>
)}
{isSubmitted ? (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Поздравляем! Ваше обращение направлено в Клиентправ.</h3>

View File

@@ -47,6 +47,34 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
const tryAuth = async () => {
setStatus('loading');
try {
// Сначала проверяем сохранённую сессию — при возврате «Домой» не показывать форму входа
const savedToken = localStorage.getItem('session_token');
if (savedToken) {
try {
const verifyRes = await fetch('/api/v1/session/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: savedToken }),
});
const verifyData = await verifyRes.json();
if (verifyRes.ok && verifyData.success && verifyData.valid) {
setGreeting(verifyData.greeting || 'Привет!');
// В Telegram подставляем имя и аватар из WebApp (или из localStorage)
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
if (tgUser?.first_name) {
setGreeting(`Привет, ${tgUser.first_name}!`);
}
let avatarUrl = tgUser?.photo_url || localStorage.getItem('user_avatar_url') || '';
if (avatarUrl) {
setAvatar(avatarUrl);
onAvatarChange?.(avatarUrl);
}
setStatus('success');
return;
}
} catch (_) {}
}
// Telegram Mini App
if (isTelegramContext()) {
const script = document.createElement('script');

View File

@@ -0,0 +1,112 @@
type MiniappLogEntry = {
ts: number;
iso: string;
event: string;
build?: string;
data?: unknown;
};
declare global {
interface Window {
__MINIAPP_LOGS__?: MiniappLogEntry[];
__MINIAPP_BUILD__?: string;
}
}
const LS_KEY = 'miniapp_debug_logs_v1';
const LS_BUILD_KEY = 'miniapp_debug_build_v1';
const MAX_ENTRIES = 250;
function safeJsonParse<T>(s: string | null): T | null {
if (!s) return null;
try {
return JSON.parse(s) as T;
} catch {
return null;
}
}
function getBuffer(): MiniappLogEntry[] {
if (!window.__MINIAPP_LOGS__) {
window.__MINIAPP_LOGS__ = safeJsonParse<MiniappLogEntry[]>(localStorage.getItem(LS_KEY)) || [];
}
return window.__MINIAPP_LOGS__;
}
function getBuildTag(): string | undefined {
const b = window.__MINIAPP_BUILD__;
return typeof b === 'string' && b.length ? b : undefined;
}
function persist(buf: MiniappLogEntry[]) {
try {
const trimmed = buf.slice(-MAX_ENTRIES);
localStorage.setItem(LS_KEY, JSON.stringify(trimmed));
} catch {
// ignore
}
}
export function miniappLog(event: string, data?: unknown) {
const entry: MiniappLogEntry = {
ts: Date.now(),
iso: new Date().toISOString(),
event,
build: getBuildTag(),
data,
};
const buf = getBuffer();
buf.push(entry);
if (buf.length > MAX_ENTRIES * 2) {
window.__MINIAPP_LOGS__ = buf.slice(-MAX_ENTRIES);
}
persist(window.__MINIAPP_LOGS__ || buf);
// В TG/MAX консоль не всегда доступна, но пусть будет
// eslint-disable-next-line no-console
console.log('[MINIAPP][LOG]', entry.event, entry.data || '');
}
export function miniappDumpLogs() {
return getBuffer().slice(-MAX_ENTRIES);
}
export async function miniappSendLogs(reason: string) {
// Если билд сменился — не мешаем старые логи с новыми.
// Это не “фикс бага”, а гарантия чистой диагностики.
try {
const cur = getBuildTag();
const prev = localStorage.getItem(LS_BUILD_KEY) || '';
if (cur && prev && cur !== prev) {
localStorage.removeItem(LS_KEY);
window.__MINIAPP_LOGS__ = [];
}
if (cur) localStorage.setItem(LS_BUILD_KEY, cur);
} catch {
// ignore
}
const payload = {
reason,
client: {
href: window.location.href,
origin: window.location.origin,
pathname: window.location.pathname,
search: window.location.search,
hash: window.location.hash,
ua: navigator.userAgent,
referrer: document.referrer,
ts: Date.now(),
},
logs: miniappDumpLogs(),
};
try {
await fetch('/api/v1/utils/client-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch {
// ignore
}
}