feat: add soft ui auth page

This commit is contained in:
root
2026-02-20 09:31:13 +03:00
parent a4cc4f9de6
commit 8c3e993eb7
15 changed files with 1014 additions and 24 deletions

View File

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

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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 />

View File

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

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

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