Сделал фронтенд динамическим и исправил создание услуг (ошибка 405)
This commit is contained in:
+277
-1
@@ -1396,19 +1396,25 @@
|
||||
const nextBtn = document.getElementById('carousel-next');
|
||||
const servicesProgressBar = document.getElementById('services-progress-bar');
|
||||
|
||||
let updateServicesProgress = () => {};
|
||||
|
||||
if (carousel && prevBtn && nextBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
if (carousel.firstElementChild) {
|
||||
const cardWidth = carousel.firstElementChild.getBoundingClientRect().width + 32; // ширина карточки + gap-8 (32px)
|
||||
carousel.scrollBy({ left: -cardWidth, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
nextBtn.addEventListener('click', () => {
|
||||
if (carousel.firstElementChild) {
|
||||
const cardWidth = carousel.firstElementChild.getBoundingClientRect().width + 32; // ширина карточки + gap-8 (32px)
|
||||
carousel.scrollBy({ left: cardWidth, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновление прогресс-бара услуг
|
||||
function updateServicesProgress() {
|
||||
updateServicesProgress = function() {
|
||||
if (servicesProgressBar) {
|
||||
const scrollWidth = carousel.scrollWidth;
|
||||
const clientWidth = carousel.clientWidth;
|
||||
@@ -1430,6 +1436,276 @@
|
||||
window.addEventListener('load', updateServicesProgress);
|
||||
}
|
||||
|
||||
// Динамическая загрузка услуг из базы данных
|
||||
function loadDynamicServices() {
|
||||
if (!carousel) return;
|
||||
|
||||
const serviceStaticMeta = {
|
||||
"web_1": {
|
||||
badge: "⚡ Быстрый разбор",
|
||||
bullets: [
|
||||
{ icon: "🔍", text: "Анализ игры" },
|
||||
{ icon: "🎯", text: "Работа над механикой и позиционированием" },
|
||||
{ icon: "👤", text: "Персональные рекомендации" },
|
||||
{ icon: "💬", text: "Фидбек после занятия" }
|
||||
],
|
||||
image: "Images/coaching_session.png"
|
||||
},
|
||||
"web_2": {
|
||||
badge: "👥 Групповой формат",
|
||||
bullets: [
|
||||
{ icon: "👥", text: "Тренировки в мини-группе" },
|
||||
{ icon: "🎯", text: "Разбор игровых ситуаций" },
|
||||
{ icon: "🤝", text: "Командное взаимодействие" },
|
||||
{ icon: "💬", text: "Разбор домашних игр" }
|
||||
],
|
||||
image: "Images/group_4_coaching.png"
|
||||
},
|
||||
"web_3": {
|
||||
badge: "👥 Групповой формат",
|
||||
bullets: [
|
||||
{ icon: "👥", text: "8 тренировок в группе" },
|
||||
{ icon: "📊", text: "Трекинг прогресса" },
|
||||
{ icon: "⚔️", text: "Участие во внутренних праках" },
|
||||
{ icon: "🧠", text: "Психология и тактика" }
|
||||
],
|
||||
image: "Images/group_8_coaching.png"
|
||||
},
|
||||
"web_4": {
|
||||
badge: "👤 Индивидуально",
|
||||
bullets: [
|
||||
{ icon: "🎯", text: "Персональный тренер" },
|
||||
{ icon: "🎥", text: "Разбор записей (VOD)" },
|
||||
{ icon: "📈", text: "Индивидуальный план" },
|
||||
{ icon: "💬", text: "Постоянный контакт в Discord" }
|
||||
],
|
||||
image: "Images/indiv_4_coaching.png"
|
||||
},
|
||||
"web_5": {
|
||||
badge: "⭐ VIP формат",
|
||||
bullets: [
|
||||
{ icon: "👑", text: "Полное сопровождение" },
|
||||
{ icon: "📁", text: "Личное портфолио игрока" },
|
||||
{ icon: "📞", text: "Прямая связь 24/7" },
|
||||
{ icon: "🏆", text: "Подготовка к турнирам" }
|
||||
],
|
||||
image: "Images/indiv_8_coaching.png"
|
||||
}
|
||||
};
|
||||
|
||||
const colors = [
|
||||
{ border: 'border-cyber-blue', shadow: 'shadow-[0_0_15px_rgba(59,130,246,0.2)]', bgGlow: 'bg-cyber-blue/10', borderGlow: 'border-cyber-blue/30', borderGlowLight: 'border-cyber-blue/20', badgeBg: 'bg-cyber-blue', textGlow: 'text-glow-blue', borderBullet: 'border-cyber-blue/40', shadowBullet: 'shadow-[0_0_10px_rgba(59,130,246,0.1)]', borderBtn: 'border-cyber-blue', bulletColor: 'text-cyber-blue', tagIcon: 'text-cyber-blue', shadowColorRgb: '59, 130, 246' },
|
||||
{ border: 'border-cyber-purple', shadow: 'shadow-[0_0_15px_rgba(124,58,237,0.2)]', bgGlow: 'bg-cyber-purple/10', borderGlow: 'border-cyber-purple/30', borderGlowLight: 'border-cyber-purple/20', badgeBg: 'bg-cyber-purple', textGlow: 'text-glow-purple', borderBullet: 'border-cyber-purple/40', shadowBullet: 'shadow-[0_0_10px_rgba(124,58,237,0.1)]', borderBtn: 'border-cyber-purple', bulletColor: 'text-cyber-purple', tagIcon: 'text-cyber-purple', shadowColorRgb: '124, 58, 237' },
|
||||
{ border: 'border-cyber-pink', shadow: 'shadow-[0_0_15px_rgba(236,72,153,0.2)]', bgGlow: 'bg-cyber-pink/10', borderGlow: 'border-cyber-pink/30', borderGlowLight: 'border-cyber-pink/20', badgeBg: 'bg-cyber-pink', textGlow: 'text-glow-pink', borderBullet: 'border-cyber-pink/40', shadowBullet: 'shadow-[0_0_10px_rgba(236,72,153,0.1)]', borderBtn: 'border-cyber-pink', bulletColor: 'text-cyber-pink', tagIcon: 'text-cyber-pink', shadowColorRgb: '236, 72, 153' }
|
||||
];
|
||||
|
||||
fetch('/api/services')
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error('Failed to load services API');
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.status === 'success' && data.services) {
|
||||
const servicesObj = data.services;
|
||||
const servicesList = Object.keys(servicesObj).map(key => {
|
||||
return { id: key, ...servicesObj[key] };
|
||||
});
|
||||
|
||||
if (servicesList.length === 0) return;
|
||||
|
||||
let cardsHtml = '';
|
||||
servicesList.forEach((svc, index) => {
|
||||
const colorClass = colors[index % colors.length];
|
||||
|
||||
// Format title
|
||||
let titleHtml = svc.name;
|
||||
if (svc.name.includes("Абонемент на")) {
|
||||
titleHtml = svc.name.replace("Абонемент на", "Абонемент<br>на");
|
||||
} else if (svc.name.includes("Разовая тренировка")) {
|
||||
titleHtml = "Разовая<br>тренировка";
|
||||
}
|
||||
|
||||
// Format subtitle
|
||||
let subtitle = '';
|
||||
if (svc.description && (svc.description.toLowerCase().includes("counter-strike") || svc.description.toLowerCase().includes("dota") || svc.description.toLowerCase().includes("tanks"))) {
|
||||
subtitle = svc.description.split(/[\n,;]/).map(s => s.trim()).filter(s => s.length > 0).join(" • ");
|
||||
} else if (serviceStaticMeta[svc.id]) {
|
||||
subtitle = "Counter-Strike 2 • Dota 2 • World of Tanks";
|
||||
} else {
|
||||
subtitle = "КУРС ОБУЧЕНИЯ ИКП";
|
||||
}
|
||||
|
||||
// Image fallback logic
|
||||
let image = "Images/coaching_session.png";
|
||||
if (serviceStaticMeta[svc.id]) {
|
||||
image = serviceStaticMeta[svc.id].image;
|
||||
} else {
|
||||
const lowerName = svc.name.toLowerCase();
|
||||
if (lowerName.includes("групп")) {
|
||||
image = "Images/group_4_coaching.png";
|
||||
} else if (lowerName.includes("индивид") || lowerName.includes("vip")) {
|
||||
image = "Images/indiv_4_coaching.png";
|
||||
}
|
||||
}
|
||||
|
||||
// Badge
|
||||
let badge = "⭐ Услуга";
|
||||
if (serviceStaticMeta[svc.id]) {
|
||||
badge = serviceStaticMeta[svc.id].badge;
|
||||
} else {
|
||||
const lowerName = svc.name.toLowerCase();
|
||||
if (lowerName.includes("разов") || lowerName.includes("однократ")) badge = "⚡ Быстрый старт";
|
||||
else if (lowerName.includes("групп")) badge = "👥 Групповой";
|
||||
else if (lowerName.includes("индивид")) badge = "👤 Индивидуально";
|
||||
else if (lowerName.includes("абонемент") || lowerName.includes("пакет")) badge = "📦 Пакет услуг";
|
||||
}
|
||||
|
||||
// Bullets parser logic
|
||||
let bullets = [];
|
||||
if (serviceStaticMeta[svc.id]) {
|
||||
const isDefaultDesc = !svc.description ||
|
||||
svc.description.toLowerCase().includes("counter-strike") ||
|
||||
svc.description.toLowerCase().includes("dota") ||
|
||||
svc.description.toLowerCase().includes("tanks");
|
||||
if (isDefaultDesc) {
|
||||
bullets = serviceStaticMeta[svc.id].bullets;
|
||||
}
|
||||
}
|
||||
if (bullets.length === 0 && svc.description) {
|
||||
const lines = svc.description.split(/[\n;]/).map(l => l.trim()).filter(l => l.length > 0);
|
||||
if (lines.length > 1) {
|
||||
bullets = lines.map(line => {
|
||||
const cleanLine = line.replace(/^[\s\-\*\•\d\.\:\)\>]+/, '').trim();
|
||||
let icon = "⚡";
|
||||
const lower = cleanLine.toLowerCase();
|
||||
if (lower.includes("анализ") || lower.includes("разбор")) icon = "🔍";
|
||||
else if (lower.includes("тренер") || lower.includes("индивидуальн") || lower.includes("персональн")) icon = "👤";
|
||||
else if (lower.includes("механик") || lower.includes("цель") || lower.includes("стрельб")) icon = "🎯";
|
||||
else if (lower.includes("групп") || lower.includes("команд") || lower.includes("совестн")) icon = "👥";
|
||||
else if (lower.includes("прогресс") || lower.includes("план") || lower.includes("отчет")) icon = "📈";
|
||||
else if (lower.includes("дискорд") || lower.includes("discord") || lower.includes("связь") || lower.includes("контакт")) icon = "💬";
|
||||
else if (lower.includes("турнир") || lower.includes("кубок") || lower.includes("побед")) icon = "🏆";
|
||||
return { icon, text: cleanLine };
|
||||
});
|
||||
}
|
||||
}
|
||||
if (bullets.length === 0) {
|
||||
bullets = [
|
||||
{ icon: "🎮", text: svc.description || "Практические занятия" },
|
||||
{ icon: "📈", text: "Развитие навыков и тактики" },
|
||||
{ icon: "👤", text: "Индивидуальный подход" },
|
||||
{ icon: "💬", text: "Обратная связь по итогам" }
|
||||
];
|
||||
}
|
||||
|
||||
let bulletsHtml = '';
|
||||
bullets.forEach(b => {
|
||||
bulletsHtml += `
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full border ${colorClass.borderBullet} flex items-center justify-center ${colorClass.bulletColor} text-sm shrink-0 ${colorClass.shadowBullet}">
|
||||
${b.icon}
|
||||
</div>
|
||||
<span class="text-[11px] font-bold uppercase tracking-wider text-gray-300">${b.text}</span>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
// Price formatting
|
||||
let priceText = svc.price;
|
||||
if (!priceText && svc.price_numeric !== undefined) {
|
||||
priceText = svc.price_numeric.toLocaleString('ru-RU') + ' ₽';
|
||||
}
|
||||
|
||||
cardsHtml += `
|
||||
<div class="snap-start shrink-0 w-full sm:w-[380px] md:w-[410px]">
|
||||
<div class="cyber-card p-1 border ${colorClass.border} ${colorClass.shadow} h-full">
|
||||
<div class="bg-dark p-6 h-full clip-path-polygon flex flex-col relative overflow-hidden group">
|
||||
<!-- Декоративные уголки и фоновые элементы -->
|
||||
<div class="absolute -top-10 -right-10 w-40 h-40 ${colorClass.bgGlow} rounded-full blur-3xl pointer-events-none"></div>
|
||||
|
||||
<!-- Заголовок и логотип -->
|
||||
<div class="flex justify-between items-start gap-4 mb-3">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-3xl font-black italic tracking-tighter uppercase text-white leading-none ${colorClass.textGlow}">
|
||||
${titleHtml}
|
||||
</h3>
|
||||
<div class="text-[9px] font-mono text-gray-400 tracking-wider uppercase">
|
||||
${subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<img src="Images/Logo.jpg" alt="ИКП" class="h-10 w-auto object-contain rounded border ${colorClass.borderGlow}">
|
||||
</div>
|
||||
|
||||
<!-- Изображение сессии -->
|
||||
<div class="relative w-full h-40 mb-5 overflow-hidden border ${colorClass.borderGlowLight} rounded-md">
|
||||
<img src="${image}" alt="${svc.name}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-[#0a0a0f] via-transparent to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<!-- Преимущества -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<!-- Бэйдж -->
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 ${colorClass.badgeBg} text-[#0a0a0f] text-[10px] font-black uppercase tracking-wider clip-path-polygon shadow-[0_0_10px_rgba(${colorClass.shadowColorRgb},0.3)]">
|
||||
${badge}
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
${bulletsHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Блок цены и кнопки -->
|
||||
<div class="pt-4 mt-auto border-t ${colorClass.borderGlowLight}">
|
||||
<div class="flex items-center justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<span class="text-[9px] font-mono text-gray-500 block uppercase tracking-widest">Цена:</span>
|
||||
<span class="text-3xl font-black text-white ${colorClass.textGlow}">${priceText}</span>
|
||||
</div>
|
||||
<a href="form/index.html?service_id=${svc.id}" class="cyber-btn text-xs whitespace-nowrap !py-3 !px-6 ${colorClass.borderBtn} flex items-center gap-2 group/btn">
|
||||
Записаться
|
||||
<svg class="w-4 h-4 transition-transform group-hover/btn:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Нижняя плашка с тегами (Стратегия, Дисциплина, Практика, Результат) -->
|
||||
<div class="grid grid-cols-4 gap-1 pt-4 border-t border-cyber-gray text-[8px] font-mono font-bold uppercase text-gray-500 text-center">
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="${colorClass.tagIcon} text-xs">♟</span>
|
||||
<span>Стратегия</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="${colorClass.tagIcon} text-xs">🎯</span>
|
||||
<span>Дисциплина</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="${colorClass.tagIcon} text-xs">📈</span>
|
||||
<span>Практика</span>
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<span class="${colorClass.tagIcon} text-xs">🏆</span>
|
||||
<span>Результат</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
carousel.innerHTML = cardsHtml;
|
||||
updateServicesProgress();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load dynamic services from database:', err);
|
||||
});
|
||||
}
|
||||
|
||||
loadDynamicServices();
|
||||
|
||||
// Управление вертикальной каруселью вакансий
|
||||
const vacViewport = document.getElementById('vacancies-carousel-viewport');
|
||||
const vacTrack = document.getElementById('vacancies-carousel-track');
|
||||
|
||||
@@ -1311,6 +1311,72 @@ def admin_logout():
|
||||
return redirect(f"{base}/application/o/ikp-admin/end-session/")
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
@app.route('/admin/services/add', methods=['POST'])
|
||||
def admin_add_service():
|
||||
if not session.get('logged_in'):
|
||||
return jsonify({"status": "error", "message": "Не авторизован"}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"status": "error", "message": "Отсутствуют данные запроса"}), 400
|
||||
|
||||
service_id = data.get("id", "").strip()
|
||||
name = data.get("name", "").strip()
|
||||
price = data.get("price", "").strip()
|
||||
price_numeric = data.get("price_numeric")
|
||||
description = data.get("description", "").strip()
|
||||
|
||||
if not service_id or not name or not price:
|
||||
return jsonify({"status": "error", "message": "ID, название и цена обязательны для заполнения"}), 400
|
||||
|
||||
try:
|
||||
price_numeric = float(price_numeric)
|
||||
except (ValueError, TypeError):
|
||||
price_numeric = 0.0
|
||||
|
||||
success = db.add_service(service_id, name, price, price_numeric, description)
|
||||
if success:
|
||||
return jsonify({"status": "success", "message": "Услуга добавлена"})
|
||||
else:
|
||||
return jsonify({"status": "error", "message": f"Услуга с ID '{service_id}' уже существует"}), 400
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
@app.route('/admin/services/edit/<service_id>', methods=['POST'])
|
||||
def admin_edit_service(service_id):
|
||||
if not session.get('logged_in'):
|
||||
return jsonify({"status": "error", "message": "Не авторизован"}), 403
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"status": "error", "message": "Отсутствуют данные запроса"}), 400
|
||||
|
||||
name = data.get("name", "").strip()
|
||||
price = data.get("price", "").strip()
|
||||
price_numeric = data.get("price_numeric")
|
||||
description = data.get("description", "").strip()
|
||||
is_active = int(data.get("is_active", 1))
|
||||
|
||||
if not name or not price:
|
||||
return jsonify({"status": "error", "message": "Название и цена обязательны для заполнения"}), 400
|
||||
|
||||
try:
|
||||
price_numeric = float(price_numeric)
|
||||
except (ValueError, TypeError):
|
||||
price_numeric = 0.0
|
||||
|
||||
success = db.update_service(service_id, name, price, price_numeric, description, is_active)
|
||||
if success:
|
||||
return jsonify({"status": "success", "message": "Услуга обновлена"})
|
||||
else:
|
||||
return jsonify({"status": "error", "message": "Не удалось обновить услугу (возможно, ID не найден)"}), 404
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "message": str(e)}), 500
|
||||
|
||||
@app.route('/admin/check-payment/<reg_id>', methods=['POST'])
|
||||
def admin_check_payment(reg_id):
|
||||
if not session.get('logged_in'):
|
||||
|
||||
Reference in New Issue
Block a user