Unified auth and sessions: POST /api/v1/auth, session by channel:id and token, need_contact fix, n8n parsing, TTL 24h
This commit is contained in:
@@ -1,16 +1,28 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import ClaimForm from './pages/ClaimForm';
|
||||
import HelloAuth from './pages/HelloAuth';
|
||||
import Profile from './pages/Profile';
|
||||
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 [pathname, setPathname] = useState<string>(() => {
|
||||
const p = window.location.pathname || '';
|
||||
if (p !== '/hello' && !p.startsWith('/hello')) return '/hello';
|
||||
return p;
|
||||
});
|
||||
const [avatarUrl, setAvatarUrl] = useState<string>(() => localStorage.getItem('user_avatar_url') || '');
|
||||
const lastRouteTsRef = useRef<number>(Date.now());
|
||||
const lastPathRef = useRef<string>(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
const path = window.location.pathname || '/';
|
||||
if (path !== '/hello' && !path.startsWith('/hello')) {
|
||||
window.history.replaceState({}, '', '/hello' + (window.location.search || '') + (window.location.hash || ''));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setPathname(window.location.pathname || '');
|
||||
window.addEventListener('popstate', onPopState);
|
||||
@@ -65,12 +77,14 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{pathname.startsWith('/hello') ? (
|
||||
{pathname === '/profile' ? (
|
||||
<Profile onNavigate={navigateTo} />
|
||||
) : pathname.startsWith('/hello') ? (
|
||||
<HelloAuth onAvatarChange={setAvatarUrl} onNavigate={navigateTo} />
|
||||
) : (
|
||||
<ClaimForm forceNewClaim={isNewClaimPage} />
|
||||
)}
|
||||
<BottomBar currentPath={pathname} avatarUrl={avatarUrl || undefined} />
|
||||
<BottomBar currentPath={pathname} avatarUrl={avatarUrl || undefined} onNavigate={navigateTo} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,22 +6,24 @@ import { miniappLog } from '../utils/miniappLogger';
|
||||
interface BottomBarProps {
|
||||
currentPath: string;
|
||||
avatarUrl?: string;
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
||||
export default function BottomBar({ currentPath, avatarUrl, onNavigate }: BottomBarProps) {
|
||||
const isHome = currentPath.startsWith('/hello');
|
||||
const isProfile = currentPath === '/profile';
|
||||
const [backEnabled, setBackEnabled] = useState(false);
|
||||
|
||||
// В некоторых webview бывает «ghost click» сразу после навигации — даём бару чуть устояться
|
||||
useEffect(() => {
|
||||
if (isHome) {
|
||||
if (isHome || isProfile) {
|
||||
setBackEnabled(false);
|
||||
return;
|
||||
}
|
||||
setBackEnabled(false);
|
||||
const t = window.setTimeout(() => setBackEnabled(true), 1200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [isHome, currentPath]);
|
||||
}, [isHome, isProfile, currentPath]);
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -35,7 +37,7 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
||||
e.preventDefault();
|
||||
const tgWebApp = (window as any).Telegram?.WebApp;
|
||||
const tgInitData = typeof tgWebApp?.initData === 'string' ? tgWebApp.initData : '';
|
||||
const isTg =
|
||||
const hasTgContext =
|
||||
tgInitData.length > 0 ||
|
||||
window.location.href.includes('tgWebAppData') ||
|
||||
navigator.userAgent.includes('Telegram');
|
||||
@@ -43,45 +45,70 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
||||
const maxWebApp = (window as any).WebApp;
|
||||
const maxInitData = typeof maxWebApp?.initData === 'string' ? maxWebApp.initData : '';
|
||||
const maxStartParam = maxWebApp?.initDataUnsafe?.start_param;
|
||||
const isMax =
|
||||
const hasMaxContext =
|
||||
maxInitData.length > 0 ||
|
||||
(typeof maxStartParam === 'string' && maxStartParam.length > 0);
|
||||
|
||||
// Если пользователь не поделился контактом, initData может быть пустым — всё равно пробуем close по наличию WebApp
|
||||
const hasTgWebApp = !!tgWebApp && typeof tgWebApp.close === 'function';
|
||||
const hasMaxWebApp = !!maxWebApp && (typeof maxWebApp.close === 'function' || typeof maxWebApp.postEvent === 'function');
|
||||
|
||||
miniappLog('bottom_bar_exit_click', {
|
||||
currentPath,
|
||||
isTg,
|
||||
isMax,
|
||||
hasTgContext,
|
||||
hasMaxContext,
|
||||
tgInitDataLen: tgInitData.length,
|
||||
maxInitDataLen: maxInitData.length,
|
||||
hasTgClose: typeof tgWebApp?.close === 'function',
|
||||
hasMaxClose: typeof maxWebApp?.close === 'function',
|
||||
hasMaxPostEvent: typeof maxWebApp?.postEvent === 'function',
|
||||
hasTgClose: hasTgWebApp,
|
||||
hasMaxClose: hasMaxWebApp,
|
||||
});
|
||||
|
||||
// ВАЖНО: telegram-web-app.js может объявлять Telegram.WebApp.close() вне Telegram.
|
||||
// Поэтому выбираем платформу по реальному initData, иначе в MAX будем вызывать TG close и рано выходить.
|
||||
if (isTg) {
|
||||
// ВАЖНО: выбираем платформу по контексту (URL/UA/initData). Если оба есть — приоритет у того, у кого есть initData.
|
||||
if (hasTgContext && hasTgWebApp && !hasMaxContext) {
|
||||
try {
|
||||
if (typeof tgWebApp?.close === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'tg' });
|
||||
tgWebApp.close();
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'tg' });
|
||||
tgWebApp.close();
|
||||
return;
|
||||
} catch (err) {
|
||||
miniappLog('bottom_bar_exit_error', { platform: 'tg', error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
if (isMax) {
|
||||
if (hasMaxContext && hasMaxWebApp) {
|
||||
try {
|
||||
if (typeof maxWebApp?.close === 'function') {
|
||||
if (typeof maxWebApp.close === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max' });
|
||||
maxWebApp.close();
|
||||
return;
|
||||
}
|
||||
if (typeof maxWebApp?.postEvent === 'function') {
|
||||
if (typeof maxWebApp.postEvent === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max', method: 'postEvent' });
|
||||
maxWebApp.postEvent('web_app_close');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
miniappLog('bottom_bar_exit_error', { platform: 'max', error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Когда контакт не дан, initData может быть пустым — пробуем закрыть по наличию объекта WebApp (без требования initData)
|
||||
if (hasTgWebApp && !hasMaxWebApp) {
|
||||
try {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'tg_no_init', note: 'close without initData' });
|
||||
tgWebApp.close();
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (hasMaxWebApp && !hasTgWebApp) {
|
||||
try {
|
||||
if (typeof maxWebApp.close === 'function') {
|
||||
miniappLog('bottom_bar_exit_close', { platform: 'max_no_init', note: 'close without initData' });
|
||||
maxWebApp.close();
|
||||
return;
|
||||
}
|
||||
if (typeof maxWebApp.postEvent === 'function') {
|
||||
maxWebApp.postEvent('web_app_close');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -92,7 +119,7 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
||||
|
||||
return (
|
||||
<nav className="app-bottom-bar" aria-label="Навигация">
|
||||
{!isHome && (
|
||||
{!isHome && !isProfile && (
|
||||
<button
|
||||
type="button"
|
||||
className="app-bar-item"
|
||||
@@ -104,11 +131,29 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
||||
<span>Назад</span>
|
||||
</button>
|
||||
)}
|
||||
<a href="/hello" className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}>
|
||||
<a
|
||||
href="/hello"
|
||||
className={`app-bar-item ${isHome ? 'app-bar-item--active' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (onNavigate && !isHome) {
|
||||
e.preventDefault();
|
||||
onNavigate('/hello');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Home size={24} strokeWidth={1.8} />
|
||||
<span>Домой</span>
|
||||
</a>
|
||||
<a href="/hello" className="app-bar-item">
|
||||
<a
|
||||
href="/profile"
|
||||
className={`app-bar-item ${isProfile ? 'app-bar-item--active' : ''}`}
|
||||
onClick={(e) => {
|
||||
if (onNavigate && !isProfile) {
|
||||
e.preventDefault();
|
||||
onNavigate('/profile');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="app-bar-avatar" />
|
||||
) : (
|
||||
@@ -116,7 +161,16 @@ export default function BottomBar({ currentPath, avatarUrl }: BottomBarProps) {
|
||||
)}
|
||||
<span>Профиль</span>
|
||||
</a>
|
||||
<a href="/hello" className="app-bar-item">
|
||||
<a
|
||||
href="/hello"
|
||||
className="app-bar-item"
|
||||
onClick={(e) => {
|
||||
if (onNavigate && !currentPath.startsWith('/hello')) {
|
||||
e.preventDefault();
|
||||
onNavigate('/hello');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Headphones size={24} strokeWidth={1.8} />
|
||||
<span>Поддержка</span>
|
||||
</a>
|
||||
|
||||
@@ -75,14 +75,18 @@ export default function StepDescription({
|
||||
return;
|
||||
}
|
||||
|
||||
const entryChannel =
|
||||
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
|
||||
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
|
||||
: 'web';
|
||||
|
||||
console.log('📝 Отправка описания проблемы на сервер:', {
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id,
|
||||
contact_id: formData.contact_id,
|
||||
entry_channel: entryChannel,
|
||||
description_length: safeDescription.length,
|
||||
description_preview: safeDescription.substring(0, 100),
|
||||
});
|
||||
|
||||
const response = await fetch('/api/v1/claims/description', {
|
||||
@@ -92,9 +96,10 @@ export default function StepDescription({
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id, // ✅ Unified ID пользователя
|
||||
contact_id: formData.contact_id, // ✅ Contact ID пользователя
|
||||
unified_id: formData.unified_id,
|
||||
contact_id: formData.contact_id,
|
||||
problem_description: safeDescription,
|
||||
entry_channel: entryChannel, // telegram | max | web — для роутинга в n8n
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -114,6 +119,10 @@ export default function StepDescription({
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('✅ Описание успешно отправлено:', responseData);
|
||||
console.log('📥 Ответ n8n (description):', responseData);
|
||||
if (responseData && typeof responseData === 'object') {
|
||||
console.log('📥 Ключи ответа n8n:', Object.keys(responseData));
|
||||
}
|
||||
|
||||
message.success('Описание отправлено, подбираем рекомендации...');
|
||||
updateFormData({
|
||||
|
||||
@@ -52,6 +52,7 @@ interface Props {
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
backToDraftsList?: () => void; // ✅ Возврат к списку черновиков напрямую
|
||||
onNewClaim?: () => void; // ✅ Переход на форму нового обращения (шаг «Описание»)
|
||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||
}
|
||||
|
||||
@@ -94,6 +95,21 @@ const buildPrefillMap = (prefill?: Array<{ name: string; value: any }>) => {
|
||||
|
||||
const YES_VALUES = ['да', 'yes', 'true', '1'];
|
||||
|
||||
/** Единое событие от бэкенда: тип + текст (+ data для consumer_complaint) */
|
||||
type DisplayEventType = 'trash_message' | 'out_of_scope' | 'consumer_consultation' | 'consumer_complaint';
|
||||
interface ResponseEvent {
|
||||
event_type: DisplayEventType;
|
||||
message: string;
|
||||
data?: Record<string, any>;
|
||||
suggested_actions?: any[];
|
||||
}
|
||||
const DISPLAY_STYLE: Record<DisplayEventType, { bg: string; border: string; title: string }> = {
|
||||
trash_message: { bg: '#fff2f0', border: '#ffccc7', title: 'Не по тематике' },
|
||||
out_of_scope: { bg: '#fff7e6', border: '#ffd591', title: 'Вне нашей компетенции' },
|
||||
consumer_consultation: { bg: '#e6f7ff', border: '#91d5ff', title: 'Консультация' },
|
||||
consumer_complaint: { bg: '#f6ffed', border: '#b7eb8f', title: 'Обращение принято' },
|
||||
};
|
||||
|
||||
const isAffirmative = (value: any) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
@@ -113,6 +129,7 @@ export default function StepWizardPlan({
|
||||
onNext,
|
||||
onPrev,
|
||||
backToDraftsList,
|
||||
onNewClaim,
|
||||
addDebugEvent,
|
||||
}: Props) {
|
||||
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
|
||||
@@ -123,6 +140,8 @@ export default function StepWizardPlan({
|
||||
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [outOfScopeData, setOutOfScopeData] = useState<any>(null);
|
||||
/** Единое событие от бэка: тип + текст — одно окошко с цветом по типу */
|
||||
const [responseEvent, setResponseEvent] = useState<ResponseEvent | null>(null);
|
||||
const [plan, setPlan] = useState<any>(formData.wizardPlan || null);
|
||||
const [prefillMap, setPrefillMap] = useState<Record<string, any>>(
|
||||
formData.wizardPrefill || buildPrefillMap(formData.wizardPrefillArray)
|
||||
@@ -465,83 +484,143 @@ export default function StepWizardPlan({
|
||||
payload_preview: JSON.stringify(payload).substring(0, 200),
|
||||
});
|
||||
|
||||
// ❌ OUT OF SCOPE: Вопрос не связан с защитой прав потребителей
|
||||
if (eventType === 'out_of_scope') {
|
||||
debugLoggerRef.current?.('wizard', 'warning', '⚠️ Вопрос вне скоупа', {
|
||||
session_id: sessionId,
|
||||
message: payload.message,
|
||||
// Не показывать служебное сообщение подключения SSE как ответ пользователю
|
||||
if (payload.status === 'connected' && payload.message === 'Подключено к событиям') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Единый формат от бэка: event_type + message (тип и текст)
|
||||
const displayTypes: DisplayEventType[] = ['trash_message', 'out_of_scope', 'consumer_consultation', 'consumer_complaint'];
|
||||
let isDisplayEvent = payload.event_type && displayTypes.includes(payload.event_type as DisplayEventType) && payload.message != null;
|
||||
// Fallback: пришло только message без event_type — показываем как out_of_scope (но не служебное "Подключено к событиям")
|
||||
if (!isDisplayEvent && payload.message != null && String(payload.message).trim() && payload.message !== 'Подключено к событиям') {
|
||||
payload.event_type = payload.event_type || 'out_of_scope';
|
||||
payload.event_type = displayTypes.includes(payload.event_type as DisplayEventType) ? payload.event_type : 'out_of_scope';
|
||||
isDisplayEvent = true;
|
||||
}
|
||||
|
||||
if (isDisplayEvent) {
|
||||
const ev: ResponseEvent = {
|
||||
event_type: payload.event_type as DisplayEventType,
|
||||
message: payload.message || 'Ответ получен',
|
||||
data: payload.data,
|
||||
suggested_actions: payload.suggested_actions,
|
||||
});
|
||||
|
||||
};
|
||||
setResponseEvent(ev);
|
||||
setIsWaiting(false);
|
||||
setOutOfScopeData(payload); // Сохраняем полные данные
|
||||
setConnectionError(null); // Не используем connectionError
|
||||
|
||||
setConnectionError(null);
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// consumer_complaint с data: список документов или план — обновляем formData и при необходимости план
|
||||
if (ev.event_type === 'consumer_complaint' && ev.data) {
|
||||
const docs = ev.data.documents_required ?? payload.documents_required;
|
||||
if (docs && Array.isArray(docs)) {
|
||||
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
||||
session_id: sessionId,
|
||||
documents_count: docs.length,
|
||||
});
|
||||
updateFormData({
|
||||
documents_required: docs,
|
||||
claim_id: ev.data.claim_id || payload.claim_id,
|
||||
wizardPlanStatus: 'documents_ready',
|
||||
});
|
||||
message.success(`Получен список документов: ${docs.length} шт.`);
|
||||
}
|
||||
const wizardPlan = ev.data.wizard_plan ?? extractWizardPayload(payload)?.wizard_plan;
|
||||
if (wizardPlan) {
|
||||
const wizardPayload = extractWizardPayload(payload) || { wizard_plan: wizardPlan, answers_prefill: ev.data.answers_prefill, coverage_report: ev.data.coverage_report };
|
||||
const answersPrefill = wizardPayload.answers_prefill ?? ev.data.answers_prefill;
|
||||
const coverageReport = wizardPayload.coverage_report ?? ev.data.coverage_report;
|
||||
const prefill = buildPrefillMap(answersPrefill);
|
||||
setPlan(wizardPlan);
|
||||
setPrefillMap(prefill);
|
||||
updateFormData({
|
||||
wizardPlan: wizardPlan,
|
||||
wizardPrefill: prefill,
|
||||
wizardPrefillArray: answersPrefill,
|
||||
wizardCoverageReport: coverageReport,
|
||||
wizardPlanStatus: 'ready',
|
||||
});
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Для trash и out_of_scope закрываем SSE
|
||||
if (ev.event_type === 'trash_message' || ev.event_type === 'out_of_scope') {
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Обратная совместимость: старый формат без нормализации (out_of_scope, trash_message, documents_list_ready, wizard)
|
||||
if (eventType === 'out_of_scope') {
|
||||
setResponseEvent({
|
||||
event_type: 'out_of_scope',
|
||||
message: payload.message || 'К сожалению, мы не можем помочь с этим вопросом.',
|
||||
suggested_actions: payload.suggested_actions,
|
||||
});
|
||||
setOutOfScopeData(payload);
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (eventType === 'trash_message' || payload?.payload?.intent === 'trash') {
|
||||
const msg = payload?.payload?.message || payload?.message || 'К сожалению, это обращение не по тематике защиты прав потребителей.';
|
||||
setResponseEvent({
|
||||
event_type: 'trash_message',
|
||||
message: msg,
|
||||
suggested_actions: payload?.payload?.suggested_actions || payload?.suggested_actions,
|
||||
});
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ НОВЫЙ ФЛОУ: Обработка списка документов
|
||||
if (eventType === 'documents_list_ready') {
|
||||
const documentsRequired = payload.documents_required || [];
|
||||
|
||||
debugLoggerRef.current?.('wizard', 'success', '📋 Получен список документов!', {
|
||||
session_id: sessionId,
|
||||
documents_count: documentsRequired.length,
|
||||
documents: documentsRequired.map((d: any) => d.name),
|
||||
setResponseEvent({
|
||||
event_type: 'consumer_complaint',
|
||||
message: `Подготовлен список документов: ${documentsRequired.length} шт.`,
|
||||
data: { documents_required: documentsRequired, claim_id: payload.claim_id },
|
||||
});
|
||||
|
||||
console.log('📋 documents_list_ready:', {
|
||||
claim_id: payload.claim_id,
|
||||
documents_required: documentsRequired,
|
||||
});
|
||||
|
||||
// Сохраняем в formData для нового флоу
|
||||
updateFormData({
|
||||
documents_required: documentsRequired,
|
||||
claim_id: payload.claim_id,
|
||||
wizardPlanStatus: 'documents_ready', // Новый статус
|
||||
wizardPlanStatus: 'documents_ready',
|
||||
});
|
||||
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Пока показываем alert для теста, потом переход к StepDocumentsNew
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
message.success(`Получен список документов: ${documentsRequired.length} шт.`);
|
||||
|
||||
// TODO: onNext() для перехода к StepDocumentsNew
|
||||
return;
|
||||
}
|
||||
|
||||
const wizardPayload = extractWizardPayload(payload);
|
||||
const hasWizardPlan = Boolean(wizardPayload);
|
||||
|
||||
if (eventType?.includes('wizard') || hasWizardPlan) {
|
||||
const wizardPlan = wizardPayload?.wizard_plan;
|
||||
const answersPrefill = wizardPayload?.answers_prefill;
|
||||
const coverageReport = wizardPayload?.coverage_report;
|
||||
|
||||
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
|
||||
session_id: sessionId,
|
||||
questions: wizardPlan?.questions?.length || 0,
|
||||
setResponseEvent({
|
||||
event_type: 'consumer_complaint',
|
||||
message: payload.message || 'План готов.',
|
||||
data: { wizard_plan: wizardPlan, answers_prefill: answersPrefill, coverage_report: coverageReport },
|
||||
});
|
||||
|
||||
const prefill = buildPrefillMap(answersPrefill);
|
||||
setPlan(wizardPlan);
|
||||
setPrefillMap(prefill);
|
||||
setIsWaiting(false);
|
||||
setConnectionError(null);
|
||||
|
||||
updateFormData({
|
||||
wizardPlan: wizardPlan,
|
||||
wizardPrefill: prefill,
|
||||
@@ -549,11 +628,7 @@ export default function StepWizardPlan({
|
||||
wizardCoverageReport: coverageReport,
|
||||
wizardPlanStatus: 'ready',
|
||||
});
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
|
||||
source.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
@@ -860,6 +935,11 @@ export default function StepWizardPlan({
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
console.log('📥 Ответ n8n (wizard):', parsed);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
console.log('📥 Ключи ответа n8n:', Object.keys(parsed));
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.');
|
||||
addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', {
|
||||
@@ -2613,8 +2693,103 @@ export default function StepWizardPlan({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OUT OF SCOPE: Вопрос вне нашей компетенции */}
|
||||
{outOfScopeData && (
|
||||
{/* Единое окошко: тип + текст, цвет по event_type */}
|
||||
{responseEvent && (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<div style={{
|
||||
background: DISPLAY_STYLE[responseEvent.event_type].bg,
|
||||
border: `1px solid ${DISPLAY_STYLE[responseEvent.event_type].border}`,
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
maxWidth: 600,
|
||||
margin: '0 auto',
|
||||
}}>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
{responseEvent.event_type === 'trash_message' && '❌ '}
|
||||
{responseEvent.event_type === 'out_of_scope' && '⚠️ '}
|
||||
{responseEvent.event_type === 'consumer_consultation' && 'ℹ️ '}
|
||||
{responseEvent.event_type === 'consumer_complaint' && '✅ '}
|
||||
{DISPLAY_STYLE[responseEvent.event_type].title}
|
||||
</Title>
|
||||
<Paragraph style={{ fontSize: 16, marginBottom: 16 }}>
|
||||
{responseEvent.message}
|
||||
</Paragraph>
|
||||
{responseEvent.suggested_actions && responseEvent.suggested_actions.length > 0 && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Paragraph strong style={{ marginBottom: 12 }}>Что можно сделать:</Paragraph>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{responseEvent.suggested_actions.map((action: any, index: number) => (
|
||||
<Card key={index} size="small" style={{ textAlign: 'left', background: '#fafafa' }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{action.title}</div>
|
||||
<div style={{ color: '#666', fontSize: 14 }}>{action.description}</div>
|
||||
{action.actionType === 'external_link' && action.url && (
|
||||
<a href={action.url} target="_blank" rel="noopener noreferrer" style={{ marginTop: 8, display: 'inline-block' }}>
|
||||
{action.urlText || 'Перейти →'}
|
||||
</a>
|
||||
)}
|
||||
{action.actionType === 'contact_support' && (
|
||||
<Button
|
||||
type="link"
|
||||
style={{ marginTop: 8, padding: 0 }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
message.loading('Отправляем запрос в поддержку...', 0);
|
||||
await fetch('https://n8n.clientright.pro/webhook/3ef6ff67-f3f2-418e-a300-86cb4659dbde', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: formData.session_id,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
unified_id: formData.unified_id,
|
||||
reason: responseEvent.message,
|
||||
message: responseEvent.message,
|
||||
action: 'contact_support',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
message.destroy();
|
||||
message.success('Запрос отправлен! Мы свяжемся с вами в ближайшее время. Возвращаем на главную...');
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (error) {
|
||||
message.destroy();
|
||||
message.error('Не удалось отправить запрос. Попробуйте позже.');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Связаться с поддержкой →
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{(responseEvent.event_type === 'trash_message' || responseEvent.event_type === 'out_of_scope') && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setResponseEvent(null);
|
||||
setOutOfScopeData(null);
|
||||
if (onNewClaim) onNewClaim();
|
||||
else {
|
||||
updateFormData({ wizardPlan: null, wizardPlanStatus: null, problemDescription: '' });
|
||||
window.history.pushState({}, '', '/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Новое обращение
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OUT OF SCOPE (старый формат, если пришло без event_type/message) */}
|
||||
{!responseEvent && outOfScopeData && (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||||
<div style={{
|
||||
background: '#fff7e6',
|
||||
@@ -2706,13 +2881,14 @@ export default function StepWizardPlan({
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button type="primary" onClick={() => {
|
||||
// Сбрасываем состояние и возвращаемся на первый экран
|
||||
updateFormData({
|
||||
wizardPlan: null,
|
||||
wizardPlanStatus: null,
|
||||
problemDescription: '',
|
||||
});
|
||||
window.location.href = '/';
|
||||
setOutOfScopeData(null);
|
||||
if (onNewClaim) {
|
||||
onNewClaim(); // переход на форму «Описание проблемы», без дашборда
|
||||
} else {
|
||||
updateFormData({ wizardPlan: null, wizardPlanStatus: null, problemDescription: '' });
|
||||
window.history.pushState({}, '', '/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
}}>
|
||||
Новое обращение
|
||||
</Button>
|
||||
|
||||
@@ -128,17 +128,35 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
const [platformChecked, setPlatformChecked] = useState(false);
|
||||
const forceNewClaimRef = useRef(false);
|
||||
|
||||
// Раннее определение TG/MAX, чтобы не показывать экран телефона в мини-приложении (иначе до 2.5 с оба флага false)
|
||||
// Раннее определение TG/MAX, чтобы не показывать экран телефона в мини-приложении.
|
||||
// 1) По URL (TG iframe приходит с tgWebAppData/tgWebAppVersion). 2) По initData. 3) По наличию SDK (WebApp / Telegram.WebApp).
|
||||
useEffect(() => {
|
||||
const url = typeof window !== 'undefined' ? window.location.href || '' : '';
|
||||
if (url.indexOf('tgWebAppData') !== -1 || url.indexOf('tgWebAppVersion') !== -1) {
|
||||
setIsTelegramMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
const tgInitData = (window as any).Telegram?.WebApp?.initData;
|
||||
const maxInitData = (window as any).WebApp?.initData;
|
||||
if (tgInitData && typeof tgInitData === 'string' && tgInitData.length > 0) {
|
||||
setIsTelegramMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
}
|
||||
if (max && typeof max === 'string' && max.length > 0) {
|
||||
if (maxInitData && typeof maxInitData === 'string' && maxInitData.length > 0) {
|
||||
setIsMaxMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
}
|
||||
// В MAX подключается только max-web-app.js → есть window.WebApp; в TG — telegram-web-app.js → есть Telegram.WebApp.
|
||||
if (typeof (window as any).Telegram?.WebApp === 'object') {
|
||||
setIsTelegramMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
}
|
||||
if (typeof (window as any).WebApp === 'object') {
|
||||
setIsMaxMiniApp(true);
|
||||
setPlatformChecked(true);
|
||||
return true;
|
||||
@@ -213,164 +231,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ✅ Telegram Mini App: попытка авторизоваться через initData при первом заходе
|
||||
// Авторизация выполняется на /hello (универсальный auth). Здесь только помечаем, что платформа проверена.
|
||||
useEffect(() => {
|
||||
const tryTelegramAuth = async () => {
|
||||
try {
|
||||
// Только window: parent недоступен из-за cross-origin (iframe Telegram)
|
||||
const getTg = () => (window as any).Telegram;
|
||||
|
||||
// Ждём появления initData: скрипт Telegram может подгрузиться с задержкой
|
||||
const maxWaitMs = 2500;
|
||||
const intervalMs = 150;
|
||||
let webApp: TelegramWebApp | null = null;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts * intervalMs < maxWaitMs) {
|
||||
const tg = getTg();
|
||||
webApp = tg?.WebApp ?? null;
|
||||
if (webApp?.initData) {
|
||||
console.log('[TG] initData появился через', attempts * intervalMs, 'ms, длина=', webApp.initData.length);
|
||||
break;
|
||||
}
|
||||
attempts++;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
|
||||
if (!webApp?.initData) {
|
||||
const tg = getTg();
|
||||
console.log('[TG] После ожидания', maxWaitMs, 'ms: Telegram=', !!tg, 'WebApp=', !!tg?.WebApp, 'initData=', !!tg?.WebApp?.initData, '→ пропускаем tg/auth');
|
||||
// Если Telegram не найден — пробуем MAX Mini App (window.WebApp от MAX Bridge)
|
||||
let maxWebApp = (window as any).WebApp;
|
||||
const maxWait = 4000;
|
||||
for (let t = 0; t < maxWait; t += 200) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
maxWebApp = (window as any).WebApp;
|
||||
if (maxWebApp?.initData && maxWebApp.initData.length > 0) break;
|
||||
}
|
||||
if (maxWebApp?.initData && typeof maxWebApp.initData === 'string' && maxWebApp.initData.length > 0) {
|
||||
const hasHash = maxWebApp.initData.includes('hash=');
|
||||
console.log('[MAX] Обнаружен MAX WebApp, initData длина=', maxWebApp.initData.length, ', есть hash=', hasHash);
|
||||
setIsMaxMiniApp(true);
|
||||
try { maxWebApp.ready?.(); } catch (_) {}
|
||||
const existingToken = localStorage.getItem('session_token');
|
||||
if (existingToken) {
|
||||
console.log('[MAX] session_token уже есть → max/auth не вызываем');
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
setTgDebug('MAX: POST /api/v1/max/auth...');
|
||||
try {
|
||||
const maxRes = await fetch('/api/v1/max/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ init_data: maxWebApp.initData }),
|
||||
});
|
||||
const maxData = await maxRes.json();
|
||||
if (maxData.need_contact) {
|
||||
setTgDebug('MAX: Нужен контакт — закрываем приложение');
|
||||
try { maxWebApp.close?.(); } catch (_) {}
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
if (maxRes.ok && maxData.success) {
|
||||
if (maxData.session_token) {
|
||||
localStorage.setItem('session_token', maxData.session_token);
|
||||
sessionIdRef.current = maxData.session_token;
|
||||
}
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
unified_id: maxData.unified_id,
|
||||
phone: maxData.phone,
|
||||
contact_id: maxData.contact_id,
|
||||
session_id: maxData.session_token,
|
||||
}));
|
||||
setIsPhoneVerified(true);
|
||||
setShowDraftSelection(!!maxData.has_drafts);
|
||||
setHasDrafts(!!maxData.has_drafts);
|
||||
setCurrentStep(0); // дашборд «Мои обращения» при заходе из MAX
|
||||
} else {
|
||||
console.error('[MAX] max/auth ответ', maxRes.status, maxData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MAX] Ошибка max/auth:', e);
|
||||
}
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Логирование для отладки
|
||||
if (webApp.initDataUnsafe?.user) {
|
||||
const u = webApp.initDataUnsafe.user;
|
||||
console.log('[TG] initDataUnsafe.user:', { id: u.id, username: u.username, first_name: u.first_name });
|
||||
}
|
||||
|
||||
// Если сессия уже есть в localStorage — ничего не делаем, дальше сработает обычное restoreSession
|
||||
const existingToken = localStorage.getItem('session_token');
|
||||
if (existingToken) {
|
||||
setTgDebug('TG: session_token уже есть → tg/auth не вызываем');
|
||||
console.log('[TG] session_token уже в localStorage → tg/auth не вызываем');
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setTgDebug('TG: POST /api/v1/tg/auth...');
|
||||
console.log('[TG] Вызываем POST /api/v1/tg/auth, initData длина=', webApp.initData.length);
|
||||
|
||||
const response = await fetch('/api/v1/tg/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
init_data: webApp.initData,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[TG] /api/v1/tg/auth ответ: status=', response.status, 'ok=', response.ok, 'data=', data);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
console.warn('[TG] Telegram auth не успешен → показываем экран телефона/SMS. detail=', data.detail || data);
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionToken = data.session_token;
|
||||
|
||||
// Сохраняем session_token так же, как после SMS-логина
|
||||
if (sessionToken) {
|
||||
localStorage.setItem('session_token', sessionToken);
|
||||
sessionIdRef.current = sessionToken;
|
||||
}
|
||||
|
||||
// Сохраняем базовые данные пользователя (phone может быть пустым)
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
unified_id: data.unified_id,
|
||||
phone: data.phone,
|
||||
contact_id: data.contact_id,
|
||||
session_id: sessionToken,
|
||||
}));
|
||||
|
||||
// Помечаем телефон как уже "подтверждённый" для Telegram-флоу
|
||||
setIsPhoneVerified(true);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
tryTelegramAuth();
|
||||
setTelegramAuthChecked(true);
|
||||
setPlatformChecked(true);
|
||||
}, []);
|
||||
|
||||
// ✅ Восстановление сессии при загрузке страницы (после попытки Telegram auth)
|
||||
@@ -383,13 +247,9 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
|
||||
const restoreSession = async () => {
|
||||
console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑');
|
||||
console.log('🔑 Все ключи в localStorage:', Object.keys(localStorage));
|
||||
console.log('🔑 Значения всех ключей:', JSON.stringify(localStorage));
|
||||
|
||||
const savedSessionToken = localStorage.getItem('session_token');
|
||||
|
||||
const savedSessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null)
|
||||
|| localStorage.getItem('session_token');
|
||||
if (!savedSessionToken) {
|
||||
console.log('❌ Session token NOT found in localStorage');
|
||||
setSessionRestored(true);
|
||||
return;
|
||||
}
|
||||
@@ -436,8 +296,9 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
|
||||
// На странице /new («Подать жалобу») не показываем черновики
|
||||
if (forceNewClaimRef.current) {
|
||||
// Если сессия валидна — не возвращаем на экран телефона
|
||||
setCurrentStep(1); // сразу к описанию (индекс зависит от step-структуры; ниже goBack не даст попасть на «Вход»)
|
||||
// Если сессия валидна — не возвращаем на экран телефона. В TG/MAX нет шага «Вход», первый шаг формы = индекс 0 (Обращение); в вебе первый = Вход (0), Обращение = 1.
|
||||
const isMiniApp = !!(typeof window !== 'undefined' && ((window as any).Telegram?.WebApp?.initData || (window as any).WebApp?.initData));
|
||||
setCurrentStep(isMiniApp ? 0 : 1);
|
||||
if (!(window as any).Telegram?.WebApp?.initData && !(window as any).WebApp?.initData) {
|
||||
message.success('Добро пожаловать!');
|
||||
}
|
||||
@@ -459,7 +320,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
|
||||
// Сессию удаляем только если сервер ЯВНО сказал “invalid”.
|
||||
if (response.ok && data?.success && data?.valid === false) {
|
||||
console.log('❌ Session invalid or expired, removing from localStorage');
|
||||
try { sessionStorage.removeItem('session_token'); } catch (_) {}
|
||||
localStorage.removeItem('session_token');
|
||||
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
|
||||
return;
|
||||
@@ -1329,9 +1190,10 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
// Обработчик создания новой заявки
|
||||
const handleNewClaim = useCallback(() => {
|
||||
console.log('🆕 Начинаем новое обращение');
|
||||
console.log('🆕 Текущий currentStep:', currentStep);
|
||||
console.log('🆕 isPhoneVerified:', isPhoneVerified);
|
||||
|
||||
// ✅ Режим «новая жалоба»: без дашборда/черновиков, первый шаг = форма «Описание»
|
||||
forceNewClaimRef.current = true;
|
||||
window.history.pushState({}, '', '/new');
|
||||
|
||||
// ✅ Генерируем НОВУЮ сессию для новой жалобы
|
||||
const newSessionId = 'sess_' + generateUUIDv4();
|
||||
console.log('🆕 Генерируем новую сессию для жалобы:', newSessionId);
|
||||
@@ -1340,8 +1202,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
// ✅ Обновляем sessionIdRef на новую сессию
|
||||
sessionIdRef.current = newSessionId;
|
||||
|
||||
// ✅ session_token в localStorage остаётся ПРЕЖНИМ (авторизация сохраняется)
|
||||
const savedSessionToken = localStorage.getItem('session_token');
|
||||
const savedSessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token');
|
||||
console.log('🆕 session_token в localStorage (авторизация):', savedSessionToken || '(не сохранён)');
|
||||
console.log('🆕 Авторизация сохранена: unified_id=', formData.unified_id, 'phone=', formData.phone);
|
||||
|
||||
@@ -1367,13 +1228,9 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
});
|
||||
|
||||
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
|
||||
|
||||
// ✅ Переходим к шагу описания проблемы
|
||||
// После сброса флагов черновиков, steps будут:
|
||||
// Шаг 0 - Phone (уже верифицирован, но в массиве есть)
|
||||
// Шаг 1 - Description (сюда переходим)
|
||||
// Шаг 2 - WizardPlan
|
||||
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
|
||||
// В TG/MAX нет шага «Вход», поэтому Обращение = индекс 0; в вебе Вход = 0, Обращение = 1.
|
||||
const isMiniApp = !!(typeof window !== 'undefined' && ((window as any).Telegram?.WebApp?.initData || (window as any).WebApp?.initData));
|
||||
setCurrentStep(isMiniApp ? 0 : 1);
|
||||
}, [updateFormData, currentStep, isPhoneVerified, formData.unified_id, formData.phone]);
|
||||
|
||||
// ✅ Автоматический редирект на экран черновиков после успешной отправки
|
||||
@@ -1615,6 +1472,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг 2: свободное описание
|
||||
stepsArray.push({
|
||||
@@ -1641,11 +1499,11 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
onPrev={prevStep}
|
||||
onNext={nextStep}
|
||||
backToDraftsList={backToDraftsList}
|
||||
onNewClaim={handleNewClaim}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг подтверждения заявления (показывается после получения данных из claim:plan)
|
||||
// ✅ НОВЫЙ ФЛОУ: StepClaimConfirmation с SMS подтверждением
|
||||
@@ -1669,6 +1527,15 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
return stepsArray;
|
||||
}, [formData, isPhoneVerified, nextStep, prevStep, backToDraftsList, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, draftDetailClaimId, draftsListFilter, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts, isTelegramMiniApp, isMaxMiniApp, platformChecked]);
|
||||
|
||||
// Синхронизация currentStep при выходе за границы (например после смены списка шагов в TG/MAX)
|
||||
useEffect(() => {
|
||||
if (steps.length === 0) return;
|
||||
const safe = Math.min(currentStep, steps.length - 1);
|
||||
if (currentStep < 0 || currentStep >= steps.length || currentStep !== safe) {
|
||||
setCurrentStep(Math.max(0, safe));
|
||||
}
|
||||
}, [steps.length, currentStep]);
|
||||
|
||||
// Кнопка «Назад» в нижнем баре: обработка через событие (вместо кнопок в контенте)
|
||||
// ВАЖНО: держим effect ниже prevStep и steps (иначе TDZ/стейл шаги).
|
||||
useEffect(() => {
|
||||
@@ -1676,7 +1543,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
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');
|
||||
const isAuthed = !!formData.unified_id || isPhoneVerified || !!(typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || !!localStorage.getItem('session_token');
|
||||
|
||||
miniappLog('claim_form_go_back_event', {
|
||||
currentStep,
|
||||
@@ -1791,8 +1658,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
}
|
||||
|
||||
// ✅ В обычном веб — полный сброс сессии и возврат к Step1Phone
|
||||
// Получаем session_token из localStorage
|
||||
const sessionToken = localStorage.getItem('session_token') || formData.session_id;
|
||||
const sessionToken = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null) || localStorage.getItem('session_token') || formData.session_id;
|
||||
|
||||
if (sessionToken) {
|
||||
try {
|
||||
@@ -1812,7 +1678,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем session_token из localStorage
|
||||
try { sessionStorage.removeItem('session_token'); } catch (_) {}
|
||||
localStorage.removeItem('session_token');
|
||||
|
||||
// Полный сброс: очищаем все данные авторизации и черновиков
|
||||
@@ -1857,7 +1723,19 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const isDocumentsStep = steps[currentStep]?.title === 'Документы';
|
||||
// Пустой список шагов — не показывать форму с «Загрузка шага», показать общий loader
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: '20px 0', paddingBottom: 90, background: '#ffffff', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<Spin size="large" tip="Подготовка формы..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Если currentStep вышел за границы — показываем последний валидный шаг; всегда есть steps[0]
|
||||
const safeStepIndex = Math.min(currentStep, Math.max(0, steps.length - 1));
|
||||
const stepToShow = steps[safeStepIndex] ?? steps[0];
|
||||
const isDocumentsStep = stepToShow?.title === 'Документы';
|
||||
|
||||
return (
|
||||
<div className={`claim-form-container ${isTelegramMiniApp ? 'telegram-mini-app' : ''}`} style={{ padding: isDocumentsStep ? 0 : '20px 0', paddingBottom: 90, background: '#ffffff' }}>
|
||||
@@ -1866,7 +1744,7 @@ 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 }}>
|
||||
{steps[currentStep] ? steps[currentStep].content : (
|
||||
{stepToShow ? stepToShow.content : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}><p>Загрузка шага...</p></div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1881,7 +1759,7 @@ export default function ClaimForm({ forceNewClaim = false }: ClaimFormProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="steps-content">
|
||||
{steps[currentStep] ? steps[currentStep].content : (
|
||||
{stepToShow ? stepToShow.content : (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
<p>Загрузка шага...</p>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,9 @@ interface HelloAuthProps {
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
const INIT_DATA_WAIT_MS = 5500;
|
||||
const INIT_DATA_POLL_MS = 200;
|
||||
|
||||
export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps) {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [greeting, setGreeting] = useState<string>('Привет!');
|
||||
@@ -30,6 +33,7 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
||||
const [phone, setPhone] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [codeSent, setCodeSent] = useState<boolean>(false);
|
||||
const [noInitDataAfterTimeout, setNoInitDataAfterTimeout] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isTelegramContext = () => {
|
||||
@@ -44,104 +48,98 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
||||
);
|
||||
};
|
||||
|
||||
const getChannelAndInitData = (): { channel: 'telegram' | 'max'; initData: string } | null => {
|
||||
const tg = (window as any).Telegram?.WebApp;
|
||||
const max = (window as any).WebApp;
|
||||
const tgData = typeof tg?.initData === 'string' && tg.initData.length > 0 ? tg.initData : null;
|
||||
const maxData = typeof max?.initData === 'string' && max.initData.length > 0 ? max.initData : null;
|
||||
if (tgData && isTelegramContext()) return { channel: 'telegram', initData: tgData };
|
||||
if (maxData) return { channel: 'max', initData: maxData };
|
||||
if (tgData) return { channel: 'telegram', initData: tgData };
|
||||
return null;
|
||||
};
|
||||
|
||||
const tryAuth = async () => {
|
||||
setStatus('loading');
|
||||
setNoInitDataAfterTimeout(false);
|
||||
setError('');
|
||||
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 (_) {}
|
||||
if (isTelegramContext() && !(window as any).Telegram?.WebApp) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => resolve();
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
// Telegram Mini App
|
||||
if (isTelegramContext()) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://telegram.org/js/telegram-web-app.js';
|
||||
script.async = true;
|
||||
script.onload = async () => {
|
||||
const tg = (window as any).Telegram;
|
||||
const webApp = tg?.WebApp;
|
||||
const initData = webApp?.initData;
|
||||
if (initData) {
|
||||
const res = await fetch('/api/v1/auth2/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platform: 'tg', init_data: initData }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setGreeting(data.greeting || 'Привет!');
|
||||
let avatarUrl = data.avatar_url;
|
||||
if (!avatarUrl && webApp?.initDataUnsafe?.user?.photo_url) {
|
||||
avatarUrl = webApp.initDataUnsafe.user.photo_url;
|
||||
}
|
||||
if (avatarUrl) {
|
||||
setAvatar(avatarUrl);
|
||||
localStorage.setItem('user_avatar_url', avatarUrl);
|
||||
onAvatarChange?.(avatarUrl);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
setError(data.detail || 'Ошибка авторизации Telegram');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setStatus('idle');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
return;
|
||||
// 1) Ждём появления initData (TG или MAX) с таймаутом
|
||||
let channelInit = getChannelAndInitData();
|
||||
if (!channelInit) {
|
||||
const deadline = Date.now() + INIT_DATA_WAIT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, INIT_DATA_POLL_MS));
|
||||
channelInit = getChannelAndInitData();
|
||||
if (channelInit) break;
|
||||
}
|
||||
}
|
||||
|
||||
// MAX Mini App
|
||||
const maxWebApp = (window as any).WebApp;
|
||||
const initData = maxWebApp?.initData;
|
||||
if (initData) {
|
||||
const res = await fetch('/api/v1/auth2/login', {
|
||||
if (channelInit) {
|
||||
const { channel, initData } = channelInit;
|
||||
const res = await fetch('/api/v1/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platform: 'max', init_data: initData }),
|
||||
body: JSON.stringify({ channel, init_data: initData }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const data: Record<string, unknown> = await res.json().catch(() => ({}));
|
||||
const needContact = data?.need_contact === true || data?.need_contact === 'true' || data?.need_contact === 1;
|
||||
if (needContact) {
|
||||
const webApp = channel === 'telegram' ? (window as any).Telegram?.WebApp : (window as any).WebApp;
|
||||
const doClose = () => {
|
||||
try {
|
||||
if (typeof webApp?.close === 'function') webApp.close();
|
||||
else if (typeof webApp?.postEvent === 'function') webApp.postEvent('web_app_close');
|
||||
} catch (_) {}
|
||||
};
|
||||
doClose();
|
||||
setTimeout(doClose, 200);
|
||||
return;
|
||||
}
|
||||
if (res.ok && data.success) {
|
||||
setGreeting(data.greeting || 'Привет!');
|
||||
if (data.avatar_url) {
|
||||
setAvatar(data.avatar_url);
|
||||
localStorage.setItem('user_avatar_url', data.avatar_url);
|
||||
onAvatarChange?.(data.avatar_url);
|
||||
const token = data.session_token as string | undefined;
|
||||
if (token) {
|
||||
try {
|
||||
sessionStorage.setItem('session_token', token);
|
||||
} catch (_) {}
|
||||
}
|
||||
setGreeting('Привет!');
|
||||
const tgUser = (window as any).Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
const maxUser = (window as any).WebApp?.initDataUnsafe?.user;
|
||||
const user = tgUser || maxUser;
|
||||
if (user?.first_name) setGreeting(`Привет, ${user.first_name}!`);
|
||||
const avatarUrl = user?.photo_url || (data.avatar_url as string);
|
||||
if (avatarUrl) {
|
||||
setAvatar(avatarUrl);
|
||||
localStorage.setItem('user_avatar_url', avatarUrl);
|
||||
onAvatarChange?.(avatarUrl);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
setError(data.detail || 'Ошибка авторизации MAX');
|
||||
setError((data.message as string) || (data.detail as string) || 'Ошибка авторизации');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: SMS
|
||||
// If there's a claim_id in URL/hash/MAX start_param, try to load draft and redirect to form
|
||||
// 2) initData не появился за таймаут
|
||||
const likelyMiniapp = window.location.href.includes('tgWebAppData') || window.location.href.includes('tgWebAppVersion') || !!(window as any).WebApp || !!(window as any).Telegram?.WebApp;
|
||||
if (likelyMiniapp) {
|
||||
setNoInitDataAfterTimeout(true);
|
||||
setStatus('idle');
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Веб: claim_id в URL и SMS fallback
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let claimFromUrl: string | null = params.get('claim_id');
|
||||
const parseStart = (s: string | null) => {
|
||||
@@ -150,7 +148,6 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
||||
const m = d.match(/claim_id=([^&]+)/i) || d.match(/claim_id_([0-9a-f-]{36})/i) || d.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
};
|
||||
// MAX может отдавать start_param строкой или объектом WebAppStartParam
|
||||
const startParamToStr = (v: unknown): string | null => {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'string') return v;
|
||||
@@ -164,7 +161,7 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
||||
return String(v);
|
||||
};
|
||||
if (!claimFromUrl) claimFromUrl = parseStart(params.get('startapp') || params.get('WebAppStartParam'));
|
||||
if (!claimFromUrl && typeof window !== 'undefined' && window.location.hash) {
|
||||
if (!claimFromUrl && window.location.hash) {
|
||||
const h = new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
claimFromUrl = h.get('claim_id') || parseStart(h.get('startapp') || h.get('WebAppStartParam'));
|
||||
}
|
||||
@@ -175,21 +172,12 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
||||
const draftRes = await fetch(`/api/v1/claims/drafts/${claimFromUrl}`);
|
||||
if (draftRes.ok) {
|
||||
const draftData = await draftRes.json();
|
||||
// If backend provided session_token in draft, store it
|
||||
const st = draftData?.claim?.session_token;
|
||||
if (st) {
|
||||
localStorage.setItem('session_token', st);
|
||||
console.log('HelloAuth: session_token from draft saved', st);
|
||||
}
|
||||
// Redirect to root so ClaimForm can restore session and load the draft
|
||||
if (st) localStorage.setItem('session_token', st);
|
||||
window.location.href = `/?claim_id=${encodeURIComponent(claimFromUrl)}`;
|
||||
return;
|
||||
} else {
|
||||
console.warn('HelloAuth: draft not found or error', draftRes.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('HelloAuth: error fetching draft by claim_id', e);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
setStatus('idle');
|
||||
} catch (e) {
|
||||
@@ -199,7 +187,21 @@ export default function HelloAuth({ onAvatarChange, onNavigate }: HelloAuthProps
|
||||
};
|
||||
|
||||
tryAuth();
|
||||
}, []);
|
||||
}, [onAvatarChange, onNavigate]);
|
||||
|
||||
if (noInitDataAfterTimeout && status === 'idle') {
|
||||
return (
|
||||
<div className="hello-auth-page">
|
||||
<Card className="hello-auth-card">
|
||||
<h2>Не удалось определить приложение</h2>
|
||||
<p style={{ marginBottom: 16 }}>Если вы открыли мини-приложение из Telegram или MAX — обновите страницу.</p>
|
||||
<Button type="primary" onClick={() => window.location.reload()}>
|
||||
Обновите страницу
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
|
||||
34
frontend/src/pages/Profile.css
Normal file
34
frontend/src/pages/Profile.css
Normal file
@@ -0,0 +1,34 @@
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
padding-bottom: 90px;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.profile-card .ant-card-head {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.profile-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.profile-card .ant-descriptions-item-label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
background: #f9fafb !important;
|
||||
}
|
||||
|
||||
.profile-card .ant-descriptions-item-content {
|
||||
color: #111827;
|
||||
}
|
||||
152
frontend/src/pages/Profile.tsx
Normal file
152
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Descriptions, Spin, Typography } from 'antd';
|
||||
import { User } from 'lucide-react';
|
||||
import './Profile.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** Поля профиля из CRM (поддержка snake_case и camelCase) */
|
||||
const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string }> = [
|
||||
{ key: 'last_name', keys: ['last_name', 'lastName'], label: 'Фамилия' },
|
||||
{ key: 'first_name', keys: ['first_name', 'firstName'], label: 'Имя' },
|
||||
{ key: 'middle_name', keys: ['middle_name', 'middleName', 'otchestvo'], label: 'Отчество' },
|
||||
{ key: 'birth_date', keys: ['birth_date', 'birthDate', 'birthday'], label: 'Дата рождения' },
|
||||
{ key: 'birth_place', keys: ['birth_place', 'birthPlace'], label: 'Место рождения' },
|
||||
{ key: 'inn', keys: ['inn'], label: 'ИНН' },
|
||||
{ key: 'email', keys: ['email'], label: 'Электронная почта' },
|
||||
{ key: 'registration_address', keys: ['registration_address', 'address', 'mailingstreet'], label: 'Адрес регистрации' },
|
||||
{ key: 'mailing_address', keys: ['mailing_address', 'postal_address'], label: 'Почтовый адрес' },
|
||||
{ key: 'bank_for_compensation', keys: ['bank_for_compensation', 'bank'], label: 'Банк для получения возмещения' },
|
||||
{ key: 'phone', keys: ['phone', 'mobile', 'mobile_phone'], label: 'Мобильный телефон' },
|
||||
];
|
||||
|
||||
function getValue(obj: Record<string, unknown>, keys: string[]): string {
|
||||
for (const k of keys) {
|
||||
const v = obj[k];
|
||||
if (v != null && String(v).trim() !== '') return String(v).trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
interface ProfileProps {
|
||||
onNavigate?: (path: string) => void;
|
||||
}
|
||||
|
||||
export default function Profile({ onNavigate }: ProfileProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [contact, setContact] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const token = (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('session_token') : null)
|
||||
|| localStorage.getItem('session_token');
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
onNavigate?.('/hello');
|
||||
return;
|
||||
}
|
||||
const entryChannel =
|
||||
(typeof window !== 'undefined' && (window as any).Telegram?.WebApp?.initData) ? 'telegram'
|
||||
: (typeof window !== 'undefined' && (window as any).WebApp?.initData) ? 'max'
|
||||
: 'web';
|
||||
const chatId = (() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const tg = (window as any).Telegram?.WebApp?.initDataUnsafe?.user?.id;
|
||||
if (tg != null) return String(tg);
|
||||
const max = (window as any).WebApp?.initDataUnsafe?.user?.id;
|
||||
if (max != null) return String(max);
|
||||
return undefined;
|
||||
})();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params = new URLSearchParams({ session_token: token, entry_channel: entryChannel });
|
||||
if (chatId) params.set('chat_id', chatId);
|
||||
fetch(`/api/v1/profile/contact?${params.toString()}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
try { sessionStorage.removeItem('session_token'); } catch (_) {}
|
||||
localStorage.removeItem('session_token');
|
||||
throw new Error('Сессия истекла');
|
||||
}
|
||||
throw new Error('Ошибка загрузки');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { items?: unknown[] }) => {
|
||||
if (cancelled) return;
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
const first = items.length > 0 && typeof items[0] === 'object' && items[0] !== null
|
||||
? (items[0] as Record<string, unknown>)
|
||||
: null;
|
||||
setContact(first);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e?.message || 'Не удалось загрузить данные');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [onNavigate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card className="profile-card">
|
||||
<div className="profile-loading">
|
||||
<Spin size="large" tip="Загрузка профиля..." />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card className="profile-card">
|
||||
<Title level={4}>Профиль</Title>
|
||||
<Text type="danger">{error}</Text>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="primary" onClick={() => onNavigate?.('/hello')}>
|
||||
Войти снова
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card className="profile-card">
|
||||
<Title level={4}>Профиль</Title>
|
||||
<Text type="secondary">Контактных данных пока нет. Они появятся после обработки ваших обращений.</Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = PROFILE_FIELDS.map(({ keys, label }) => ({
|
||||
key: keys[0],
|
||||
label,
|
||||
children: getValue(contact, keys) || '—',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Card className="profile-card" title={<><User size={20} style={{ marginRight: 8, verticalAlign: 'middle' }} /> Профиль</>}>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
{items.map((item) => (
|
||||
<Descriptions.Item key={item.key} label={item.label}>
|
||||
{item.children}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user