feat: add soft ui auth page
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
<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 -->
|
||||
</head>
|
||||
<body>
|
||||
|
||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"imask": "^7.6.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
@@ -3562,6 +3563,14 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.575.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -7725,6 +7734,12 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"lucide-react": {
|
||||
"version": "0.575.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz",
|
||||
"integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==",
|
||||
"requires": {}
|
||||
},
|
||||
"math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
||||
@@ -13,33 +13,33 @@
|
||||
"start": "serve -s dist -l 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"antd": "^5.21.6",
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
"axios": "^1.7.7",
|
||||
"@tanstack/react-query": "^5.59.16",
|
||||
"zustand": "^5.0.1",
|
||||
"antd": "^5.21.6",
|
||||
"axios": "^1.7.7",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"imask": "^7.6.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"serve": "^14.2.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"browser-image-compression": "^2.0.2"
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"serve": "^14.2.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zustand": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"eslint": "^9.13.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.11.0",
|
||||
"@typescript-eslint/parser": "^8.11.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.13"
|
||||
"eslint-plugin-react-refresh": "^0.4.13",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import ClaimForm from './pages/ClaimForm'
|
||||
import HelloAuth from './pages/HelloAuth'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const pathname = window.location.pathname || '';
|
||||
if (pathname.startsWith('/hello')) {
|
||||
return <HelloAuth />;
|
||||
}
|
||||
return (
|
||||
<div className="App">
|
||||
<ClaimForm />
|
||||
|
||||
@@ -110,6 +110,8 @@ export default function ClaimForm() {
|
||||
const [tgDebug, setTgDebug] = useState<string>('');
|
||||
/** Дефолт = веб. Скин TG подставляется только при заходе через Telegram Mini App. */
|
||||
const [isTelegramMiniApp, setIsTelegramMiniApp] = useState(false);
|
||||
/** Заход через MAX Mini App. */
|
||||
const [isMaxMiniApp, setIsMaxMiniApp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
|
||||
@@ -182,6 +184,68 @@ export default function ClaimForm() {
|
||||
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);
|
||||
if (maxData.has_drafts) {
|
||||
setShowDraftSelection(true);
|
||||
setHasDrafts(true);
|
||||
setCurrentStep(0);
|
||||
} else {
|
||||
setCurrentStep(1);
|
||||
}
|
||||
} else {
|
||||
console.error('[MAX] max/auth ответ', maxRes.status, maxData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MAX] Ошибка max/auth:', e);
|
||||
}
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
setTelegramAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
115
frontend/src/pages/HelloAuth.css
Normal file
115
frontend/src/pages/HelloAuth.css
Normal file
@@ -0,0 +1,115 @@
|
||||
.hello-page {
|
||||
min-height: 100vh;
|
||||
padding: 32px;
|
||||
background: #f5f7fb;
|
||||
}
|
||||
|
||||
.hello-hero {
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
background: #ffffff;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hello-hero-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hello-hero-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hello-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.hello-avatar.placeholder {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.2), rgba(37, 99, 235, 0.4));
|
||||
}
|
||||
|
||||
.hello-hero-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.hello-hero-subtitle {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.hello-hero-body {
|
||||
padding-top: 8px;
|
||||
min-height: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hello-hero-error {
|
||||
color: #d4380d;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hello-grid {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.tile-card {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.06);
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
padding: 24px 16px;
|
||||
background: #ffffff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tile-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 22px 36px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.tile-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: #f8fafc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tile-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hello-page {
|
||||
padding: 16px;
|
||||
}
|
||||
.tile-card {
|
||||
min-height: 140px;
|
||||
}
|
||||
}
|
||||
235
frontend/src/pages/HelloAuth.tsx
Normal file
235
frontend/src/pages/HelloAuth.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Button, Input, Space, Spin, message, Row, Col } from 'antd';
|
||||
import {
|
||||
User,
|
||||
IdCard,
|
||||
Trophy,
|
||||
ShieldCheck,
|
||||
CalendarDays,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Building2,
|
||||
} from 'lucide-react';
|
||||
import './HelloAuth.css';
|
||||
|
||||
type Status = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
export default function HelloAuth() {
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [greeting, setGreeting] = useState<string>('Привет!');
|
||||
const [error, setError] = useState<string>('');
|
||||
const [avatar, setAvatar] = useState<string>('');
|
||||
const [phone, setPhone] = useState<string>('');
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [codeSent, setCodeSent] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isTelegramContext = () => {
|
||||
const url = window.location.href;
|
||||
const ref = document.referrer;
|
||||
const ua = navigator.userAgent;
|
||||
return (
|
||||
url.includes('tgWebAppData') ||
|
||||
url.includes('tgWebAppVersion') ||
|
||||
ref.includes('telegram') ||
|
||||
ua.includes('Telegram')
|
||||
);
|
||||
};
|
||||
|
||||
const tryAuth = async () => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
// 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 || 'Привет!');
|
||||
if (data.avatar_url) {
|
||||
setAvatar(data.avatar_url);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
setError(data.detail || 'Ошибка авторизации Telegram');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setStatus('idle');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
return;
|
||||
}
|
||||
|
||||
// MAX Mini App
|
||||
const maxWebApp = (window as any).WebApp;
|
||||
const initData = maxWebApp?.initData;
|
||||
if (initData) {
|
||||
const res = await fetch('/api/v1/auth2/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platform: 'max', init_data: initData }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setGreeting(data.greeting || 'Привет!');
|
||||
if (data.avatar_url) {
|
||||
setAvatar(data.avatar_url);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
setError(data.detail || 'Ошибка авторизации MAX');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: SMS
|
||||
setStatus('idle');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
tryAuth();
|
||||
}, []);
|
||||
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
if (!phone || phone.length < 10) {
|
||||
message.error('Введите номер телефона');
|
||||
return;
|
||||
}
|
||||
const normalized = phone.startsWith('7') ? phone : `7${phone}`;
|
||||
const res = await fetch('/api/v1/sms/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: normalized }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
message.error(data.detail || 'Ошибка отправки кода');
|
||||
return;
|
||||
}
|
||||
setCodeSent(true);
|
||||
message.success('Код отправлен');
|
||||
} catch (e) {
|
||||
message.error('Ошибка соединения');
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCode = async () => {
|
||||
try {
|
||||
const normalized = phone.startsWith('7') ? phone : `7${phone}`;
|
||||
const res = await fetch('/api/v1/auth2/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ platform: 'sms', phone: normalized, code }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setGreeting(data.greeting || 'Привет!');
|
||||
if (data.avatar_url) {
|
||||
setAvatar(data.avatar_url);
|
||||
}
|
||||
setStatus('success');
|
||||
return;
|
||||
}
|
||||
message.error(data.detail || 'Неверный код');
|
||||
} catch (e) {
|
||||
message.error('Ошибка соединения');
|
||||
}
|
||||
};
|
||||
|
||||
const tiles = [
|
||||
{ title: 'Профиль', icon: User, color: '#2563EB' },
|
||||
{ title: 'Членство', icon: IdCard, color: '#10B981' },
|
||||
{ title: 'Достижения', icon: Trophy, color: '#F59E0B' },
|
||||
{ title: 'Общественный контроллер', icon: ShieldCheck, color: '#22C55E' },
|
||||
{ title: 'Календарь мероприятий', icon: CalendarDays, color: '#4F46E5' },
|
||||
{ title: 'Образцы документов', icon: FileText, color: '#1E40AF' },
|
||||
{ title: 'FAQ', icon: HelpCircle, color: '#0EA5E9' },
|
||||
{ title: 'Регистрация компании', icon: Building2, color: '#0F766E' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="hello-page">
|
||||
<Card className="hello-hero" bordered={false}>
|
||||
<div className="hello-hero-header">
|
||||
<div className="hello-hero-profile">
|
||||
{avatar ? (
|
||||
<img src={avatar} alt="avatar" className="hello-avatar" />
|
||||
) : (
|
||||
<div className="hello-avatar placeholder" />
|
||||
)}
|
||||
<div>
|
||||
<div className="hello-hero-title">{greeting}</div>
|
||||
<div className="hello-hero-subtitle">Добро пожаловать в кабинет</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hello-hero-body">
|
||||
{status === 'loading' ? (
|
||||
<Spin size="large" tip="Авторизация..." />
|
||||
) : status === 'success' ? (
|
||||
<p>Теперь ты в системе — можно продолжать</p>
|
||||
) : status === 'error' ? (
|
||||
<p className="hello-hero-error">{error}</p>
|
||||
) : (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Телефон (10 цифр)"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 10))}
|
||||
/>
|
||||
<Button type="primary" onClick={sendCode} block>
|
||||
Отправить код
|
||||
</Button>
|
||||
{codeSent && (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Код из SMS"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<Button type="primary" onClick={verifyCode} block>
|
||||
Проверить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Row gutter={[16, 16]} className="hello-grid">
|
||||
{tiles.map((tile) => {
|
||||
const Icon = tile.icon;
|
||||
return (
|
||||
<Col key={tile.title} xs={12} sm={8} md={6}>
|
||||
<Card className="tile-card" hoverable bordered={false}>
|
||||
<div className="tile-icon" style={{ color: tile.color }}>
|
||||
<Icon size={26} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div className="tile-title">{tile.title}</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user