add toast

This commit is contained in:
Даниил Ивлев 2025-09-09 17:52:08 +05:00
parent 65a9dddf08
commit a837e97e72
4 changed files with 157 additions and 89 deletions

View File

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

View File

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

View File

@ -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('Произошла ошибка при загрузке файла')
}
}

View File

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