v1.0
This commit is contained in:
10
certbot/.well-known/acme-challenge/index.html
Executable file
10
certbot/.well-known/acme-challenge/index.html
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Title</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>errr</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
358
templates/application_detail.html
Normal file
358
templates/application_detail.html
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
loadApplication();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1040
templates/applications.html
Normal file
1040
templates/applications.html
Normal file
File diff suppressed because it is too large
Load Diff
864
templates/favorites.html
Normal file
864
templates/favorites.html
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка клика по кнопке "Назад"
|
||||||
|
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>
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<i class="fas fa-briefcase"></i>
|
<i class="fas fa-briefcase"></i>
|
||||||
Rabota.Today
|
МП.Ярмарка
|
||||||
</div>
|
</div>
|
||||||
<div class="nav">
|
<div class="nav">
|
||||||
<a href="/">Главная</a>
|
<a href="/">Главная</a>
|
||||||
@@ -209,7 +209,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
© 2024 Rabota.Today - Ярмарка вакансий
|
© 2026 Rabota.Today - Ярмарка вакансий
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -243,6 +243,8 @@
|
|||||||
<a href="/">Главная</a>
|
<a href="/">Главная</a>
|
||||||
<a href="/vacancies">Вакансии</a>
|
<a href="/vacancies">Вакансии</a>
|
||||||
<a href="/resumes">Резюме</a>
|
<a href="/resumes">Резюме</a>
|
||||||
|
<a href="/favorites">Избранное</a>
|
||||||
|
<a href="/applications">Отклики</a>
|
||||||
<a href="/profile" style="background: #3b82f6;">
|
<a href="/profile" style="background: #3b82f6;">
|
||||||
<i class="fas fa-user-circle"></i> ${user.full_name.split(' ')[0]}
|
<i class="fas fa-user-circle"></i> ${user.full_name.split(' ')[0]}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -188,7 +188,7 @@
|
|||||||
<div class="auth-header">
|
<div class="auth-header">
|
||||||
<h1>
|
<h1>
|
||||||
<i class="fas fa-briefcase"></i>
|
<i class="fas fa-briefcase"></i>
|
||||||
Rabota.Today
|
МП.Ярмарка
|
||||||
</h1>
|
</h1>
|
||||||
<p>Вход в личный кабинет</p>
|
<p>Вход в личный кабинет</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
699
templates/register.html
Normal file
699
templates/register.html
Normal 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>
|
||||||
641
templates/resume_detail.html
Normal file
641
templates/resume_detail.html
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка при старте
|
||||||
|
checkAuth().then(() => {
|
||||||
|
loadResume();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<i class="fas fa-briefcase"></i>
|
<i class="fas fa-briefcase"></i>
|
||||||
Rabota.Today
|
МП.Ярмарка
|
||||||
</div>
|
</div>
|
||||||
<div class="nav" id="nav">
|
<div class="nav" id="nav">
|
||||||
<!-- Навигация будет заполнена динамически -->
|
<!-- Навигация будет заполнена динамически -->
|
||||||
@@ -364,6 +364,8 @@
|
|||||||
<a href="/">Главная</a>
|
<a href="/">Главная</a>
|
||||||
<a href="/vacancies">Вакансии</a>
|
<a href="/vacancies">Вакансии</a>
|
||||||
<a href="/resumes" class="active">Резюме</a>
|
<a href="/resumes" class="active">Резюме</a>
|
||||||
|
<a href="/favorites">Избранное</a>
|
||||||
|
<a href="/applications">Отклики</a>
|
||||||
<a href="/profile" class="profile-link">
|
<a href="/profile" class="profile-link">
|
||||||
<i class="fas fa-user-circle"></i>
|
<i class="fas fa-user-circle"></i>
|
||||||
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
|
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
|
||||||
|
|||||||
@@ -295,7 +295,7 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<i class="fas fa-briefcase"></i>
|
<i class="fas fa-briefcase"></i>
|
||||||
Rabota.Today
|
МП.Ярмарка
|
||||||
</div>
|
</div>
|
||||||
<div class="nav" id="nav">
|
<div class="nav" id="nav">
|
||||||
<!-- Навигация будет заполнена динамически -->
|
<!-- Навигация будет заполнена динамически -->
|
||||||
@@ -378,6 +378,8 @@
|
|||||||
<a href="/">Главная</a>
|
<a href="/">Главная</a>
|
||||||
<a href="/vacancies" class="active">Вакансии</a>
|
<a href="/vacancies" class="active">Вакансии</a>
|
||||||
<a href="/resumes">Резюме</a>
|
<a href="/resumes">Резюме</a>
|
||||||
|
<a href="/favorites">Избранное</a>
|
||||||
|
<a href="/applications">Отклики</a>
|
||||||
<a href="/profile" class="profile-link">
|
<a href="/profile" class="profile-link">
|
||||||
<i class="fas fa-user-circle"></i>
|
<i class="fas fa-user-circle"></i>
|
||||||
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
|
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
|
||||||
@@ -421,7 +423,8 @@
|
|||||||
<span class="vacancy-title">${escapeHtml(v.title)}</span>
|
<span class="vacancy-title">${escapeHtml(v.title)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="vacancy-company">
|
<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>
|
||||||
<div class="vacancy-salary">${escapeHtml(v.salary || 'з/п не указана')}</div>
|
<div class="vacancy-salary">${escapeHtml(v.salary || 'з/п не указана')}</div>
|
||||||
<div class="vacancy-tags">
|
<div class="vacancy-tags">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 900px;
|
max-width: 1000px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
@@ -54,6 +56,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
@@ -61,17 +64,66 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a:hover {
|
.nav a:hover {
|
||||||
background: rgba(255,255,255,0.1);
|
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 {
|
.back-link {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
color: #4f7092;
|
color: #4f7092;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link i {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: #0b1c34;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vacancy-detail {
|
.vacancy-detail {
|
||||||
@@ -81,42 +133,115 @@
|
|||||||
box-shadow: 0 20px 40px rgba(0,20,40,0.1);
|
box-shadow: 0 20px 40px rgba(0,20,40,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vacancy-title {
|
.vacancy-header {
|
||||||
font-size: 32px;
|
display: flex;
|
||||||
color: #0b1c34;
|
justify-content: space-between;
|
||||||
margin-bottom: 10px;
|
align-items: start;
|
||||||
}
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
.vacancy-company {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #3b82f6;
|
|
||||||
margin-bottom: 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 {
|
.vacancy-salary {
|
||||||
font-size: 28px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0f2b4f;
|
color: #0f2b4f;
|
||||||
margin: 20px 0;
|
margin: 25px 0;
|
||||||
padding: 20px 0;
|
padding: 25px 0;
|
||||||
border-top: 2px solid #dee9f5;
|
border-top: 2px solid #dee9f5;
|
||||||
border-bottom: 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 {
|
.section-title {
|
||||||
font-size: 20px;
|
font-size: 24px;
|
||||||
color: #0b1c34;
|
color: #0b1c34;
|
||||||
margin: 30px 0 15px;
|
margin: 30px 0 20px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vacancy-description {
|
.vacancy-description {
|
||||||
color: #1f3f60;
|
color: #1f3f60;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
white-space: pre-line;
|
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 {
|
.contact-info {
|
||||||
background: #f9fcff;
|
background: #eef4fa;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
padding: 25px;
|
padding: 25px;
|
||||||
margin: 30px 0;
|
margin: 30px 0;
|
||||||
@@ -126,19 +251,26 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
padding: 10px 0;
|
padding: 12px 0;
|
||||||
color: #1f3f60;
|
color: #1f3f60;
|
||||||
|
border-bottom: 1px solid #d9e6f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contact-item i {
|
.contact-item i {
|
||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
width: 20px;
|
width: 24px;
|
||||||
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
@@ -148,6 +280,13 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
font-size: 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@@ -155,16 +294,70 @@
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #1b3f6b;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 2px solid #3b82f6;
|
border: 2px solid #3b82f6;
|
||||||
color: #0b1c34;
|
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 {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px;
|
padding: 60px;
|
||||||
color: #4f7092;
|
color: #4f7092;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 30px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 40px 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -173,84 +366,259 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<i class="fas fa-briefcase"></i>
|
<i class="fas fa-briefcase"></i>
|
||||||
Rabota.Today
|
МП.Ярмарка
|
||||||
</div>
|
</div>
|
||||||
<div class="nav">
|
<div class="nav" id="nav">
|
||||||
<a href="/">Главная</a>
|
<!-- Навигация будет заполнена динамически -->
|
||||||
<a href="/vacancies">Вакансии</a>
|
|
||||||
<a href="/resumes">Резюме</a>
|
|
||||||
<a href="/login">Войти</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/vacancies" class="back-link"><i class="fas fa-arrow-left"></i> Назад к вакансиям</a>
|
<a href="/vacancies" class="back-link"><i class="fas fa-arrow-left"></i> Назад к вакансиям</a>
|
||||||
|
|
||||||
<div id="vacancyDetail" class="vacancy-detail">
|
<div id="vacancyDetail" class="vacancy-detail">
|
||||||
<div class="loading">Загрузка...</div>
|
<div class="loading">Загрузка вакансии...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API_BASE_URL = 'http://localhost:8000/api';
|
const API_BASE_URL = 'http://localhost:8000/api';
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
// Получаем ID вакансии из URL
|
// Получаем ID вакансии из URL
|
||||||
const pathParts = window.location.pathname.split('/');
|
const pathParts = window.location.pathname.split('/');
|
||||||
const vacancyId = pathParts[pathParts.length - 1];
|
const vacancyId = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
async function loadVacancy() {
|
// Проверка авторизации
|
||||||
try {
|
async function checkAuth() {
|
||||||
const response = await fetch(`${API_BASE_URL}/vacancies/${vacancyId}`);
|
const token = localStorage.getItem('accessToken');
|
||||||
const vacancy = await response.json();
|
|
||||||
|
|
||||||
const container = document.getElementById('vacancyDetail');
|
if (token) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/user`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
container.innerHTML = `
|
if (response.ok) {
|
||||||
<h1 class="vacancy-title">${escapeHtml(vacancy.title)}</h1>
|
currentUser = await response.json();
|
||||||
<div class="vacancy-company">
|
} else {
|
||||||
<i class="fas fa-building"></i> ${escapeHtml(vacancy.company_name || 'Компания не указана')}
|
localStorage.removeItem('accessToken');
|
||||||
</div>
|
}
|
||||||
|
} 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>
|
if (currentUser) {
|
||||||
<div class="contact-info">
|
// Пользователь авторизован
|
||||||
<div class="contact-item">
|
nav.innerHTML = `
|
||||||
<i class="fab fa-telegram"></i>
|
<a href="/">Главная</a>
|
||||||
<span>${escapeHtml(vacancy.contact || 'Контакт не указан')}</span>
|
<a href="/vacancies">Вакансии</a>
|
||||||
</div>
|
<a href="/resumes">Резюме</a>
|
||||||
<div class="contact-item">
|
<a href="/favorites">Избранное</a>
|
||||||
<i class="fas fa-calendar"></i>
|
<a href="/applications">Отклики</a>
|
||||||
<span>Опубликовано: ${new Date(vacancy.created_at).toLocaleDateString()}</span>
|
<a href="/profile" class="profile-link">
|
||||||
</div>
|
<i class="fas fa-user-circle"></i>
|
||||||
</div>
|
<span class="user-name">${escapeHtml(currentUser.full_name.split(' ')[0])}</span>
|
||||||
|
${currentUser.is_admin ? '<span class="admin-badge">Admin</span>' : ''}
|
||||||
<div class="action-buttons">
|
</a>
|
||||||
<button class="btn btn-primary" onclick="applyForVacancy()">
|
`;
|
||||||
<i class="fas fa-paper-plane"></i> Откликнуться
|
} else {
|
||||||
</button>
|
// Пользователь не авторизован
|
||||||
<button class="btn btn-outline" onclick="saveToFavorites()">
|
nav.innerHTML = `
|
||||||
<i class="fas fa-heart"></i> В избранное
|
<a href="/">Главная</a>
|
||||||
</button>
|
<a href="/vacancies">Вакансии</a>
|
||||||
</div>
|
<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() {
|
async function applyForVacancy() {
|
||||||
const token = localStorage.getItem('accessToken');
|
const token = localStorage.getItem('accessToken');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
if (confirm('Для отклика нужно войти в систему. Перейти на страницу входа?')) {
|
redirectToLogin();
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,14 +632,18 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert('Отклик отправлен! Работодатель свяжется с вами.');
|
alert('Отклик отправлен! Работодатель свяжется с вами.');
|
||||||
|
// Перезагружаем вакансию, чтобы увидеть обновленный статус
|
||||||
|
loadVacancy();
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Ошибка отправки');
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Ошибка отправки');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('Ошибка при отправке отклика');
|
alert(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавить в избранное
|
||||||
function saveToFavorites() {
|
function saveToFavorites() {
|
||||||
const token = localStorage.getItem('accessToken');
|
const token = localStorage.getItem('accessToken');
|
||||||
|
|
||||||
@@ -286,6 +658,196 @@
|
|||||||
alert('Вакансия добавлена в избранное');
|
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) {
|
function escapeHtml(unsafe) {
|
||||||
if (!unsafe) return '';
|
if (!unsafe) return '';
|
||||||
return unsafe.toString()
|
return unsafe.toString()
|
||||||
@@ -296,8 +858,10 @@
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка вакансии
|
// Загрузка при старте
|
||||||
loadVacancy();
|
checkAuth().then(() => {
|
||||||
|
loadVacancy();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
721
test_data.py
Normal file
721
test_data.py
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
# test_data.py (исправленная версия с правильной транслитерацией)
|
||||||
|
import sqlite3
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Конфигурация
|
||||||
|
DB_NAME = "rabota_today.db"
|
||||||
|
|
||||||
|
|
||||||
|
# Функция для транслитерации кириллицы в латиницу
|
||||||
|
def transliterate(text):
|
||||||
|
"""Преобразование кириллицы в латиницу для email"""
|
||||||
|
translit_dict = {
|
||||||
|
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'e',
|
||||||
|
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||||
|
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||||
|
'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch',
|
||||||
|
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya',
|
||||||
|
'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'E',
|
||||||
|
'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M',
|
||||||
|
'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U',
|
||||||
|
'Ф': 'F', 'Х': 'Kh', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Shch',
|
||||||
|
'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya'
|
||||||
|
}
|
||||||
|
|
||||||
|
result = ''
|
||||||
|
for char in text:
|
||||||
|
result += translit_dict.get(char, char)
|
||||||
|
|
||||||
|
# Приводим к нижнему регистру
|
||||||
|
result = result.lower()
|
||||||
|
# Заменяем пробелы и спецсимволы на точки
|
||||||
|
result = re.sub(r'[^a-z0-9]', '.', result)
|
||||||
|
# Убираем множественные точки
|
||||||
|
result = re.sub(r'\.+', '.', result)
|
||||||
|
# Убираем точки в начале и конце
|
||||||
|
result = result.strip('.')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Список тегов по категориям
|
||||||
|
TAGS = {
|
||||||
|
'skill': [
|
||||||
|
'Python', 'JavaScript', 'TypeScript', 'React', 'Vue.js', 'Angular',
|
||||||
|
'Node.js', 'Django', 'Flask', 'FastAPI', 'Java', 'Spring',
|
||||||
|
'C#', '.NET', 'PHP', 'Laravel', 'Ruby', 'Rails', 'Go', 'Rust',
|
||||||
|
'SQL', 'PostgreSQL', 'MySQL', 'MongoDB', 'Redis', 'Docker',
|
||||||
|
'Kubernetes', 'AWS', 'Azure', 'GCP', 'Git', 'CI/CD', 'Linux',
|
||||||
|
'HTML', 'CSS', 'SASS', 'Tailwind', 'Bootstrap', 'Figma',
|
||||||
|
'Photoshop', 'UI/UX', 'Аналитика данных', 'Machine Learning',
|
||||||
|
'Data Science', 'Excel', '1С', 'SAP', 'CRM', 'Salesforce'
|
||||||
|
],
|
||||||
|
'industry': [
|
||||||
|
'IT', 'Финансы', 'Банки', 'Ритейл', 'E-commerce', 'Медицина',
|
||||||
|
'Образование', 'Строительство', 'Производство', 'Логистика',
|
||||||
|
'Транспорт', 'Маркетинг', 'Реклама', 'PR', 'Консалтинг',
|
||||||
|
'Юриспруденция', 'HR', 'Туризм', 'HoReCa', 'СМИ', 'Телеком'
|
||||||
|
],
|
||||||
|
'position': [
|
||||||
|
'Разработчик', 'Программист', 'Тестировщик', 'QA', 'DevOps',
|
||||||
|
'Системный администратор', 'Аналитик', 'Data Scientist',
|
||||||
|
'Менеджер', 'Руководитель', 'Директор', 'Team Lead',
|
||||||
|
'Project Manager', 'Product Manager', 'Scrum Master',
|
||||||
|
'Дизайнер', 'Верстальщик', 'Маркетолог', 'SMM', 'SEO',
|
||||||
|
'Бухгалтер', 'Экономист', 'Юрист', 'HR', 'Recruiter'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Имена и фамилии для генерации
|
||||||
|
FIRST_NAMES = [
|
||||||
|
'Александр', 'Дмитрий', 'Максим', 'Сергей', 'Андрей', 'Алексей',
|
||||||
|
'Артём', 'Илья', 'Кирилл', 'Михаил', 'Никита', 'Макар',
|
||||||
|
'Егор', 'Роман', 'Олег', 'Владимир', 'Иван', 'Денис',
|
||||||
|
'Анна', 'Елена', 'Ольга', 'Наталья', 'Екатерина', 'Мария',
|
||||||
|
'Анастасия', 'Татьяна', 'Ирина', 'Юлия', 'Светлана', 'Дарья'
|
||||||
|
]
|
||||||
|
|
||||||
|
LAST_NAMES = [
|
||||||
|
'Иванов', 'Петров', 'Сидоров', 'Смирнов', 'Кузнецов', 'Попов',
|
||||||
|
'Васильев', 'Соколов', 'Михайлов', 'Новиков', 'Федоров', 'Морозов',
|
||||||
|
'Волков', 'Алексеев', 'Лебедев', 'Семенов', 'Егоров', 'Павлов',
|
||||||
|
'Козлов', 'Степанов', 'Николаев', 'Орлов', 'Андреев', 'Макаров'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Названия компаний (уже на латинице)
|
||||||
|
COMPANIES = [
|
||||||
|
{'name': 'Yandex', 'website': 'yandex.ru', 'industry': 'IT'},
|
||||||
|
{'name': 'VK', 'website': 'vk.com', 'industry': 'IT'},
|
||||||
|
{'name': 'Sber', 'website': 'sberbank.ru', 'industry': 'Финансы'},
|
||||||
|
{'name': 'Tinkoff', 'website': 'tinkoff.ru', 'industry': 'Финансы'},
|
||||||
|
{'name': 'Ozon', 'website': 'ozon.ru', 'industry': 'E-commerce'},
|
||||||
|
{'name': 'Wildberries', 'website': 'wildberries.ru', 'industry': 'E-commerce'},
|
||||||
|
{'name': 'Avito', 'website': 'avito.ru', 'industry': 'IT'},
|
||||||
|
{'name': '2GIS', 'website': '2gis.ru', 'industry': 'IT'},
|
||||||
|
{'name': 'Kaspersky', 'website': 'kaspersky.ru', 'industry': 'IT'},
|
||||||
|
{'name': 'Skyeng', 'website': 'skyeng.ru', 'industry': 'Образование'},
|
||||||
|
{'name': 'Skillbox', 'website': 'skillbox.ru', 'industry': 'Образование'},
|
||||||
|
{'name': 'GeekBrains', 'website': 'gb.ru', 'industry': 'Образование'},
|
||||||
|
{'name': 'Rostelecom', 'website': 'rt.ru', 'industry': 'Телеком'},
|
||||||
|
{'name': 'MTS', 'website': 'mts.ru', 'industry': 'Телеком'},
|
||||||
|
{'name': 'Beeline', 'website': 'beeline.ru', 'industry': 'Телеком'},
|
||||||
|
{'name': 'Megafon', 'website': 'megafon.ru', 'industry': 'Телеком'},
|
||||||
|
{'name': 'X5 Retail Group', 'website': 'x5.ru', 'industry': 'Ритейл'},
|
||||||
|
{'name': 'Magnit', 'website': 'magnit.ru', 'industry': 'Ритейл'},
|
||||||
|
{'name': 'Lenta', 'website': 'lenta.com', 'industry': 'Ритейл'}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Описания компаний
|
||||||
|
COMPANY_DESCRIPTIONS = [
|
||||||
|
"Крупнейшая IT-компания в России, занимаемся разработкой поисковых систем и сервисов.",
|
||||||
|
"Ведущая социальная сеть, развиваем экосистему сервисов для общения и развлечений.",
|
||||||
|
"Крупнейший банк, внедряем инновационные технологии в финансовый сектор.",
|
||||||
|
"Инновационный банк, полностью цифровой сервис для миллионов клиентов.",
|
||||||
|
"Лидер e-commerce, развиваем маркетплейс и логистику по всей стране.",
|
||||||
|
"Крупнейший маркетплейс, работаем с миллионами заказов ежедневно.",
|
||||||
|
"Платформа для объявлений, помогаем людям покупать и продавать.",
|
||||||
|
"Разрабатываем картографические сервисы и навигационные приложения.",
|
||||||
|
"Мировой лидер в области кибербезопасности, защищаем пользователей по всему миру.",
|
||||||
|
"Образовательная платформа, помогаем людям осваивать новые профессии.",
|
||||||
|
"Онлайн-университет, обучаем востребованным digital-профессиям.",
|
||||||
|
"Телеком-оператор, предоставляем услуги связи и цифровые сервисы."
|
||||||
|
]
|
||||||
|
|
||||||
|
# Заголовки вакансий
|
||||||
|
VACANCY_TITLES = [
|
||||||
|
"Python разработчик",
|
||||||
|
"Java разработчик",
|
||||||
|
"Frontend разработчик (React)",
|
||||||
|
"Backend разработчик (Node.js)",
|
||||||
|
"Fullstack разработчик",
|
||||||
|
"DevOps инженер",
|
||||||
|
"Системный администратор",
|
||||||
|
"QA тестировщик",
|
||||||
|
"Data Scientist",
|
||||||
|
"Аналитик данных",
|
||||||
|
"Project Manager",
|
||||||
|
"Product Manager",
|
||||||
|
"UI/UX дизайнер",
|
||||||
|
"Веб-дизайнер",
|
||||||
|
"Маркетолог",
|
||||||
|
"SMM менеджер",
|
||||||
|
"SEO специалист",
|
||||||
|
"HR менеджер",
|
||||||
|
"Recruiter",
|
||||||
|
"Менеджер по продажам",
|
||||||
|
"Руководитель отдела разработки",
|
||||||
|
"Team Lead",
|
||||||
|
"Бухгалтер",
|
||||||
|
"Юрист",
|
||||||
|
"Системный аналитик"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Описания вакансий
|
||||||
|
VACANCY_DESCRIPTIONS = [
|
||||||
|
"""Требования:
|
||||||
|
- Опыт работы от 2 лет
|
||||||
|
- Знание Python, Django/Flask
|
||||||
|
- Понимание ООП и паттернов проектирования
|
||||||
|
- Опыт работы с базами данных (PostgreSQL, MySQL)
|
||||||
|
- Знание Git, Linux
|
||||||
|
|
||||||
|
Условия:
|
||||||
|
- Удаленная работа
|
||||||
|
- Гибкий график
|
||||||
|
- ДМС со стоматологией
|
||||||
|
- Белая заработная плата
|
||||||
|
- Возможность роста""",
|
||||||
|
|
||||||
|
"""Что нужно делать:
|
||||||
|
- Разработка новых фич
|
||||||
|
- Рефакторинг существующего кода
|
||||||
|
- Участие в code review
|
||||||
|
- Оптимизация производительности
|
||||||
|
|
||||||
|
Мы ждем:
|
||||||
|
- Опыт от 3 лет
|
||||||
|
- Знание Java 11+, Spring Boot
|
||||||
|
- Понимание микросервисной архитектуры
|
||||||
|
- Опыт с Docker, Kubernetes""",
|
||||||
|
|
||||||
|
"""Обязанности:
|
||||||
|
- Верстка интерфейсов по макетам
|
||||||
|
- Разработка новых компонентов
|
||||||
|
- Интеграция с API
|
||||||
|
- Оптимизация загрузки страниц
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
- React, Redux, TypeScript
|
||||||
|
- HTML5, CSS3, адаптивная верстка
|
||||||
|
- Опыт работы с REST API
|
||||||
|
- Git, Webpack""",
|
||||||
|
|
||||||
|
"""Кого мы ищем:
|
||||||
|
- Опыт разработки от 1 года
|
||||||
|
- Знание Node.js, Express/NestJS
|
||||||
|
- Опыт работы с MongoDB или PostgreSQL
|
||||||
|
- Понимание асинхронного программирования
|
||||||
|
- Будет плюсом знание TypeScript""",
|
||||||
|
|
||||||
|
"""Задачи:
|
||||||
|
- Управление инфраструктурой
|
||||||
|
- Настройка CI/CD
|
||||||
|
- Мониторинг и логирование
|
||||||
|
- Автоматизация процессов
|
||||||
|
|
||||||
|
Требования:
|
||||||
|
- Опыт работы с Docker, Kubernetes
|
||||||
|
- Знание облачных платформ (AWS/Azure/GCP)
|
||||||
|
- Опыт с системами мониторинга
|
||||||
|
- Понимание сетевых протоколов"""
|
||||||
|
]
|
||||||
|
|
||||||
|
# Сопроводительные письма
|
||||||
|
COVER_LETTERS = [
|
||||||
|
"Здравствуйте! Меня заинтересовала ваша вакансия. Имею большой опыт в данной сфере, готов выполнять тестовое задание и проходить собеседование.",
|
||||||
|
"Добрый день! Откликаюсь на вакансию. Уверен, что мой опыт и навыки будут полезны вашей компании. Готов к сотрудничеству!",
|
||||||
|
"Здравствуйте! Хочу предложить свою кандидатуру. Имею успешный опыт реализации аналогичных проектов. Буду рад обсудить детали.",
|
||||||
|
"Добрый день! Мой опыт полностью соответствует требованиям. Ищу интересные задачи и профессиональный рост. Рассмотрю любые предложения.",
|
||||||
|
"Здравствуйте! Откликаюсь на вакансию. Готов приступить к работе с завтрашнего дня. Могу выполнить тестовое задание для подтверждения квалификации.",
|
||||||
|
"Добрый день! Опыт работы 5 лет в данной сфере. Уверен, что смогу принести пользу компании. Жду обратной связи."
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ответы работодателей
|
||||||
|
EMPLOYER_RESPONSES = [
|
||||||
|
"Здравствуйте! Спасибо за отклик. Приглашаем вас на собеседование в пятницу в 15:00.",
|
||||||
|
"Добрый день! Ваше резюме нам подходит. Когда вам удобно созвониться для интервью?",
|
||||||
|
"Здравствуйте! К сожалению, мы вынуждены отказать, так как ищем кандидата с большим опытом.",
|
||||||
|
"Добрый день! Спасибо за интерес к нашей компании. Мы готовы сделать вам предложение о работе.",
|
||||||
|
"Здравствуйте! Приглашаем вас на техническое собеседование в четверг. Ждем подтверждения.",
|
||||||
|
"Добрый день! Ваш опыт выглядит интересно. Хотели бы обсудить детали сотрудничества."
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
"""Хеширование пароля"""
|
||||||
|
salt = "rabota.today.salt"
|
||||||
|
return hashlib.sha256((password + salt).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_email(first_name, last_name, domain=None):
|
||||||
|
"""Генерация email на латинице"""
|
||||||
|
# Транслитерируем имя и фамилию
|
||||||
|
first_lat = transliterate(first_name)
|
||||||
|
last_lat = transliterate(last_name)
|
||||||
|
|
||||||
|
# Случайный выбор формата email
|
||||||
|
formats = [
|
||||||
|
f"{first_lat}.{last_lat}",
|
||||||
|
f"{first_lat}{last_lat}",
|
||||||
|
f"{first_lat[0]}.{last_lat}",
|
||||||
|
f"{first_lat}.{last_lat[0]}",
|
||||||
|
f"{first_lat}{random.randint(1, 999)}"
|
||||||
|
]
|
||||||
|
|
||||||
|
username = random.choice(formats)
|
||||||
|
|
||||||
|
if domain:
|
||||||
|
# Для работодателей используем домен компании
|
||||||
|
email = f"{username}@{domain}"
|
||||||
|
else:
|
||||||
|
# Для соискателей используем популярные домены
|
||||||
|
domains = ['gmail.com', 'yandex.ru', 'mail.ru', 'inbox.ru', 'list.ru', 'bk.ru']
|
||||||
|
email = f"{username}@{random.choice(domains)}"
|
||||||
|
|
||||||
|
return email.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def update_database_schema():
|
||||||
|
"""Обновление схемы базы данных (добавление недостающих колонок)"""
|
||||||
|
conn = sqlite3.connect(DB_NAME)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Проверяем существование таблицы applications и добавляем недостающие колонки
|
||||||
|
cursor.execute("PRAGMA table_info(applications)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'applications' in [t[0] for t in cursor.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()]:
|
||||||
|
if 'viewed_at' not in columns:
|
||||||
|
cursor.execute("ALTER TABLE applications ADD COLUMN viewed_at TIMESTAMP")
|
||||||
|
print("✅ Добавлена колонка viewed_at в таблицу applications")
|
||||||
|
|
||||||
|
if 'response_message' not in columns:
|
||||||
|
cursor.execute("ALTER TABLE applications ADD COLUMN response_message TEXT")
|
||||||
|
print("✅ Добавлена колонка response_message в таблицу applications")
|
||||||
|
|
||||||
|
if 'response_at' not in columns:
|
||||||
|
cursor.execute("ALTER TABLE applications ADD COLUMN response_at TIMESTAMP")
|
||||||
|
print("✅ Добавлена колонка response_at в таблицу applications")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_database():
|
||||||
|
"""Очистка базы данных"""
|
||||||
|
conn = sqlite3.connect(DB_NAME)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Отключаем проверку внешних ключей временно
|
||||||
|
cursor.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
|
||||||
|
# Очищаем все таблицы
|
||||||
|
tables = [
|
||||||
|
'applications',
|
||||||
|
'favorites',
|
||||||
|
'resume_tags',
|
||||||
|
'vacancy_tags',
|
||||||
|
'work_experience',
|
||||||
|
'education',
|
||||||
|
'resumes',
|
||||||
|
'vacancies',
|
||||||
|
'companies',
|
||||||
|
'users',
|
||||||
|
'tags'
|
||||||
|
]
|
||||||
|
|
||||||
|
for table in tables:
|
||||||
|
try:
|
||||||
|
cursor.execute(f"DELETE FROM {table}")
|
||||||
|
print(f" Очищена таблица: {table}")
|
||||||
|
except sqlite3.OperationalError as e:
|
||||||
|
print(f" Таблица {table} не существует или ошибка: {e}")
|
||||||
|
|
||||||
|
# Сбрасываем счетчики автоинкремента
|
||||||
|
try:
|
||||||
|
cursor.execute("DELETE FROM sqlite_sequence")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("✅ База данных очищена")
|
||||||
|
|
||||||
|
|
||||||
|
def create_test_data():
|
||||||
|
"""Создание тестовых данных"""
|
||||||
|
conn = sqlite3.connect(DB_NAME)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
print("\n🚀 Начинаем создание тестовых данных...")
|
||||||
|
|
||||||
|
# 1. Создаем теги
|
||||||
|
print("\n📌 Создаем теги...")
|
||||||
|
tag_ids = {}
|
||||||
|
for category, tags in TAGS.items():
|
||||||
|
for tag in tags:
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
"INSERT INTO tags (name, category) VALUES (?, ?)",
|
||||||
|
(tag, category)
|
||||||
|
)
|
||||||
|
tag_ids[tag] = cursor.lastrowid
|
||||||
|
print(f" ✓ Добавлен тег: {tag} ({category})")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
# Тег уже существует
|
||||||
|
cursor.execute("SELECT id FROM tags WHERE name = ?", (tag,))
|
||||||
|
tag_ids[tag] = cursor.fetchone()[0]
|
||||||
|
print(f" • Тег уже существует: {tag}")
|
||||||
|
|
||||||
|
# 2. Создаем админа
|
||||||
|
print("\n👑 Создаем администратора...")
|
||||||
|
admin_password = hash_password("admin123")
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO users (full_name, email, phone, telegram, password_hash, role, is_admin)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
"Admin Adminov",
|
||||||
|
"admin@rabota.today",
|
||||||
|
"+7 (999) 999-99-99",
|
||||||
|
"@admin",
|
||||||
|
admin_password,
|
||||||
|
"admin",
|
||||||
|
1
|
||||||
|
))
|
||||||
|
print(" ✓ Администратор создан")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
print(" • Администратор уже существует")
|
||||||
|
|
||||||
|
# 3. Создаем работодателей и их компании
|
||||||
|
print("\n🏢 Создаем работодателей...")
|
||||||
|
employer_ids = []
|
||||||
|
|
||||||
|
for i, company in enumerate(COMPANIES[:15]): # Берем первые 15 компаний
|
||||||
|
# Выбираем имя и фамилию
|
||||||
|
first_name = random.choice(FIRST_NAMES)
|
||||||
|
last_name = random.choice(LAST_NAMES)
|
||||||
|
full_name = f"{first_name} {last_name}"
|
||||||
|
|
||||||
|
# Генерируем email на латинице с доменом компании
|
||||||
|
email = generate_email(first_name, last_name, company['name'].lower())
|
||||||
|
|
||||||
|
phone = f"+7 (9{random.randint(10, 99)}) {random.randint(100, 999)}-{random.randint(10, 99)}-{random.randint(10, 99)}"
|
||||||
|
telegram = f"@{transliterate(first_name)}_{random.randint(1, 999)}"
|
||||||
|
password = hash_password("password123")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO users (full_name, email, phone, telegram, password_hash, role, is_admin)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (full_name, email, phone, telegram, password, "employer", 0))
|
||||||
|
|
||||||
|
employer_id = cursor.lastrowid
|
||||||
|
employer_ids.append(employer_id)
|
||||||
|
print(f" ✓ Создан работодатель: {full_name}")
|
||||||
|
print(f" Email: {email}")
|
||||||
|
|
||||||
|
# Создаем компанию для работодателя
|
||||||
|
description = random.choice(COMPANY_DESCRIPTIONS)
|
||||||
|
address = f"г. Москва, ул. {random.choice(['Tverskaya', 'Nevskogo', 'Lenina'])}, д. {random.randint(1, 50)}"
|
||||||
|
company_phone = f"+7 (495) {random.randint(100, 999)}-{random.randint(10, 99)}-{random.randint(10, 99)}"
|
||||||
|
company_email = f"hr@{company['name'].lower()}.ru"
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO companies (user_id, name, description, website, address, phone, email)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
employer_id,
|
||||||
|
company['name'],
|
||||||
|
description,
|
||||||
|
f"https://{company['website']}",
|
||||||
|
address,
|
||||||
|
company_phone,
|
||||||
|
company_email
|
||||||
|
))
|
||||||
|
print(f" 🏢 Компания: {company['name']}")
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError as e:
|
||||||
|
print(f" ✗ Ошибка при создании работодателя: {e}")
|
||||||
|
|
||||||
|
# 4. Создаем соискателей
|
||||||
|
print("\n👨💼 Создаем соискателей...")
|
||||||
|
employee_ids = []
|
||||||
|
|
||||||
|
for i in range(30): # Создаем 30 соискателей
|
||||||
|
# Выбираем имя и фамилию
|
||||||
|
first_name = random.choice(FIRST_NAMES)
|
||||||
|
last_name = random.choice(LAST_NAMES)
|
||||||
|
full_name = f"{first_name} {last_name}"
|
||||||
|
|
||||||
|
# Генерируем email на латинице
|
||||||
|
email = generate_email(first_name, last_name)
|
||||||
|
|
||||||
|
phone = f"+7 (9{random.randint(10, 99)}) {random.randint(100, 999)}-{random.randint(10, 99)}-{random.randint(10, 99)}"
|
||||||
|
telegram = f"@{transliterate(first_name)}_{random.randint(1, 999)}"
|
||||||
|
password = hash_password("password123")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO users (full_name, email, phone, telegram, password_hash, role, is_admin)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (full_name, email, phone, telegram, password, "employee", 0))
|
||||||
|
|
||||||
|
employee_id = cursor.lastrowid
|
||||||
|
employee_ids.append(employee_id)
|
||||||
|
|
||||||
|
# Создаем резюме
|
||||||
|
desired_position = random.choice(VACANCY_TITLES)
|
||||||
|
about_me = f"Опыт работы {random.randint(1, 10)} лет. {random.choice(['Ответственный', 'Коммуникабельный', 'Целеустремленный', 'Креативный', 'Аккуратный'])} специалист. Ищу интересные задачи и профессиональный рост."
|
||||||
|
desired_salary = f"{random.choice(['от', 'до'])} {random.choice([50, 70, 100, 120, 150, 180, 200, 250])} 000 ₽"
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO resumes (user_id, desired_position, about_me, desired_salary, views)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (employee_id, desired_position, about_me, desired_salary, random.randint(0, 100)))
|
||||||
|
|
||||||
|
resume_id = cursor.lastrowid
|
||||||
|
|
||||||
|
# Добавляем теги к резюме (3-7 случайных тегов)
|
||||||
|
resume_tags = random.sample(list(TAGS['skill']), random.randint(3, 7))
|
||||||
|
for tag in resume_tags:
|
||||||
|
tag_id = tag_ids[tag]
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR IGNORE INTO resume_tags (resume_id, tag_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""", (resume_id, tag_id))
|
||||||
|
|
||||||
|
# Добавляем опыт работы (1-3 места)
|
||||||
|
for j in range(random.randint(1, 3)):
|
||||||
|
years_ago = random.randint(1, 5)
|
||||||
|
start_year = 2020 + random.randint(0, 3)
|
||||||
|
end_year = start_year + random.randint(1, 3)
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO work_experience (resume_id, position, company, period)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
resume_id,
|
||||||
|
random.choice(VACANCY_TITLES),
|
||||||
|
random.choice(COMPANIES)['name'],
|
||||||
|
f"{start_year}–{end_year if end_year <= 2025 else 2025}"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Добавляем образование (1-2 записи)
|
||||||
|
for j in range(random.randint(1, 2)):
|
||||||
|
institutions = ['MGU', 'SPbGU', 'MFTI', 'HSE', 'BMSTU', 'ITMO', 'NGU', 'UrFU']
|
||||||
|
specialties = ['Прикладная математика', 'Программная инженерия', 'Информатика', 'Экономика',
|
||||||
|
'Менеджмент', 'Дизайн']
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO education (resume_id, institution, specialty, graduation_year)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
resume_id,
|
||||||
|
random.choice(institutions),
|
||||||
|
random.choice(specialties),
|
||||||
|
str(2015 + random.randint(0, 10))
|
||||||
|
))
|
||||||
|
|
||||||
|
if i % 5 == 0:
|
||||||
|
print(f" ✓ Создано {i + 1} соискателей...")
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError as e:
|
||||||
|
print(f" ✗ Ошибка при создании соискателя: {e}")
|
||||||
|
|
||||||
|
print(f" ✓ Всего создано соискателей: {len(employee_ids)}")
|
||||||
|
|
||||||
|
# 5. Создаем вакансии
|
||||||
|
print("\n📋 Создаем вакансии...")
|
||||||
|
vacancy_ids = []
|
||||||
|
|
||||||
|
for employer_id in employer_ids:
|
||||||
|
# У каждого работодателя 3-8 вакансий
|
||||||
|
for i in range(random.randint(3, 8)):
|
||||||
|
title = random.choice(VACANCY_TITLES)
|
||||||
|
salary = f"{random.choice(['от', 'до'])} {random.choice([50, 70, 100, 120, 150, 180, 200, 250, 300])} 000 ₽"
|
||||||
|
description = random.choice(VACANCY_DESCRIPTIONS)
|
||||||
|
contact = f"@hr_{random.choice(COMPANIES)['name'].lower()}"
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO vacancies (user_id, title, salary, description, contact, views, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (
|
||||||
|
employer_id,
|
||||||
|
title,
|
||||||
|
salary,
|
||||||
|
description,
|
||||||
|
contact,
|
||||||
|
random.randint(0, 500),
|
||||||
|
1 # активна
|
||||||
|
))
|
||||||
|
|
||||||
|
vacancy_id = cursor.lastrowid
|
||||||
|
vacancy_ids.append(vacancy_id)
|
||||||
|
|
||||||
|
# Добавляем теги к вакансии (2-6 тегов)
|
||||||
|
vacancy_tags = random.sample(list(TAGS['skill']), random.randint(2, 6))
|
||||||
|
for tag in vacancy_tags:
|
||||||
|
tag_id = tag_ids[tag]
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR IGNORE INTO vacancy_tags (vacancy_id, tag_id)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""", (vacancy_id, tag_id))
|
||||||
|
|
||||||
|
print(f" ✓ Создано вакансий: {len(vacancy_ids)}")
|
||||||
|
|
||||||
|
# 6. Создаем отклики
|
||||||
|
print("\n✉️ Создаем отклики...")
|
||||||
|
|
||||||
|
statuses = ['pending', 'viewed', 'accepted', 'rejected']
|
||||||
|
weights = [0.3, 0.3, 0.2, 0.2] # вероятности статусов
|
||||||
|
|
||||||
|
applications_count = 0
|
||||||
|
|
||||||
|
for employee_id in employee_ids[:20]: # Берем первых 20 соискателей для откликов
|
||||||
|
# Каждый соискатель откликается на 2-8 вакансий
|
||||||
|
for i in range(random.randint(2, 8)):
|
||||||
|
vacancy_id = random.choice(vacancy_ids)
|
||||||
|
|
||||||
|
# Проверяем, не откликался ли уже
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM applications
|
||||||
|
WHERE vacancy_id = ? AND user_id = ?
|
||||||
|
""", (vacancy_id, employee_id))
|
||||||
|
|
||||||
|
if cursor.fetchone():
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = random.choices(statuses, weights=weights)[0]
|
||||||
|
message = random.choice(COVER_LETTERS) if random.random() > 0.3 else None
|
||||||
|
|
||||||
|
created_at = datetime.now() - timedelta(days=random.randint(1, 30))
|
||||||
|
created_at_str = created_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO applications (vacancy_id, user_id, message, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (vacancy_id, employee_id, message, status, created_at_str))
|
||||||
|
|
||||||
|
application_id = cursor.lastrowid
|
||||||
|
applications_count += 1
|
||||||
|
|
||||||
|
# Если статус не pending, добавляем дату просмотра
|
||||||
|
if status != 'pending':
|
||||||
|
viewed_at = created_at + timedelta(hours=random.randint(1, 48))
|
||||||
|
viewed_at_str = viewed_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE applications SET viewed_at = ? WHERE id = ?
|
||||||
|
""", (viewed_at_str, application_id))
|
||||||
|
|
||||||
|
# Если принято или отклонено, добавляем ответ
|
||||||
|
if status in ['accepted', 'rejected']:
|
||||||
|
response_message = random.choice(EMPLOYER_RESPONSES)
|
||||||
|
response_at = created_at + timedelta(days=random.randint(1, 5))
|
||||||
|
response_at_str = response_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE applications
|
||||||
|
SET response_message = ?, response_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", (response_message, response_at_str, application_id))
|
||||||
|
|
||||||
|
print(f" ✓ Создано откликов: {applications_count}")
|
||||||
|
|
||||||
|
# 7. Создаем избранное
|
||||||
|
print("\n❤️ Создаем избранное...")
|
||||||
|
|
||||||
|
favorites_count = 0
|
||||||
|
|
||||||
|
for user_id in employee_ids[:15] + employer_ids[:10]: # Берем часть пользователей
|
||||||
|
# Каждый пользователь добавляет в избранное 3-10 элементов
|
||||||
|
for i in range(random.randint(3, 10)):
|
||||||
|
if random.random() > 0.5 or user_id in employee_ids:
|
||||||
|
# Добавляем вакансию в избранное
|
||||||
|
item_id = random.choice(vacancy_ids)
|
||||||
|
item_type = 'vacancy'
|
||||||
|
else:
|
||||||
|
# Добавляем резюме в избранное (только для работодателей)
|
||||||
|
if user_id in employer_ids:
|
||||||
|
# Берем случайного соискателя
|
||||||
|
random_employee = random.choice(employee_ids)
|
||||||
|
cursor.execute("SELECT id FROM resumes WHERE user_id = ?", (random_employee,))
|
||||||
|
resume = cursor.fetchone()
|
||||||
|
if not resume:
|
||||||
|
continue
|
||||||
|
item_id = resume[0]
|
||||||
|
item_type = 'resume'
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
created_at = datetime.now() - timedelta(days=random.randint(1, 20))
|
||||||
|
created_at_str = created_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO favorites (user_id, item_type, item_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""", (user_id, item_type, item_id, created_at_str))
|
||||||
|
favorites_count += 1
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
# Уже в избранном
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f" ✓ Создано записей в избранном: {favorites_count}")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("✅ ТЕСТОВЫЕ ДАННЫЕ УСПЕШНО СОЗДАНЫ!")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"👤 Пользователей: {len(employer_ids) + len(employee_ids) + 1}")
|
||||||
|
print(f" - Админ: 1")
|
||||||
|
print(f" - Работодателей: {len(employer_ids)}")
|
||||||
|
print(f" - Соискателей: {len(employee_ids)}")
|
||||||
|
print(f"🏢 Компаний: {len(employer_ids)}")
|
||||||
|
print(f"📋 Вакансий: {len(vacancy_ids)}")
|
||||||
|
print(f"📄 Резюме: {len(employee_ids)}")
|
||||||
|
print(f"🏷️ Тегов: {len(tag_ids)}")
|
||||||
|
print(f"✉️ Откликов: {applications_count}")
|
||||||
|
print(f"❤️ Записей в избранном: {favorites_count}")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\n🔑 ДАННЫЕ ДЛЯ ВХОДА:")
|
||||||
|
print("-" * 60)
|
||||||
|
print("👑 Админ:")
|
||||||
|
print(" Email: admin@rabota.today")
|
||||||
|
print(" Пароль: admin123")
|
||||||
|
print("\n👨💼 Работодатель (пример):")
|
||||||
|
if employer_ids:
|
||||||
|
cursor = sqlite3.connect(DB_NAME).cursor()
|
||||||
|
cursor.execute("SELECT email FROM users WHERE id = ?", (employer_ids[0],))
|
||||||
|
employer_email = cursor.fetchone()
|
||||||
|
if employer_email:
|
||||||
|
print(f" Email: {employer_email[0]}")
|
||||||
|
print(" Пароль: password123")
|
||||||
|
print("\n👨💻 Соискатель (пример):")
|
||||||
|
if employee_ids:
|
||||||
|
cursor = sqlite3.connect(DB_NAME).cursor()
|
||||||
|
cursor.execute("SELECT email FROM users WHERE id = ?", (employee_ids[0],))
|
||||||
|
employee_email = cursor.fetchone()
|
||||||
|
if employee_email:
|
||||||
|
print(f" Email: {employee_email[0]}")
|
||||||
|
print(" Пароль: password123")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("🔄 Начинаем подготовку тестовых данных...")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Спрашиваем подтверждение
|
||||||
|
response = input("Это удалит все существующие данные. Продолжить? (y/n): ")
|
||||||
|
|
||||||
|
if response.lower() == 'y':
|
||||||
|
try:
|
||||||
|
# Сначала обновляем схему базы данных
|
||||||
|
update_database_schema()
|
||||||
|
# Затем очищаем данные
|
||||||
|
clear_database()
|
||||||
|
# И создаем новые
|
||||||
|
create_test_data()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Ошибка: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
else:
|
||||||
|
print("❌ Операция отменена")
|
||||||
Reference in New Issue
Block a user