diff --git a/.dockerignore b/.dockerignore index 1c2bdeb..718e846 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,11 +6,10 @@ npm-debug.log* .git .gitignore README.md -.env -.env.local -.env.production.local -.env.development.local coverage .nyc_output .DS_Store -*.tsbuildinfo \ No newline at end of file +*.tsbuildinfo + +# Exclude app mode files for landing build +app/app \ No newline at end of file diff --git a/.env.example b/.env.example index 061fcf7..ff3059e 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,9 @@ NEXT_PUBLIC_API_BASE_URL=https://hr.aiquity.xyz:8000/api 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 diff --git a/Dockerfile b/Dockerfile index 2566d36..3af2a49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,27 @@ FROM base AS builder COPY --from=deps /app/node_modules ./node_modules 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 FROM base AS runner 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 adduser --system --uid 1001 nextjs diff --git a/app/app/page.tsx b/app/app/page.tsx new file mode 100644 index 0000000..4612856 --- /dev/null +++ b/app/app/page.tsx @@ -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 ( + + + + ) +} + +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 = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) + + 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 ( + +
+ {/* Header */} +
+

+ Найдите работу мечты +

+

+ Выберите понравившуюся вам вакансию, заполните форму и прикрепите резюме.
+ После недолговременной обработки вашего документа мы предоставим вам возможность подключится к сессии для собеседования, если ваше резюме удовлетворит вакансию. +

