diff --git a/codebase/backend/frontend-test-site/app/_layout.tsx b/codebase/backend/frontend-test-site/app/_layout.tsx index 210f608..14a662b 100644 --- a/codebase/backend/frontend-test-site/app/_layout.tsx +++ b/codebase/backend/frontend-test-site/app/_layout.tsx @@ -3,11 +3,12 @@ import { StatusBar } from "expo-status-bar"; import React from "react"; import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context"; import { AppProvider } from "../src/app-context"; +import { colors } from "../src/theme"; export default function RootLayout() { return - + diff --git a/codebase/backend/frontend-test-site/app/auth/callback.tsx b/codebase/backend/frontend-test-site/app/auth/callback.tsx index 32133c3..6bfb77c 100644 --- a/codebase/backend/frontend-test-site/app/auth/callback.tsx +++ b/codebase/backend/frontend-test-site/app/auth/callback.tsx @@ -1,22 +1,44 @@ -import { useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams, useRouter } from "expo-router"; import React, { useEffect, useRef, useState } from "react"; import { Text, View } from "react-native"; import { useApp } from "../../src/app-context"; +import { AuthLoadingView } from "../../src/components/AuthLoadingView"; +import { ScreenShell } from "../../src/components/ScreenShell"; +import { clearPendingTextIntent, loadPendingTextIntent } from "../../src/pending-intent"; +import { dialogApi } from "../../src/services"; +import { spacing } from "../../src/theme"; import type { Consents } from "../../src/types"; -import { Button, ErrorNotice, Loading, styles } from "../../src/ui"; +import { Button, ErrorNotice, styles } from "../../src/ui"; export default function AuthCallbackScreen() { const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>(); const app = useApp(); + const router = useRouter(); const [error, setError] = useState(); const completionStarted = useRef(false); - const retry = () => { + + const complete = async () => { if (!params.code || !params.state || typeof window === "undefined") return; const raw = window.sessionStorage.getItem("han.pending-consents"); - if (!raw) return; + if (!raw) throw new Error("Не найдены локально принятые согласия. Начните вход заново."); setError(undefined); - void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError); + const consents = JSON.parse(raw) as Consents; + await app.finishCallback(params.code, params.state, consents); + + const intent = loadPendingTextIntent(); + if (intent) { + const dialog = await dialogApi.create(intent.dialogKey); + await dialogApi.sendText(dialog.dialog_id, intent.text, intent.messageKey); + clearPendingTextIntent(); + window.sessionStorage.removeItem("han.pending-consents"); + router.replace(`/dialogs/${dialog.dialog_id}`); + return; + } + + window.sessionStorage.removeItem("han.pending-consents"); + router.replace("/"); }; + useEffect(() => { if (completionStarted.current) return; if (params.error) { @@ -26,19 +48,23 @@ export default function AuthCallbackScreen() { } if (!params.code || !params.state) return; completionStarted.current = true; - const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null; - if (!raw) { - setError(new Error("Не найдены локально принятые согласия. Начните вход заново.")); - return; - } - const consents = JSON.parse(raw) as Consents; - void app.finishCallback(params.code, params.state, consents) - .then(() => window.sessionStorage.removeItem("han.pending-consents")) - .catch(setError); + void complete().catch(setError); }, [params.code, params.state, params.error]); - return - Завершение входа - {!error && } - {error && <> + +
+

${msg("otpHeading")}

+

${msg("otpSent", maskedPhone!"***")}

- - + +
+ +
+ <#list 0..5 as index> + autocomplete="one-time-code" autofocus + aria-invalid="<#if message?has_content>true<#else>false"/> + +
+ + <#if message?has_content> + + + +
+

${msg("otpResendCountdown")} 0:59

+ +
+ + +
+ + diff --git a/codebase/backend/keycloak/themes/han-phone/login/phone.ftl b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl index 0e9c257..5caf740 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/phone.ftl +++ b/codebase/backend/keycloak/themes/han-phone/login/phone.ftl @@ -1,16 +1,51 @@ <#import "template.ftl" as layout> -<@layout.registrationLayout displayMessage=true; section> +<@layout.registrationLayout displayMessage=false; section> <#if section = "header">${msg("phoneTitle")} <#elseif section = "form"> -
-
- - + +
+
+
+ HAN + +
+

${msg("authBrandSubtitle")}

+ +
+

${msg("phoneWelcome")}

+

${msg("phoneIntro")}

+
+ + +
+ + +

${msg("phoneCountry")}

