add interview
This commit is contained in:
parent
056f70a1ad
commit
f31af7ef11
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880
|
110
README.md
110
README.md
@ -1 +1,109 @@
|
||||
# hr-ai
|
||||
# HR AI Frontend
|
||||
|
||||
Современная платформа для поиска работы с искусственным интеллектом, построенная на Next.js и TypeScript.
|
||||
|
||||
## Возможности
|
||||
|
||||
- 📋 Просмотр списка вакансий с поиском и фильтрацией
|
||||
- 🔍 Детальная информация о каждой вакансии
|
||||
- 📄 Загрузка резюме с уведомлением о подготовке сессии собеседования
|
||||
- 🔐 Авторизация через cookie-сессии (без JWT)
|
||||
- 🎨 Современный и адаптивный дизайн
|
||||
- ⚡ Быстрая загрузка и отзывчивый интерфейс
|
||||
|
||||
## Технологический стек
|
||||
|
||||
- **Framework**: Next.js 14 (App Router)
|
||||
- **Language**: TypeScript
|
||||
- **Styling**: Tailwind CSS
|
||||
- **Icons**: Lucide React
|
||||
- **HTTP Client**: Fetch API с cookie-авторизацией
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
1. Установите зависимости:
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
2. Создайте файл `.env.local` (уже создан):
|
||||
```env
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
3. Запустите development сервер:
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
4. Откройте [http://localhost:3000](http://localhost:3000) в браузере
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── globals.css # Глобальные стили
|
||||
│ ├── layout.tsx # Корневой layout
|
||||
│ ├── page.tsx # Главная страница (список вакансий)
|
||||
│ └── vacancy/[id]/ # Страница детальной информации о вакансии
|
||||
├── components/ # React компоненты
|
||||
│ └── ResumeUploadForm.tsx
|
||||
├── lib/ # Утилиты и API клиент
|
||||
│ └── api-client.ts
|
||||
├── types/ # TypeScript типы
|
||||
│ └── api.ts
|
||||
└── public/ # Статические файлы
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
Приложение интегрируется с HR AI Backend API:
|
||||
|
||||
### Основные эндпоинты:
|
||||
- `GET /api/v1/vacancies/` - Получение списка вакансий
|
||||
- `GET /api/v1/vacancies/{id}` - Получение вакансии по ID
|
||||
- `POST /api/v1/resumes/` - Загрузка резюме
|
||||
- `GET /api/v1/sessions/current` - Получение информации о сессии
|
||||
|
||||
### Авторизация:
|
||||
Все запросы выполняются с `credentials: 'include'` для работы с cookie-сессиями.
|
||||
|
||||
## Особенности реализации
|
||||
|
||||
### Загрузка резюме
|
||||
- Поддержка файлов: PDF, DOC, DOCX, TXT
|
||||
- Максимальный размер файла: 10 МБ
|
||||
- Валидация формы перед отправкой
|
||||
- Уведомление об успешной отправке
|
||||
|
||||
### Поиск вакансий
|
||||
- Поиск по названию вакансии
|
||||
- Фильтрация активных вакансий
|
||||
- Красивое отображение карточек вакансий
|
||||
|
||||
### Детальная страница вакансии
|
||||
- Полная информация о вакансии
|
||||
- Контактные данные
|
||||
- Форма для отклика прямо на странице
|
||||
|
||||
## Скрипты
|
||||
|
||||
- `yarn dev` - Запуск в режиме разработки
|
||||
- `yarn build` - Сборка продакшн версии
|
||||
- `yarn start` - Запуск продакшн версии
|
||||
- `yarn lint` - Проверка кода с ESLint
|
||||
|
||||
## Стилизация
|
||||
|
||||
Проект использует Tailwind CSS для стилизации с кастомной темой:
|
||||
- Основной цвет: Blue (primary-*)
|
||||
- Адаптивный дизайн для всех устройств
|
||||
- Современные компоненты с hover эффектами
|
||||
|
||||
## Будущие улучшения
|
||||
|
||||
- [ ] Пагинация для списка вакансий
|
||||
- [ ] Расширенные фильтры (по зарплате, опыту, локации)
|
||||
- [ ] Избранные вакансии
|
||||
- [ ] История откликов
|
||||
- [ ] Уведомления в реальном времени
|
102
app/interview/[id]/page.tsx
Normal file
102
app/interview/[id]/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import InterviewSession from '@/components/InterviewSession'
|
||||
import { useValidateInterview } from '@/hooks/useResume'
|
||||
import { ArrowLeft, AlertCircle, Loader } from 'lucide-react'
|
||||
|
||||
export default function InterviewPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resumeId = parseInt(params.id as string)
|
||||
|
||||
const { data: validationData, isLoading, error } = useValidateInterview(resumeId)
|
||||
|
||||
const handleInterviewEnd = () => {
|
||||
// Перенаправляем обратно к вакансии или на главную страницу
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
|
||||
<Loader className="h-12 w-12 text-blue-600 animate-spin mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Проверяем готовность к собеседованию
|
||||
</h2>
|
||||
<p className="text-gray-600 text-center max-w-md">
|
||||
Пожалуйста, подождите...
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !validationData?.can_interview) {
|
||||
const errorMessage = error?.response?.status === 404
|
||||
? 'Резюме не найдено'
|
||||
: error?.response?.status === 400
|
||||
? 'Резюме еще не готово к собеседованию'
|
||||
: validationData?.message || 'Собеседование недоступно'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
|
||||
<AlertCircle className="h-12 w-12 text-red-600 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Собеседование недоступно
|
||||
</h2>
|
||||
<p className="text-gray-600 text-center max-w-md mb-6">
|
||||
{errorMessage}
|
||||
</p>
|
||||
<div className="space-x-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
На главную
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Navigation Header */}
|
||||
<div className="bg-white border-b border-gray-200 px-4 py-3">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="inline-flex items-center text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 mr-2" />
|
||||
Вернуться назад
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
HR Собеседование
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Резюме #{resumeId}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-24"></div> {/* Spacer for centering */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interview Session */}
|
||||
<InterviewSession
|
||||
resumeId={resumeId}
|
||||
onEnd={handleInterviewEnd}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
311
components/InterviewSession.tsx
Normal file
311
components/InterviewSession.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Room, RoomEvent, Track, RemoteTrack, LocalTrack } from 'livekit-client'
|
||||
import { useTracks, RoomAudioRenderer, LiveKitRoom, useRoomContext } from '@livekit/components-react'
|
||||
import { useInterviewToken } from '@/hooks/useResume'
|
||||
import {
|
||||
Mic,
|
||||
MicOff,
|
||||
Phone,
|
||||
PhoneOff,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Loader,
|
||||
CheckCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
interface InterviewSessionProps {
|
||||
resumeId: number
|
||||
onEnd?: () => void
|
||||
}
|
||||
|
||||
interface InterviewState {
|
||||
isConnected: boolean
|
||||
isRecording: boolean
|
||||
isMuted: boolean
|
||||
isSpeaking: boolean
|
||||
connectionState: 'connecting' | 'connected' | 'disconnected' | 'failed'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function InterviewSession({ resumeId, onEnd }: InterviewSessionProps) {
|
||||
const { data: tokenData, isLoading, error } = useInterviewToken(resumeId, true)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
|
||||
<Loader className="h-12 w-12 text-blue-600 animate-spin mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Подключаемся к собеседованию
|
||||
</h2>
|
||||
<p className="text-gray-600 text-center max-w-md">
|
||||
Пожалуйста, подождите, мы подготавливаем для вас сессию
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !tokenData?.token) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
|
||||
<AlertCircle className="h-12 w-12 text-red-600 mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Ошибка подключения
|
||||
</h2>
|
||||
<p className="text-gray-600 text-center max-w-md mb-6">
|
||||
Не удалось подключиться к сессии собеседования
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Вернуться назад
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<LiveKitRoom
|
||||
token={tokenData.token}
|
||||
serverUrl={tokenData.serverUrl || process.env.NEXT_PUBLIC_LIVEKIT_URL!}
|
||||
audio={true}
|
||||
video={false}
|
||||
onConnected={() => console.log('Connected to LiveKit')}
|
||||
onDisconnected={() => console.log('Disconnected from LiveKit')}
|
||||
onError={(error) => {
|
||||
console.error('LiveKit error:', error)
|
||||
}}
|
||||
>
|
||||
<InterviewRoom resumeId={resumeId} onEnd={onEnd} />
|
||||
</LiveKitRoom>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InterviewRoom({ resumeId, onEnd }: InterviewSessionProps) {
|
||||
const room = useRoomContext()
|
||||
const tracks = useTracks([Track.Source.Microphone, Track.Source.ScreenShare], {
|
||||
onlySubscribed: false,
|
||||
})
|
||||
|
||||
const [state, setState] = useState<InterviewState>({
|
||||
isConnected: false,
|
||||
isRecording: false,
|
||||
isMuted: false,
|
||||
isSpeaking: false,
|
||||
connectionState: 'connecting'
|
||||
})
|
||||
|
||||
const [interviewStarted, setInterviewStarted] = useState(false)
|
||||
const [currentQuestion, setCurrentQuestion] = useState('')
|
||||
const [aiSpeaking, setAiSpeaking] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
|
||||
const handleConnected = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: true,
|
||||
connectionState: 'connected'
|
||||
}))
|
||||
// Начинаем собеседование
|
||||
startInterview()
|
||||
}
|
||||
|
||||
const handleDisconnected = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
connectionState: 'disconnected'
|
||||
}))
|
||||
}
|
||||
|
||||
const handleDataReceived = (payload: Uint8Array, participant: any) => {
|
||||
try {
|
||||
const message = JSON.parse(new TextDecoder().decode(payload))
|
||||
handleServerMessage(message)
|
||||
} catch (error) {
|
||||
console.error('Error parsing server message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.Connected, handleConnected)
|
||||
room.on(RoomEvent.Disconnected, handleDisconnected)
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.Connected, handleConnected)
|
||||
room.off(RoomEvent.Disconnected, handleDisconnected)
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const startInterview = async () => {
|
||||
if (!room) return
|
||||
|
||||
try {
|
||||
// Отправляем сигнал серверу о начале собеседования
|
||||
await room.localParticipant.publishData(
|
||||
new TextEncoder().encode(JSON.stringify({
|
||||
type: 'start_interview',
|
||||
resumeId
|
||||
})),
|
||||
{ reliable: true }
|
||||
)
|
||||
setInterviewStarted(true)
|
||||
} catch (error) {
|
||||
console.error('Error starting interview:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleServerMessage = (message: any) => {
|
||||
switch (message.type) {
|
||||
case 'question':
|
||||
setCurrentQuestion(message.text)
|
||||
setAiSpeaking(true)
|
||||
break
|
||||
case 'ai_speaking_start':
|
||||
setAiSpeaking(true)
|
||||
break
|
||||
case 'ai_speaking_end':
|
||||
setAiSpeaking(false)
|
||||
break
|
||||
case 'interview_complete':
|
||||
// Собеседование завершено
|
||||
if (onEnd) onEnd()
|
||||
break
|
||||
default:
|
||||
console.log('Unknown message type:', message.type)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMute = async () => {
|
||||
if (!room) return
|
||||
|
||||
try {
|
||||
const audioTrack = room.localParticipant.getTrackPublication(Track.Source.Microphone)
|
||||
if (audioTrack) {
|
||||
if (state.isMuted) {
|
||||
await audioTrack.unmute()
|
||||
} else {
|
||||
await audioTrack.mute()
|
||||
}
|
||||
setState(prev => ({ ...prev, isMuted: !prev.isMuted }))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling mute:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const endInterview = async () => {
|
||||
if (!room) return
|
||||
|
||||
try {
|
||||
// Отправляем сигнал серверу о завершении собеседования
|
||||
await room.localParticipant.publishData(
|
||||
new TextEncoder().encode(JSON.stringify({
|
||||
type: 'end_interview',
|
||||
resumeId
|
||||
})),
|
||||
{ reliable: true }
|
||||
)
|
||||
|
||||
room.disconnect()
|
||||
if (onEnd) onEnd()
|
||||
} catch (error) {
|
||||
console.error('Error ending interview:', error)
|
||||
room.disconnect()
|
||||
if (onEnd) onEnd()
|
||||
}
|
||||
}
|
||||
|
||||
const getConnectionStatusColor = () => {
|
||||
switch (state.connectionState) {
|
||||
case 'connected':
|
||||
return 'text-green-600'
|
||||
case 'connecting':
|
||||
return 'text-yellow-600'
|
||||
case 'failed':
|
||||
return 'text-red-600'
|
||||
default:
|
||||
return 'text-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6">
|
||||
<RoomAudioRenderer />
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-2xl w-full">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="h-20 w-20 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<Volume2 className="h-10 w-10 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
HR Собеседование
|
||||
</h1>
|
||||
<p className={`text-sm ${getConnectionStatusColor()}`}>
|
||||
{state.connectionState === 'connected' && 'Подключено'}
|
||||
{state.connectionState === 'connecting' && 'Подключение...'}
|
||||
{state.connectionState === 'disconnected' && 'Отключено'}
|
||||
{state.connectionState === 'failed' && 'Ошибка подключения'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Question */}
|
||||
{currentQuestion && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center mb-2">
|
||||
{aiSpeaking && <Loader className="h-4 w-4 text-blue-600 animate-spin mr-2" />}
|
||||
<span className="text-sm font-medium text-blue-800">
|
||||
{aiSpeaking ? 'HR Агент говорит...' : 'Текущий вопрос:'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-blue-900">{currentQuestion}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!interviewStarted && state.isConnected && (
|
||||
<div className="text-center text-gray-600 mb-6">
|
||||
Ожидаем начала собеседования...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center space-x-6 mb-6">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`p-4 rounded-full transition-colors ${
|
||||
state.isMuted
|
||||
? 'bg-red-100 text-red-600 hover:bg-red-200'
|
||||
: 'bg-green-100 text-green-600 hover:bg-green-200'
|
||||
}`}
|
||||
>
|
||||
{state.isMuted ? <MicOff className="h-6 w-6" /> : <Mic className="h-6 w-6" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={endInterview}
|
||||
className="p-4 rounded-full bg-red-100 text-red-600 hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<PhoneOff className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="text-center text-sm text-gray-500 space-y-1">
|
||||
<p>Говорите четко и ждите, пока агент закончит свой вопрос</p>
|
||||
<p>Для завершения собеседования нажмите красную кнопку</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import InputMask from 'react-input-mask'
|
||||
import { ResumeCreate } from '@/types/api'
|
||||
import { useCreateResume, useResumesByVacancy } from '@/hooks/useResume'
|
||||
import { Upload, FileText, X, CheckCircle, Clock } from 'lucide-react'
|
||||
import { Upload, FileText, X, CheckCircle, Clock, Loader, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface ResumeUploadFormProps {
|
||||
vacancyId: number
|
||||
@ -24,11 +24,29 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const createResumeMutation = useCreateResume()
|
||||
const { data: existingResumes, isLoading: isLoadingResumes } = useResumesByVacancy(vacancyId)
|
||||
const { data: existingResumes, isLoading: isLoadingResumes, refetch } = useResumesByVacancy(vacancyId)
|
||||
|
||||
// Проверяем есть ли уже резюме для этой вакансии в текущей сессии
|
||||
const hasExistingResume = existingResumes && existingResumes.length > 0
|
||||
|
||||
// Находим непарсенные резюме
|
||||
const pendingResumes = existingResumes?.filter(resume =>
|
||||
resume.status === 'pending' || resume.status === 'parsing'
|
||||
) || []
|
||||
|
||||
const hasPendingResumes = pendingResumes.length > 0
|
||||
|
||||
// Автообновление для непарсенных резюме
|
||||
useEffect(() => {
|
||||
if (hasPendingResumes) {
|
||||
const interval = setInterval(() => {
|
||||
refetch()
|
||||
}, 3000) // 3 секунды
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [hasPendingResumes, refetch])
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({ ...prev, [name]: value }))
|
||||
@ -125,42 +143,212 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusDisplay = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Обрабатывается'
|
||||
case 'parsing':
|
||||
return 'Обрабатывается'
|
||||
case 'parse_failed':
|
||||
return 'Ошибка обработки'
|
||||
case 'parsed':
|
||||
return 'Обработано'
|
||||
case 'under_review':
|
||||
return 'На проверке'
|
||||
case 'interview_scheduled':
|
||||
return 'Собеседование назначено'
|
||||
case 'interviewed':
|
||||
return 'Проведено собеседование'
|
||||
case 'accepted':
|
||||
return 'Принят'
|
||||
case 'rejected':
|
||||
return 'Отклонен'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка ошибок парсинга
|
||||
const hasParseFailedResumes = existingResumes?.some(resume => resume.status === 'parse_failed') || false
|
||||
|
||||
if (hasParseFailedResumes) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="h-6 w-6 text-red-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-red-800">
|
||||
Ошибка обработки резюме
|
||||
</h3>
|
||||
<p className="mt-2 text-red-700">
|
||||
Не удалось обработать ваше резюме. Попробуйте загрузить файл в другом формате (PDF, DOC, DOCX, TXT)
|
||||
или обратитесь к нам за помощью.
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{existingResumes?.map((resume) => (
|
||||
<div key={resume.id} className="flex items-center text-sm">
|
||||
<span className="text-red-600">
|
||||
Отправлено: {new Date(resume.created_at).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})} • Статус: {getStatusDisplay(resume.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Показываем крутилку для статусов pending/parsing
|
||||
if (hasPendingResumes) {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-8 text-center">
|
||||
<Loader className="h-12 w-12 text-blue-600 mx-auto mb-4 animate-spin" />
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<h3 className="text-2xl font-bold text-blue-800 mr-3">
|
||||
Обрабатываем ваше резюме...
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-blue-700 mb-6 max-w-md mx-auto">
|
||||
Пожалуйста, подождите. Мы анализируем ваше резюме и готовим персональные вопросы для собеседования.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{existingResumes?.map((resume) => (
|
||||
<div key={resume.id} className="flex items-center justify-center text-sm">
|
||||
<span className="text-blue-600">
|
||||
Отправлено: {new Date(resume.created_at).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
<br />Статус: {getStatusDisplay(resume.status)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Обычное успешное состояние для parsed и других завершенных статусов
|
||||
if (success || hasExistingResume) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-6 w-6 text-green-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-green-800">
|
||||
{success ? 'Резюме успешно отправлено!' : 'Ваше резюме уже отправлено!'}
|
||||
</h3>
|
||||
<p className="mt-2 text-green-700">
|
||||
Готовим для вас сессию для собеседования. Мы свяжемся с вами в ближайшее время.
|
||||
</p>
|
||||
{hasExistingResume && existingResumes && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{existingResumes.map((resume) => (
|
||||
<div key={resume.id} className="flex items-center text-sm text-green-600">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
<span>
|
||||
Отправлено: {new Date(resume.created_at).toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})} • Статус: {resume.status === 'pending' ? 'На рассмотрении' :
|
||||
resume.status === 'under_review' ? 'На проверке' :
|
||||
resume.status === 'interview_scheduled' ? 'Собеседование назначено' :
|
||||
resume.status === 'interviewed' ? 'Проведено собеседование' :
|
||||
resume.status === 'accepted' ? 'Принят' :
|
||||
resume.status === 'rejected' ? 'Отклонен' : resume.status}
|
||||
</span>
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
{hasExistingResume && existingResumes && existingResumes.map((resume) => (
|
||||
<div key={resume.id} className="p-6 border-b border-gray-100 last:border-b-0">
|
||||
{/* Status and Date Row */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
Резюме
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(resume.created_at).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
resume.status === 'parsed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: resume.status === 'parsing' || resume.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: resume.status === 'parse_failed'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{getStatusDisplay(resume.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content based on status */}
|
||||
{resume.status === 'parsed' && (
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="text-center">
|
||||
<h4 className="text-lg font-semibold text-green-900 mb-2">
|
||||
Мы готовы!
|
||||
</h4>
|
||||
<p className="text-sm text-green-700 mb-4">
|
||||
Ваше резюме успешно обработано. Можете приступать к интервью с HR агентом.
|
||||
</p>
|
||||
<a
|
||||
href={`/interview/${resume.id}`}
|
||||
className="inline-flex items-center px-6 py-3 bg-green-600 border border-transparent rounded-lg font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-all duration-200 transform hover:scale-105"
|
||||
>
|
||||
Начать собеседование
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(resume.status === 'parsing' || resume.status === 'pending') && (
|
||||
<div className="bg-gradient-to-r from-yellow-50 to-amber-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-yellow-600 mr-3"></div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-yellow-900">
|
||||
Обрабатываем ваше резюме
|
||||
</h4>
|
||||
<p className="text-xs text-yellow-700 mt-1">
|
||||
Анализируем опыт и готовим персональные вопросы
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resume.status === 'parse_failed' && (
|
||||
<div className="bg-gradient-to-r from-red-50 to-pink-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="h-5 w-5 text-red-600 mr-3">⚠️</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-red-900">
|
||||
Ошибка обработки
|
||||
</h4>
|
||||
<p className="text-xs text-red-700 mt-1">
|
||||
Попробуйте загрузить файл в другом формате
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!['parsed', 'parsing', 'pending', 'parse_failed'].includes(resume.status) && (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-center">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">
|
||||
{getStatusDisplay(resume.status)}
|
||||
</h4>
|
||||
<p className="text-xs text-blue-700">
|
||||
Мы свяжемся с вами для следующих шагов
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -38,7 +38,28 @@ export const useResumesByVacancy = (vacancyId: number) => {
|
||||
queryKey: ['resumes', 'by-vacancy', vacancyId],
|
||||
queryFn: () => resumeService.getResumes({ vacancy_id: vacancyId }),
|
||||
enabled: !!vacancyId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
staleTime: 0, // Не кешируем для частых обновлений
|
||||
retry: 2,
|
||||
refetchInterval: false, // Отключаем автоматический refetch, управляем вручную
|
||||
})
|
||||
}
|
||||
|
||||
export const useValidateInterview = (resumeId: number, enabled: boolean = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['interview', 'validate', resumeId],
|
||||
queryFn: () => resumeService.validateInterview(resumeId),
|
||||
enabled: enabled && !!resumeId,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 минут
|
||||
})
|
||||
}
|
||||
|
||||
export const useInterviewToken = (resumeId: number, enabled: boolean = false) => {
|
||||
return useQuery({
|
||||
queryKey: ['interview', 'token', resumeId],
|
||||
queryFn: () => resumeService.getInterviewToken(resumeId),
|
||||
enabled: enabled && !!resumeId,
|
||||
retry: false,
|
||||
staleTime: 30 * 60 * 1000, // 30 минут - токены живут дольше
|
||||
})
|
||||
}
|
@ -9,9 +9,11 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "^2.9.14",
|
||||
"@tanstack/react-query": "^5.85.6",
|
||||
"@tanstack/react-query-devtools": "^5.85.6",
|
||||
"ky": "^1.9.1",
|
||||
"livekit-client": "^2.15.6",
|
||||
"lucide-react": "^0.294.0",
|
||||
"next": "14.0.4",
|
||||
"react": "^18.2.0",
|
||||
|
@ -48,4 +48,12 @@ export const resumeService = {
|
||||
const endpoint = `api/v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
|
||||
return kyClient.get(endpoint).json<ResumeRead[]>()
|
||||
},
|
||||
|
||||
async validateInterview(resumeId: number): Promise<{ can_interview: boolean; message?: string }> {
|
||||
return kyClient.get(`api/v1/interview/${resumeId}/validate-interview`).json()
|
||||
},
|
||||
|
||||
async getInterviewToken(resumeId: number): Promise<{ token: string; roomName: string; serverUrl: string }> {
|
||||
return kyClient.post(`api/v1/interview/${resumeId}/token`).json()
|
||||
},
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
export type EmploymentType = 'full' | 'part' | 'project' | 'volunteer' | 'probation'
|
||||
export type Experience = 'noExperience' | 'between1And3' | 'between3And6' | 'moreThan6'
|
||||
export type Schedule = 'fullDay' | 'shift' | 'flexible' | 'remote' | 'flyInFlyOut'
|
||||
export type ResumeStatus = 'pending' | 'under_review' | 'interview_scheduled' | 'interviewed' | 'rejected' | 'accepted'
|
||||
export type ResumeStatus = 'pending' | 'parsing' | 'parse_failed' | 'parsed' | 'under_review' | 'interview_scheduled' | 'interviewed' | 'rejected' | 'accepted'
|
||||
|
||||
export interface VacancyRead {
|
||||
id: number
|
||||
|
140
yarn.lock
140
yarn.lock
@ -7,6 +7,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
|
||||
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
|
||||
|
||||
"@bufbuild/protobuf@^1.10.0":
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@bufbuild/protobuf/-/protobuf-1.10.1.tgz#1d76d15290c0212076c15ede94d15157ba0c6344"
|
||||
integrity sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==
|
||||
|
||||
"@emnapi/core@^1.4.3":
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.5.0.tgz#85cd84537ec989cebb2343606a1ee663ce4edaf0"
|
||||
@ -61,6 +66,26 @@
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
|
||||
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
|
||||
|
||||
"@floating-ui/core@^1.6.0":
|
||||
version "1.7.3"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.3.tgz#462d722f001e23e46d86fd2bd0d21b7693ccb8b7"
|
||||
integrity sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==
|
||||
dependencies:
|
||||
"@floating-ui/utils" "^0.2.10"
|
||||
|
||||
"@floating-ui/dom@1.6.13":
|
||||
version "1.6.13"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.13.tgz#a8a938532aea27a95121ec16e667a7cbe8c59e34"
|
||||
integrity sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==
|
||||
dependencies:
|
||||
"@floating-ui/core" "^1.6.0"
|
||||
"@floating-ui/utils" "^0.2.9"
|
||||
|
||||
"@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.9":
|
||||
version "0.2.10"
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
|
||||
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
|
||||
|
||||
"@humanwhocodes/config-array@^0.13.0":
|
||||
version "0.13.0"
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748"
|
||||
@ -118,6 +143,36 @@
|
||||
"@jridgewell/resolve-uri" "^3.1.0"
|
||||
"@jridgewell/sourcemap-codec" "^1.4.14"
|
||||
|
||||
"@livekit/components-core@0.12.9":
|
||||
version "0.12.9"
|
||||
resolved "https://registry.yarnpkg.com/@livekit/components-core/-/components-core-0.12.9.tgz#87502ed28a61c65db8306b684073e0dd26c86da2"
|
||||
integrity sha512-bwrZsHf6GaHIO+lLyA6Yps1STTX9YIeL3ixwt+Ufi88OgkNYdp41Ug8oeVDlf7tzdxa+r3Xkfaj/qvIG84Yo6A==
|
||||
dependencies:
|
||||
"@floating-ui/dom" "1.6.13"
|
||||
loglevel "1.9.1"
|
||||
rxjs "7.8.2"
|
||||
|
||||
"@livekit/components-react@^2.9.14":
|
||||
version "2.9.14"
|
||||
resolved "https://registry.yarnpkg.com/@livekit/components-react/-/components-react-2.9.14.tgz#d0b26ccbfe419c7e9191d41a5e5151e10f057cdb"
|
||||
integrity sha512-fQ3t4PdcM+AORo62FWmJcfqWe7ODwVaU4nsqxse+fp6L5a+0K2uMD7yQ2jrutXIaUQigU/opzTUxPcpdk9+0ow==
|
||||
dependencies:
|
||||
"@livekit/components-core" "0.12.9"
|
||||
clsx "2.1.1"
|
||||
usehooks-ts "3.1.1"
|
||||
|
||||
"@livekit/mutex@1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@livekit/mutex/-/mutex-1.1.1.tgz#72492b611d55be8130ba2271b7a436d94b1bc6d4"
|
||||
integrity sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==
|
||||
|
||||
"@livekit/protocol@1.39.3":
|
||||
version "1.39.3"
|
||||
resolved "https://registry.yarnpkg.com/@livekit/protocol/-/protocol-1.39.3.tgz#dfbb801f6de232d64d918e0ad796268ff709cc35"
|
||||
integrity sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==
|
||||
dependencies:
|
||||
"@bufbuild/protobuf" "^1.10.0"
|
||||
|
||||
"@napi-rs/wasm-runtime@^0.2.11":
|
||||
version "0.2.12"
|
||||
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2"
|
||||
@ -764,6 +819,11 @@ client-only@0.0.1:
|
||||
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
|
||||
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==
|
||||
|
||||
clsx@2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||
|
||||
color-convert@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
||||
@ -1278,6 +1338,11 @@ esutils@^2.0.2:
|
||||
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||
|
||||
events@^3.3.0:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
|
||||
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
|
||||
|
||||
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
|
||||
@ -1959,6 +2024,21 @@ lines-and-columns@^1.1.6:
|
||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
|
||||
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
|
||||
|
||||
livekit-client@^2.15.6:
|
||||
version "2.15.6"
|
||||
resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.15.6.tgz#6cfd919d36221680b58917d156a1cae6a85ca7da"
|
||||
integrity sha512-bLdNXklpMfWofw9pCF2XGyYA3OUddXXG4KY+gTN7dh+YvG7TX+YaP/Kt9ugdZ3KziQLqK2HG1ict4s7uD0JAiQ==
|
||||
dependencies:
|
||||
"@livekit/mutex" "1.1.1"
|
||||
"@livekit/protocol" "1.39.3"
|
||||
events "^3.3.0"
|
||||
loglevel "^1.9.2"
|
||||
sdp-transform "^2.15.0"
|
||||
ts-debounce "^4.0.0"
|
||||
tslib "2.8.1"
|
||||
typed-emitter "^2.1.0"
|
||||
webrtc-adapter "^9.0.1"
|
||||
|
||||
locate-path@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||
@ -1966,11 +2046,26 @@ locate-path@^6.0.0:
|
||||
dependencies:
|
||||
p-locate "^5.0.0"
|
||||
|
||||
lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
|
||||
|
||||
lodash.merge@^4.6.2:
|
||||
version "4.6.2"
|
||||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
|
||||
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
|
||||
|
||||
loglevel@1.9.1:
|
||||
version "1.9.1"
|
||||
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.1.tgz#d63976ac9bcd03c7c873116d41c2a85bafff1be7"
|
||||
integrity sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==
|
||||
|
||||
loglevel@^1.9.2:
|
||||
version "1.9.2"
|
||||
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08"
|
||||
integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==
|
||||
|
||||
loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
|
||||
@ -2494,6 +2589,13 @@ run-parallel@^1.1.9:
|
||||
dependencies:
|
||||
queue-microtask "^1.2.2"
|
||||
|
||||
rxjs@7.8.2, rxjs@^7.5.2:
|
||||
version "7.8.2"
|
||||
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b"
|
||||
integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
|
||||
dependencies:
|
||||
tslib "^2.1.0"
|
||||
|
||||
safe-array-concat@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3"
|
||||
@ -2529,6 +2631,16 @@ scheduler@^0.23.2:
|
||||
dependencies:
|
||||
loose-envify "^1.1.0"
|
||||
|
||||
sdp-transform@^2.15.0:
|
||||
version "2.15.0"
|
||||
resolved "https://registry.yarnpkg.com/sdp-transform/-/sdp-transform-2.15.0.tgz#79d37a2481916f36a0534e07b32ceaa87f71df42"
|
||||
integrity sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==
|
||||
|
||||
sdp@^3.2.0:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/sdp/-/sdp-3.2.1.tgz#a2f79eecd7c5adb90d54e1bc9812775d80f3c06c"
|
||||
integrity sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==
|
||||
|
||||
semver@^6.3.1:
|
||||
version "6.3.1"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
|
||||
@ -2880,6 +2992,11 @@ ts-api-utils@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064"
|
||||
integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==
|
||||
|
||||
ts-debounce@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ts-debounce/-/ts-debounce-4.0.0.tgz#33440ef64fab53793c3d546a8ca6ae539ec15841"
|
||||
integrity sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==
|
||||
|
||||
ts-interface-checker@^0.1.9:
|
||||
version "0.1.13"
|
||||
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
|
||||
@ -2895,7 +3012,7 @@ tsconfig-paths@^3.15.0:
|
||||
minimist "^1.2.6"
|
||||
strip-bom "^3.0.0"
|
||||
|
||||
tslib@^2.4.0:
|
||||
tslib@2.8.1, tslib@^2.1.0, tslib@^2.4.0:
|
||||
version "2.8.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
|
||||
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
|
||||
@ -2957,6 +3074,13 @@ typed-array-length@^1.0.7:
|
||||
possible-typed-array-names "^1.0.0"
|
||||
reflect.getprototypeof "^1.0.6"
|
||||
|
||||
typed-emitter@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-2.1.0.tgz#ca78e3d8ef1476f228f548d62e04e3d4d3fd77fb"
|
||||
integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==
|
||||
optionalDependencies:
|
||||
rxjs "^7.5.2"
|
||||
|
||||
typescript@^5.3.3:
|
||||
version "5.9.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6"
|
||||
@ -3019,6 +3143,13 @@ uri-js@^4.2.2:
|
||||
dependencies:
|
||||
punycode "^2.1.0"
|
||||
|
||||
usehooks-ts@3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-3.1.1.tgz#0bb7f38f36f8219ee4509cc5e944ae610fb97656"
|
||||
integrity sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==
|
||||
dependencies:
|
||||
lodash.debounce "^4.0.8"
|
||||
|
||||
util-deprecate@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
|
||||
@ -3039,6 +3170,13 @@ watchpack@2.4.0:
|
||||
glob-to-regexp "^0.4.1"
|
||||
graceful-fs "^4.1.2"
|
||||
|
||||
webrtc-adapter@^9.0.1:
|
||||
version "9.0.3"
|
||||
resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz#b446ed7cd72129d00c652dd7b9a5716d9ffdd87d"
|
||||
integrity sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==
|
||||
dependencies:
|
||||
sdp "^3.2.0"
|
||||
|
||||
which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e"
|
||||
|
Loading…
Reference in New Issue
Block a user