add interview

This commit is contained in:
Даниил Ивлев 2025-09-03 14:37:38 +05:00
parent 056f70a1ad
commit f31af7ef11
10 changed files with 917 additions and 37 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_LIVEKIT_URL=ws://localhost:7880

110
README.md
View File

@ -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
View 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>
)
}

View 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>
)
}

View File

@ -1,10 +1,10 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import InputMask from 'react-input-mask' import InputMask from 'react-input-mask'
import { ResumeCreate } from '@/types/api' import { ResumeCreate } from '@/types/api'
import { useCreateResume, useResumesByVacancy } from '@/hooks/useResume' 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 { interface ResumeUploadFormProps {
vacancyId: number vacancyId: number
@ -24,11 +24,29 @@ export default function ResumeUploadForm({ vacancyId, vacancyTitle, onSuccess }:
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const createResumeMutation = useCreateResume() 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 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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value })) 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) { if (success || hasExistingResume) {
return ( return (
<div className="bg-green-50 border border-green-200 rounded-lg p-6"> <div className="bg-white border border-gray-200 rounded-lg shadow-sm">
<div className="flex items-center"> {hasExistingResume && existingResumes && existingResumes.map((resume) => (
<CheckCircle className="h-6 w-6 text-green-600 mr-3" /> <div key={resume.id} className="p-6 border-b border-gray-100 last:border-b-0">
<div> {/* Status and Date Row */}
<h3 className="text-lg font-medium text-green-800"> <div className="flex items-center justify-between mb-4">
{success ? 'Резюме успешно отправлено!' : 'Ваше резюме уже отправлено!'} <div className="flex items-center space-x-3">
</h3> <div className="h-10 w-10 rounded-full bg-green-100 flex items-center justify-center">
<p className="mt-2 text-green-700"> <CheckCircle className="h-5 w-5 text-green-600" />
Готовим для вас сессию для собеседования. Мы свяжемся с вами в ближайшее время. </div>
</p> <div>
{hasExistingResume && existingResumes && ( <p className="text-sm font-medium text-gray-900">
<div className="mt-4 space-y-2"> Резюме
{existingResumes.map((resume) => ( </p>
<div key={resume.id} className="flex items-center text-sm text-green-600"> <p className="text-xs text-gray-500">
<Clock className="h-4 w-4 mr-2" /> {new Date(resume.created_at).toLocaleDateString('ru-RU', {
<span> day: '2-digit',
Отправлено: {new Date(resume.created_at).toLocaleDateString('ru-RU', { month: '2-digit',
day: 'numeric', year: 'numeric',
month: 'long', hour: '2-digit',
hour: '2-digit', minute: '2-digit'
minute: '2-digit' })}
})} Статус: {resume.status === 'pending' ? 'На рассмотрении' : </p>
resume.status === 'under_review' ? 'На проверке' : </div>
resume.status === 'interview_scheduled' ? 'Собеседование назначено' : </div>
resume.status === 'interviewed' ? 'Проведено собеседование' :
resume.status === 'accepted' ? 'Принят' : <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
resume.status === 'rejected' ? 'Отклонен' : resume.status} resume.status === 'parsed'
</span> ? '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>
</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>
</div> ))}
</div> </div>
) )
} }

View File

@ -38,7 +38,28 @@ export const useResumesByVacancy = (vacancyId: number) => {
queryKey: ['resumes', 'by-vacancy', vacancyId], queryKey: ['resumes', 'by-vacancy', vacancyId],
queryFn: () => resumeService.getResumes({ vacancy_id: vacancyId }), queryFn: () => resumeService.getResumes({ vacancy_id: vacancyId }),
enabled: !!vacancyId, enabled: !!vacancyId,
staleTime: 2 * 60 * 1000, // 2 minutes staleTime: 0, // Не кешируем для частых обновлений
retry: 2, 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 минут - токены живут дольше
}) })
} }

View File

@ -9,9 +9,11 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@livekit/components-react": "^2.9.14",
"@tanstack/react-query": "^5.85.6", "@tanstack/react-query": "^5.85.6",
"@tanstack/react-query-devtools": "^5.85.6", "@tanstack/react-query-devtools": "^5.85.6",
"ky": "^1.9.1", "ky": "^1.9.1",
"livekit-client": "^2.15.6",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"next": "14.0.4", "next": "14.0.4",
"react": "^18.2.0", "react": "^18.2.0",

View File

@ -48,4 +48,12 @@ export const resumeService = {
const endpoint = `api/v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}` const endpoint = `api/v1/resumes/${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
return kyClient.get(endpoint).json<ResumeRead[]>() 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()
},
} }

View File

@ -1,7 +1,7 @@
export type EmploymentType = 'full' | 'part' | 'project' | 'volunteer' | 'probation' export type EmploymentType = 'full' | 'part' | 'project' | 'volunteer' | 'probation'
export type Experience = 'noExperience' | 'between1And3' | 'between3And6' | 'moreThan6' export type Experience = 'noExperience' | 'between1And3' | 'between3And6' | 'moreThan6'
export type Schedule = 'fullDay' | 'shift' | 'flexible' | 'remote' | 'flyInFlyOut' 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 { export interface VacancyRead {
id: number id: number

140
yarn.lock
View File

@ -7,6 +7,11 @@
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== 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": "@emnapi/core@^1.4.3":
version "1.5.0" version "1.5.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.5.0.tgz#85cd84537ec989cebb2343606a1ee663ce4edaf0" 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" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== 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": "@humanwhocodes/config-array@^0.13.0":
version "0.13.0" version "0.13.0"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" 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/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14" "@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": "@napi-rs/wasm-runtime@^0.2.11":
version "0.2.12" version "0.2.12"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" 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" resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== 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: color-convert@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" 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" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== 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: fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" 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" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== 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: locate-path@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
@ -1966,11 +2046,26 @@ locate-path@^6.0.0:
dependencies: dependencies:
p-locate "^5.0.0" 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: lodash.merge@^4.6.2:
version "4.6.2" version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== 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: loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
@ -2494,6 +2589,13 @@ run-parallel@^1.1.9:
dependencies: dependencies:
queue-microtask "^1.2.2" 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: safe-array-concat@^1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" 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: dependencies:
loose-envify "^1.1.0" 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: semver@^6.3.1:
version "6.3.1" version "6.3.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" 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" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064"
integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== 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: ts-interface-checker@^0.1.9:
version "0.1.13" version "0.1.13"
resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" 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" minimist "^1.2.6"
strip-bom "^3.0.0" 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" version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
@ -2957,6 +3074,13 @@ typed-array-length@^1.0.7:
possible-typed-array-names "^1.0.0" possible-typed-array-names "^1.0.0"
reflect.getprototypeof "^1.0.6" 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: typescript@^5.3.3:
version "5.9.2" version "5.9.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6"
@ -3019,6 +3143,13 @@ uri-js@^4.2.2:
dependencies: dependencies:
punycode "^2.1.0" 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: util-deprecate@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" 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" glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2" 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: which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e"