+
+ + <#if message?has_content> + + + + +
-

${msg("phoneHelp")}

- - + + +
+ diff --git a/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css b/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css index f98e492..ade3f84 100644 --- a/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css +++ b/codebase/backend/keycloak/themes/han-phone/login/resources/css/han-login.css @@ -1,18 +1,409 @@ :root { - --han-primary: #246bfd; - --han-text: #172033; + --han-primary: #030213; + --han-text: #252525; + --han-muted: #717182; + --han-border: rgba(0, 0, 0, 0.12); + --han-surface: #ffffff; + --han-input: #f3f3f5; + --han-error: #d4183d; } -body { color: var(--han-text); } -.pf-c-button.pf-m-primary { background: var(--han-primary); border-radius: 10px; min-height: 44px; } -input[type="tel"], input[name="otp"] { +* { box-sizing: border-box; - width: 100%; - min-height: 44px; - margin-top: 8px; - padding: 10px 12px; - border: 1px solid #9aa6bd; - border-radius: 8px; } -.han-help { color: #596780; font-size: .9rem; } -.han-test-mode { color: #7a4b00; background: #fff3cd; padding: 8px 10px; border-radius: 8px; } + +html, +body, +body.login-pf { + min-height: 100%; + margin: 0; + background: var(--han-surface); + color: var(--han-text); + font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.login-pf-page, +.pf-v5-c-login, +.pf-c-login { + width: 100%; + min-height: 100vh; + padding: 0; + background: var(--han-surface); +} + +.login-pf-page { + display: flex; + justify-content: center; +} + +.pf-v5-c-login__container, +.pf-c-login__container { + display: grid; + width: 100%; + min-height: 100vh; + grid-template-areas: "main"; + grid-template-columns: minmax(0, 390px); + justify-content: center; + margin: 0 auto; +} + +.login-pf-page .card-pf, +.pf-v5-c-login__main, +.pf-c-login__main { + grid-area: main; + width: 100%; + max-width: 390px; + min-height: 100vh; + margin: 0 auto; + padding: 0; + border: 0; + border-radius: 0; + background: var(--han-surface); + box-shadow: none; +} + +#kc-header, +#kc-header-wrapper, +#kc-page-title { + display: none; +} + +#kc-content, +#kc-content-wrapper, +#kc-form, +#kc-form-wrapper { + width: 100%; + max-width: none; + margin: 0; +} + +.han-auth-screen { + display: flex; + width: 100%; + min-height: 100vh; + flex-direction: column; + padding: 56px 24px 32px; + background: var(--han-surface); +} + +.han-phone-screen { + justify-content: space-between; +} + +.han-auth-main { + display: flex; + flex: 1; + flex-direction: column; +} + +.han-wordmark { + display: flex; + align-items: flex-end; + gap: 4px; + margin-bottom: 8px; +} + +.han-wordmark-text { + color: var(--han-text); + font-size: 36px; + font-weight: 700; + letter-spacing: -1.5px; + line-height: 1; +} + +.han-wordmark-dot { + width: 8px; + height: 8px; + margin-bottom: 5px; + border-radius: 50%; + background: var(--han-primary); +} + +.han-brand-subtitle { + margin: 0 0 48px; + color: var(--han-muted); + font-size: 14px; + line-height: 1.5; +} + +.han-auth-heading { + margin-bottom: 32px; +} + +.han-auth-heading h1 { + margin: 0 0 8px; + color: var(--han-text); + font-size: 24px; + font-weight: 600; + line-height: 1.3; +} + +.han-auth-heading p { + margin: 0; + color: var(--han-muted); + font-size: 14px; + line-height: 1.5; +} + +.han-auth-heading p strong, +.han-auth-heading p span { + color: var(--han-text); + font-weight: 500; +} + +.han-field { + margin-bottom: 16px; +} + +.han-field label { + display: block; + margin-bottom: 6px; + color: var(--han-text); + font-size: 14px; + font-weight: 500; +} + +.han-field input { + appearance: none; + width: 100%; + height: 56px; + padding: 0 16px; + border: 1px solid var(--han-border); + border-radius: 12px; + outline: none; + background: var(--han-input); + color: var(--han-text); + font-size: 16px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.han-field input::placeholder { + color: rgba(113, 113, 130, 0.6); +} + +.han-field input:focus { + border-color: rgba(3, 2, 19, 0.5); + box-shadow: 0 0 0 3px rgba(3, 2, 19, 0.12); +} + +.han-field input[aria-invalid="true"] { + border-color: var(--han-error); +} + +.han-field-hint { + margin: 6px 4px 0; + color: var(--han-muted); + font-size: 12px; +} + +.han-primary-button { + display: flex; + width: 100%; + height: 56px; + align-items: center; + justify-content: center; + gap: 10px; + border: 0; + border-radius: 12px; + background: var(--han-primary); + color: #ffffff; + cursor: pointer; + font-size: 15px; + font-weight: 500; + transition: opacity 0.15s ease, transform 0.1s ease; +} + +.han-primary-button:hover { + opacity: 0.9; +} + +.han-primary-button:active { + transform: scale(0.98); +} + +.han-primary-button:focus-visible, +.han-back-button:focus-visible, +.han-resend button:focus-visible { + outline: 3px solid rgba(3, 2, 19, 0.18); + outline-offset: 2px; +} + +.han-primary-button:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.han-button-arrow { + font-size: 20px; + line-height: 1; +} + +.han-legal { + margin: 32px 0 0; + color: var(--han-muted); + font-size: 12px; + line-height: 1.5; + text-align: center; +} + +.han-legal span { + color: rgba(37, 37, 37, 0.7); + text-decoration: underline; + text-underline-offset: 2px; +} + +.han-error { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 0 0 16px; + padding: 12px 16px; + border: 1px solid rgba(212, 24, 61, 0.25); + border-radius: 12px; + background: rgba(212, 24, 61, 0.08); + color: var(--han-error); + font-size: 14px; + font-weight: 500; + line-height: 1.4; +} + +.han-error-icon { + display: inline-flex; + width: 17px; + height: 17px; + flex: 0 0 17px; + align-items: center; + justify-content: center; + margin-top: 1px; + border: 1.5px solid currentColor; + border-radius: 50%; + font-size: 11px; + font-weight: 700; +} + +.han-otp-screen { + padding-top: 52px; +} + +.han-back-button { + display: inline-flex; + align-self: flex-start; + align-items: center; + gap: 6px; + margin: 0 0 40px -4px; + padding: 4px; + border: 0; + background: transparent; + color: var(--han-muted); + cursor: pointer; + font-size: 14px; +} + +.han-back-button:hover { + color: var(--han-text); +} + +.han-otp-heading { + margin-bottom: 40px; +} + +.han-otp-inputs { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.han-otp-digit { + appearance: none; + width: 100%; + min-width: 0; + height: 56px; + padding: 0; + border: 1px solid var(--han-border); + border-radius: 12px; + outline: none; + background: var(--han-input); + color: var(--han-text); + font-size: 20px; + font-weight: 600; + text-align: center; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.han-otp-digit:focus { + border-color: rgba(3, 2, 19, 0.55); + box-shadow: 0 0 0 3px rgba(3, 2, 19, 0.12); +} + +.han-otp-digit.han-filled { + background: rgba(3, 2, 19, 0.05); +} + +.han-otp-digit[aria-invalid="true"] { + border-color: var(--han-error); + background: rgba(212, 24, 61, 0.05); + color: var(--han-error); +} + +.han-otp-error { + margin-top: 0; +} + +.han-resend { + display: flex; + min-height: 40px; + align-items: center; + justify-content: center; + margin: 0 0 24px; + color: var(--han-muted); + font-size: 14px; +} + +.han-resend p { + margin: 0; +} + +.han-resend strong { + color: var(--han-text); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.han-resend button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px; + border: 0; + background: transparent; + color: var(--han-primary); + cursor: pointer; + font-size: 14px; + font-weight: 500; +} + +.han-shake { + animation: han-shake 0.4s ease; +} + +@keyframes han-shake { + 0%, 100% { transform: translateX(0); } + 15% { transform: translateX(-6px); } + 30% { transform: translateX(6px); } + 45% { transform: translateX(-5px); } + 60% { transform: translateX(5px); } + 75% { transform: translateX(-3px); } + 90% { transform: translateX(3px); } +} + +@media (max-width: 360px) { + .han-auth-screen { + padding-right: 18px; + padding-left: 18px; + } + + .han-otp-inputs { + gap: 7px; + } +} diff --git a/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js b/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js new file mode 100644 index 0000000..54c8eb7 --- /dev/null +++ b/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js @@ -0,0 +1,103 @@ +(function () { + function initPhoneForm() { + var input = document.getElementById("phone"); + var submit = document.getElementById("han-phone-submit"); + if (!input || !submit) return; + + function formatPhone(value) { + var digits = value.replace(/\D/g, ""); + if (!digits) return ""; + if (digits.indexOf("8") === 0) digits = "7" + digits.slice(1); + else if (digits.indexOf("7") !== 0) digits = "7" + digits; + digits = digits.slice(0, 11); + + var result = "+7"; + if (digits.length > 1) result += " (" + digits.slice(1, 4); + if (digits.length >= 4) result += ") " + digits.slice(4, 7); + if (digits.length >= 7) result += " " + digits.slice(7, 9); + if (digits.length >= 9) result += " " + digits.slice(9, 11); + return result; + } + + function updatePhone() { + input.value = formatPhone(input.value); + submit.disabled = input.value.replace(/\D/g, "").length !== 11; + } + + input.addEventListener("input", updatePhone); + updatePhone(); + } + + function initOtpForm() { + var fields = Array.prototype.slice.call(document.querySelectorAll(".han-otp-digit")); + var hidden = document.getElementById("otp"); + var submit = document.getElementById("han-otp-submit"); + if (!fields.length || !hidden || !submit) return; + + function syncOtp() { + fields.forEach(function (field) { + field.classList.toggle("han-filled", Boolean(field.value)); + }); + hidden.value = fields.map(function (field) { return field.value; }).join(""); + submit.disabled = hidden.value.length !== fields.length; + } + + fields.forEach(function (field, index) { + field.addEventListener("input", function () { + var entered = field.value.replace(/\D/g, ""); + if (entered.length > 1) { + entered.slice(0, fields.length).split("").forEach(function (digit, digitIndex) { + fields[digitIndex].value = digit; + }); + fields[Math.min(entered.length, fields.length) - 1].focus(); + } else { + field.value = entered.slice(-1); + if (field.value && index < fields.length - 1) fields[index + 1].focus(); + } + syncOtp(); + }); + + field.addEventListener("keydown", function (event) { + if (event.key === "Backspace" && !field.value && index > 0) { + fields[index - 1].value = ""; + fields[index - 1].focus(); + syncOtp(); + } + if (event.key === "ArrowLeft" && index > 0) fields[index - 1].focus(); + if (event.key === "ArrowRight" && index < fields.length - 1) fields[index + 1].focus(); + }); + + field.addEventListener("paste", function (event) { + event.preventDefault(); + var pasted = event.clipboardData.getData("text").replace(/\D/g, "").slice(0, fields.length); + fields.forEach(function (otpField) { otpField.value = ""; }); + pasted.split("").forEach(function (digit, digitIndex) { + fields[digitIndex].value = digit; + }); + if (pasted.length) fields[Math.min(pasted.length, fields.length) - 1].focus(); + syncOtp(); + }); + }); + + syncOtp(); + + var seconds = 59; + var countdown = document.getElementById("han-resend-countdown"); + var countdownValue = countdown && countdown.querySelector("strong"); + var resend = document.getElementById("han-resend-button"); + if (!countdown || !countdownValue || !resend) return; + + var timer = window.setInterval(function () { + seconds -= 1; + countdownValue.textContent = "0:" + String(seconds).padStart(2, "0"); + if (seconds <= 0) { + window.clearInterval(timer); + countdown.hidden = true; + resend.hidden = false; + } + }, 1000); + } + + initPhoneForm(); + initOtpForm(); +})(); diff --git a/figma/src/app/pages/AuthLoading.tsx b/figma/src/app/pages/AuthLoading.tsx new file mode 100644 index 0000000..6d2ede0 --- /dev/null +++ b/figma/src/app/pages/AuthLoading.tsx @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router'; + +export function AuthLoading() { + const navigate = useNavigate(); + const location = useLocation(); + const phone = (location.state as { phone?: string })?.phone ?? ''; + + useEffect(() => { + // Имитируем авторизацию — через 2.8 секунды переходим на главную + const t = setTimeout(() => navigate('/'), 2800); + return () => clearTimeout(t); + }, [navigate]); + + return ( +
+ {/* Анимированный логотип */} +
+
+ {/* Пульсирующие кольца */} +
+
+
+
+
+
+ + {/* Центральный круг с логотипом */} +
+ HAN +
+
+ + {/* Индикатор загрузки */} +
+ {[0, 1, 2].map(i => ( +
+ ))} +
+
+ + {/* Текст */} +
+

Выполняем вход

+

+ Проверяем данные{phone ? ` для ${phone}` : ''}... +

+
+ + {/* Подсказка снизу */} +
+

+ HAN — ваш персональный консультант по вопросам миграции в России +

+
+ + +
+ ); +} diff --git a/figma/src/app/pages/AuthOtp.tsx b/figma/src/app/pages/AuthOtp.tsx new file mode 100644 index 0000000..cc33065 --- /dev/null +++ b/figma/src/app/pages/AuthOtp.tsx @@ -0,0 +1,218 @@ +import { useState, useRef, useEffect, KeyboardEvent, ClipboardEvent } from 'react'; +import { useNavigate, useLocation } from 'react-router'; +import { ArrowLeft, RefreshCw, AlertCircle } from 'lucide-react'; + +const CODE_LENGTH = 6; +// Правильный код для демо +const DEMO_CODE = '123456'; + +export function AuthOtp() { + const navigate = useNavigate(); + const location = useLocation(); + const phone = (location.state as { phone?: string })?.phone ?? '+7 (___) ___ __ __'; + + const [digits, setDigits] = useState(Array(CODE_LENGTH).fill('')); + const [error, setError] = useState(null); + const [shake, setShake] = useState(false); + const [countdown, setCountdown] = useState(59); + const [canResend, setCanResend] = useState(false); + + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + + useEffect(() => { + inputRefs.current[0]?.focus(); + }, []); + + useEffect(() => { + if (countdown === 0) { setCanResend(true); return; } + const t = setTimeout(() => setCountdown(c => c - 1), 1000); + return () => clearTimeout(t); + }, [countdown]); + + function handleChange(index: number, value: string) { + const digit = value.replace(/\D/g, '').slice(-1); + const next = [...digits]; + next[index] = digit; + setDigits(next); + setError(null); + + if (digit && index < CODE_LENGTH - 1) { + inputRefs.current[index + 1]?.focus(); + } + + // Автоматическая проверка когда введены все цифры + if (next.every(d => d !== '')) { + verifyCode(next.join('')); + } + } + + function handleKeyDown(index: number, e: KeyboardEvent) { + if (e.key === 'Backspace') { + if (digits[index]) { + const next = [...digits]; + next[index] = ''; + setDigits(next); + setError(null); + } else if (index > 0) { + inputRefs.current[index - 1]?.focus(); + const next = [...digits]; + next[index - 1] = ''; + setDigits(next); + setError(null); + } + } + if (e.key === 'ArrowLeft' && index > 0) inputRefs.current[index - 1]?.focus(); + if (e.key === 'ArrowRight' && index < CODE_LENGTH - 1) inputRefs.current[index + 1]?.focus(); + } + + function handlePaste(e: ClipboardEvent) { + e.preventDefault(); + const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH); + if (!pasted) return; + const next = Array(CODE_LENGTH).fill(''); + pasted.split('').forEach((d, i) => { next[i] = d; }); + setDigits(next); + setError(null); + const focusIdx = Math.min(pasted.length, CODE_LENGTH - 1); + inputRefs.current[focusIdx]?.focus(); + if (next.every(d => d !== '')) verifyCode(next.join('')); + } + + function verifyCode(code: string) { + if (code === DEMO_CODE) { + navigate('/auth/loading', { state: { phone } }); + } else { + setError('Неверный код. Проверьте и попробуйте снова'); + setShake(true); + setTimeout(() => setShake(false), 500); + // Очищаем поля после ошибки + setTimeout(() => { + setDigits(Array(CODE_LENGTH).fill('')); + inputRefs.current[0]?.focus(); + }, 600); + } + } + + function handleResend() { + if (!canResend) return; + setDigits(Array(CODE_LENGTH).fill('')); + setError(null); + setCountdown(59); + setCanResend(false); + inputRefs.current[0]?.focus(); + } + + const isComplete = digits.every(d => d !== ''); + + return ( +
+
+ {/* Назад */} + + + {/* Заголовок */} +
+

