diff --git a/CHANGELOG_MINIAPP.md b/CHANGELOG_MINIAPP.md new file mode 100644 index 0000000..3b45486 --- /dev/null +++ b/CHANGELOG_MINIAPP.md @@ -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`. diff --git a/backend/app/api/claims.py b/backend/app/api/claims.py index e9148b6..3019614 100644 --- a/backend/app/api/claims.py +++ b/backend/app/api/claims.py @@ -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 [] diff --git a/backend/app/main.py b/backend/app/main.py index 3013879..1acca7a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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(): """Информация о платформе""" diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 4a005ab..cdcf1c5 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -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"] - diff --git a/frontend/index.html b/frontend/index.html index fe5dc8f..b43178d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,9 +5,17 @@ Clientright — защита прав потребителей - - - + +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f08b7f4..6ecf55f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(() => window.location.pathname || ''); const [avatarUrl, setAvatarUrl] = useState(() => localStorage.getItem('user_avatar_url') || ''); + const lastRouteTsRef = useRef(Date.now()); + const lastPathRef = useRef(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]); diff --git a/frontend/src/components/BottomBar.css b/frontend/src/components/BottomBar.css index 4a17c40..e84a132 100644 --- a/frontend/src/components/BottomBar.css +++ b/frontend/src/components/BottomBar.css @@ -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; diff --git a/frontend/src/components/BottomBar.tsx b/frontend/src/components/BottomBar.tsx index 125937b..7e873fe 100644 --- a/frontend/src/components/BottomBar.tsx +++ b/frontend/src/components/BottomBar.tsx @@ -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 (