+
+ + {/* Search */} +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+
+ + {/* Error */} + {error && ( +
+

Не удалось загрузить вакансии

+ +
+ )} + + {/* Vacancies Grid */} + {!error && vacancies.length > 0 && ( +
+ {getVacanciesWithPlaceholders(vacancies).map((item) => { + if ('isPlaceholder' in item) { + return + } + + const vacancy = item as VacancyRead + return ( + +
+
+

+ {vacancy.title} +

+ {vacancy.premium && ( + + Premium + + )} +
+ +
+
+ + {formatSalary(vacancy)} +
+ +
+ + {formatNullableField(vacancy.area_name)} +
+ +
+ + {getExperienceText(vacancy.experience)} +
+
+ +
+

{formatNullableField(vacancy.company_name)}

+

{formatNullableField(vacancy.description)}

+
+ +
+ + {getEmploymentText(vacancy.employment_type)} + + + {new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')} + +
+
+ + ) + })} +
+ )} + + {/* Empty State */} + {!error && !isLoading && vacancies.length === 0 && ( +
+
+ +
+

+ Вакансии не найдены +

+

+ Попробуйте изменить параметры поиска или вернитесь позже +

+
+ )} + + {/* Create Vacancy Button */} + {!showCreateForm && ( +
+ +
+ )} + + {/* Create Vacancy Form */} + {showCreateForm && ( +
+
+ +
+ +
+ )} +
+
+ ) +} diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index c6652f3..f1c7937 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -4,8 +4,17 @@ import { useParams, useRouter } from 'next/navigation' import InterviewSession from '@/components/InterviewSession' import { useValidateInterview } from '@/hooks/useResume' import { ArrowLeft, AlertCircle, Loader } from 'lucide-react' +import ModeGuard from '@/components/ModeGuard' export default function InterviewPage() { + return ( + + + + ) +} + +function InterviewPageContent() { const params = useParams() const router = useRouter() const resumeId = parseInt(params.id as string) diff --git a/app/layout.tsx b/app/layout.tsx index 997794a..f4f7dd7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,8 +3,8 @@ import './globals.css' import QueryProvider from '@/components/QueryProvider' export const metadata: Metadata = { - title: 'HR AI - Поиск работы', - description: 'Платформа для поиска вакансий с искусственным интеллектом', + title: 'HR AI - AI-собеседования для масштабного найма', + description: 'Автоматизируйте подбор персонала с помощью искусственного интеллекта. Проводите сотни собеседований одновременно.', } export default function RootLayout({ @@ -19,23 +19,7 @@ export default function RootLayout({ -
-
-
-
-

HR AI

-
- -
-
-
-
- {children} -
+ {children}
diff --git a/app/page.tsx b/app/page.tsx index 8df6fc2..f79b22a 100644 --- a/app/page.tsx +++ b/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' -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' +// Режим работы приложения: 'landing' или 'app' +const APP_MODE = process.env.NEXT_PUBLIC_APP_MODE || 'landing' export default function HomePage() { - 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 = () => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) - - 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) { + // Если режим лендинга - показываем лендинг + if (APP_MODE === 'landing') { return ( -
-
-
+ + + ) } - return ( -
- {/* Header */} -
-

- Найдите работу мечты -

-

- Выберите понравившуюся вам вакансию, заполните форму и прикрепите резюме.
- После недолговременной обработки вашего документа мы предоставим вам возможность подключится к сессии для собеседования, если ваше резюме удовлетворит вакансию. -

-
- - {/* Search */} -
-
-
- - setSearchTerm(e.target.value)} - /> -
- -
-
- - {/* Error */} - {error && ( -
-

Не удалось загрузить вакансии

- -
- )} - - {/* Vacancies Grid */} - {!error && vacancies.length > 0 && ( -
- {getVacanciesWithPlaceholders(vacancies).map((item, index) => { - if ('isPlaceholder' in item) { - return - } - - const vacancy = item as VacancyRead - return ( - -
-
-

- {vacancy.title} -

- {vacancy.premium && ( - - Premium - - )} -
- -
-
- - {formatSalary(vacancy)} -
- -
- - {formatNullableField(vacancy.area_name)} -
- -
- - {getExperienceText(vacancy.experience)} -
-
- -
-

{formatNullableField(vacancy.company_name)}

-

{formatNullableField(vacancy.description)}

-
- -
- - {getEmploymentText(vacancy.employment_type)} - - - {new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')} - -
-
- - ) - })} -
- )} - - {/* Empty State */} - {!error && !isLoading && vacancies.length === 0 && ( -
-
- -
-

- Вакансии не найдены -

-

- Попробуйте изменить параметры поиска или вернитесь позже -

-
- )} - - {/* Create Vacancy Button */} - {!showCreateForm && ( -
- -
- )} - - {/* Create Vacancy Form */} - {showCreateForm && ( -
-
- -
- -
- )} -
- ) -} \ No newline at end of file + // Иначе показываем основное приложение + return +} diff --git a/app/vacancy/[id]/page.tsx b/app/vacancy/[id]/page.tsx index 2c1f122..ae46980 100644 --- a/app/vacancy/[id]/page.tsx +++ b/app/vacancy/[id]/page.tsx @@ -17,8 +17,17 @@ import { FileText } from 'lucide-react' import ResumeUploadForm from '@/components/ResumeUploadForm' +import ModeGuard from '@/components/ModeGuard' export default function VacancyPage() { + return ( + + + + ) +} + +function VacancyPageContent() { const params = useParams() const router = useRouter() const vacancyId = parseInt(params.id as string) diff --git a/app/vacancy/report/[id]/page.tsx b/app/vacancy/report/[id]/page.tsx index 1e35d25..097eceb 100644 --- a/app/vacancy/report/[id]/page.tsx +++ b/app/vacancy/report/[id]/page.tsx @@ -7,8 +7,17 @@ import { import VacancyReports from "@/components/VacancyReports"; import { useInterviewReports } from "@/hooks/useReports"; import React from "react"; +import ModeGuard from '@/components/ModeGuard' export default function VacancyPage() { + return ( + + + + ) +} + +function VacancyReportPageContent() { const params = useParams() const router = useRouter() const vacancyId = parseInt(params.id as string) diff --git a/components/AppHomePage.tsx b/components/AppHomePage.tsx new file mode 100644 index 0000000..4facdfa --- /dev/null +++ b/components/AppHomePage.tsx @@ -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 = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) + + 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 ( + +
+ {/* Header */} +
+

+ Найдите работу мечты +

+

+ Выберите понравившуюся вам вакансию, заполните форму и прикрепите резюме.
+ После недолговременной обработки вашего документа мы предоставим вам возможность подключится к сессии для собеседования, если ваше резюме удовлетворит вакансию. +

+
+ + {/* Search */} +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+
+ + {/* Error */} + {error && ( +
+

Не удалось загрузить вакансии

+ +
+ )} + + {/* Vacancies Grid */} + {!error && vacancies.length > 0 && ( +
+ {getVacanciesWithPlaceholders(vacancies).map((item) => { + if ('isPlaceholder' in item) { + return + } + + const vacancy = item as VacancyRead + return ( + +
+
+

+ {vacancy.title} +

+ {vacancy.premium && ( + + Premium + + )} +
+ +
+
+ + {formatSalary(vacancy)} +
+ +
+ + {formatNullableField(vacancy.area_name)} +
+ +
+ + {getExperienceText(vacancy.experience)} +
+
+ +
+

{formatNullableField(vacancy.company_name)}

+

{formatNullableField(vacancy.description)}

+
+ +
+ + {getEmploymentText(vacancy.employment_type)} + + + {new Date(vacancy.published_at || vacancy.created_at).toLocaleDateString('ru-RU')} + +
+
+ + ) + })} +
+ )} + + {/* Empty State */} + {!error && !isLoading && vacancies.length === 0 && ( +
+
+ +
+

+ Вакансии не найдены +

+

+ Попробуйте изменить параметры поиска или вернитесь позже +

+
+ )} + + {/* Create Vacancy Button */} + {!showCreateForm && ( +
+ +
+ )} + + {/* Create Vacancy Form */} + {showCreateForm && ( +
+
+ +
+ +
+ )} +
+
+ ) +} diff --git a/components/AppLayout.tsx b/components/AppLayout.tsx new file mode 100644 index 0000000..0a09902 --- /dev/null +++ b/components/AppLayout.tsx @@ -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 ( + <> +
+
+
+
+ + HR AI + +
+ +
+
+
+
+ {children} +
+ + ) +} diff --git a/components/ContactForm.tsx b/components/ContactForm.tsx new file mode 100644 index 0000000..01582f1 --- /dev/null +++ b/components/ContactForm.tsx @@ -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({ + 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 ( +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Иван Иванов" + /> +
+
+ + setFormData({ ...formData, email: e.target.value })} + placeholder="ivan@company.com" + /> +
+
+ +
+
+ + setFormData({ ...formData, phone: e.target.value })} + placeholder="+7 (999) 123-45-67" + /> +
+
+ + setFormData({ ...formData, company: e.target.value })} + placeholder="Название компании" + /> +
+
+ +
+ +