first commit

This commit is contained in:
Bivekich
2025-06-26 06:59:59 +03:00
commit d44874775c
450 changed files with 76635 additions and 0 deletions

View File

@ -0,0 +1,175 @@
import React, { useState } from 'react'
import { ApolloProvider } from '@apollo/client'
import { apolloClient } from '@/lib/apollo'
import PhoneInput from './PhoneInput'
import CodeVerification from './CodeVerification'
import UserRegistration from './UserRegistration'
import type { AuthState, AuthStep, ClientAuthResponse, VerificationResponse } from '@/types/auth'
interface AuthModalProps {
isOpen: boolean
onClose: () => void
onSuccess: (client: any, token?: string) => void
}
const AuthModal: React.FC<AuthModalProps> = ({ isOpen, onClose, onSuccess }) => {
const [authState, setAuthState] = useState<AuthState>({
step: 'phone',
phone: '',
sessionId: '',
isExistingClient: false
})
const [error, setError] = useState('')
const handlePhoneSuccess = (data: ClientAuthResponse) => {
setError('')
// Всегда переходим к вводу кода, независимо от того, существует клиент или нет
setAuthState(prev => ({
...prev,
step: 'code',
sessionId: data.sessionId,
client: data.client,
isExistingClient: data.exists
}))
}
const handleCodeSuccess = (data: VerificationResponse) => {
if (data.success && data.client) {
onSuccess(data.client, data.token)
onClose()
}
}
const handleRegistrationSuccess = (data: VerificationResponse) => {
if (data.success && data.client) {
onSuccess(data.client, data.token)
onClose()
}
}
const handleError = (errorMessage: string) => {
setError(errorMessage)
}
const handleBack = () => {
setAuthState(prev => ({
...prev,
step: 'phone'
}))
setError('')
}
const handleGoToRegistration = () => {
setAuthState(prev => ({
...prev,
step: 'registration'
}))
setError('')
}
const handleClose = () => {
setAuthState({
step: 'phone',
phone: '',
sessionId: '',
isExistingClient: false
})
setError('')
onClose()
}
if (!isOpen) return null
const renderStep = () => {
switch (authState.step) {
case 'phone':
return (
<PhoneInput
onSuccess={(data, phone) => {
setAuthState(prev => ({
...prev,
phone: phone,
sessionId: data.sessionId,
client: data.client,
isExistingClient: data.exists
}))
handlePhoneSuccess(data)
}}
onError={handleError}
onRegister={handleGoToRegistration}
/>
)
case 'code':
return (
<CodeVerification
phone={authState.phone}
sessionId={authState.sessionId}
isExistingClient={authState.isExistingClient}
onSuccess={handleCodeSuccess}
onError={handleError}
onBack={handleBack}
onRegister={handleGoToRegistration}
/>
)
case 'registration':
return (
<UserRegistration
phone={authState.phone}
sessionId={authState.sessionId}
onSuccess={handleRegistrationSuccess}
onError={handleError}
/>
)
default:
return null
}
}
return (
<ApolloProvider client={apolloClient}>
{/* Overlay */}
<div
className="fixed inset-0 bg-black/10 z-index-40 margin-top-[132px] transition-opacity duration-200"
aria-label="Затемнение фона"
tabIndex={-1}
onClick={handleClose}
/>
{/* Модальное окно */}
<div className="flex relative w-full bg-white mx-auto z-50">
<div className="flex relative flex-col gap-4 items-start px-32 py-10 w-full bg-white max-w-[1920px] min-h-[320px] max-md:px-16 max-md:py-8 max-sm:gap-8 max-sm:p-5 mx-auto z-50"
style={{ marginTop: 0, position: 'relative' }}
>
{/* Кнопка закрытия */}
<button
onClick={handleClose}
className="absolute right-8 top-8 p-2 hover:opacity-70 focus:outline-none"
aria-label="Закрыть окно авторизации"
tabIndex={0}
>
<svg width="30" height="30" viewBox="0 0 30 30" fill="none">
<path d="M8 23.75L6.25 22L13.25 15L6.25 8L8 6.25L15 13.25L22 6.25L23.75 8L16.75 15L23.75 22L22 23.75L15 16.75L8 23.75Z" fill="#000814"/>
</svg>
</button>
{/* Заголовок */}
<div className="flex relative justify-between items-start w-full max-sm:flex-col max-sm:gap-5">
<div className="relative text-5xl font-bold uppercase leading-[62.4px] text-gray-950 max-md:text-5xl max-sm:self-start max-sm:text-3xl">
ВХОД
</div>
</div>
{/* Ошибка */}
{error && (
<div className="mb-4 px-4 py-3 bg-red-50 border border-red-200 rounded">
<p className="text-red-800 m-0">{error}</p>
</div>
)}
{/* Контент */}
<div className="flex relative flex-col gap-5 items-start self-stretch w-full">
{renderStep()}
</div>
</div>
</div>
</ApolloProvider>
)
}
export default AuthModal

