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 */}
+
+
+ {/* Error */}
+ {error && (
+
+
Не удалось загрузить вакансии
+
refetch()}
+ className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
+ >
+ Попробовать снова
+
+
+ )}
+
+ {/* 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 && (
+
+
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"
+ >
+
+ Создать вакансию
+
+
+ )}
+
+ {/* Create Vacancy Form */}
+ {showCreateForm && (
+
+
+ setShowCreateForm(false)}
+ className="text-gray-500 hover:text-gray-700 text-sm"
+ >
+ ← Свернуть
+
+
+
+
+ )}
+
+
+ )
+}
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({
-
-
- {children}
-
+ {children}