add toast
This commit is contained in:
parent
65a9dddf08
commit
a837e97e72
142
app/page.tsx
142
app/page.tsx
@ -64,6 +64,45 @@ export default function HomePage() {
|
||||
return mapping[employment as keyof typeof mapping] || employment
|
||||
}
|
||||
|
||||
const formatNullableField = (value: string | null | undefined) => {
|
||||
if (!value || value === 'null') return 'Не указано'
|
||||
return value
|
||||
}
|
||||
|
||||
const VacancyPlaceholder = () => (
|
||||
<div className="bg-white rounded-lg shadow-md border border-gray-200 p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="h-6 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-2/3"></div>
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-1/2"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-4/5"></div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="h-6 bg-gray-200 rounded-full animate-pulse w-24"></div>
|
||||
<div className="h-4 bg-gray-200 rounded animate-pulse w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const getVacanciesWithPlaceholders = (vacancies: VacancyRead[]) => {
|
||||
const itemsPerRow = 3
|
||||
const remainder = vacancies.length % itemsPerRow
|
||||
const placeholdersNeeded = remainder === 0 ? 0 : itemsPerRow - remainder
|
||||
|
||||
const placeholders = Array(placeholdersNeeded).fill(null).map((_, index) => ({
|
||||
id: `placeholder-${index}`,
|
||||
isPlaceholder: true
|
||||
}))
|
||||
|
||||
return [...vacancies, ...placeholders]
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
@ -123,57 +162,64 @@ export default function HomePage() {
|
||||
{/* Vacancies Grid */}
|
||||
{!error && vacancies.length > 0 && (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{vacancies.map((vacancy) => (
|
||||
<Link
|
||||
key={vacancy.id}
|
||||
href={`/vacancy/${vacancy.id}`}
|
||||
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">
|
||||
{vacancy.title}
|
||||
</h3>
|
||||
{vacancy.premium && (
|
||||
<span className="ml-2 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded">
|
||||
Premium
|
||||
{getVacanciesWithPlaceholders(vacancies).map((item, index) => {
|
||||
if ('isPlaceholder' in item) {
|
||||
return <VacancyPlaceholder key={item.id} />
|
||||
}
|
||||
|
||||
const vacancy = item as VacancyRead
|
||||
return (
|
||||
<Link
|
||||
key={vacancy.id}
|
||||
href={`/vacancy/${vacancy.id}`}
|
||||
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-2">
|
||||
{vacancy.title}
|
||||
</h3>
|
||||
{vacancy.premium && (
|
||||
<span className="ml-2 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded">
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<Banknote className="h-4 w-4 mr-2" />
|
||||
<span>{formatSalary(vacancy)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
<span>{formatNullableField(vacancy.area_name)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
<span>{getExperienceText(vacancy.experience)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700 mb-4">
|
||||
<p className="font-medium">{formatNullableField(vacancy.company_name)}</p>
|
||||
<p className="line-clamp-2 mt-1">{formatNullableField(vacancy.description)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="px-3 py-1 bg-primary-100 text-primary-800 text-xs font-medium rounded-full">
|
||||
{getEmploymentText(vacancy.employment_type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<Banknote className="h-4 w-4 mr-2" />
|
||||
<span>{formatSalary(vacancy)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
<span>{vacancy.area_name || 'Не указано'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600 text-sm">
|
||||
<Clock className="h-4 w-4 mr-2" />
|
||||
<span>{getExperienceText(vacancy.experience)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-700 mb-4">
|
||||
<p className="font-medium">{vacancy.company_name || 'Не указано'}</p>
|
||||
<p className="line-clamp-2 mt-1">{vacancy.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="px-3 py-1 bg-primary-100 text-primary-800 text-xs font-medium rounded-full">
|
||||
{getEmploymentText(vacancy.employment_type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -74,6 +74,11 @@ export default function VacancyPage() {
|
||||
return mapping[schedule as keyof typeof mapping] || schedule
|
||||
}
|
||||
|
||||
const formatNullableField = (value: string | null | undefined) => {
|
||||
if (!value || value === 'null') return 'Не указано'
|
||||
return value
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
@ -131,7 +136,7 @@ export default function VacancyPage() {
|
||||
<div className="flex items-center mb-6">
|
||||
<Building className="h-5 w-5 text-gray-400 mr-2"/>
|
||||
<span className="text-lg font-medium text-gray-900">
|
||||
{vacancy.company_name || 'Не указано'}
|
||||
{formatNullableField(vacancy.company_name)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -143,7 +148,7 @@ export default function VacancyPage() {
|
||||
|
||||
<div className="flex items-center text-gray-600">
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
<span>{vacancy.area_name || 'Не указано'}</span>
|
||||
<span>{formatNullableField(vacancy.area_name)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-gray-600">
|
||||
@ -158,7 +163,7 @@ export default function VacancyPage() {
|
||||
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Calendar className="h-4 w-4 mr-2"/>
|
||||
<span>{ getScheduleText(vacancy.schedule) }</span>
|
||||
<span>{ formatNullableField(getScheduleText(vacancy.schedule)) }</span>
|
||||
</div>
|
||||
|
||||
{ vacancy.published_at && (
|
||||
@ -177,7 +182,7 @@ export default function VacancyPage() {
|
||||
</h2>
|
||||
<div className="prose prose-gray max-w-none">
|
||||
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
|
||||
{ vacancy.description }
|
||||
{ formatNullableField(vacancy.description) }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -189,7 +194,7 @@ export default function VacancyPage() {
|
||||
Ключевые навыки
|
||||
</h2>
|
||||
<div className="prose prose-gray max-w-none">
|
||||
<p className="text-gray-700">{ vacancy.key_skills }</p>
|
||||
<p className="text-gray-700">{ formatNullableField(vacancy.key_skills) }</p>
|
||||
</div>
|
||||
</div>
|
||||
) }
|
||||
@ -202,7 +207,7 @@ export default function VacancyPage() {
|
||||
</h2>
|
||||
<div className="prose prose-gray max-w-none">
|
||||
<p className="whitespace-pre-line text-gray-700 leading-relaxed">
|
||||
{ vacancy.company_description }
|
||||
{ formatNullableField(vacancy.company_description) }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -218,13 +223,13 @@ export default function VacancyPage() {
|
||||
{ vacancy.address && (
|
||||
<div className="flex items-start">
|
||||
<MapPin className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0"/>
|
||||
<span>{ vacancy.address }</span>
|
||||
<span>{ formatNullableField(vacancy.address) }</span>
|
||||
</div>
|
||||
) }
|
||||
{ vacancy.metro_stations && (
|
||||
<div className="flex items-start">
|
||||
<span className="text-sm font-medium mr-2">Метро:</span>
|
||||
<span className="text-sm">{ vacancy.metro_stations }</span>
|
||||
<span className="text-sm">{ formatNullableField(vacancy.metro_stations) }</span>
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
@ -244,7 +249,7 @@ export default function VacancyPage() {
|
||||
{ vacancy.contacts_name && (
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Users className="h-4 w-4 mr-2"/>
|
||||
<span>{ vacancy.contacts_name }</span>
|
||||
<span>{ formatNullableField(vacancy.contacts_name) }</span>
|
||||
</div>
|
||||
) }
|
||||
{ vacancy.contacts_email && (
|
||||
@ -254,7 +259,7 @@ export default function VacancyPage() {
|
||||
href={ `mailto:${ vacancy.contacts_email }` }
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{ vacancy.contacts_email }
|
||||
{ formatNullableField(vacancy.contacts_email) }
|
||||
</a>
|
||||
</div>
|
||||
) }
|
||||
@ -265,7 +270,7 @@ export default function VacancyPage() {
|
||||
href={ `tel:${ vacancy.contacts_phone }` }
|
||||
className="hover:text-primary-600"
|
||||
>
|
||||
{ vacancy.contacts_phone }
|
||||
{ formatNullableField(vacancy.contacts_phone) }
|
||||
</a>
|
||||
</div>
|
||||
) }
|
||||
|
@ -57,7 +57,6 @@ export default function VacancyUploadForm() {
|
||||
e.preventDefault()
|
||||
|
||||
if (!selectedFile) {
|
||||
alert('Пожалуйста, выберите файл')
|
||||
return
|
||||
}
|
||||
|
||||
@ -71,7 +70,6 @@ export default function VacancyUploadForm() {
|
||||
setSelectedFile(null)
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке файла:', error)
|
||||
alert('Произошла ошибка при загрузке файла')
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -7,7 +7,8 @@ export const useVacancies = (params?: GetVacanciesParams) => {
|
||||
return useQuery({
|
||||
queryKey: ['vacancies', params],
|
||||
queryFn: () => vacancyService.getVacancies(params),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
staleTime: 0, // Данные сразу считаются устаревшими
|
||||
refetchInterval: 5000, // Обновлять каждые 5 секунд
|
||||
retry: 2,
|
||||
})
|
||||
}
|
||||
@ -23,39 +24,57 @@ export const useVacancy = (id: number) => {
|
||||
}
|
||||
|
||||
export const useParseVacancyFile = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: ({ file, createVacancy }: { file: File; createVacancy?: boolean }) =>
|
||||
vacancyService.parseFileAsync(file, createVacancy),
|
||||
onSuccess: (data) => {
|
||||
// Показать уведомление об успешном запуске парсинга
|
||||
alert('Задача парсинга запущена! Скоро вакансия появится в списке.')
|
||||
|
||||
// Начать опрос списка вакансий каждые 5 секунд
|
||||
pollIntervalRef.current = setInterval(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['vacancies'] })
|
||||
}, 5000)
|
||||
|
||||
// Остановить опрос через 2 минуты
|
||||
setTimeout(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current)
|
||||
pollIntervalRef.current = null
|
||||
}
|
||||
}, 120000)
|
||||
// Показать toast уведомление
|
||||
showToast('Задача парсинга запущена! Скоро вакансия появится в списке.')
|
||||
},
|
||||
})
|
||||
|
||||
// Очистить интервал при размонтировании компонента
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return mutation
|
||||
}
|
||||
|
||||
// Простая функция для показа toast без React компонента
|
||||
const showToast = (message: string) => {
|
||||
// Создать элемент toast
|
||||
const toast = document.createElement('div')
|
||||
toast.textContent = message
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 20px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
z-index: 9999;
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transform: translateX(100%);
|
||||
`
|
||||
|
||||
// Добавить в DOM
|
||||
document.body.appendChild(toast)
|
||||
|
||||
// Анимация появления
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)'
|
||||
}, 10)
|
||||
|
||||
// Удалить через 5 секунд
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0'
|
||||
toast.style.transform = 'translateX(100%)'
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
document.body.removeChild(toast)
|
||||
}
|
||||
}, 300)
|
||||
}, 10000)
|
||||
}
|
Loading…
Reference in New Issue
Block a user