create landing
This commit is contained in:
parent
52b074cd05
commit
dcd0c61138
@ -6,11 +6,10 @@ npm-debug.log*
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
README.md
|
README.md
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.production.local
|
|
||||||
.env.development.local
|
|
||||||
coverage
|
coverage
|
||||||
.nyc_output
|
.nyc_output
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Exclude app mode files for landing build
|
||||||
|
app/app
|
||||||
@ -1,2 +1,9 @@
|
|||||||
NEXT_PUBLIC_API_BASE_URL=https://hr.aiquity.xyz:8000/api
|
NEXT_PUBLIC_API_BASE_URL=https://hr.aiquity.xyz:8000/api
|
||||||
NEXT_PUBLIC_LIVEKIT_URL=wss://hackaton-eizc9zqk.livekit.cloud
|
NEXT_PUBLIC_LIVEKIT_URL=wss://hackaton-eizc9zqk.livekit.cloud
|
||||||
|
|
||||||
|
# App mode: 'landing' for landing page, 'app' for main application
|
||||||
|
# Default: 'landing'
|
||||||
|
NEXT_PUBLIC_APP_MODE=landing
|
||||||
|
|
||||||
|
# API URL for contact form (used by landing page)
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
|||||||
17
Dockerfile
17
Dockerfile
@ -11,12 +11,27 @@ FROM base AS builder
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Build-time environment variables (NEXT_PUBLIC_* are embedded at build time)
|
||||||
|
ARG NEXT_PUBLIC_APP_MODE=landing
|
||||||
|
ARG NEXT_PUBLIC_API_URL=https://hr.aiquity.xyz
|
||||||
|
ARG NEXT_PUBLIC_API_BASE_URL=https://hr.aiquity.xyz/api
|
||||||
|
ARG NEXT_PUBLIC_LIVEKIT_URL=wss://hackaton-eizc9zqk.livekit.cloud
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_APP_MODE=$NEXT_PUBLIC_APP_MODE
|
||||||
|
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
|
||||||
|
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV production
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_PUBLIC_APP_MODE=landing
|
||||||
|
ENV NEXT_PUBLIC_API_URL=https://hr.aiquity.xyz
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=https://hr.aiquity.xyz/api
|
||||||
|
ENV NEXT_PUBLIC_LIVEKIT_URL=wss://hackaton-eizc9zqk.livekit.cloud
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|||||||
284
app/app/page.tsx
Normal file
284
app/app/page.tsx
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { VacancyRead } from '@/types/api'
|
||||||
|
import { useVacancies } from '@/hooks/useVacancy'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Search, MapPin, Clock, Banknote, Plus } from 'lucide-react'
|
||||||
|
import VacancyUploadForm from '@/components/VacancyUploadForm'
|
||||||
|
import AppLayout from '@/components/AppLayout'
|
||||||
|
import ModeGuard from '@/components/ModeGuard'
|
||||||
|
|
||||||
|
export default function AppPage() {
|
||||||
|
return (
|
||||||
|
<ModeGuard>
|
||||||
|
<AppPageContent />
|
||||||
|
</ModeGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppPageContent() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [searchParams, setSearchParams] = useState({
|
||||||
|
active_only: true,
|
||||||
|
title: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: vacancies = [], isLoading, error, refetch } = useVacancies(searchParams)
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSearchParams({
|
||||||
|
active_only: true,
|
||||||
|
title: searchTerm || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSalary = (vacancy: VacancyRead) => {
|
||||||
|
if (!vacancy.salary_from && !vacancy.salary_to) return 'Зарплата не указана'
|
||||||
|
|
||||||
|
const currency = vacancy.salary_currency === 'RUR' ? '₽' : vacancy.salary_currency
|
||||||
|
|
||||||
|
if (vacancy.salary_from && vacancy.salary_to) {
|
||||||
|
return `${vacancy.salary_from.toLocaleString()} - ${vacancy.salary_to.toLocaleString()} ${currency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vacancy.salary_from) {
|
||||||
|
return `от ${vacancy.salary_from.toLocaleString()} ${currency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vacancy.salary_to) {
|
||||||
|
return `до ${vacancy.salary_to.toLocaleString()} ${currency}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExperienceText = (experience: string) => {
|
||||||
|
const mapping = {
|
||||||
|
noExperience: 'Без опыта',
|
||||||
|
between1And3: '1-3 года',
|
||||||
|
between3And6: '3-6 лет',
|
||||||
|
moreThan6: 'Более 6 лет'
|
||||||
|
}
|
||||||
|
return mapping[experience as keyof typeof mapping] || experience
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmploymentText = (employment: string) => {
|
||||||
|
const mapping = {
|
||||||
|
full: 'Полная занятость',
|
||||||
|
part: 'Частичная занятость',
|
||||||
|
project: 'Проектная работа',
|
||||||
|
volunteer: 'Волонтерство',
|
||||||
|
probation: 'Стажировка'
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="flex justify-center items-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
Найдите работу мечты
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||||
|
Выберите понравившуюся вам вакансию, заполните форму и прикрепите резюме.<br/>
|
||||||
|
После недолговременной обработки вашего документа мы предоставим вам возможность подключится к сессии для собеседования, если ваше резюме удовлетворит вакансию.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по названию вакансии..."
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Найти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-red-600">Не удалось загрузить вакансии</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vacancies Grid */}
|
||||||
|
{!error && vacancies.length > 0 && (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{getVacanciesWithPlaceholders(vacancies).map((item) => {
|
||||||
|
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>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!error && !isLoading && vacancies.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<Search className="h-16 w-16 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
Вакансии не найдены
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Попробуйте изменить параметры поиска или вернитесь позже
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Vacancy Button */}
|
||||||
|
{!showCreateForm && (
|
||||||
|
<div className="flex justify-center pt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 shadow-lg hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
|
Создать вакансию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Vacancy Form */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="text-gray-500 hover:text-gray-700 text-sm"
|
||||||
|
>
|
||||||
|
← Свернуть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<VacancyUploadForm />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -4,8 +4,17 @@ import { useParams, useRouter } from 'next/navigation'
|
|||||||
import InterviewSession from '@/components/InterviewSession'
|
import InterviewSession from '@/components/InterviewSession'
|
||||||
import { useValidateInterview } from '@/hooks/useResume'
|
import { useValidateInterview } from '@/hooks/useResume'
|
||||||
import { ArrowLeft, AlertCircle, Loader } from 'lucide-react'
|
import { ArrowLeft, AlertCircle, Loader } from 'lucide-react'
|
||||||
|
import ModeGuard from '@/components/ModeGuard'
|
||||||
|
|
||||||
export default function InterviewPage() {
|
export default function InterviewPage() {
|
||||||
|
return (
|
||||||
|
<ModeGuard>
|
||||||
|
<InterviewPageContent />
|
||||||
|
</ModeGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InterviewPageContent() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const resumeId = parseInt(params.id as string)
|
const resumeId = parseInt(params.id as string)
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import './globals.css'
|
|||||||
import QueryProvider from '@/components/QueryProvider'
|
import QueryProvider from '@/components/QueryProvider'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'HR AI - Поиск работы',
|
title: 'HR AI - AI-собеседования для масштабного найма',
|
||||||
description: 'Платформа для поиска вакансий с искусственным интеллектом',
|
description: 'Автоматизируйте подбор персонала с помощью искусственного интеллекта. Проводите сотни собеседований одновременно.',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -19,23 +19,7 @@ export default function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body className="min-h-screen bg-gray-50">
|
<body className="min-h-screen bg-gray-50">
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
<header className="bg-white shadow-sm border-b">
|
{children}
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex justify-between items-center h-16">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<h1 className="text-xl font-bold text-primary-600">HR AI</h1>
|
|
||||||
</div>
|
|
||||||
<nav className="flex space-x-4">
|
|
||||||
<a href="/" className="text-gray-600 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium">
|
|
||||||
Вакансии
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
274
app/page.tsx
274
app/page.tsx
@ -1,270 +1,20 @@
|
|||||||
'use client'
|
import LandingPage from '@/components/LandingPage'
|
||||||
|
import LandingLayout from '@/components/LandingLayout'
|
||||||
|
import AppHomePage from '@/components/AppHomePage'
|
||||||
|
|
||||||
import { useState } from 'react'
|
// Режим работы приложения: 'landing' или 'app'
|
||||||
import { VacancyRead } from '@/types/api'
|
const APP_MODE = process.env.NEXT_PUBLIC_APP_MODE || 'landing'
|
||||||
import { useVacancies } from '@/hooks/useVacancy'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { Search, MapPin, Clock, Banknote, Plus } from 'lucide-react'
|
|
||||||
import VacancyUploadForm from '@/components/VacancyUploadForm'
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
// Если режим лендинга - показываем лендинг
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
if (APP_MODE === 'landing') {
|
||||||
const [searchParams, setSearchParams] = useState({
|
|
||||||
active_only: true,
|
|
||||||
title: undefined as string | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { data: vacancies = [], isLoading, error, refetch } = useVacancies(searchParams)
|
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSearchParams({
|
|
||||||
active_only: true,
|
|
||||||
title: searchTerm || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatSalary = (vacancy: VacancyRead) => {
|
|
||||||
if (!vacancy.salary_from && !vacancy.salary_to) return 'Зарплата не указана'
|
|
||||||
|
|
||||||
const currency = vacancy.salary_currency === 'RUR' ? '₽' : vacancy.salary_currency
|
|
||||||
|
|
||||||
if (vacancy.salary_from && vacancy.salary_to) {
|
|
||||||
return `${vacancy.salary_from.toLocaleString()} - ${vacancy.salary_to.toLocaleString()} ${currency}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vacancy.salary_from) {
|
|
||||||
return `от ${vacancy.salary_from.toLocaleString()} ${currency}`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vacancy.salary_to) {
|
|
||||||
return `до ${vacancy.salary_to.toLocaleString()} ${currency}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExperienceText = (experience: string) => {
|
|
||||||
const mapping = {
|
|
||||||
noExperience: 'Без опыта',
|
|
||||||
between1And3: '1-3 года',
|
|
||||||
between3And6: '3-6 лет',
|
|
||||||
moreThan6: 'Более 6 лет'
|
|
||||||
}
|
|
||||||
return mapping[experience as keyof typeof mapping] || experience
|
|
||||||
}
|
|
||||||
|
|
||||||
const getEmploymentText = (employment: string) => {
|
|
||||||
const mapping = {
|
|
||||||
full: 'Полная занятость',
|
|
||||||
part: 'Частичная занятость',
|
|
||||||
project: 'Проектная работа',
|
|
||||||
volunteer: 'Волонтерство',
|
|
||||||
probation: 'Стажировка'
|
|
||||||
}
|
|
||||||
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 (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-[400px]">
|
<LandingLayout>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
<LandingPage />
|
||||||
</div>
|
</LandingLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Иначе показываем основное приложение
|
||||||
<div className="space-y-8">
|
return <AppHomePage />
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
|
||||||
Найдите работу мечты
|
|
||||||
</h1>
|
|
||||||
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
|
||||||
Выберите понравившуюся вам вакансию, заполните форму и прикрепите резюме.<br/>
|
|
||||||
После недолговременной обработки вашего документа мы предоставим вам возможность подключится к сессии для собеседования, если ваше резюме удовлетворит вакансию.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="max-w-2xl mx-auto">
|
|
||||||
<form onSubmit={handleSearch} className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Поиск по названию вакансии..."
|
|
||||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
|
||||||
>
|
|
||||||
Найти
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-red-600">Не удалось загрузить вакансии</p>
|
|
||||||
<button
|
|
||||||
onClick={() => refetch()}
|
|
||||||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
|
||||||
>
|
|
||||||
Попробовать снова
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Vacancies Grid */}
|
|
||||||
{!error && vacancies.length > 0 && (
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{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>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty State */}
|
|
||||||
{!error && !isLoading && vacancies.length === 0 && (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="text-gray-400 mb-4">
|
|
||||||
<Search className="h-16 w-16 mx-auto" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
||||||
Вакансии не найдены
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Попробуйте изменить параметры поиска или вернитесь позже
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Vacancy Button */}
|
|
||||||
{!showCreateForm && (
|
|
||||||
<div className="flex justify-center pt-8">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateForm(true)}
|
|
||||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 shadow-lg hover:shadow-xl transition-all"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5 mr-2" />
|
|
||||||
Создать вакансию
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create Vacancy Form */}
|
|
||||||
{showCreateForm && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateForm(false)}
|
|
||||||
className="text-gray-500 hover:text-gray-700 text-sm"
|
|
||||||
>
|
|
||||||
← Свернуть
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<VacancyUploadForm />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@ -17,8 +17,17 @@ import {
|
|||||||
FileText
|
FileText
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import ResumeUploadForm from '@/components/ResumeUploadForm'
|
import ResumeUploadForm from '@/components/ResumeUploadForm'
|
||||||
|
import ModeGuard from '@/components/ModeGuard'
|
||||||
|
|
||||||
export default function VacancyPage() {
|
export default function VacancyPage() {
|
||||||
|
return (
|
||||||
|
<ModeGuard>
|
||||||
|
<VacancyPageContent />
|
||||||
|
</ModeGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VacancyPageContent() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const vacancyId = parseInt(params.id as string)
|
const vacancyId = parseInt(params.id as string)
|
||||||
|
|||||||
@ -7,8 +7,17 @@ import {
|
|||||||
import VacancyReports from "@/components/VacancyReports";
|
import VacancyReports from "@/components/VacancyReports";
|
||||||
import { useInterviewReports } from "@/hooks/useReports";
|
import { useInterviewReports } from "@/hooks/useReports";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import ModeGuard from '@/components/ModeGuard'
|
||||||
|
|
||||||
export default function VacancyPage() {
|
export default function VacancyPage() {
|
||||||
|
return (
|
||||||
|
<ModeGuard>
|
||||||
|
<VacancyReportPageContent />
|
||||||
|
</ModeGuard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function VacancyReportPageContent() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const vacancyId = parseInt(params.id as string)
|
const vacancyId = parseInt(params.id as string)
|
||||||
|
|||||||
275
components/AppHomePage.tsx
Normal file
275
components/AppHomePage.tsx
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { VacancyRead } from '@/types/api'
|
||||||
|
import { useVacancies } from '@/hooks/useVacancy'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { Search, MapPin, Clock, Banknote, Plus } from 'lucide-react'
|
||||||
|
import VacancyUploadForm from '@/components/VacancyUploadForm'
|
||||||
|
import AppLayout from '@/components/AppLayout'
|
||||||
|
|
||||||
|
export default function AppHomePage() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||||
|
const [searchParams, setSearchParams] = useState({
|
||||||
|
active_only: true,
|
||||||
|
title: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: vacancies = [], isLoading, error, refetch } = useVacancies(searchParams)
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSearchParams({
|
||||||
|
active_only: true,
|
||||||
|
title: searchTerm || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSalary = (vacancy: VacancyRead) => {
|
||||||
|
if (!vacancy.salary_from && !vacancy.salary_to) return 'Зарплата не указана'
|
||||||
|
|
||||||
|
const currency = vacancy.salary_currency === 'RUR' ? '₽' : vacancy.salary_currency
|
||||||
|
|
||||||
|
if (vacancy.salary_from && vacancy.salary_to) {
|
||||||
|
return `${vacancy.salary_from.toLocaleString()} - ${vacancy.salary_to.toLocaleString()} ${currency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vacancy.salary_from) {
|
||||||
|
return `от ${vacancy.salary_from.toLocaleString()} ${currency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vacancy.salary_to) {
|
||||||
|
return `до ${vacancy.salary_to.toLocaleString()} ${currency}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExperienceText = (experience: string) => {
|
||||||
|
const mapping = {
|
||||||
|
noExperience: 'Без опыта',
|
||||||
|
between1And3: '1-3 года',
|
||||||
|
between3And6: '3-6 лет',
|
||||||
|
moreThan6: 'Более 6 лет'
|
||||||
|
}
|
||||||
|
return mapping[experience as keyof typeof mapping] || experience
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEmploymentText = (employment: string) => {
|
||||||
|
const mapping = {
|
||||||
|
full: 'Полная занятость',
|
||||||
|
part: 'Частичная занятость',
|
||||||
|
project: 'Проектная работа',
|
||||||
|
volunteer: 'Волонтерство',
|
||||||
|
probation: 'Стажировка'
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="flex justify-center items-center min-h-[400px]">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
Найдите работу мечты
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||||
|
Выберите понравившуюся вам вакансию, заполните форму и прикрепите резюме.<br/>
|
||||||
|
После недолговременной обработки вашего документа мы предоставим вам возможность подключится к сессии для собеседования, если ваше резюме удовлетворит вакансию.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по названию вакансии..."
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
Найти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-red-600">Не удалось загрузить вакансии</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vacancies Grid */}
|
||||||
|
{!error && vacancies.length > 0 && (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{getVacanciesWithPlaceholders(vacancies).map((item) => {
|
||||||
|
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>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!error && !isLoading && vacancies.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<Search className="h-16 w-16 mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
Вакансии не найдены
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Попробуйте изменить параметры поиска или вернитесь позже
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Vacancy Button */}
|
||||||
|
{!showCreateForm && (
|
||||||
|
<div className="flex justify-center pt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 shadow-lg hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
|
Создать вакансию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Vacancy Form */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="text-gray-500 hover:text-gray-700 text-sm"
|
||||||
|
>
|
||||||
|
← Свернуть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<VacancyUploadForm />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
components/AppLayout.tsx
Normal file
37
components/AppLayout.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="bg-white shadow-sm border-b">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Link href="/" className="text-xl font-bold text-primary-600">
|
||||||
|
HR AI
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<nav className="flex space-x-4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-gray-600 hover:text-primary-600 px-3 py-2 rounded-md text-sm font-medium"
|
||||||
|
>
|
||||||
|
Вакансии
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
160
components/ContactForm.tsx
Normal file
160
components/ContactForm.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Send } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ContactFormData {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
company: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactForm() {
|
||||||
|
const [formData, setFormData] = useState<ContactFormData>({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
company: '',
|
||||||
|
message: ''
|
||||||
|
})
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setSubmitStatus('idle')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||||
|
const response = await fetch(`${apiUrl}/api/v1/contact`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSubmitStatus('success')
|
||||||
|
setFormData({ name: '', email: '', phone: '', company: '', message: '' })
|
||||||
|
} else {
|
||||||
|
setSubmitStatus('error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setSubmitStatus('error')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow-lg p-8 md:p-12">
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Ваше имя *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="Иван Иванов"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
required
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
placeholder="ivan@company.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Телефон
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
id="phone"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
placeholder="+7 (999) 123-45-67"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Компания
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="company"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
|
value={formData.company}
|
||||||
|
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
||||||
|
placeholder="Название компании"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Сообщение
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
|
||||||
|
value={formData.message}
|
||||||
|
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||||
|
placeholder="Расскажите о вашей компании и задачах..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitStatus === 'success' && (
|
||||||
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg text-green-800">
|
||||||
|
Спасибо! Мы свяжемся с вами в ближайшее время.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitStatus === 'error' && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||||
|
Произошла ошибка. Попробуйте ещё раз или напишите нам на email.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full inline-flex items-center justify-center px-8 py-4 bg-primary-600 text-white font-semibold rounded-lg hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-3"></div>
|
||||||
|
Отправка...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-5 w-5 mr-2" />
|
||||||
|
Отправить заявку
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
47
components/LandingLayout.tsx
Normal file
47
components/LandingLayout.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface LandingLayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LandingLayout({ children }: LandingLayoutProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="absolute top-0 left-0 right-0 z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-20">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Link href="/" className="text-2xl font-bold text-white">
|
||||||
|
HR AI
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<nav className="hidden md:flex items-center space-x-8">
|
||||||
|
<a
|
||||||
|
href="#features"
|
||||||
|
className="text-white/80 hover:text-white transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Возможности
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#how-it-works"
|
||||||
|
className="text-white/80 hover:text-white transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Как это работает
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
className="text-white/80 hover:text-white transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Контакты
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
344
components/LandingPage.tsx
Normal file
344
components/LandingPage.tsx
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Users,
|
||||||
|
FileText,
|
||||||
|
Mic,
|
||||||
|
BarChart3,
|
||||||
|
Clock,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowRight,
|
||||||
|
Zap,
|
||||||
|
Shield,
|
||||||
|
TrendingUp,
|
||||||
|
Mail,
|
||||||
|
MessageSquare
|
||||||
|
} from 'lucide-react'
|
||||||
|
import ContactForm from './ContactForm'
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Bot,
|
||||||
|
title: 'AI-интервьюер',
|
||||||
|
description: 'Искусственный интеллект проводит собеседования 24/7, задавая релевантные вопросы по резюме кандидата'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Mic,
|
||||||
|
title: 'Голосовое общение',
|
||||||
|
description: 'Естественное голосовое взаимодействие через WebRTC — как настоящий телефонный звонок'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
title: 'Автоматический анализ',
|
||||||
|
description: 'Мгновенный парсинг резюме и вакансий из PDF, DOCX и других форматов'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BarChart3,
|
||||||
|
title: 'Объективные оценки',
|
||||||
|
description: 'Детальные отчёты по 6 критериям: технические навыки, опыт, коммуникация и другие'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clock,
|
||||||
|
title: 'Экономия времени',
|
||||||
|
description: 'Сократите время на первичный скрининг кандидатов с дней до минут'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
title: 'Без предвзятости',
|
||||||
|
description: 'AI оценивает только профессиональные качества, исключая субъективные факторы'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const howItWorks = [
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
title: 'Загрузите вакансию',
|
||||||
|
description: 'Импортируйте описание вакансии из файла или введите вручную'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
title: 'Кандидат откликается',
|
||||||
|
description: 'Соискатель загружает резюме и заполняет контактные данные'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
title: 'AI проводит интервью',
|
||||||
|
description: 'Наш AI-агент звонит кандидату и проводит структурированное собеседование'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
title: 'Получите отчёт',
|
||||||
|
description: 'Детальный анализ с оценками, сильными и слабыми сторонами кандидата'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: '90%', label: 'Экономия времени HR' },
|
||||||
|
{ value: '24/7', label: 'Доступность системы' },
|
||||||
|
{ value: '6', label: 'Критериев оценки' },
|
||||||
|
{ value: '∞', label: 'Масштабируемость' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="relative bg-gradient-to-br from-primary-600 via-primary-700 to-primary-900 text-white overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmYiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PHBhdGggZD0iTTM2IDM0djItSDI0di0yaDEyek0zNiAyNHYySDI0di0yaDEyeiIvPjwvZz48L2c+PC9zdmc+')] opacity-30"></div>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 md:py-32 relative">
|
||||||
|
<div className="text-center max-w-4xl mx-auto">
|
||||||
|
<div className="inline-flex items-center px-4 py-2 bg-white/10 rounded-full text-sm font-medium mb-8 backdrop-blur-sm">
|
||||||
|
<Zap className="h-4 w-4 mr-2" />
|
||||||
|
Революция в подборе персонала
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold mb-6 leading-tight">
|
||||||
|
AI-собеседования для
|
||||||
|
<span className="text-primary-200"> масштабного найма</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl md:text-2xl text-primary-100 mb-10 max-w-3xl mx-auto">
|
||||||
|
Автоматизируйте первичный скрининг кандидатов с помощью искусственного интеллекта.
|
||||||
|
Проводите сотни собеседований одновременно без потери качества.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
className="inline-flex items-center justify-center px-8 py-4 bg-white text-primary-700 font-semibold rounded-xl hover:bg-primary-50 transition-colors shadow-lg hover:shadow-xl"
|
||||||
|
>
|
||||||
|
Получить демо
|
||||||
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#how-it-works"
|
||||||
|
className="inline-flex items-center justify-center px-8 py-4 border-2 border-white/30 text-white font-semibold rounded-xl hover:bg-white/10 transition-colors backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
Как это работает
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wave divider */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0">
|
||||||
|
<svg viewBox="0 0 1440 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M0 120L60 105C120 90 240 60 360 45C480 30 600 30 720 37.5C840 45 960 60 1080 67.5C1200 75 1320 75 1380 75L1440 75V120H1380C1320 120 1200 120 1080 120C960 120 840 120 720 120C600 120 480 120 360 120C240 120 120 120 60 120H0Z" fill="#f9fafb"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Stats Section */}
|
||||||
|
<section className="py-16 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<div key={index} className="text-center">
|
||||||
|
<div className="text-4xl md:text-5xl font-bold text-primary-600 mb-2">
|
||||||
|
{stat.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 font-medium">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section className="py-20 bg-white" id="features">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
Возможности платформы
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Полный цикл автоматизации подбора персонала — от получения резюме до финального отчёта
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="p-6 bg-gray-50 rounded-2xl hover:bg-white hover:shadow-lg transition-all duration-300 border border-transparent hover:border-gray-200"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-primary-100 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<feature.icon className="h-6 w-6 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* How It Works Section */}
|
||||||
|
<section className="py-20 bg-gray-50" id="how-it-works">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
Как это работает
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Простой процесс интеграции в ваш рекрутинговый процесс
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{howItWorks.map((item, index) => (
|
||||||
|
<div key={index} className="relative">
|
||||||
|
<div className="bg-white rounded-2xl p-6 shadow-sm border border-gray-100 h-full">
|
||||||
|
<div className="w-10 h-10 bg-primary-600 text-white rounded-full flex items-center justify-center font-bold text-lg mb-4">
|
||||||
|
{item.step}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
{item.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{index < howItWorks.length - 1 && (
|
||||||
|
<div className="hidden lg:block absolute top-1/2 -right-4 transform -translate-y-1/2">
|
||||||
|
<ArrowRight className="h-6 w-6 text-gray-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Benefits Section */}
|
||||||
|
<section className="py-20 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-6">
|
||||||
|
Почему компании выбирают HR AI
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<CheckCircle2 className="h-6 w-6 text-green-500 mr-4 mt-1 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-1">Масштабируемость без ограничений</h3>
|
||||||
|
<p className="text-gray-600">Проводите тысячи собеседований одновременно без увеличения штата HR</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<CheckCircle2 className="h-6 w-6 text-green-500 mr-4 mt-1 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-1">Стандартизированная оценка</h3>
|
||||||
|
<p className="text-gray-600">Все кандидаты оцениваются по единым критериям, что обеспечивает объективность</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<CheckCircle2 className="h-6 w-6 text-green-500 mr-4 mt-1 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-1">Мгновенные результаты</h3>
|
||||||
|
<p className="text-gray-600">Получайте детальные отчёты сразу после завершения собеседования</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<CheckCircle2 className="h-6 w-6 text-green-500 mr-4 mt-1 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-1">Улучшение качества найма</h3>
|
||||||
|
<p className="text-gray-600">AI анализирует не только ответы, но и способ коммуникации кандидата</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-br from-primary-50 to-primary-100 rounded-3xl p-8 lg:p-12">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<TrendingUp className="h-8 w-8 text-primary-600 mr-4" />
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-primary-700">90%</div>
|
||||||
|
<div className="text-gray-600">сокращение времени на скрининг</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Users className="h-8 w-8 text-primary-600 mr-4" />
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-primary-700">∞</div>
|
||||||
|
<div className="text-gray-600">кандидатов в день</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<BarChart3 className="h-8 w-8 text-primary-600 mr-4" />
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-primary-700">6</div>
|
||||||
|
<div className="text-gray-600">метрик в каждом отчёте</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Contact Form Section */}
|
||||||
|
<section className="py-20 bg-gray-50" id="contact">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
Свяжитесь с нами
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600">
|
||||||
|
Получите персональную демонстрацию и узнайте, как HR AI может помочь вашей компании
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-gray-900 text-gray-400 py-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-white mb-4">HR AI</div>
|
||||||
|
<p className="text-sm">
|
||||||
|
Автоматизация подбора персонала с помощью искусственного интеллекта
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-semibold mb-4">Контакты</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
|
<span>accounts@rocketfounders.co</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<MessageSquare className="h-4 w-4 mr-2" />
|
||||||
|
<span>Telegram: @eugenesmykov</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-semibold mb-4">Ссылки</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<a href="#features" className="block hover:text-white transition-colors">Возможности</a>
|
||||||
|
<a href="#how-it-works" className="block hover:text-white transition-colors">Как это работает</a>
|
||||||
|
<a href="#contact" className="block hover:text-white transition-colors">Контакты</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-sm">
|
||||||
|
© 2025 HR AI. Все права защищены.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
components/ModeGuard.tsx
Normal file
30
components/ModeGuard.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, ReactNode } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
const APP_MODE = process.env.NEXT_PUBLIC_APP_MODE || 'landing'
|
||||||
|
|
||||||
|
interface ModeGuardProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ModeGuard({ children }: ModeGuardProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (APP_MODE === 'landing') {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
if (APP_MODE === 'landing') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppMode(): boolean {
|
||||||
|
return APP_MODE === 'app'
|
||||||
|
}
|
||||||
@ -45,7 +45,7 @@ echo -e "${YELLOW}Configuring Docker for Yandex Cloud Container Registry...${NC}
|
|||||||
yc container registry configure-docker
|
yc container registry configure-docker
|
||||||
|
|
||||||
echo -e "${YELLOW}Building Docker image: ${FULL_IMAGE_NAME}${NC}"
|
echo -e "${YELLOW}Building Docker image: ${FULL_IMAGE_NAME}${NC}"
|
||||||
docker build -t "${FULL_IMAGE_NAME}" .
|
docker build --no-cache -t "${FULL_IMAGE_NAME}" .
|
||||||
|
|
||||||
echo -e "${YELLOW}Pushing image to registry...${NC}"
|
echo -e "${YELLOW}Pushing image to registry...${NC}"
|
||||||
docker push "${FULL_IMAGE_NAME}"
|
docker push "${FULL_IMAGE_NAME}"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user