View File

@ -0,0 +1,159 @@
import React, { useState, useRef, useEffect } from 'react'
import { useMutation } from '@apollo/client'
import { SEND_SMS_CODE, VERIFY_CODE } from '@/lib/graphql'
import type { SMSCodeResponse, VerificationResponse } from '@/types/auth'
interface CodeVerificationProps {
phone: string
sessionId: string
isExistingClient: boolean
onSuccess: (data: VerificationResponse) => void
onError: (error: string) => void
onBack: () => void
onRegister: () => void
}
const CodeVerification: React.FC<CodeVerificationProps> = ({
phone,
sessionId,
isExistingClient,
onSuccess,
onError,
onBack,
onRegister
}) => {
const [code, setCode] = useState(['', '', '', '', ''])
const [isLoading, setIsLoading] = useState(false)
const [smsCode, setSmsCode] = useState('')
const inputRefs = useRef<(HTMLInputElement | null)[]>([])
const [sendSMS] = useMutation<{ sendSMSCode: SMSCodeResponse }>(SEND_SMS_CODE)
const [verifyCode] = useMutation<{ verifyCode: VerificationResponse }>(VERIFY_CODE)
// SMS код уже отправлен в PhoneInput, здесь только показываем
useEffect(() => {
console.log('CodeVerification mounted for', isExistingClient ? 'existing' : 'new', 'client')
}, [])
const handleCodeChange = (index: number, value: string) => {
if (!/^\d*$/.test(value)) return
const newCode = [...code]
newCode[index] = value.slice(-1)
setCode(newCode)
// Автоматически переходим к следующему полю
if (value && index < 4) {
inputRefs.current[index + 1]?.focus()
}
// Если все поля заполнены, отправляем код
if (newCode.every(digit => digit !== '') && !isLoading) {
handleVerify(newCode.join(''))
}
}
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace' && !code[index] && index > 0) {
inputRefs.current[index - 1]?.focus()
}
}
const handleVerify = async (codeString?: string) => {
const finalCode = codeString || code.join('')
if (finalCode.length !== 5) {
onError('Введите полный код')
return
}
setIsLoading(true)
try {
const { data } = await verifyCode({
variables: {
phone,
code: finalCode,
sessionId
}
})
if (data?.verifyCode?.success) {
if (data.verifyCode.client) {
// Если клиент существует - авторизуем
onSuccess(data.verifyCode)
} else {
// Если клиент новый - переходим к регистрации
onRegister()
}
}
} catch (error) {
console.error('Ошибка верификации:', error)
onError('Неверный код')
setCode(['', '', '', '', ''])
inputRefs.current[0]?.focus()
} finally {
setIsLoading(false)
}
}
return (
<div className="flex flex-col gap-5 w-full">
<label className="text-2xl leading-8 text-gray-950 mb-2 font-normal font-[Onest,sans-serif]">Введите код из СМС</label>
<div className="flex gap-5 items-center w-full max-md:flex-col max-md:gap-4 max-sm:gap-3">
{/* 5 полей для цифр */}
<div className="flex gap-3">
{code.map((digit, index) => (
<input
key={index}
ref={el => { inputRefs.current[index] = el }}
type="text"
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
className="w-[62px] h-[62px] px-4 py-3 text-[18px] leading-[1.4] font-normal font-[Onest,sans-serif] text-neutral-500 bg-white border border-stone-300 rounded focus:outline-none text-center"
maxLength={1}
disabled={isLoading}
aria-label={`Цифра ${index + 1}`}
/>
))}
</div>
{/* Кнопка "Войти" */}
<button
type="button"
onClick={() => handleVerify()}
disabled={isLoading || code.some(digit => digit === '')}
style={{ color: 'white' }}
className="flex items-center justify-center flex-shrink-0 bg-red-600 rounded-xl px-8 py-5 text-lg font-medium leading-5 text-white disabled:opacity-50 disabled:cursor-not-allowed h-[62px] max-sm:px-6 max-sm:py-4"
aria-label="Войти"
tabIndex={0}
>
{isLoading ? 'Проверяем...' : 'Войти'}
</button>
</div>
{/* Кнопка "Ввести другой номер" под вводом кода */}
<button
type="button"
onClick={onBack}
className="flex gap-3 items-center hover:opacity-70 mt-2"
aria-label="Ввести другой номер"
tabIndex={0}
>
<svg width="40" height="13" viewBox="0 0 40 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.469669 5.96967C0.176777 6.26256 0.176777 6.73743 0.469669 7.03033L5.24264 11.8033C5.53553 12.0962 6.01041 12.0962 6.3033 11.8033C6.59619 11.5104 6.59619 11.0355 6.3033 10.7426L2.06066 6.5L6.3033 2.25736C6.5962 1.96446 6.5962 1.48959 6.3033 1.1967C6.01041 0.903803 5.53553 0.903803 5.24264 1.1967L0.469669 5.96967ZM40 5.75L1 5.75L1 7.25L40 7.25L40 5.75Z" fill="#424F60"/>
</svg>
<span className="text-lg leading-[1.4] font-normal font-[Onest,sans-serif] text-[#424F60]">Ввести другой номер</span>
</button>
{/* Отладочная информация */}
{smsCode && (
<div className="mt-4 px-4 py-3 bg-blue-50 border border-blue-200 rounded">
<p className="text-sm text-blue-800 m-0 font-[Onest,sans-serif]">
<strong>Код для тестирования:</strong> {smsCode}
</p>
</div>
)}
</div>
)
}
export default CodeVerification