+ Введите код +

+

+ Отправили SMS на номер{' '} + {phone} +

+
+ + {/* Поля ввода OTP */} +
+
+ {digits.map((digit, i) => ( + { inputRefs.current[i] = el; }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={e => handleChange(i, e.target.value)} + onKeyDown={e => handleKeyDown(i, e)} + onPaste={handlePaste} + className={` + w-12 h-14 text-center text-xl font-semibold rounded-xl border transition-all + focus:outline-none focus:ring-2 + ${error + ? 'border-destructive bg-destructive/5 text-destructive focus:ring-destructive/20' + : digit + ? 'border-primary/50 bg-primary/5 text-foreground focus:ring-primary/30 focus:border-primary/60' + : 'border-border bg-input-background text-foreground focus:ring-primary/30 focus:border-primary/50' + } + `} + /> + ))} +
+ + {/* Блок ошибки */} + {error && ( +
+ +
+

{error}

+

+ Для демо введите код: 123456 +

+
+
+ )} +
+ + {/* Повторная отправка */} +
+ {canResend ? ( + + ) : ( +

+ Отправить повторно через{' '} + + 0:{countdown.toString().padStart(2, '0')} + +

+ )} +
+ + {/* Кнопка подтверждения */} + +
+ + +
+ ); +} diff --git a/figma/src/app/pages/AuthPhone.tsx b/figma/src/app/pages/AuthPhone.tsx new file mode 100644 index 0000000..8cb92f2 --- /dev/null +++ b/figma/src/app/pages/AuthPhone.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router'; +import { ArrowRight } from 'lucide-react'; + +export function AuthPhone() { + const navigate = useNavigate(); + const [phone, setPhone] = useState(''); + + function formatPhone(raw: string) { + const digits = raw.replace(/\D/g, '').slice(0, 11); + if (digits.length === 0) return ''; + let result = '+7'; + if (digits.length > 1) result += ' (' + digits.slice(1, 4); + if (digits.length >= 4) result += ') ' + digits.slice(4, 7); + if (digits.length >= 7) result += ' ' + digits.slice(7, 9); + if (digits.length >= 9) result += ' ' + digits.slice(9, 11); + return result; + } + + function handleInput(e: React.ChangeEvent) { + const raw = e.target.value.replace(/\D/g, ''); + // Если начинается с 8, заменяем на 7 + const normalized = raw.startsWith('8') ? '7' + raw.slice(1) : raw.startsWith('7') ? raw : '7' + raw; + setPhone(formatPhone(normalized)); + } + + const isValid = phone.replace(/\D/g, '').length === 11; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (isValid) navigate('/auth/otp', { state: { phone } }); + } + + return ( +
+
+ {/* Верхняя часть */} +
+ {/* Логотип */} +
+
+ HAN + +
+

