This commit is contained in:
2026-03-13 19:58:52 +03:00
parent cd1129ea72
commit 65eca64a5f
14 changed files with 6598 additions and 299 deletions

View File

@@ -0,0 +1,358 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Отклик | Rabota.Today</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
/* Стили аналогичные предыдущим страницам */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background: linear-gradient(145deg, #eef5fa 0%, #e0eaf5 100%);
min-height: 100vh;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #0b1c34;
color: white;
padding: 20px 40px;
border-radius: 40px;
margin-bottom: 40px;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 28px;
font-weight: 700;
display: flex;
align-items: center;
gap: 15px;
}
.logo i {
color: #3b82f6;
background: rgba(255,255,255,0.1);
padding: 12px;
border-radius: 20px;
}
.nav {
display: flex;
gap: 15px;
align-items: center;
}
.nav a {
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 30px;
}
.nav .active {
background: #3b82f6;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #4f7092;
text-decoration: none;
}
.application-detail {
background: white;
border-radius: 40px;
padding: 40px;
box-shadow: 0 20px 40px rgba(0,20,40,0.1);
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #dee9f5;
}
.status-badge {
padding: 8px 20px;
border-radius: 40px;
font-weight: 600;
}
.status-badge.pending { background: #fef3c7; color: #92400e; }
.status-badge.viewed { background: #dbeafe; color: #1e40af; }
.status-badge.accepted { background: #d1fae5; color: #065f46; }
.status-badge.rejected { background: #fee2e2; color: #b91c1c; }
.date {
color: #4f7092;
font-size: 14px;
}
.section {
margin: 30px 0;
padding: 20px;
background: #f9fcff;
border-radius: 20px;
}
.section h3 {
color: #0b1c34;
margin-bottom: 15px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
}
.info-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: white;
border-radius: 15px;
}
.info-item i {
color: #3b82f6;
width: 20px;
}
.message-box {
background: white;
padding: 20px;
border-radius: 20px;
margin: 20px 0;
font-style: italic;
border-left: 4px solid #3b82f6;
}
.response-box {
background: #e6f7e6;
padding: 20px;
border-radius: 20px;
margin: 20px 0;
border-left: 4px solid #10b981;
}
.action-buttons {
display: flex;
gap: 15px;
margin-top: 30px;
}
.btn {
padding: 14px 28px;
border-radius: 40px;
border: none;
font-weight: 600;
cursor: pointer;
}
.btn-primary {
background: #0b1c34;
color: white;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-outline {
background: transparent;
border: 2px solid #dee9f5;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<i class="fas fa-briefcase"></i>
Rabota.Today
</div>
<div class="nav" id="nav">
<!-- Навигация -->
</div>
</div>
<a href="/applications" class="back-link"><i class="fas fa-arrow-left"></i> Назад к откликам</a>
<div id="applicationDetail" class="application-detail">
<div class="loading">Загрузка...</div>
</div>
</div>
<script>
const API_BASE_URL = 'http://localhost:8000/api';
const token = localStorage.getItem('accessToken');
const pathParts = window.location.pathname.split('/');
const applicationId = pathParts[pathParts.length - 1];
if (!token) window.location.href = '/login';
async function loadApplication() {
try {
const response = await fetch(`${API_BASE_URL}/applications/${applicationId}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error('Ошибка загрузки');
const app = await response.json();
renderApplication(app);
} catch (error) {
console.error('Error:', error);
}
}
function renderApplication(app) {
const container = document.getElementById('applicationDetail');
const isEmployer = app.employer_id === currentUser?.id;
const statusText = {
'pending': 'Ожидает',
'viewed': 'Просмотрено',
'accepted': 'Принято',
'rejected': 'Отклонено'
}[app.status] || app.status;
container.innerHTML = `
<div class="status-bar">
<span class="status-badge ${app.status}">${statusText}</span>
<span class="date">Создано: ${new Date(app.created_at).toLocaleString()}</span>
</div>
<h2>${escapeHtml(app.vacancy_title)}</h2>
<div class="section">
<h3>Информация о вакансии</h3>
<div class="info-grid">
<div class="info-item">
<i class="fas fa-building"></i>
<span>${escapeHtml(app.company_name || app.employer_name)}</span>
</div>
<div class="info-item">
<i class="fas fa-money-bill"></i>
<span>${escapeHtml(app.vacancy_salary || 'з/п не указана')}</span>
</div>
<div class="info-item">
<i class="fas fa-envelope"></i>
<span>${escapeHtml(app.employer_email)}</span>
</div>
</div>
</div>
<div class="section">
<h3>Информация о соискателе</h3>
<div class="info-grid">
<div class="info-item">
<i class="fas fa-user"></i>
<span>${escapeHtml(app.applicant_name)}</span>
</div>
<div class="info-item">
<i class="fas fa-envelope"></i>
<span>${escapeHtml(app.applicant_email)}</span>
</div>
<div class="info-item">
<i class="fas fa-phone"></i>
<span>${escapeHtml(app.applicant_phone || '—')}</span>
</div>
<div class="info-item">
<i class="fab fa-telegram"></i>
<span>${escapeHtml(app.applicant_telegram || '—')}</span>
</div>
</div>
</div>
${app.message ? `
<div class="message-box">
<strong>Сопроводительное письмо:</strong>
<p>${escapeHtml(app.message)}</p>
</div>
` : ''}
${app.response_message ? `
<div class="response-box">
<strong>Ответ работодателя:</strong>
<p>${escapeHtml(app.response_message)}</p>
<small>${new Date(app.response_at).toLocaleString()}</small>
</div>
` : ''}
${isEmployer && app.status === 'pending' ? `
<div class="action-buttons">
<button class="btn btn-success" onclick="updateStatus('accepted')">Принять</button>
<button class="btn btn-danger" onclick="updateStatus('rejected')">Отклонить</button>
</div>
` : ''}
`;
}
async function updateStatus(status) {
const message = prompt('Введите сообщение (необязательно):');
try {
const response = await fetch(`${API_BASE_URL}/applications/${applicationId}/status`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
status: status,
response_message: message || null
})
});
if (response.ok) {
alert('Статус обновлен');
loadApplication();
}
} catch (error) {
alert('Ошибка');
}
}
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
loadApplication();
</script>
</body>
</html>

1040
templates/applications.html Normal file

File diff suppressed because it is too large Load Diff

864
templates/favorites.html Normal file
View File

@@ -0,0 +1,864 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Избранное | Rabota.Today</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background: linear-gradient(145deg, #eef5fa 0%, #e0eaf5 100%);
min-height: 100vh;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #0b1c34;
color: white;
padding: 20px 40px;
border-radius: 40px;
margin-bottom: 40px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.logo {
font-size: 28px;
font-weight: 700;
display: flex;
align-items: center;
gap: 15px;
}
.logo i {
color: #3b82f6;
background: rgba(255,255,255,0.1);
padding: 12px;
border-radius: 20px;
}
.nav {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.nav a {
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 30px;
transition: 0.2s;
}
.nav a:hover {
background: rgba(255,255,255,0.1);
}
.nav .active {
background: #3b82f6;
}
.profile-link {
display: flex;
align-items: center;
gap: 8px;
background: #3b82f6;
padding: 8px 20px !important;
}
.profile-link i {
font-size: 18px;
}
.user-avatar {
width: 35px;
height: 35px;
background: #3b82f6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.user-name {
font-weight: 500;
}
.admin-badge {
background: #f59e0b;
color: white;
padding: 2px 8px;
border-radius: 20px;
font-size: 12px;
margin-left: 5px;
}
.back-to-profile {
display: inline-flex;
align-items: center;
gap: 8px;
color: #4f7092;
text-decoration: none;
margin-bottom: 20px;
padding: 8px 16px;
border-radius: 30px;
background: white;
transition: 0.2s;
}
.back-to-profile:hover {
background: #eef4fa;
color: #0b1c34;
}
.favorites-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
flex-wrap: wrap;
gap: 20px;
}
.favorites-header h1 {
font-size: 32px;
color: #0b1c34;
display: flex;
align-items: center;
gap: 10px;
}
.favorites-header h1 i {
color: #b91c1c;
font-size: 28px;
}
.filter-tabs {
display: flex;
gap: 10px;
background: white;
padding: 8px;
border-radius: 50px;
box-shadow: 0 5px 15px rgba(0,0,0,0.05);
}
.filter-tab {
padding: 10px 24px;
border-radius: 40px;
cursor: pointer;
font-weight: 600;
transition: 0.2s;
color: #4f7092;
}
.filter-tab:hover {
background: #eef4fa;
}
.filter-tab.active {
background: #0b1c34;
color: white;
}
.stats-summary {
background: white;
border-radius: 30px;
padding: 20px 30px;
margin-bottom: 30px;
display: flex;
gap: 40px;
flex-wrap: wrap;
box-shadow: 0 10px 30px rgba(0,20,40,0.05);
}
.stat-item {
display: flex;
align-items: center;
gap: 10px;
}
.stat-item .value {
font-size: 28px;
font-weight: 700;
color: #0b1c34;
}
.stat-item .label {
color: #4f7092;
font-size: 16px;
}
.favorites-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 30px;
}
.favorite-card {
background: white;
border-radius: 40px;
padding: 30px;
transition: 0.3s;
cursor: pointer;
position: relative;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
.favorite-card:hover {
transform: translateY(-5px);
box-shadow: 0 20px 40px rgba(0,20,40,0.15);
}
.favorite-card.vacancy {
border-left: 5px solid #3b82f6;
}
.favorite-card.resume {
border-left: 5px solid #10b981;
}
.favorite-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 15px;
}
.favorite-type {
background: #eef4fa;
padding: 6px 14px;
border-radius: 30px;
font-size: 13px;
font-weight: 600;
color: #1f3f60;
}
.favorite-type.vacancy {
background: #dbeafe;
color: #1e40af;
}
.favorite-type.resume {
background: #d1fae5;
color: #065f46;
}
.remove-btn {
background: #fee2e2;
border: none;
color: #b91c1c;
width: 36px;
height: 36px;
border-radius: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: 0.2s;
}
.remove-btn:hover {
background: #fecaca;
transform: scale(1.1);
}
.favorite-title {
font-size: 20px;
font-weight: 700;
color: #0b1c34;
margin-bottom: 8px;
line-height: 1.3;
}
.favorite-subtitle {
color: #3b82f6;
font-weight: 600;
margin-bottom: 10px;
font-size: 15px;
}
.favorite-salary {
font-size: 20px;
font-weight: 700;
color: #0f2b4f;
margin: 15px 0;
padding: 10px 0;
border-top: 1px solid #dee9f5;
border-bottom: 1px solid #dee9f5;
}
.favorite-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 15px 0;
}
.tag {
background: #eef4fa;
padding: 6px 14px;
border-radius: 30px;
font-size: 13px;
color: #1f3f60;
font-weight: 500;
}
.favorite-footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #dee9f5;
padding-top: 20px;
margin-top: 10px;
}
.favorite-date {
color: #4f7092;
font-size: 14px;
display: flex;
align-items: center;
gap: 5px;
}
.empty-state {
text-align: center;
padding: 80px 40px;
background: white;
border-radius: 40px;
grid-column: 1/-1;
box-shadow: 0 10px 30px rgba(0,0,0,0.05);
}
.empty-state i {
font-size: 80px;
color: #cbd5e1;
margin-bottom: 25px;
}
.empty-state h2 {
font-size: 28px;
color: #0b1c34;
margin-bottom: 15px;
}
.empty-state p {
color: #4f7092;
margin-bottom: 30px;
font-size: 18px;
}
.empty-state-actions {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 14px 32px;
border-radius: 40px;
border: none;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
font-size: 16px;
transition: 0.2s;
}
.btn-primary {
background: #0b1c34;
color: white;
}
.btn-primary:hover {
background: #1b3f6b;
transform: scale(1.05);
}
.btn-outline {
background: transparent;
border: 2px solid #3b82f6;
color: #0b1c34;
}
.btn-outline:hover {
background: #eef4fa;
}
.loading {
text-align: center;
padding: 80px;
color: #4f7092;
font-size: 18px;
grid-column: 1/-1;
background: white;
border-radius: 40px;
}
.loading i {
font-size: 40px;
margin-bottom: 15px;
color: #3b82f6;
}
#notification {
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 30px;
background: white;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
z-index: 9999;
animation: slideIn 0.3s;
max-width: 350px;
display: none;
}
#notification.success {
background: #10b981;
color: white;
}
#notification.error {
background: #ef4444;
color: white;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (max-width: 768px) {
.favorites-header {
flex-direction: column;
align-items: flex-start;
}
.filter-tabs {
width: 100%;
justify-content: stretch;
}
.filter-tab {
flex: 1;
text-align: center;
padding: 10px 16px;
}
.stats-summary {
justify-content: space-around;
}
.favorites-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<i class="fas fa-briefcase"></i>
Rabota.Today
</div>
<div class="nav" id="nav">
<!-- Навигация будет заполнена динамически -->
</div>
</div>
<a href="/profile" class="back-to-profile">
<i class="fas fa-arrow-left"></i> Вернуться в профиль
</a>
<div class="favorites-header">
<h1>
<i class="fas fa-heart"></i>
Избранное
</h1>
<div class="filter-tabs">
<div class="filter-tab" data-filter="all" onclick="filterFavorites('all')">Все</div>
<div class="filter-tab" data-filter="vacancy" onclick="filterFavorites('vacancy')">Вакансии</div>
<div class="filter-tab" data-filter="resume" onclick="filterFavorites('resume')">Резюме</div>
</div>
</div>
<!-- Статистика избранного -->
<div class="stats-summary" id="statsSummary">
<div class="stat-item">
<span class="value" id="totalCount">0</span>
<span class="label">всего</span>
</div>
<div class="stat-item">
<span class="value" id="vacancyCount">0</span>
<span class="label">вакансий</span>
</div>
<div class="stat-item">
<span class="value" id="resumeCount">0</span>
<span class="label">резюме</span>
</div>
</div>
<div id="favoritesContainer" class="favorites-grid">
<div class="loading">
<i class="fas fa-spinner fa-spin"></i>
<div>Загрузка избранного...</div>
</div>
</div>
</div>
<div id="notification"></div>
<script>
const API_BASE_URL = 'http://localhost:8000/api';
let currentUser = null;
let currentFilter = 'all';
let favorites = [];
// Получаем параметр filter из URL
const urlParams = new URLSearchParams(window.location.search);
const urlFilter = urlParams.get('type');
if (urlFilter && ['all', 'vacancy', 'resume'].includes(urlFilter)) {
currentFilter = urlFilter;
}
// Проверка авторизации
const token = localStorage.getItem('accessToken');
if (!token) {
window.location.href = '/login';
}
// Проверка авторизации и получение данных пользователя
async function checkAuth() {
try {
const response = await fetch(`${API_BASE_URL}/user`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
currentUser = await response.json();
} else {
localStorage.removeItem('accessToken');
window.location.href = '/login';
}
} catch (error) {
console.error('Error checking auth:', error);
}
updateNavigation();
activateTabFromUrl();
}
// Активация таба из URL
function activateTabFromUrl() {
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.filter === currentFilter) {
tab.classList.add('active');
}
});
}
// Обновление навигации
function updateNavigation() {
const nav = document.getElementById('nav');
if (currentUser) {
nav.innerHTML = `
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/favorites" class="active">Избранное</a>
<a href="/applications">Отклики</a>
<a href="/profile" class="profile-link">
<i class="fas fa-user-circle"></i>
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
${currentUser.is_admin ? '<span class="admin-badge">Admin</span>' : ''}
</a>
`;
}
}
// Загрузка избранного
async function loadFavorites() {
try {
const response = await fetch(`${API_BASE_URL}/favorites`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Ошибка загрузки');
favorites = await response.json();
// Обновляем статистику
updateStats();
// Отображаем избранное с текущим фильтром
renderFavorites();
} catch (error) {
console.error('Error loading favorites:', error);
document.getElementById('favoritesContainer').innerHTML =
'<div class="loading">Ошибка загрузки избранного</div>';
}
}
// Обновление статистики
function updateStats() {
const vacancyCount = favorites.filter(f => f.item_type === 'vacancy').length;
const resumeCount = favorites.filter(f => f.item_type === 'resume').length;
document.getElementById('totalCount').textContent = favorites.length;
document.getElementById('vacancyCount').textContent = vacancyCount;
document.getElementById('resumeCount').textContent = resumeCount;
}
// Отображение избранного с фильтром
function renderFavorites() {
const container = document.getElementById('favoritesContainer');
const filtered = currentFilter === 'all'
? favorites
: favorites.filter(f => f.item_type === currentFilter);
if (filtered.length === 0) {
let title = '';
let message = '';
let actionButtons = '';
if (currentFilter === 'vacancy') {
title = 'Нет избранных вакансий';
message = 'Добавляйте вакансии в избранное, чтобы не потерять интересные предложения';
actionButtons = `
<div class="empty-state-actions">
<a href="/vacancies" class="btn btn-primary">
<i class="fas fa-search"></i> Найти вакансии
</a>
<a href="/resumes" class="btn btn-outline">
<i class="fas fa-users"></i> Смотреть резюме
</a>
</div>
`;
} else if (currentFilter === 'resume') {
title = 'Нет избранных резюме';
message = 'Сохраняйте резюме кандидатов, чтобы вернуться к ним позже';
actionButtons = `
<div class="empty-state-actions">
<a href="/resumes" class="btn btn-primary">
<i class="fas fa-users"></i> Найти резюме
</a>
<a href="/vacancies" class="btn btn-outline">
<i class="fas fa-briefcase"></i> Смотреть вакансии
</a>
</div>
`;
} else {
title = 'Список избранного пуст';
message = 'Добавляйте вакансии и резюме в избранное, чтобы не потерять их';
actionButtons = `
<div class="empty-state-actions">
<a href="/vacancies" class="btn btn-primary">
<i class="fas fa-search"></i> Найти вакансии
</a>
<a href="/resumes" class="btn btn-primary">
<i class="fas fa-users"></i> Найти резюме
</a>
</div>
`;
}
container.innerHTML = `
<div class="empty-state">
<i class="far fa-heart"></i>
<h2>${title}</h2>
<p>${message}</p>
${actionButtons}
</div>
`;
return;
}
container.innerHTML = filtered.map(f => {
const data = f.item_data;
const date = new Date(f.created_at).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
});
if (f.item_type === 'vacancy') {
return `
<div class="favorite-card vacancy" onclick="window.location.href='${data.url}'">
<div class="favorite-header">
<span class="favorite-type vacancy">
<i class="fas fa-briefcase"></i> Вакансия
</span>
<button class="remove-btn" onclick="event.stopPropagation(); removeFromFavorites('vacancy', ${f.item_id})">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="favorite-title">${escapeHtml(data.title)}</div>
<div class="favorite-subtitle">
<i class="fas fa-building"></i> ${escapeHtml(data.company || 'Компания')}
</div>
<div class="favorite-salary">${escapeHtml(data.salary || 'Зарплата не указана')}</div>
<div class="favorite-tags">
${(data.tags || []).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
</div>
<div class="favorite-footer">
<span class="favorite-date">
<i class="far fa-calendar"></i> ${date}
</span>
</div>
</div>
`;
} else {
return `
<div class="favorite-card resume" onclick="window.location.href='${data.url}'">
<div class="favorite-header">
<span class="favorite-type resume">
<i class="fas fa-user"></i> Резюме
</span>
<button class="remove-btn" onclick="event.stopPropagation(); removeFromFavorites('resume', ${f.item_id})">
<i class="fas fa-trash"></i>
</button>
</div>
<div class="favorite-title">${escapeHtml(data.name)}</div>
<div class="favorite-subtitle">
<i class="fas fa-briefcase"></i> ${escapeHtml(data.title || 'Должность не указана')}
</div>
<div class="favorite-salary">${escapeHtml(data.salary || 'Зарплата не указана')}</div>
<div class="favorite-tags">
${(data.tags || []).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}
</div>
<div class="favorite-footer">
<span class="favorite-date">
<i class="far fa-calendar"></i> ${date}
</span>
</div>
</div>
`;
}
}).join('');
}
// Фильтрация избранного
function filterFavorites(filter) {
currentFilter = filter;
// Обновляем URL без перезагрузки страницы
const url = new URL(window.location);
url.searchParams.set('type', filter);
window.history.pushState({}, '', url);
// Обновляем активный таб
document.querySelectorAll('.filter-tab').forEach(tab => {
tab.classList.remove('active');
if (tab.dataset.filter === filter) {
tab.classList.add('active');
}
});
renderFavorites();
}
// Удаление из избранного
async function removeFromFavorites(itemType, itemId) {
if (!confirm('Удалить из избранного?')) return;
try {
const response = await fetch(`${API_BASE_URL}/favorites`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
item_type: itemType,
item_id: itemId
})
});
if (response.ok) {
// Удаляем из локального массива
favorites = favorites.filter(f => !(f.item_type === itemType && f.item_id === itemId));
updateStats();
renderFavorites();
showNotification('Удалено из избранного', 'success');
} else {
const error = await response.json();
throw new Error(error.detail);
}
} catch (error) {
showNotification(error.message, 'error');
}
}
// Функция для показа уведомлений
function showNotification(message, type = 'success') {
const notification = document.getElementById('notification');
notification.className = type;
notification.innerHTML = message;
notification.style.display = 'block';
setTimeout(() => {
notification.style.display = 'none';
}, 3000);
}
// Экранирование HTML
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Обработка клика по кнопке "Назад"
window.onpopstate = function() {
const params = new URLSearchParams(window.location.search);
const filter = params.get('type') || 'all';
if (['all', 'vacancy', 'resume'].includes(filter)) {
currentFilter = filter;
activateTabFromUrl();
renderFavorites();
}
};
// Загрузка при старте
checkAuth().then(() => {
loadFavorites();
});
</script>
</body>
</html>

View File

@@ -170,7 +170,7 @@
<div class="header">
<div class="logo">
<i class="fas fa-briefcase"></i>
Rabota.Today
МП.Ярмарка
</div>
<div class="nav">
<a href="/">Главная</a>
@@ -209,7 +209,7 @@
</div>
<div class="footer">
© 2024 Rabota.Today - Ярмарка вакансий
© 2026 Rabota.Today - Ярмарка вакансий
</div>
<script>
@@ -243,6 +243,8 @@
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/favorites">Избранное</a>
<a href="/applications">Отклики</a>
<a href="/profile" style="background: #3b82f6;">
<i class="fas fa-user-circle"></i> ${user.full_name.split(' ')[0]}
</a>

View File

@@ -188,7 +188,7 @@
<div class="auth-header">
<h1>
<i class="fas fa-briefcase"></i>
Rabota.Today
МП.Ярмарка
</h1>
<p>Вход в личный кабинет</p>
</div>

File diff suppressed because it is too large Load Diff

699
templates/register.html Normal file
View File

@@ -0,0 +1,699 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Регистрация | Rabota.Today</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background: linear-gradient(145deg, #0b1c34 0%, #1a3650 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.register-container {
max-width: 600px;
width: 100%;
background: white;
border-radius: 48px;
box-shadow: 0 40px 80px rgba(0, 0, 0, 0.3);
overflow: hidden;
animation: slideUp 0.5s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.register-header {
background: #0b1c34;
color: white;
padding: 40px;
text-align: center;
}
.register-header h1 {
font-size: 36px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
margin-bottom: 10px;
}
.register-header h1 i {
color: #3b82f6;
background: rgba(255,255,255,0.1);
padding: 12px;
border-radius: 20px;
}
.register-header p {
color: #9bb8da;
font-size: 16px;
}
.register-form {
padding: 40px;
}
.role-selector {
display: flex;
gap: 16px;
margin-bottom: 30px;
background: #f0f7ff;
padding: 8px;
border-radius: 60px;
}
.role-option {
flex: 1;
border: none;
background: transparent;
padding: 16px 20px;
border-radius: 50px;
font-weight: 700;
font-size: 16px;
color: #385073;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: 0.2s;
}
.role-option i {
font-size: 18px;
}
.role-option.active {
background: white;
color: #0b1c34;
box-shadow: 0 8px 20px rgba(0,40,80,0.1);
}
.input-group {
margin-bottom: 20px;
}
.input-group label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #1f3f60;
margin-bottom: 8px;
font-size: 14px;
}
.input-group label i {
color: #3b82f6;
width: 20px;
}
.input-group input {
width: 100%;
padding: 16px 20px;
background: #f9fcff;
border: 2px solid #dee9f5;
border-radius: 30px;
font-size: 16px;
transition: 0.2s;
}
.input-group input:focus {
border-color: #3b82f6;
background: white;
outline: none;
box-shadow: 0 0 0 4px rgba(59,130,246,0.1);
}
.input-group input.error {
border-color: #ef4444;
background: #fef2f2;
}
.input-row {
display: flex;
gap: 15px;
}
.input-row .input-group {
flex: 1;
}
.password-requirements {
background: #f0f7ff;
border-radius: 20px;
padding: 15px 20px;
margin: 20px 0;
font-size: 13px;
}
.password-requirements p {
color: #1f3f60;
font-weight: 600;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.requirement {
color: #4f7092;
margin: 5px 0;
display: flex;
align-items: center;
gap: 8px;
}
.requirement i {
width: 16px;
font-size: 12px;
}
.requirement.valid {
color: #10b981;
}
.requirement.valid i {
color: #10b981;
}
.requirement.invalid {
color: #ef4444;
}
.requirement.invalid i {
color: #ef4444;
}
.terms {
display: flex;
align-items: center;
gap: 10px;
margin: 25px 0;
font-size: 14px;
color: #4f7092;
}
.terms input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.terms a {
color: #3b82f6;
text-decoration: none;
font-weight: 600;
}
.terms a:hover {
text-decoration: underline;
}
.btn-register {
width: 100%;
background: #0f2b4f;
color: white;
border: none;
padding: 18px;
border-radius: 40px;
font-weight: 700;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: 0.2s;
margin-bottom: 20px;
}
.btn-register:hover:not(:disabled) {
background: #1b3f6b;
transform: scale(1.02);
}
.btn-register:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-link {
text-align: center;
color: #4f7092;
font-size: 15px;
}
.login-link a {
color: #3b82f6;
text-decoration: none;
font-weight: 600;
margin-left: 5px;
}
.login-link a:hover {
text-decoration: underline;
}
.error-message {
background: #fee2e2;
color: #b91c1c;
padding: 15px 20px;
border-radius: 30px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.success-message {
background: #d1fae5;
color: #065f46;
padding: 15px 20px;
border-radius: 30px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
}
.loader {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.footer {
text-align: center;
padding: 20px 40px;
background: #f8fafc;
border-top: 1px solid #dee9f5;
font-size: 13px;
color: #4f7092;
}
.footer a {
color: #3b82f6;
text-decoration: none;
}
@media (max-width: 600px) {
.input-row {
flex-direction: column;
gap: 0;
}
.role-selector {
flex-direction: column;
gap: 8px;
}
}
</style>
</head>
<body>
<div class="register-container">
<div class="register-header">
<h1>
<i class="fas fa-briefcase"></i>
МП.Ярмарка
</h1>
<p>Создайте аккаунт для доступа к ярмарке вакансий</p>
</div>
<div class="register-form">
<!-- Сообщения об ошибках/успехе -->
<div id="errorMessage" class="error-message" style="display: none;">
<i class="fas fa-exclamation-circle"></i>
<span id="errorText"></span>
</div>
<div id="successMessage" class="success-message" style="display: none;">
<i class="fas fa-check-circle"></i>
<span id="successText"></span>
</div>
<!-- Выбор роли -->
<div class="role-selector">
<button class="role-option active" id="roleEmployeeBtn" type="button">
<i class="fas fa-user"></i> Я соискатель
</button>
<button class="role-option" id="roleEmployerBtn" type="button">
<i class="fas fa-building"></i> Я работодатель
</button>
</div>
<!-- Форма регистрации -->
<form id="registerForm" onsubmit="handleRegister(event)">
<div class="input-group">
<label><i class="fas fa-user-circle"></i> ФИО *</label>
<input type="text" id="fullName" placeholder="Иванов Иван Иванович" required>
</div>
<div class="input-group">
<label><i class="fas fa-envelope"></i> Email *</label>
<input type="email" id="email" placeholder="ivan@example.com" required>
</div>
<div class="input-row">
<div class="input-group">
<label><i class="fas fa-phone-alt"></i> Телефон *</label>
<input type="tel" id="phone" placeholder="+7 (999) 123-45-67" required>
</div>
<div class="input-group">
<label><i class="fab fa-telegram-plane"></i> Telegram</label>
<input type="text" id="telegram" placeholder="@username">
</div>
</div>
<div class="input-group">
<label><i class="fas fa-lock"></i> Пароль *</label>
<input type="password" id="password" placeholder="Минимум 6 символов" required>
</div>
<div class="input-group">
<label><i class="fas fa-lock"></i> Подтверждение пароля *</label>
<input type="password" id="confirmPassword" placeholder="Введите пароль еще раз" required>
</div>
<!-- Требования к паролю -->
<div class="password-requirements" id="passwordRequirements">
<p><i class="fas fa-shield-alt"></i> Требования к паролю:</p>
<div class="requirement" id="reqLength">
<i class="far fa-circle"></i> Минимум 6 символов
</div>
<div class="requirement" id="reqMatch">
<i class="far fa-circle"></i> Пароли совпадают
</div>
</div>
<!-- Согласие с условиями -->
<div class="terms">
<input type="checkbox" id="terms" required>
<label for="terms">
Я принимаю <a href="#" onclick="showTerms()">условия использования</a>
и даю согласие на обработку персональных данных
</label>
</div>
<!-- Кнопка регистрации -->
<button type="submit" class="btn-register" id="registerBtn">
<span>Зарегистрироваться</span>
<i class="fas fa-arrow-right"></i>
</button>
</form>
<!-- Ссылка на вход -->
<div class="login-link">
Уже есть аккаунт?
<a href="/login">Войти</a>
</div>
</div>
<div class="footer">
© 2026 Rabota.Today - Ярмарка вакансий.
<a href="/">На главную</a>
</div>
</div>
<!-- Модальное окно с условиями использования -->
<div id="termsModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); align-items: center; justify-content: center; z-index: 2000;">
<div style="background: white; max-width: 500px; width: 90%; border-radius: 40px; padding: 40px; max-height: 80vh; overflow-y: auto;">
<h3 style="margin-bottom: 20px; color: #0b1c34;">Условия использования</h3>
<div style="color: #1f3f60; line-height: 1.6; margin-bottom: 30px;">
<p>1. Регистрируясь на платформе, вы подтверждаете, что предоставленная информация является достоверной.</p>
<p>2. Администрация платформы имеет право удалять аккаунты, нарушающие правила этики и законодательства РФ.</p>
<p>3. Запрещено размещение вакансий, противоречащих законодательству РФ.</p>
<p>4. Запрещено размещение резюме с недостоверной информацией.</p>
<p>5. Платформа не несет ответственности за достоверность информации, размещенной пользователями.</p>
<p>6. Персональные данные обрабатываются в соответствии с политикой конфиденциальности.</p>
</div>
<button class="btn-register" onclick="closeTerms()" style="margin-bottom: 0;">Закрыть</button>
</div>
</div>
<script>
const API_BASE_URL = 'http://localhost:8000/api';
// Переключение роли
document.getElementById('roleEmployeeBtn').addEventListener('click', () => {
document.getElementById('roleEmployeeBtn').classList.add('active');
document.getElementById('roleEmployerBtn').classList.remove('active');
});
document.getElementById('roleEmployerBtn').addEventListener('click', () => {
document.getElementById('roleEmployerBtn').classList.add('active');
document.getElementById('roleEmployeeBtn').classList.remove('active');
});
// Валидация пароля в реальном времени
const passwordInput = document.getElementById('password');
const confirmInput = document.getElementById('confirmPassword');
function validatePassword() {
const password = passwordInput.value;
const confirm = confirmInput.value;
const reqLength = document.getElementById('reqLength');
const reqMatch = document.getElementById('reqMatch');
// Проверка длины
if (password.length >= 6) {
reqLength.className = 'requirement valid';
reqLength.innerHTML = '<i class="fas fa-check-circle"></i> Минимум 6 символов ✓';
} else {
reqLength.className = 'requirement invalid';
reqLength.innerHTML = '<i class="fas fa-times-circle"></i> Минимум 6 символов';
}
// Проверка совпадения
if (password && confirm && password === confirm) {
reqMatch.className = 'requirement valid';
reqMatch.innerHTML = '<i class="fas fa-check-circle"></i> Пароли совпадают ✓';
} else if (confirm) {
reqMatch.className = 'requirement invalid';
reqMatch.innerHTML = '<i class="fas fa-times-circle"></i> Пароли совпадают';
} else {
reqMatch.className = 'requirement';
reqMatch.innerHTML = '<i class="far fa-circle"></i> Пароли совпадают';
}
}
passwordInput.addEventListener('input', validatePassword);
confirmInput.addEventListener('input', validatePassword);
// Показать сообщение об ошибке
function showError(message) {
const errorDiv = document.getElementById('errorMessage');
const errorText = document.getElementById('errorText');
errorText.textContent = message;
errorDiv.style.display = 'flex';
setTimeout(() => {
errorDiv.style.display = 'none';
}, 5000);
}
// Показать сообщение об успехе
function showSuccess(message) {
const successDiv = document.getElementById('successMessage');
const successText = document.getElementById('successText');
successText.textContent = message;
successDiv.style.display = 'flex';
setTimeout(() => {
successDiv.style.display = 'none';
}, 3000);
}
// Показать состояние загрузки
function setLoading(isLoading) {
const registerBtn = document.getElementById('registerBtn');
if (isLoading) {
registerBtn.innerHTML = '<span class="loader"></span><span>Регистрация...</span>';
registerBtn.disabled = true;
} else {
registerBtn.innerHTML = '<span>Зарегистрироваться</span><i class="fas fa-arrow-right"></i>';
registerBtn.disabled = false;
}
}
// Обработка регистрации
async function handleRegister(event) {
event.preventDefault();
// Получаем значения полей
const fullName = document.getElementById('fullName').value.trim();
const email = document.getElementById('email').value.trim();
const phone = document.getElementById('phone').value.trim();
const telegram = document.getElementById('telegram').value.trim() || null;
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
const termsChecked = document.getElementById('terms').checked;
// Определяем роль
const role = document.getElementById('roleEmployeeBtn').classList.contains('active') ? 'employee' : 'employer';
// Валидация
if (!fullName) {
showError('Введите ФИО');
document.getElementById('fullName').focus();
return;
}
if (!email) {
showError('Введите email');
document.getElementById('email').focus();
return;
}
if (!email.includes('@') || !email.includes('.')) {
showError('Введите корректный email');
document.getElementById('email').focus();
return;
}
if (!phone) {
showError('Введите телефон');
document.getElementById('phone').focus();
return;
}
// Простая валидация телефона (можно улучшить)
const phoneDigits = phone.replace(/\D/g, '');
if (phoneDigits.length < 10) {
showError('Введите корректный телефон (минимум 10 цифр)');
document.getElementById('phone').focus();
return;
}
if (password.length < 6) {
showError('Пароль должен содержать минимум 6 символов');
document.getElementById('password').focus();
return;
}
if (password !== confirmPassword) {
showError('Пароли не совпадают');
document.getElementById('confirmPassword').focus();
return;
}
if (!termsChecked) {
showError('Необходимо принять условия использования');
return;
}
// Подготавливаем данные для отправки
const userData = {
full_name: fullName,
email: email,
phone: phone,
telegram: telegram,
password: password,
role: role
};
setLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Ошибка регистрации');
}
// Успешная регистрация
showSuccess('Регистрация успешна! Перенаправляем...');
// Сохраняем токен
localStorage.setItem('accessToken', data.access_token);
localStorage.setItem('userId', data.user_id);
localStorage.setItem('userRole', data.role);
localStorage.setItem('userName', data.full_name);
// Перенаправляем в профиль через секунду
setTimeout(() => {
window.location.href = '/profile';
}, 1500);
} catch (error) {
showError(error.message);
setLoading(false);
}
}
// Функции для модального окна с условиями
function showTerms() {
document.getElementById('termsModal').style.display = 'flex';
return false;
}
function closeTerms() {
document.getElementById('termsModal').style.display = 'none';
}
// Закрытие модалки по клику вне окна
window.onclick = function(event) {
const modal = document.getElementById('termsModal');
if (event.target === modal) {
modal.style.display = 'none';
}
};
// Автоматическое форматирование телефона (простой вариант)
document.getElementById('phone').addEventListener('input', function(e) {
let x = e.target.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/);
if (x) {
e.target.value = !x[2] ? x[1] :
'+7 (' + x[2] + ') ' + x[3] + (x[4] ? '-' + x[4] : '') + (x[5] ? '-' + x[5] : '');
}
});
// Проверяем, может пользователь уже залогинен
if (localStorage.getItem('accessToken')) {
window.location.href = '/profile';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,641 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Резюме | Rabota.Today</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body {
background: linear-gradient(145deg, #eef5fa 0%, #e0eaf5 100%);
min-height: 100vh;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
.header {
background: #0b1c34;
color: white;
padding: 20px 40px;
border-radius: 40px;
margin-bottom: 40px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.logo {
font-size: 28px;
font-weight: 700;
display: flex;
align-items: center;
gap: 15px;
}
.logo i {
color: #3b82f6;
background: rgba(255,255,255,0.1);
padding: 12px;
border-radius: 20px;
}
.nav {
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.nav a {
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 30px;
transition: 0.2s;
}
.nav a:hover {
background: rgba(255,255,255,0.1);
}
.nav .active {
background: #3b82f6;
}
.profile-link {
display: flex;
align-items: center;
gap: 8px;
background: #3b82f6;
padding: 8px 20px !important;
}
.profile-link i {
font-size: 18px;
}
.user-avatar {
width: 35px;
height: 35px;
background: #3b82f6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.user-name {
font-weight: 500;
}
.admin-badge {
background: #f59e0b;
color: white;
padding: 2px 8px;
border-radius: 20px;
font-size: 12px;
margin-left: 5px;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #4f7092;
text-decoration: none;
font-size: 16px;
}
.back-link i {
margin-right: 8px;
}
.back-link:hover {
color: #0b1c34;
}
.resume-detail {
background: white;
border-radius: 40px;
padding: 40px;
box-shadow: 0 20px 40px rgba(0,20,40,0.1);
}
.resume-header {
display: flex;
align-items: center;
gap: 30px;
margin-bottom: 30px;
flex-wrap: wrap;
}
.resume-avatar {
width: 120px;
height: 120px;
background: #eef4fa;
border-radius: 60px;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
color: #3b82f6;
}
.resume-title {
flex: 1;
}
.resume-name {
font-size: 32px;
font-weight: 700;
color: #0b1c34;
margin-bottom: 5px;
}
.resume-position {
font-size: 20px;
color: #3b82f6;
margin-bottom: 10px;
}
.resume-stats {
display: flex;
gap: 20px;
color: #4f7092;
font-size: 14px;
}
.resume-salary {
font-size: 28px;
font-weight: 700;
color: #0f2b4f;
margin: 20px 0;
padding: 20px 0;
border-top: 2px solid #dee9f5;
border-bottom: 2px solid #dee9f5;
}
.resume-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 20px 0;
}
.tag {
background: #eef4fa;
padding: 8px 16px;
border-radius: 30px;
font-size: 14px;
color: #1f3f60;
font-weight: 500;
}
.section-title {
font-size: 24px;
color: #0b1c34;
margin: 30px 0 20px;
font-weight: 600;
}
.about-me {
background: #f9fcff;
border-radius: 20px;
padding: 25px;
line-height: 1.8;
color: #1f3f60;
font-size: 16px;
}
.experience-item, .education-item {
background: #f9fcff;
border-radius: 20px;
padding: 25px;
margin-bottom: 20px;
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
}
.item-title {
font-size: 18px;
font-weight: 700;
color: #0b1c34;
}
.item-subtitle {
color: #3b82f6;
font-weight: 600;
margin-bottom: 10px;
}
.item-period {
color: #4f7092;
font-size: 14px;
}
.contact-section {
background: #eef4fa;
border-radius: 30px;
padding: 30px;
margin: 30px 0;
}
.contact-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.contact-item {
display: flex;
align-items: center;
gap: 15px;
padding: 12px 0;
color: #1f3f60;
}
.contact-item i {
color: #3b82f6;
width: 24px;
font-size: 18px;
}
.action-buttons {
display: flex;
gap: 15px;
margin-top: 30px;
flex-wrap: wrap;
}
.btn {
padding: 16px 32px;
border-radius: 40px;
border: none;
font-weight: 600;
cursor: pointer;
flex: 1;
min-width: 200px;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: 0.2s;
}
.btn-primary {
background: #0b1c34;
color: white;
}
.btn-primary:hover {
background: #1b3f6b;
transform: translateY(-2px);
}
.btn-outline {
background: transparent;
border: 2px solid #3b82f6;
color: #0b1c34;
}
.btn-outline:hover {
background: #eef4fa;
}
.loading {
text-align: center;
padding: 60px;
color: #4f7092;
font-size: 18px;
}
.error-message {
background: #fee2e2;
color: #b91c1c;
padding: 20px;
border-radius: 30px;
text-align: center;
margin: 40px 0;
}
.view-counter {
display: inline-flex;
align-items: center;
gap: 5px;
background: #eef4fa;
padding: 5px 15px;
border-radius: 30px;
font-size: 14px;
color: #1f3f60;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">
<i class="fas fa-briefcase"></i>
МП.Ярмарка
</div>
<div class="nav" id="nav">
<!-- Навигация будет заполнена динамически -->
</div>
</div>
<a href="/resumes" class="back-link"><i class="fas fa-arrow-left"></i> Назад к резюме</a>
<div id="resumeDetail" class="resume-detail">
<div class="loading">Загрузка резюме...</div>
</div>
</div>
<script>
const API_BASE_URL = 'http://localhost:8000/api';
let currentUser = null;
// Получаем ID резюме из URL
const pathParts = window.location.pathname.split('/');
const resumeId = pathParts[pathParts.length - 1];
// Проверка авторизации
async function checkAuth() {
const token = localStorage.getItem('accessToken');
if (token) {
try {
const response = await fetch(`${API_BASE_URL}/user`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
currentUser = await response.json();
} else {
localStorage.removeItem('accessToken');
}
} catch (error) {
console.error('Error checking auth:', error);
}
}
updateNavigation();
}
// Обновление навигации
function updateNavigation() {
const nav = document.getElementById('nav');
if (currentUser) {
// Пользователь авторизован
nav.innerHTML = `
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/favorites">Избранное</a>
<a href="/applications">Отклики</a>
<a href="/profile" class="profile-link">
<i class="fas fa-user-circle"></i>
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
${currentUser.is_admin ? '<span class="admin-badge">Admin</span>' : ''}
</a>
`;
} else {
// Пользователь не авторизован
nav.innerHTML = `
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/login">Войти</a>
<a href="/register">Регистрация</a>
`;
}
}
// Загрузка резюме
async function loadResume() {
try {
const response = await fetch(`${API_BASE_URL}/resumes/${resumeId}`);
if (!response.ok) {
throw new Error('Резюме не найдено');
}
const resume = await response.json();
renderResume(resume);
} catch (error) {
console.error('Error loading resume:', error);
document.getElementById('resumeDetail').innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-circle"></i>
Резюме не найдено или было удалено
</div>
<div style="text-align: center; margin-top: 20px;">
<a href="/resumes" class="btn btn-primary">Вернуться к списку</a>
</div>
`;
}
}
// Отображение резюме
function renderResume(resume) {
const container = document.getElementById('resumeDetail');
const token = localStorage.getItem('accessToken');
const canContact = token && currentUser && currentUser.role === 'employer';
// Формируем блок с опытом работы
const experienceHtml = resume.work_experience && resume.work_experience.length > 0
? resume.work_experience.map(exp => `
<div class="experience-item">
<div class="item-header">
<span class="item-title">${escapeHtml(exp.position)}</span>
<span class="item-period">${escapeHtml(exp.period || 'Период не указан')}</span>
</div>
<div class="item-subtitle">${escapeHtml(exp.company)}</div>
</div>
`).join('')
: '<p style="color: #4f7092;">Опыт работы не указан</p>';
// Формируем блок с образованием
const educationHtml = resume.education && resume.education.length > 0
? resume.education.map(edu => `
<div class="education-item">
<div class="item-header">
<span class="item-title">${escapeHtml(edu.institution)}</span>
<span class="item-period">${escapeHtml(edu.graduation_year || 'Год не указан')}</span>
</div>
<div class="item-subtitle">${escapeHtml(edu.specialty || 'Специальность не указана')}</div>
</div>
`).join('')
: '<p style="color: #4f7092;">Образование не указано</p>';
container.innerHTML = `
<div class="resume-header">
<div class="resume-avatar">
<i class="fas fa-user"></i>
</div>
<div class="resume-title">
<div class="resume-name">${escapeHtml(resume.full_name || 'Имя не указано')}</div>
<div class="resume-position">${escapeHtml(resume.desired_position || 'Желаемая должность не указана')}</div>
<div class="resume-stats">
<span class="view-counter">
<i class="fas fa-eye"></i> ${resume.views || 0} просмотров
</span>
<span><i class="fas fa-calendar"></i> Обновлено: ${new Date(resume.updated_at).toLocaleDateString()}</span>
</div>
</div>
</div>
<div class="resume-salary">${escapeHtml(resume.desired_salary || 'Зарплатные ожидания не указаны')}</div>
${resume.tags && resume.tags.length > 0 ? `
<div class="resume-tags">
${resume.tags.map(t => `<span class="tag">${escapeHtml(t.name)}</span>`).join('')}
</div>
` : ''}
<h2 class="section-title">О себе</h2>
<div class="about-me">${escapeHtml(resume.about_me || 'Информация не заполнена')}</div>
<h2 class="section-title">Опыт работы</h2>
${experienceHtml}
<h2 class="section-title">Образование</h2>
${educationHtml}
<div class="contact-section">
<h3 style="color: #0b1c34; margin-bottom: 20px;">Контактная информация</h3>
${canContact ? `
<div class="contact-grid">
<div class="contact-item">
<i class="fas fa-envelope"></i>
<span>${escapeHtml(resume.email || 'Email не указан')}</span>
</div>
<div class="contact-item">
<i class="fas fa-phone"></i>
<span>${escapeHtml(resume.phone || 'Телефон не указан')}</span>
</div>
<div class="contact-item">
<i class="fab fa-telegram"></i>
<span>${escapeHtml(resume.telegram || 'Telegram не указан')}</span>
</div>
</div>
` : `
<div style="text-align: center; padding: 30px; background: white; border-radius: 20px;">
<i class="fas fa-lock" style="font-size: 48px; color: #4f7092; margin-bottom: 15px;"></i>
<p style="color: #1f3f60; margin-bottom: 20px;">
Контактные данные доступны только авторизованным работодателям
</p>
<a href="/login" class="btn btn-primary">Войти как работодатель</a>
</div>
`}
</div>
<div class="action-buttons">
${canContact ? `
<button class="btn btn-primary" onclick="contactCandidate()">
<i class="fas fa-paper-plane"></i> Связаться
</button>
` : !token ? `
<button class="btn btn-primary" onclick="redirectToLogin()">
<i class="fas fa-sign-in-alt"></i> Войдите чтобы увидеть контакты
</button>
` : ''}
<button class="btn btn-outline" onclick="saveToFavorites()">
<i class="fas fa-heart"></i> В избранное
</button>
<button class="btn btn-outline" onclick="shareResume()">
<i class="fas fa-share-alt"></i> Поделиться
</button>
</div>
`;
}
// Связаться с кандидатом
function contactCandidate() {
const token = localStorage.getItem('accessToken');
if (!token) {
redirectToLogin();
return;
}
// Здесь можно открыть модальное окно с формой сообщения
alert('Функция связи будет доступна в ближайшее время');
}
// Добавить в избранное
function saveToFavorites() {
const token = localStorage.getItem('accessToken');
if (!token) {
if (confirm('Для добавления в избранное нужно войти в систему. Перейти на страницу входа?')) {
window.location.href = '/login';
}
return;
}
alert('Резюме добавлено в избранное');
}
// Поделиться резюме
function shareResume() {
const url = window.location.href;
navigator.clipboard.writeText(url).then(() => {
alert('Ссылка скопирована в буфер обмена');
}).catch(() => {
alert('Ссылка: ' + url);
});
}
// Редирект на страницу входа
function redirectToLogin() {
if (confirm('Для просмотра контактов нужно войти в систему. Перейти на страницу входа?')) {
window.location.href = '/login';
}
}
// Экранирование HTML
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe.toString()
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// Загрузка при старте
checkAuth().then(() => {
loadResume();
});
</script>
</body>
</html>

View File

@@ -289,7 +289,7 @@
<div class="header">
<div class="logo">
<i class="fas fa-briefcase"></i>
Rabota.Today
МП.Ярмарка
</div>
<div class="nav" id="nav">
<!-- Навигация будет заполнена динамически -->
@@ -364,6 +364,8 @@
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes" class="active">Резюме</a>
<a href="/favorites">Избранное</a>
<a href="/applications">Отклики</a>
<a href="/profile" class="profile-link">
<i class="fas fa-user-circle"></i>
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>

View File

@@ -295,7 +295,7 @@
<div class="header">
<div class="logo">
<i class="fas fa-briefcase"></i>
Rabota.Today
МП.Ярмарка
</div>
<div class="nav" id="nav">
<!-- Навигация будет заполнена динамически -->
@@ -378,6 +378,8 @@
<a href="/">Главная</a>
<a href="/vacancies" class="active">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/favorites">Избранное</a>
<a href="/applications">Отклики</a>
<a href="/profile" class="profile-link">
<i class="fas fa-user-circle"></i>
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
@@ -421,7 +423,8 @@
<span class="vacancy-title">${escapeHtml(v.title)}</span>
</div>
<div class="vacancy-company">
<i class="fas fa-building"></i> ${escapeHtml(v.company_name || 'Компания')}
<i class="fas fa-building"></i>
<strong>${escapeHtml(v.company_name || 'Компания')}</strong>
</div>
<div class="vacancy-salary">${escapeHtml(v.salary || 'з/п не указана')}</div>
<div class="vacancy-tags">

View File

@@ -19,7 +19,7 @@
}
.container {
max-width: 900px;
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
@@ -33,6 +33,8 @@
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 20px;
}
.logo {
@@ -54,6 +56,7 @@
display: flex;
gap: 15px;
align-items: center;
flex-wrap: wrap;
}
.nav a {
@@ -61,17 +64,66 @@
text-decoration: none;
padding: 10px 20px;
border-radius: 30px;
transition: 0.2s;
}
.nav a:hover {
background: rgba(255,255,255,0.1);
}
.nav .active {
background: #3b82f6;
}
.profile-link {
display: flex;
align-items: center;
gap: 8px;
background: #3b82f6;
padding: 8px 20px !important;
}
.profile-link i {
font-size: 18px;
}
.user-avatar {
width: 35px;
height: 35px;
background: #3b82f6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.user-name {
font-weight: 500;
}
.admin-badge {
background: #f59e0b;
color: white;
padding: 2px 8px;
border-radius: 20px;
font-size: 12px;
margin-left: 5px;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #4f7092;
text-decoration: none;
font-size: 16px;
}
.back-link i {
margin-right: 8px;
}
.back-link:hover {
color: #0b1c34;
}
.vacancy-detail {
@@ -81,42 +133,115 @@
box-shadow: 0 20px 40px rgba(0,20,40,0.1);
}
.vacancy-title {
font-size: 32px;
color: #0b1c34;
margin-bottom: 10px;
}
.vacancy-company {
font-size: 18px;
color: #3b82f6;
.vacancy-header {
display: flex;
justify-content: space-between;
align-items: start;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
}
.vacancy-title {
font-size: 36px;
color: #0b1c34;
font-weight: 700;
}
.vacancy-company {
font-size: 20px;
color: #3b82f6;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.company-link {
color: #3b82f6;
text-decoration: none;
border-bottom: 1px dashed #3b82f6;
}
.company-link:hover {
border-bottom: 1px solid #3b82f6;
}
.vacancy-salary {
font-size: 28px;
font-size: 32px;
font-weight: 700;
color: #0f2b4f;
margin: 20px 0;
padding: 20px 0;
margin: 25px 0;
padding: 25px 0;
border-top: 2px solid #dee9f5;
border-bottom: 2px solid #dee9f5;
}
.vacancy-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 25px 0;
}
.tag {
background: #eef4fa;
padding: 8px 16px;
border-radius: 30px;
font-size: 14px;
color: #1f3f60;
font-weight: 500;
}
.section-title {
font-size: 20px;
font-size: 24px;
color: #0b1c34;
margin: 30px 0 15px;
margin: 30px 0 20px;
font-weight: 600;
}
.vacancy-description {
color: #1f3f60;
line-height: 1.8;
white-space: pre-line;
font-size: 16px;
}
.company-info {
background: #f9fcff;
border-radius: 30px;
padding: 30px;
margin: 30px 0;
}
.company-info h3 {
color: #0b1c34;
margin-bottom: 20px;
font-size: 20px;
}
.company-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.company-info-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
color: #1f3f60;
}
.company-info-item i {
color: #3b82f6;
width: 24px;
font-size: 18px;
}
.contact-info {
background: #f9fcff;
background: #eef4fa;
border-radius: 30px;
padding: 25px;
margin: 30px 0;
@@ -126,19 +251,26 @@
display: flex;
align-items: center;
gap: 15px;
padding: 10px 0;
padding: 12px 0;
color: #1f3f60;
border-bottom: 1px solid #d9e6f5;
}
.contact-item:last-child {
border-bottom: none;
}
.contact-item i {
color: #3b82f6;
width: 20px;
width: 24px;
font-size: 18px;
}
.action-buttons {
display: flex;
gap: 15px;
margin-top: 30px;
flex-wrap: wrap;
}
.btn {
@@ -148,6 +280,13 @@
font-weight: 600;
cursor: pointer;
flex: 1;
min-width: 200px;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
transition: 0.2s;
}
.btn-primary {
@@ -155,16 +294,70 @@
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #1b3f6b;
transform: translateY(-2px);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-outline {
background: transparent;
border: 2px solid #3b82f6;
color: #0b1c34;
}
.btn-outline:hover {
background: #eef4fa;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
}
.applied-badge {
background: #10b981;
color: white;
padding: 16px 32px;
border-radius: 40px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 10px;
}
.stats {
display: flex;
gap: 20px;
color: #4f7092;
font-size: 14px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dee9f5;
}
.loading {
text-align: center;
padding: 60px;
color: #4f7092;
font-size: 18px;
}
.error-message {
background: #fee2e2;
color: #b91c1c;
padding: 20px;
border-radius: 30px;
text-align: center;
margin: 40px 0;
}
</style>
</head>
@@ -173,84 +366,259 @@
<div class="header">
<div class="logo">
<i class="fas fa-briefcase"></i>
Rabota.Today
МП.Ярмарка
</div>
<div class="nav">
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/login">Войти</a>
<div class="nav" id="nav">
<!-- Навигация будет заполнена динамически -->
</div>
</div>
<a href="/vacancies" class="back-link"><i class="fas fa-arrow-left"></i> Назад к вакансиям</a>
<div id="vacancyDetail" class="vacancy-detail">
<div class="loading">Загрузка...</div>
<div class="loading">Загрузка вакансии...</div>
</div>
</div>
<script>
const API_BASE_URL = 'http://localhost:8000/api';
let currentUser = null;
// Получаем ID вакансии из URL
const pathParts = window.location.pathname.split('/');
const vacancyId = pathParts[pathParts.length - 1];
async function loadVacancy() {
try {
const response = await fetch(`${API_BASE_URL}/vacancies/${vacancyId}`);
const vacancy = await response.json();
// Проверка авторизации
async function checkAuth() {
const token = localStorage.getItem('accessToken');
const container = document.getElementById('vacancyDetail');
if (token) {
try {
const response = await fetch(`${API_BASE_URL}/user`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
container.innerHTML = `
<h1 class="vacancy-title">${escapeHtml(vacancy.title)}</h1>
<div class="vacancy-company">
<i class="fas fa-building"></i> ${escapeHtml(vacancy.company_name || 'Компания не указана')}
</div>
if (response.ok) {
currentUser = await response.json();
} else {
localStorage.removeItem('accessToken');
}
} catch (error) {
console.error('Error checking auth:', error);
}
}
<div class="vacancy-salary">${escapeHtml(vacancy.salary || 'Зарплата не указана')}</div>
updateNavigation();
}
<h2 class="section-title">Описание вакансии</h2>
<div class="vacancy-description">${escapeHtml(vacancy.description || 'Описание отсутствует')}</div>
// Обновление навигации
function updateNavigation() {
const nav = document.getElementById('nav');
const token = localStorage.getItem('accessToken');
<h2 class="section-title">Контактная информация</h2>
<div class="contact-info">
<div class="contact-item">
<i class="fab fa-telegram"></i>
<span>${escapeHtml(vacancy.contact || 'Контакт не указан')}</span>
</div>
<div class="contact-item">
<i class="fas fa-calendar"></i>
<span>Опубликовано: ${new Date(vacancy.created_at).toLocaleDateString()}</span>
</div>
</div>
<div class="action-buttons">
<button class="btn btn-primary" onclick="applyForVacancy()">
<i class="fas fa-paper-plane"></i> Откликнуться
</button>
<button class="btn btn-outline" onclick="saveToFavorites()">
<i class="fas fa-heart"></i> В избранное
</button>
</div>
if (currentUser) {
// Пользователь авторизован
nav.innerHTML = `
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/favorites">Избранное</a>
<a href="/applications">Отклики</a>
<a href="/profile" class="profile-link">
<i class="fas fa-user-circle"></i>
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
${currentUser.is_admin ? '<span class="admin-badge">Admin</span>' : ''}
</a>
`;
} else {
// Пользователь не авторизован
nav.innerHTML = `
<a href="/">Главная</a>
<a href="/vacancies">Вакансии</a>
<a href="/resumes">Резюме</a>
<a href="/login">Войти</a>
<a href="/register">Регистрация</a>
`;
} catch (error) {
console.error('Error loading vacancy:', error);
document.getElementById('vacancyDetail').innerHTML =
'<div class="loading">Ошибка загрузки вакансии</div>';
}
}
// Загрузка вакансии
async function loadVacancy() {
const token = localStorage.getItem('accessToken');
try {
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}/vacancies/${vacancyId}`, {
headers: headers
});
if (!response.ok) {
throw new Error('Вакансия не найдена');
}
const vacancy = await response.json();
renderVacancy(vacancy);
} catch (error) {
console.error('Error loading vacancy:', error);
document.getElementById('vacancyDetail').innerHTML = `
<div class="error-message">
<i class="fas fa-exclamation-circle"></i>
Вакансия не найдена или была удалена
</div>
<div style="text-align: center; margin-top: 20px;">
<a href="/vacancies" class="btn btn-primary">Вернуться к списку</a>
</div>
`;
}
}
// Отображение вакансии
async function renderVacancy(vacancy) {
const container = document.getElementById('vacancyDetail');
const token = localStorage.getItem('accessToken');
const isApplied = vacancy.has_applied;
const canApply = token && currentUser && currentUser.role === 'employee' && !isApplied;
// Проверяем, в избранном ли вакансия
let isFavorite = false;
if (token) {
isFavorite = await checkFavorite();
}
// Формируем информацию о компании
const companyHtml = vacancy.company_name ? `
<div class="company-info">
<h3>О компании</h3>
<div class="company-info-grid">
<div class="company-info-item">
<i class="fas fa-building"></i>
<span><strong>${escapeHtml(vacancy.company_name)}</strong></span>
</div>
${vacancy.company_website ? `
<div class="company-info-item">
<i class="fas fa-globe"></i>
<a href="${escapeHtml(vacancy.company_website)}" target="_blank" class="company-link">${escapeHtml(vacancy.company_website)}</a>
</div>
` : ''}
${vacancy.company_address ? `
<div class="company-info-item">
<i class="fas fa-map-marker-alt"></i>
<span>${escapeHtml(vacancy.company_address)}</span>
</div>
` : ''}
</div>
${vacancy.company_description ? `
<div style="margin-top: 20px; color: #1f3f60; line-height: 1.6; padding-top: 20px; border-top: 1px solid #dee9f5;">
${escapeHtml(vacancy.company_description)}
</div>
` : ''}
</div>
` : '';
container.innerHTML = `
<div class="vacancy-header">
<h1 class="vacancy-title">${escapeHtml(vacancy.title)}</h1>
<div class="stats">
<span><i class="fas fa-eye"></i> ${vacancy.views || 0} просмотров</span>
<span><i class="fas fa-calendar"></i> ${new Date(vacancy.created_at).toLocaleDateString()}</span>
</div>
</div>
<div class="vacancy-company">
<i class="fas fa-building"></i>
<strong>${escapeHtml(vacancy.company_name || 'Компания не указана')}</strong>
${vacancy.company_website ? `
<a href="${escapeHtml(vacancy.company_website)}" target="_blank" style="margin-left: 10px; font-size: 14px;">
<i class="fas fa-external-link-alt"></i> Сайт
</a>
` : ''}
</div>
<div class="vacancy-salary">${escapeHtml(vacancy.salary || 'Зарплата не указана')}</div>
${vacancy.tags && vacancy.tags.length > 0 ? `
<div class="vacancy-tags">
${vacancy.tags.map(t => `<span class="tag">${escapeHtml(t.name)}</span>`).join('')}
</div>
` : ''}
<h2 class="section-title">Описание вакансии</h2>
<div class="vacancy-description">${escapeHtml(vacancy.description || 'Описание отсутствует')}</div>
${companyHtml}
<h2 class="section-title">Контактная информация</h2>
<div class="contact-info">
<div class="contact-item">
<i class="fab fa-telegram"></i>
<span>${escapeHtml(vacancy.contact || vacancy.user_telegram || 'Контакт не указан')}</span>
</div>
${vacancy.company_email ? `
<div class="contact-item">
<i class="fas fa-envelope"></i>
<span>${escapeHtml(vacancy.company_email)}</span>
</div>
` : ''}
${vacancy.company_phone ? `
<div class="contact-item">
<i class="fas fa-phone"></i>
<span>${escapeHtml(vacancy.company_phone)}</span>
</div>
` : ''}
${!vacancy.company_email && !vacancy.company_phone && vacancy.user_email ? `
<div class="contact-item">
<i class="fas fa-envelope"></i>
<span>${escapeHtml(vacancy.user_email)}</span>
</div>
` : ''}
</div>
<div class="action-buttons">
${isApplied ? `
<div class="applied-badge">
<i class="fas fa-check-circle"></i> Вы уже откликнулись
</div>
` : canApply ? `
<button class="btn btn-primary" onclick="applyForVacancy()">
<i class="fas fa-paper-plane"></i> Откликнуться
</button>
` : token && currentUser && currentUser.role === 'employer' ? `
<button class="btn btn-primary" disabled title="Работодатели не могут откликаться на вакансии">
<i class="fas fa-ban"></i> Отклик недоступен
</button>
` : !token ? `
<button class="btn btn-primary" onclick="redirectToLogin()">
<i class="fas fa-sign-in-alt"></i> Войдите чтобы откликнуться
</button>
` : ''}
<button class="btn btn-outline favorite-btn ${isFavorite ? 'favorite-active' : ''}"
onclick="${isFavorite ? 'removeFromFavorites()' : 'addToFavorites()'}">
<i class="${isFavorite ? 'fas fa-heart' : 'far fa-heart'}"></i>
${isFavorite ? 'В избранном' : 'В избранное'}
</button>
<button class="btn btn-outline" onclick="shareVacancy()">
<i class="fas fa-share-alt"></i> Поделиться
</button>
</div>
`;
}
// Отклик на вакансию
async function applyForVacancy() {
const token = localStorage.getItem('accessToken');
if (!token) {
if (confirm('Для отклика нужно войти в систему. Перейти на страницу входа?')) {
window.location.href = '/login';
}
redirectToLogin();
return;
}
@@ -264,14 +632,18 @@
if (response.ok) {
alert('Отклик отправлен! Работодатель свяжется с вами.');
// Перезагружаем вакансию, чтобы увидеть обновленный статус
loadVacancy();
} else {
throw new Error('Ошибка отправки');
const error = await response.json();
throw new Error(error.detail || 'Ошибка отправки');
}
} catch (error) {
alert('Ошибка при отправке отклика');
alert(error.message);
}
}
// Добавить в избранное
function saveToFavorites() {
const token = localStorage.getItem('accessToken');
@@ -286,6 +658,196 @@
alert('Вакансия добавлена в избранное');
}
// Поделиться вакансией
function shareVacancy() {
const url = window.location.href;
navigator.clipboard.writeText(url).then(() => {
alert('Ссылка скопирована в буфер обмена');
}).catch(() => {
alert('Ссылка: ' + url);
});
}
// Проверка, в избранном ли вакансия
async function checkFavorite() {
const token = localStorage.getItem('accessToken');
if (!token) return false;
try {
const response = await fetch(`${API_BASE_URL}/favorites/check/vacancy/${vacancyId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
return data.is_favorite;
}
} catch (error) {
console.error('Error checking favorite:', error);
}
return false;
}
// Добавить в избранное
async function addToFavorites() {
const token = localStorage.getItem('accessToken');
if (!token) {
if (confirm('Для добавления в избранное нужно войти в систему. Перейти на страницу входа?')) {
window.location.href = '/login';
}
return;
}
try {
const response = await fetch(`${API_BASE_URL}/favorites`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
item_type: 'vacancy',
item_id: parseInt(vacancyId)
})
});
if (response.ok) {
// Обновляем кнопку
const favBtn = document.querySelector('.favorite-btn');
if (favBtn) {
favBtn.innerHTML = '<i class="fas fa-heart" style="color: #b91c1c;"></i> В избранном';
favBtn.classList.add('favorite-active');
favBtn.onclick = removeFromFavorites;
}
showNotification('Добавлено в избранное', 'success');
} else {
const error = await response.json();
throw new Error(error.detail || 'Ошибка');
}
} catch (error) {
if (error.message === 'Уже в избранном') {
// Если уже в избранном, предлагаем удалить
if (confirm('Эта вакансия уже в избранном. Удалить?')) {
removeFromFavorites();
}
} else {
showNotification(error.message, 'error');
}
}
}
// Удалить из избранного
async function removeFromFavorites() {
const token = localStorage.getItem('accessToken');
try {
const response = await fetch(`${API_BASE_URL}/favorites`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
item_type: 'vacancy',
item_id: parseInt(vacancyId)
})
});
if (response.ok) {
// Обновляем кнопку
const favBtn = document.querySelector('.favorite-btn');
if (favBtn) {
favBtn.innerHTML = '<i class="far fa-heart"></i> В избранное';
favBtn.classList.remove('favorite-active');
favBtn.onclick = addToFavorites;
}
showNotification('Удалено из избранного', 'success');
} else {
const error = await response.json();
throw new Error(error.detail || 'Ошибка');
}
} catch (error) {
showNotification(error.message, 'error');
}
}
// Функция для показа уведомлений
function showNotification(message, type = 'success') {
// Создаем элемент уведомления, если его нет
let notification = document.getElementById('notification');
if (!notification) {
notification = document.createElement('div');
notification.id = 'notification';
document.body.appendChild(notification);
// Добавляем стили
const style = document.createElement('style');
style.textContent = `
#notification {
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 30px;
background: white;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
z-index: 9999;
animation: slideIn 0.3s;
max-width: 350px;
}
#notification.success {
background: #10b981;
color: white;
}
#notification.error {
background: #ef4444;
color: white;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.favorite-btn {
transition: all 0.2s;
}
.favorite-btn.favorite-active {
background: #fee2e2;
border-color: #b91c1c;
color: #b91c1c;
}
.favorite-btn.favorite-active i {
color: #b91c1c;
}
`;
document.head.appendChild(style);
}
notification.className = type;
notification.innerHTML = message;
notification.style.display = 'block';
setTimeout(() => {
notification.style.display = 'none';
}, 3000);
}
// Редирект на страницу входа
function redirectToLogin() {
if (confirm('Для отклика нужно войти в систему. Перейти на страницу входа?')) {
window.location.href = '/login';
}
}
// Экранирование HTML
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe.toString()
@@ -296,8 +858,10 @@
.replace(/'/g, "&#039;");
}
// Загрузка вакансии
loadVacancy();
// Загрузка при старте
checkAuth().then(() => {
loadVacancy();
});
</script>
</body>
</html>