View File

@ -0,0 +1,142 @@
import React, { useState } from 'react'
import { useMutation } from '@apollo/client'
import { CHECK_CLIENT_BY_PHONE, SEND_SMS_CODE } from '@/lib/graphql'
import type { ClientAuthResponse, SMSCodeResponse } from '@/types/auth'
interface PhoneInputProps {
onSuccess: (data: ClientAuthResponse, phone: string) => void
onError: (error: string) => void
onRegister: () => void
}
const PhoneInput: React.FC<PhoneInputProps> = ({ onSuccess, onError, onRegister }) => {
const [phone, setPhone] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [checkClient] = useMutation<{ checkClientByPhone: ClientAuthResponse }>(CHECK_CLIENT_BY_PHONE)
const [sendSMSCode] = useMutation<{ sendSMSCode: SMSCodeResponse }>(SEND_SMS_CODE)
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value
// Убираем все кроме цифр
let digitsOnly = value.replace(/\D/g, '')
// Если начинается с 7, убираем её
if (digitsOnly.startsWith('7')) {
digitsOnly = digitsOnly.substring(1)
}
// Ограничиваем до 10 цифр
if (digitsOnly.length <= 10) {
// Форматируем номер
let formatted = digitsOnly
if (digitsOnly.length >= 1) {
formatted = digitsOnly.replace(/(\d{1,3})(\d{0,3})(\d{0,2})(\d{0,2})/, (match, p1, p2, p3, p4) => {
let result = `(${p1}`
if (p2) result += `) ${p2}`
if (p3) result += `-${p3}`
if (p4) result += `-${p4}`
return result
})
}
setPhone(formatted)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const cleanPhone = '+7' + phone.replace(/\D/g, '')
if (phone.replace(/\D/g, '').length !== 10) {
onError('Введите корректный номер телефона')
return
}
setIsLoading(true)
try {
// Сначала проверяем существует ли клиент
const { data: clientData } = await checkClient({
variables: { phone: cleanPhone }
})
if (clientData?.checkClientByPhone) {
// Затем отправляем SMS код
const { data: smsData } = await sendSMSCode({
variables: {
phone: cleanPhone,
sessionId: clientData.checkClientByPhone.sessionId
}
})
if (smsData?.sendSMSCode?.success) {
console.log('SMS код отправлен! Код:', smsData.sendSMSCode.code)
onSuccess(clientData.checkClientByPhone, cleanPhone)
} else {
onError('Не удалось отправить SMS код')
}
}
} catch (error) {
console.error('Ошибка проверки телефона:', error)
onError('Произошла ошибка при проверке номера')
} finally {
setIsLoading(false)
}
}
return (
<div className="flex flex-col gap-5 w-full">
<label className="text-2xl leading-8 text-gray-950 mb-2 font-normal font-[Onest,sans-serif] "
style={{
fontSize: '22px',
lineHeight: '1.4',
fontWeight: 400,
fontFamily: 'Onest, sans-serif',
color: '#000814'
}}>Введите номер телефона</label>
<form onSubmit={handleSubmit} className="flex flex-col gap-5 w-full">
<div className="flex gap-5 items-center w-full max-md:flex-col max-md:gap-4 max-sm:gap-3">
<input
type="tel"
value={`+7 ${phone}`}
onChange={handlePhoneChange}
placeholder="+7 (999) 999-99-99"
className="max-w-[360px] w-full h-[70px] px-[30px] py-[20px] text-[20px] leading-[1.4] font-[Onest,sans-serif] text-neutral-500 bg-white border border-stone-300 rounded focus:outline-none min-w-0 max-md:w-[300px] max-sm:w-full"
disabled={isLoading}
required
aria-label="Введите номер телефона"
/>
<button
type="submit"
disabled={isLoading || phone.replace(/\D/g, '').length !== 10}
className="flex items-center justify-center flex-shrink-0 bg-red-600 rounded-xl px-8 py-5 text-lg font-medium leading-5 text-white disabled:opacity-50 disabled:cursor-not-allowed h-[70px] max-sm:px-6 max-sm:py-4"
style={{ color: 'white' }}
aria-label="Получить код"
tabIndex={0}
>
{isLoading ? 'Проверяем...' : 'Получить код'}
</button>
</div>
</form>
{/* <button
type="button"
onClick={onRegister}
className="flex gap-5 justify-center items-center px-7 py-5 w-80 rounded-xl border border-red-700 border-solid cursor-pointer max-md:self-center max-md:px-6 max-md:py-5 max-md:w-[280px] max-sm:px-5 max-sm:py-4 max-sm:w-full"
aria-label="Зарегистрироваться"
tabIndex={0}
>
<span className="text-xl font-medium leading-7 text-center text-gray-950 max-md:text-lg max-sm:text-base">Зарегистрироваться</span>
<span aria-hidden="true">
<svg width="31" height="16" viewBox="0 0 31 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="arrow-icon" style={{width:'30px',height:'16px',flexShrink:0}}>
<path d="M30.7071 8.70711C31.0976 8.31659 31.0976 7.68342 30.7071 7.2929L24.3431 0.928936C23.9526 0.538412 23.3195 0.538412 22.9289 0.928936C22.5384 1.31946 22.5384 1.95263 22.9289 2.34315L28.5858 8L22.9289 13.6569C22.5384 14.0474 22.5384 14.6805 22.9289 15.0711C23.3195 15.4616 23.9526 15.4616 24.3431 15.0711L30.7071 8.70711ZM0 8L-1.74846e-07 9L30 9.00001L30 8.00001L30 7.00001L1.74846e-07 7L0 8Z" fill="#000814"/>
</svg>
</span>
</button> */}
</div>
)
}
export default PhoneInput