+ Персональный консультант мигранта +

+
+ + {/* Заголовок */} +
+

+ Добро пожаловать +

+

+ Введите номер телефона — отправим код подтверждения +

+
+ + {/* Форма */} +
+
+ +
+ +
+

+ Россия · Код страны +7 +

+
+ + +
+
+ + {/* Низ страницы */} +
+

+ Нажимая «Получить код», вы соглашаетесь с{' '} + условиями использования + {' '}и{' '} + политикой конфиденциальности +

+
+
+
+ ); +} diff --git a/figma/src/app/pages/Root.tsx b/figma/src/app/pages/Root.tsx index c82aae4..0b14c6e 100644 --- a/figma/src/app/pages/Root.tsx +++ b/figma/src/app/pages/Root.tsx @@ -4,12 +4,13 @@ import { Header } from '../components/Header'; export function Root() { const location = useLocation(); const isChat = location.pathname.startsWith('/chat/'); + const isAuth = location.pathname.startsWith('/auth/'); return (
- {/* Хедер показываем на всех страницах кроме чата */} - {!isChat &&
} + {/* Хедер скрываем на чате и экранах авторизации */} + {!isChat && !isAuth &&
}
diff --git a/figma/src/app/routes.tsx b/figma/src/app/routes.tsx index bbe7e7b..f43c806 100644 --- a/figma/src/app/routes.tsx +++ b/figma/src/app/routes.tsx @@ -6,6 +6,9 @@ import { Profile } from './pages/Profile'; import { Chat } from './pages/Chat'; import { Calendar } from './pages/Calendar'; import { NotificationDetail } from './pages/NotificationDetail'; +import { AuthPhone } from './pages/AuthPhone'; +import { AuthOtp } from './pages/AuthOtp'; +import { AuthLoading } from './pages/AuthLoading'; export const router = createBrowserRouter([ { @@ -18,6 +21,9 @@ export const router = createBrowserRouter([ { path: 'chat/:id', Component: Chat }, { path: 'calendar', Component: Calendar }, { path: 'notification/:id', Component: NotificationDetail }, + { path: 'auth/phone', Component: AuthPhone }, + { path: 'auth/otp', Component: AuthOtp }, + { path: 'auth/loading', Component: AuthLoading }, ], }, ]);