From 5c57c34c1ae3089a5c8752bf89d2f50f2a160077 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Mon, 28 Jul 2025 09:08:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=B0=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=BE=D0=B2=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20API=20=D1=81=20=D0=B8=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20?= =?UTF-8?q?S3.=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D1=82=D0=B8=D0=BF=20=D0=B8=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BC=D0=B5=D1=80=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0,=20?= =?UTF-8?q?=D0=B0=20=D1=82=D0=B0=D0=BA=D0=B6=D0=B5=20=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B0=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20UserSettings=20=D0=B8=20useAuth=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=84=D1=83=D0=BD?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8.=20=D0=9E=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81=20=D0=BA=D1=8D=D1=88=D0=B5?= =?UTF-8?q?=D0=BC=20Apollo=20Client=20=D0=B4=D0=BB=D1=8F=20=D0=BC=D0=B3?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=B5=D0=BD=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=B0=20=D0=B2=20=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B5.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/upload-avatar/route.ts | 114 +++++++++++++++- src/components/dashboard/user-settings.tsx | 152 +++++++++++++++++++-- src/graphql/mutations.ts | 2 + src/hooks/useAuth.ts | 10 ++ src/services/s3-service.ts | 12 +- 5 files changed, 266 insertions(+), 24 deletions(-) diff --git a/src/app/api/upload-avatar/route.ts b/src/app/api/upload-avatar/route.ts index dcc6608..4f0d66f 100644 --- a/src/app/api/upload-avatar/route.ts +++ b/src/app/api/upload-avatar/route.ts @@ -1,7 +1,119 @@ import { NextRequest, NextResponse } from 'next/server' +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' + +const s3Client = new S3Client({ + region: 'ru-1', + endpoint: 'https://s3.twcstorage.ru', + credentials: { + accessKeyId: 'I6XD2OR7YO2ZN6L6Z629', + secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ' + }, + forcePathStyle: true +}) + +const BUCKET_NAME = '617774af-sfera' + +// Разрешенные типы изображений для аватарки +const ALLOWED_IMAGE_TYPES = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/webp' +] export async function POST(request: NextRequest) { - return NextResponse.json({ message: 'Upload avatar API' }) + try { + const formData = await request.formData() + const file = formData.get('file') as File + const userId = formData.get('userId') as string + + if (!file || !userId) { + return NextResponse.json( + { error: 'File and userId are required' }, + { status: 400 } + ) + } + + // Проверяем, что файл не пустой + if (file.size === 0) { + return NextResponse.json( + { error: 'File is empty' }, + { status: 400 } + ) + } + + // Проверяем имя файла + if (!file.name || file.name.trim().length === 0) { + return NextResponse.json( + { error: 'Invalid file name' }, + { status: 400 } + ) + } + + // Проверяем тип файла + if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { + return NextResponse.json( + { error: `File type ${file.type} is not allowed. Only images are supported.` }, + { status: 400 } + ) + } + + // Ограничиваем размер файла (2MB для аватарки) + if (file.size > 2 * 1024 * 1024) { + return NextResponse.json( + { error: 'File size must be less than 2MB' }, + { status: 400 } + ) + } + + // Генерируем уникальное имя файла + const timestamp = Date.now() + const fileExtension = file.name.split('.').pop()?.toLowerCase() + const fileName = `${userId}-${timestamp}.${fileExtension}` + const key = `avatars/${fileName}` + + // Конвертируем файл в Buffer + const buffer = Buffer.from(await file.arrayBuffer()) + + // Очищаем метаданные от недопустимых символов + const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_') + const cleanUserId = userId.replace(/[^\w-]/g, '') + + // Загружаем в S3 + const command = new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: buffer, + ContentType: file.type, + ACL: 'public-read', + Metadata: { + originalname: cleanOriginalName, + uploadedby: cleanUserId, + type: 'avatar' + } + }) + + await s3Client.send(command) + + // Возвращаем URL файла и метаданные + const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}` + + return NextResponse.json({ + success: true, + url, + key, + originalName: file.name, + size: file.size, + type: file.type + }) + + } catch (error) { + console.error('Error uploading avatar:', error) + return NextResponse.json( + { error: 'Failed to upload avatar' }, + { status: 500 } + ) + } } export async function DELETE(request: NextRequest) { diff --git a/src/components/dashboard/user-settings.tsx b/src/components/dashboard/user-settings.tsx index a7f7c4d..1124813 100644 --- a/src/components/dashboard/user-settings.tsx +++ b/src/components/dashboard/user-settings.tsx @@ -3,6 +3,8 @@ import { useAuth } from '@/hooks/useAuth' import { useMutation } from '@apollo/client' import { UPDATE_USER_PROFILE, UPDATE_ORGANIZATION_BY_INN } from '@/graphql/mutations' +import { GET_ME } from '@/graphql/queries' +import { apolloClient } from '@/lib/apollo-client' import { formatPhone } from '@/lib/utils' import S3Service from '@/services/s3-service' import { Card } from '@/components/ui/card' @@ -35,12 +37,12 @@ import { Settings, Camera } from 'lucide-react' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import Image from 'next/image' export function UserSettings() { const { getSidebarMargin } = useSidebar() - const { user } = useAuth() + const { user, updateUser } = useAuth() const [updateUserProfile, { loading: isSaving }] = useMutation(UPDATE_USER_PROFILE) const [updateOrganizationByInn, { loading: isUpdatingOrganization }] = useMutation(UPDATE_ORGANIZATION_BY_INN) const [isEditing, setIsEditing] = useState(false) @@ -48,6 +50,9 @@ export function UserSettings() { const [partnerLink, setPartnerLink] = useState('') const [isGenerating, setIsGenerating] = useState(false) const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) + const [localAvatarUrl, setLocalAvatarUrl] = useState(null) + const phoneInputRef = useRef(null) + const whatsappInputRef = useRef(null) // Инициализируем данные из пользователя и организации const [formData, setFormData] = useState({ @@ -127,10 +132,10 @@ export function UserSettings() { } setFormData({ - orgPhone: orgPhone, + orgPhone: orgPhone || '+7', managerName: user?.managerName || '', telegram: customContacts?.telegram || '', - whatsapp: customContacts?.whatsapp || '', + whatsapp: customContacts?.whatsapp || '+7', email: email, orgName: org.name || '', address: org.address || '', @@ -291,27 +296,73 @@ export function UserSettings() { try { const avatarUrl = await S3Service.uploadAvatar(file, user.id) + // Сразу обновляем локальное состояние для мгновенного отображения + setLocalAvatarUrl(avatarUrl) + // Обновляем аватар пользователя через GraphQL const result = await updateUserProfile({ variables: { input: { avatar: avatarUrl } + }, + update: (cache, { data }) => { + if (data?.updateUserProfile?.success) { + // Обновляем кеш Apollo Client + try { + const existingData: any = cache.readQuery({ query: GET_ME }) + if (existingData?.me) { + cache.writeQuery({ + query: GET_ME, + data: { + me: { + ...existingData.me, + avatar: avatarUrl + } + } + }) + } + } catch (error) { + console.log('Cache update error:', error) + } + } } }) if (result.data?.updateUserProfile?.success) { - setSaveMessage({ type: 'success', text: 'Аватар успешно обновлен! Обновляем страницу...' }) + setSaveMessage({ type: 'success', text: 'Аватар успешно обновлен!' }) + + // Обновляем локальное состояние в useAuth для мгновенного отображения в сайдбаре + updateUser({ avatar: avatarUrl }) + + // Принудительно обновляем Apollo Client кеш + await apolloClient.refetchQueries({ + include: [GET_ME] + }) + + // Очищаем input файла + if (event.target) { + event.target.value = '' + } + + // Очищаем сообщение через 3 секунды setTimeout(() => { - window.location.reload() - }, 1000) + setSaveMessage(null) + }, 3000) } else { - throw new Error('Failed to update avatar') + throw new Error(result.data?.updateUserProfile?.message || 'Failed to update avatar') } } catch (error) { console.error('Error uploading avatar:', error) - setSaveMessage({ type: 'error', text: 'Ошибка при загрузке аватара' }) + // Сбрасываем локальное состояние при ошибке + setLocalAvatarUrl(null) + const errorMessage = error instanceof Error ? error.message : 'Ошибка при загрузке аватара' + setSaveMessage({ type: 'error', text: errorMessage }) + // Очищаем сообщение об ошибке через 5 секунд + setTimeout(() => { + setSaveMessage(null) + }, 5000) } finally { setIsUploadingAvatar(false) } @@ -324,14 +375,69 @@ export function UserSettings() { } const formatPhoneInput = (value: string) => { - const cleaned = value.replace(/\D/g, '') - if (cleaned.length <= 1) return cleaned + // Убираем все нецифровые символы + const digitsOnly = value.replace(/\D/g, '') + + // Если строка пустая, возвращаем +7 + if (!digitsOnly) return '+7' + + // Если пользователь ввел первую цифру не 7, добавляем 7 перед ней + let cleaned = digitsOnly + if (!cleaned.startsWith('7')) { + cleaned = '7' + cleaned + } + + // Ограничиваем до 11 цифр (7 + 10 цифр номера) + cleaned = cleaned.slice(0, 11) + + // Форматируем в зависимости от длины + if (cleaned.length <= 1) return '+7' if (cleaned.length <= 4) return `+7 (${cleaned.slice(1)}` if (cleaned.length <= 7) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4)}` if (cleaned.length <= 9) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7)}` + if (cleaned.length <= 11) return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9)}` + return `+7 (${cleaned.slice(1, 4)}) ${cleaned.slice(4, 7)}-${cleaned.slice(7, 9)}-${cleaned.slice(9, 11)}` } + const handlePhoneInputChange = (field: string, value: string, inputRef?: React.RefObject) => { + const currentInput = inputRef?.current + const currentCursorPosition = currentInput?.selectionStart || 0 + const currentValue = formData[field as keyof typeof formData] as string || '' + + // Если пользователь пытается удалить +7, предотвращаем это + if (value.length < 2) { + value = '+7' + } + + const formatted = formatPhoneInput(value) + setFormData(prev => ({ ...prev, [field]: formatted })) + + // Вычисляем новую позицию курсора + if (currentInput) { + setTimeout(() => { + let newCursorPosition = currentCursorPosition + + // Если длина увеличилась (добавили цифру), передвигаем курсор + if (formatted.length > currentValue.length) { + newCursorPosition = currentCursorPosition + (formatted.length - currentValue.length) + } + // Если длина уменьшилась (удалили цифру), оставляем курсор на месте или сдвигаем немного + else if (formatted.length < currentValue.length) { + newCursorPosition = Math.min(currentCursorPosition, formatted.length) + } + + // Не позволяем курсору находиться перед +7 + newCursorPosition = Math.max(newCursorPosition, 2) + + // Ограничиваем курсор длиной строки + newCursorPosition = Math.min(newCursorPosition, formatted.length) + + currentInput.setSelectionRange(newCursorPosition, newCursorPosition) + }, 0) + } + } + const formatTelegram = (value: string) => { // Убираем все символы кроме букв, цифр, _ и @ let cleaned = value.replace(/[^a-zA-Z0-9_@]/g, '') @@ -665,9 +771,9 @@ export function UserSettings() {
- {user?.avatar ? ( + {(localAvatarUrl || user?.avatar) ? ( Аватар handleInputChange('orgPhone', e.target.value)} + onChange={(e) => handlePhoneInputChange('orgPhone', e.target.value, phoneInputRef)} + onKeyDown={(e) => { + // Предотвращаем удаление +7 + if ((e.key === 'Backspace' || e.key === 'Delete') && + phoneInputRef.current?.selectionStart <= 2) { + e.preventDefault() + } + }} placeholder="+7 (999) 999-99-99" readOnly={!isEditing} className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${ @@ -789,8 +903,16 @@ export function UserSettings() { WhatsApp handleInputChange('whatsapp', e.target.value)} + onChange={(e) => handlePhoneInputChange('whatsapp', e.target.value, whatsappInputRef)} + onKeyDown={(e) => { + // Предотвращаем удаление +7 + if ((e.key === 'Backspace' || e.key === 'Delete') && + whatsappInputRef.current?.selectionStart <= 2) { + e.preventDefault() + } + }} placeholder="+7 (999) 999-99-99" readOnly={!isEditing} className={`glass-input text-white placeholder:text-white/40 h-10 read-only:opacity-70 ${ diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 04d7715..f80c462 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -201,6 +201,8 @@ export const UPDATE_USER_PROFILE = gql` user { id phone + avatar + managerName organization { id inn diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 8ab6984..ed5b383 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -85,6 +85,7 @@ interface UseAuthReturn { isAuthenticated: boolean isLoading: boolean checkAuth: () => Promise + updateUser: (updatedUser: Partial) => void logout: () => void } @@ -99,6 +100,14 @@ export const useAuth = (): UseAuthReturn => { const [isCheckingAuth, setIsCheckingAuth] = useState(false) // Защита от повторных вызовов const { refreshApolloClient } = useApolloRefresh() + // Функция для обновления данных пользователя + const updateUser = (updatedUser: Partial) => { + setUser(currentUser => { + if (!currentUser) return currentUser + return { ...currentUser, ...updatedUser } + }) + } + const [sendSmsCodeMutation] = useMutation(SEND_SMS_CODE) const [verifySmsCodeMutation] = useMutation(VERIFY_SMS_CODE) const [registerFulfillmentMutation] = useMutation(REGISTER_FULFILLMENT_ORGANIZATION) @@ -363,6 +372,7 @@ export const useAuth = (): UseAuthReturn => { isAuthenticated, isLoading, checkAuth, + updateUser, logout } } \ No newline at end of file diff --git a/src/services/s3-service.ts b/src/services/s3-service.ts index 26f8ef3..adf26df 100644 --- a/src/services/s3-service.ts +++ b/src/services/s3-service.ts @@ -27,25 +27,21 @@ export class S3Service { } static async uploadAvatar(file: File, userId: string): Promise { - const fileName = `${userId}-${Date.now()}.${file.name.split('.').pop()}` - const key = `avatars/${fileName}` - try { // Создаем FormData для загрузки const formData = new FormData() formData.append('file', file) - formData.append('key', key) - formData.append('bucket', s3Config.bucket) + formData.append('userId', userId) - // Пока используем простую загрузку через наш API - // Позже можно будет сделать прямую загрузку в S3 + // Загружаем через наш API роут const response = await fetch('/api/upload-avatar', { method: 'POST', body: formData }) if (!response.ok) { - throw new Error('Failed to upload avatar') + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to upload avatar') } const result = await response.json()