View File

@ -0,0 +1,105 @@
import React, { useState } from 'react'
import { useMutation } from '@apollo/client'
import { REGISTER_NEW_CLIENT } from '@/lib/graphql'
import type { VerificationResponse } from '@/types/auth'
interface UserRegistrationProps {
phone: string
sessionId: string
onSuccess: (data: VerificationResponse) => void
onError: (error: string) => void
}
const UserRegistration: React.FC<UserRegistrationProps> = ({
phone,
sessionId,
onSuccess,
onError
}) => {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [registerClient] = useMutation<{ registerNewClient: VerificationResponse }>(REGISTER_NEW_CLIENT)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!firstName.trim()) {
onError('Введите имя')
return
}
if (!lastName.trim()) {
onError('Введите фамилию')
return
}
setIsLoading(true)
try {
const fullName = `${firstName.trim()} ${lastName.trim()}`
const { data } = await registerClient({
variables: {
phone,
name: fullName,
sessionId
}
})
if (data?.registerNewClient) {
onSuccess(data.registerNewClient)
}
} catch (error) {
onError('Не удалось зарегистрировать пользователя')
} finally {
setIsLoading(false)
}
}
return (
<div className="flex flex-col gap-5 w-full">
<form onSubmit={handleSubmit} className="flex flex-col gap-5 w-full">
<div className="flex gap-5 items-end w-full max-md:flex-col max-md:gap-4 max-sm:gap-3">
{/* Имя */}
<div className="flex flex-col gap-3 max-w-[360px] w-full">
<label className="text-2xl leading-8 text-gray-950 mb-2 font-normal font-[Onest,sans-serif]">Введите имя</label>
<input
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Иван"
className="max-w-[360px] w-full h-[62px] px-6 py-4 text-[18px] leading-[1.4] font-normal font-[Onest,sans-serif] text-neutral-500 bg-white border border-stone-300 rounded focus:outline-none"
disabled={isLoading}
required
/>
</div>
{/* Фамилия */}
<div className="flex flex-col gap-3 max-w-[360px] w-full">
<label className="text-2xl leading-8 text-gray-950 mb-2 font-normal font-[Onest,sans-serif]">Фамилию</label>
<input
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Иванов"
className="max-w-[360px] w-full h-[62px] px-6 py-4 text-[18px] leading-[1.4] font-normal font-[Onest,sans-serif] text-neutral-500 bg-white border border-stone-300 rounded focus:outline-none"
disabled={isLoading}
required
/>
</div>
{/* Кнопка */}
<button
type="submit"
disabled={isLoading || !firstName.trim() || !lastName.trim()}
className="flex items-center justify-center flex-shrink-0 bg-red-600 rounded-xl px-8 py-5 text-lg font-medium leading-5 text-white disabled:opacity-50 disabled:cursor-not-allowed h-[70px] max-sm:px-6 max-sm:py-4"
style={{
color: 'white'
}}
aria-label="Сохранить"
tabIndex={0}
>
{isLoading ? 'Сохраняем...' : 'Сохранить'}
{/* <img src="/images/Arrow_right.svg" alt="" className="ml-2 w-6 h-6" /> */}
</button>
</div>
</form>
</div>
)
}
export default UserRegistration