Добавлены новые зависимости, обновлены стили и улучшена структура проекта. Обновлен README с описанием функционала и технологий. Реализована анимация и адаптивный дизайн. Настроена авторизация с использованием Apollo Client.

This commit is contained in:
Bivekich
2025-07-16 18:00:41 +03:00
parent d260749bc9
commit 823ef9a28c
69 changed files with 15539 additions and 210 deletions

View File

@ -0,0 +1,289 @@
import axios from 'axios'
export interface DaDataCompany {
value: string
unrestricted_value: string
data: {
kpp?: string
management?: {
name?: string
post?: string
}
hid: string
type: string
state?: {
status?: string
actuality_date?: number
registration_date?: number
liquidation_date?: number
}
opf?: {
code?: string
full?: string
short?: string
}
name: {
full_with_opf?: string
short_with_opf?: string
full?: string
short?: string
}
inn: string
ogrn?: string
ogrn_date?: number
okpo?: string
okato?: string
oktmo?: string
okved?: string
employee_count?: number
phones?: object[]
emails?: object[]
finance?: {
revenue?: number
tax_system?: string
}
address?: {
value?: string
unrestricted_value?: string
data?: {
region_with_type?: string
city_with_type?: string
}
}
}
}
interface DaDataResponse {
suggestions: DaDataCompany[]
}
export interface OrganizationData {
inn: string
kpp?: string
name: string
fullName: string
address: string
addressFull?: string
ogrn?: string
ogrnDate?: Date
isActive: boolean
type: 'FULFILLMENT' | 'SELLER'
// Статус организации
status?: string
actualityDate?: Date
registrationDate?: Date
liquidationDate?: Date
// Руководитель
managementName?: string
managementPost?: string
// ОПФ
opfCode?: string
opfFull?: string
opfShort?: string
// Коды статистики
okato?: string
oktmo?: string
okpo?: string
okved?: string
// Контакты
phones?: object[]
emails?: object[]
// Финансовые данные
employeeCount?: number
revenue?: bigint
taxSystem?: string
rawData: DaDataCompany
}
export class DaDataService {
private apiKey: string
private apiUrl: string
constructor() {
this.apiKey = process.env.DADATA_API_KEY!
this.apiUrl = process.env.DADATA_API_URL!
if (!this.apiKey || !this.apiUrl) {
throw new Error('DaData API credentials not configured')
}
}
/**
* Получает информацию об организации по ИНН
*/
async getOrganizationByInn(inn: string): Promise<OrganizationData | null> {
try {
const response = await axios.post<DaDataResponse>(
`${this.apiUrl}/findById/party`,
{
query: inn,
count: 1
},
{
headers: {
'Authorization': `Token ${this.apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
}
)
if (!response.data?.suggestions?.length) {
return null
}
const company = response.data.suggestions[0]
// Определяем тип организации на основе ОПФ
const organizationType = this.determineOrganizationType(company)
return {
inn: company.data.inn,
kpp: company.data.kpp || undefined,
name: company.data.name.short || company.data.name.full || 'Название не указано',
fullName: company.data.name.full_with_opf || '',
address: company.data.address?.value || '',
addressFull: company.data.address?.unrestricted_value || undefined,
ogrn: company.data.ogrn || undefined,
ogrnDate: this.parseDate(company.data.ogrn_date),
// Статус организации
status: company.data.state?.status,
actualityDate: this.parseDate(company.data.state?.actuality_date),
registrationDate: this.parseDate(company.data.state?.registration_date),
liquidationDate: this.parseDate(company.data.state?.liquidation_date),
// Руководитель
managementName: company.data.management?.name,
managementPost: company.data.management?.post,
// ОПФ
opfCode: company.data.opf?.code,
opfFull: company.data.opf?.full,
opfShort: company.data.opf?.short,
// Коды статистики
okato: company.data.okato,
oktmo: company.data.oktmo,
okpo: company.data.okpo,
okved: company.data.okved,
// Контакты
phones: company.data.phones || undefined,
emails: company.data.emails || undefined,
// Финансовые данные
employeeCount: company.data.employee_count || undefined,
revenue: company.data.finance?.revenue ? BigInt(company.data.finance.revenue) : undefined,
taxSystem: company.data.finance?.tax_system || undefined,
isActive: company.data.state?.status === 'ACTIVE',
type: organizationType,
rawData: company
}
} catch (error) {
console.error('Error fetching organization data from DaData:', error)
return null
}
}
/**
* Безопасно парсит дату из timestamp, возвращает undefined для некорректных дат
*/
private parseDate(timestamp?: number): Date | undefined {
if (!timestamp) return undefined
try {
const date = new Date(timestamp * 1000)
// Проверяем, что дата валидна и разумна (между 1900 и 2100 годами)
if (isNaN(date.getTime()) || date.getFullYear() < 1900 || date.getFullYear() > 2100) {
return undefined
}
return date
} catch {
return undefined
}
}
/**
* Определяет тип организации на основе ОПФ (организационно-правовая форма)
*/
private determineOrganizationType(company: DaDataCompany): 'FULFILLMENT' | 'SELLER' {
const opfCode = company.data.opf?.code
// Индивидуальные предприниматели чаще работают как селлеры
if (company.data.type === 'INDIVIDUAL' || opfCode === '50102') {
return 'SELLER'
}
// ООО, АО и другие юридические лица чаще работают с фулфилментом
return 'FULFILLMENT'
}
/**
* Валидирует ИНН по контрольной сумме
*/
validateInn(inn: string): boolean {
const digits = inn.replace(/\D/g, '')
if (digits.length !== 10 && digits.length !== 12) {
return false
}
// Проверяем контрольную сумму для 10-значного ИНН (юридические лица)
if (digits.length === 10) {
const checksum = this.calculateInn10Checksum(digits)
return checksum === parseInt(digits[9])
}
// Проверяем контрольную сумму для 12-значного ИНН (ИП)
if (digits.length === 12) {
const checksum1 = this.calculateInn12Checksum1(digits)
const checksum2 = this.calculateInn12Checksum2(digits)
return checksum1 === parseInt(digits[10]) && checksum2 === parseInt(digits[11])
}
return false
}
private calculateInn10Checksum(inn: string): number {
const weights = [2, 4, 10, 3, 5, 9, 4, 6, 8]
let sum = 0
for (let i = 0; i < 9; i++) {
sum += parseInt(inn[i]) * weights[i]
}
return sum % 11 % 10
}
private calculateInn12Checksum1(inn: string): number {
const weights = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
let sum = 0
for (let i = 0; i < 10; i++) {
sum += parseInt(inn[i]) * weights[i]
}
return sum % 11 % 10
}
private calculateInn12Checksum2(inn: string): number {
const weights = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
let sum = 0
for (let i = 0; i < 11; i++) {
sum += parseInt(inn[i]) * weights[i]
}
return sum % 11 % 10
}
}

View File

@ -0,0 +1,223 @@
import axios from 'axios'
export interface MarketplaceValidationResult {
isValid: boolean
message: string
data?: {
sellerId?: string
sellerName?: string
[key: string]: unknown
}
}
export interface WildberriesSellerInfo {
id: number
name: string
inn: string
kpp?: string
}
export interface OzonSellerInfo {
id: number
name: string
status: string
}
export class MarketplaceService {
private wbApiUrl: string
private ozonApiUrl: string
constructor() {
this.wbApiUrl = process.env.WILDBERRIES_API_URL || 'https://common-api.wildberries.ru'
this.ozonApiUrl = process.env.OZON_API_URL || 'https://api-seller.ozon.ru'
}
/**
* Валидирует API ключ Wildberries
*/
async validateWildberriesApiKey(apiKey: string): Promise<MarketplaceValidationResult> {
try {
// Пытаемся получить информацию о продавце
const response = await axios.get(
`${this.wbApiUrl}/api/v1/seller-info`,
{
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json'
},
timeout: 10000
}
)
if (response.status === 200 && response.data) {
const sellerData = response.data
return {
isValid: true,
message: 'API ключ Wildberries валиден',
data: {
sellerId: sellerData.id?.toString(),
sellerName: sellerData.name || sellerData.supplierName,
inn: sellerData.inn
}
}
}
return {
isValid: false,
message: 'Не удалось получить информацию о продавце Wildberries'
}
} catch (error) {
console.error('Wildberries API validation error:', error)
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
return {
isValid: false,
message: 'Неверный API ключ Wildberries'
}
}
if (error.response?.status === 403) {
return {
isValid: false,
message: 'Доступ запрещён. Проверьте права API ключа Wildberries'
}
}
if (error.code === 'ECONNABORTED') {
return {
isValid: false,
message: 'Превышено время ожидания ответа от Wildberries API'
}
}
}
return {
isValid: false,
message: 'Ошибка при проверке API ключа Wildberries'
}
}
}
/**
* Валидирует API ключ Ozon
*/
async validateOzonApiKey(apiKey: string, clientId?: string): Promise<MarketplaceValidationResult> {
try {
// Для Ozon нужен Client-Id
if (!clientId) {
return {
isValid: false,
message: 'Для Ozon API требуется Client-Id'
}
}
// Пытаемся получить информацию о продавце
const response = await axios.post(
`${this.ozonApiUrl}/v1/seller/info`,
{},
{
headers: {
'Api-Key': apiKey,
'Client-Id': clientId,
'Content-Type': 'application/json'
},
timeout: 10000
}
)
if (response.status === 200 && response.data?.result) {
const sellerData = response.data.result as OzonSellerInfo
return {
isValid: true,
message: 'API ключ Ozon валиден',
data: {
sellerId: sellerData.id?.toString(),
sellerName: sellerData.name,
status: sellerData.status
}
}
}
return {
isValid: false,
message: 'Не удалось получить информацию о продавце Ozon'
}
} catch (error) {
console.error('Ozon API validation error:', error)
if (axios.isAxiosError(error)) {
if (error.response?.status === 401) {
return {
isValid: false,
message: 'Неверный API ключ или Client-Id для Ozon'
}
}
if (error.response?.status === 403) {
return {
isValid: false,
message: 'Доступ запрещён. Проверьте права API ключа Ozon'
}
}
if (error.code === 'ECONNABORTED') {
return {
isValid: false,
message: 'Превышено время ожидания ответа от Ozon API'
}
}
}
return {
isValid: false,
message: 'Ошибка при проверке API ключа Ozon'
}
}
}
/**
* Общий метод валидации API ключа по типу маркетплейса
*/
async validateApiKey(
marketplace: 'WILDBERRIES' | 'OZON',
apiKey: string,
clientId?: string
): Promise<MarketplaceValidationResult> {
switch (marketplace) {
case 'WILDBERRIES':
return this.validateWildberriesApiKey(apiKey)
case 'OZON':
return this.validateOzonApiKey(apiKey, clientId)
default:
return {
isValid: false,
message: 'Неподдерживаемый тип маркетплейса'
}
}
}
/**
* Проверяет формат API ключа перед отправкой запроса
*/
validateApiKeyFormat(marketplace: 'WILDBERRIES' | 'OZON', apiKey: string): boolean {
if (!apiKey || typeof apiKey !== 'string') {
return false
}
switch (marketplace) {
case 'WILDBERRIES':
// Wildberries API ключи обычно содержат буквы, цифры и дефисы
return /^[a-zA-Z0-9\-_]{10,}$/.test(apiKey)
case 'OZON':
// Ozon API ключи обычно содержат буквы, цифры и дефисы
return /^[a-zA-Z0-9\-_]{10,}$/.test(apiKey)
default:
return false
}
}
}

View File

@ -0,0 +1,78 @@
interface S3Config {
accessKeyId: string
secretAccessKey: string
region: string
endpoint: string
bucket: string
}
const s3Config: S3Config = {
accessKeyId: 'I6XD2OR7YO2ZN6L6Z629',
secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ',
region: 'ru-1',
endpoint: 'https://s3.twcstorage.ru',
bucket: '617774af-sfera'
}
export class S3Service {
private static async createSignedUrl(fileName: string, fileType: string): Promise<string> {
// Для простоты пока используем прямую загрузку через fetch
// В продакшене лучше генерировать signed URLs на backend
const timestamp = Date.now()
const key = `avatars/${timestamp}-${fileName}`
return key
}
static async uploadAvatar(file: File, userId: string): Promise<string> {
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)
// Пока используем простую загрузку через наш API
// Позже можно будет сделать прямую загрузку в S3
const response = await fetch('/api/upload-avatar', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error('Failed to upload avatar')
}
const result = await response.json()
return result.url
} catch (error) {
console.error('Error uploading avatar:', error)
throw error
}
}
static getAvatarUrl(key: string): string {
return `${s3Config.endpoint}/${s3Config.bucket}/${key}`
}
static async deleteAvatar(key: string): Promise<void> {
try {
await fetch('/api/delete-avatar', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key })
})
} catch (error) {
console.error('Error deleting avatar:', error)
throw error
}
}
}
export default S3Service

243
src/services/sms-service.ts Normal file
View File

@ -0,0 +1,243 @@
import axios from 'axios'
import { prisma } from '@/lib/prisma'
export interface SmsResponse {
success: boolean
message: string
}
export interface SmsVerificationResponse {
success: boolean
message: string
}
export class SmsService {
private email: string
private apiKey: string
private isDevelopment: boolean
constructor() {
this.email = process.env.SMS_AERO_EMAIL!
this.apiKey = process.env.SMS_AERO_API_KEY!
this.isDevelopment = process.env.NODE_ENV === 'development' || process.env.SMS_DEV_MODE === 'true'
if (!this.isDevelopment && (!this.email || !this.apiKey)) {
throw new Error('SMS Aero credentials not configured')
}
}
private generateSmsCode(): string {
if (this.isDevelopment) {
return '1234'
}
return Math.floor(1000 + Math.random() * 9000).toString()
}
private validatePhoneNumber(phone: string): boolean {
const phoneRegex = /^7\d{10}$/
return phoneRegex.test(phone)
}
private formatPhoneNumber(phone: string): string {
// Убираем все символы кроме цифр
const cleanPhone = phone.replace(/\D/g, '')
// Если номер начинается с 8, заменяем на 7
if (cleanPhone.startsWith('8')) {
return '7' + cleanPhone.slice(1)
}
// Если номер начинается с +7, убираем +
if (cleanPhone.startsWith('7')) {
return cleanPhone
}
// Если номер без кода страны, добавляем 7
if (cleanPhone.length === 10) {
return '7' + cleanPhone
}
return cleanPhone
}
async sendSmsCode(phone: string): Promise<SmsResponse> {
try {
const formattedPhone = this.formatPhoneNumber(phone)
if (!this.validatePhoneNumber(formattedPhone)) {
return {
success: false,
message: 'Неверный формат номера телефона'
}
}
const code = this.generateSmsCode()
const expiresAt = new Date(Date.now() + 5 * 60 * 1000) // 5 минут
// Удаляем старые коды для этого номера
await prisma.smsCode.deleteMany({
where: { phone: formattedPhone }
})
// Сохраняем код в базе данных
await prisma.smsCode.create({
data: {
code,
phone: formattedPhone,
expiresAt,
attempts: 0,
maxAttempts: 3
}
})
// В режиме разработки не отправляем SMS
if (this.isDevelopment) {
console.log(`Development mode: SMS code ${code} for phone ${formattedPhone}`)
return {
success: true,
message: 'SMS код отправлен успешно (режим разработки)'
}
}
// Отправляем SMS через SMS Aero API с HTTP Basic Auth
const response = await axios.get(
`https://gate.smsaero.ru/v2/sms/send`,
{
params: {
number: formattedPhone,
text: `Код подтверждения SferaV: ${code}`,
sign: 'SMS Aero'
},
auth: {
username: this.email,
password: this.apiKey
},
headers: {
'Accept': 'application/json'
}
}
)
console.log('SMS Aero response:', response.data)
if (response.data.success) {
return {
success: true,
message: 'SMS код отправлен успешно'
}
} else {
console.error('SMS Aero API error:', response.data)
return {
success: false,
message: response.data.message || 'Ошибка при отправке SMS'
}
}
} catch (error: unknown) {
console.error('Error sending SMS:', error)
// Детальная информация об ошибке
if (axios.isAxiosError(error)) {
console.error('Response status:', error.response?.status)
console.error('Response data:', error.response?.data)
if (error.response?.status === 401) {
return {
success: false,
message: 'Ошибка авторизации SMS API. Проверьте настройки.'
}
}
}
return {
success: false,
message: 'Ошибка при отправке SMS'
}
}
}
async verifySmsCode(phone: string, code: string): Promise<SmsVerificationResponse> {
try {
const formattedPhone = this.formatPhoneNumber(phone)
if (!this.validatePhoneNumber(formattedPhone)) {
return {
success: false,
message: 'Неверный формат номера телефона'
}
}
// Ищем активный код для этого номера
const smsCode = await prisma.smsCode.findFirst({
where: {
phone: formattedPhone,
isUsed: false,
expiresAt: {
gte: new Date()
}
},
orderBy: {
createdAt: 'desc'
}
})
if (!smsCode) {
return {
success: false,
message: 'Код не найден или истек'
}
}
// Проверяем количество попыток
if (smsCode.attempts >= smsCode.maxAttempts) {
// Помечаем код как использованный при превышении лимита попыток
await prisma.smsCode.update({
where: { id: smsCode.id },
data: { isUsed: true }
})
return {
success: false,
message: 'Превышено количество попыток ввода кода'
}
}
// Проверяем правильность кода
if (smsCode.code !== code) {
// Увеличиваем счетчик попыток при неправильном коде
await prisma.smsCode.update({
where: { id: smsCode.id },
data: { attempts: smsCode.attempts + 1 }
})
const remainingAttempts = smsCode.maxAttempts - smsCode.attempts - 1
return {
success: false,
message: remainingAttempts > 0
? `Неверный код. Осталось попыток: ${remainingAttempts}`
: 'Неверный код. Превышено количество попыток'
}
}
// Код правильный - помечаем как использованный
await prisma.smsCode.update({
where: { id: smsCode.id },
data: { isUsed: true }
})
return {
success: true,
message: 'Код подтвержден успешно'
}
} catch (error) {
console.error('Error verifying SMS code:', error)
return {
success: false,
message: 'Ошибка при проверке кода'
}
}
}
}