initial commit
This commit is contained in:
commit
6f573999ef
696
app.py
Normal file
696
app.py
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify
|
||||
import sqlite3
|
||||
import os
|
||||
import re
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'your_secret_key_here_change_in_production'
|
||||
|
||||
# --------------------- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ---------------------
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect('horse_farm.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def is_valid_email(email):
|
||||
if not email:
|
||||
return True
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.(ru|com)$'
|
||||
return re.match(pattern, email.lower()) is not None
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
flash('Пожалуйста, войдите в систему', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
flash('Пожалуйста, войдите в систему', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
if session.get('role') != 'admin':
|
||||
flash('Доступ запрещен. Требуются права администратора.', 'danger')
|
||||
return redirect(url_for('index'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
# ------------------------- API -------------------------
|
||||
@app.route('/api/services')
|
||||
def api_services():
|
||||
conn = get_db_connection()
|
||||
services = conn.execute('SELECT id, name, price, duration FROM services').fetchall()
|
||||
abonements = conn.execute('SELECT id, name, price, description FROM abonements').fetchall()
|
||||
certificates = conn.execute('SELECT id, name, price, description FROM certificates').fetchall()
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'services': [dict(row) for row in services],
|
||||
'abonements': [dict(row) for row in abonements],
|
||||
'certificates': [dict(row) for row in certificates]
|
||||
})
|
||||
|
||||
@app.route('/api/unread_count')
|
||||
@login_required
|
||||
def api_unread_count():
|
||||
conn = get_db_connection()
|
||||
count = conn.execute('''
|
||||
SELECT COUNT(*) as count FROM messages m
|
||||
JOIN orders o ON m.order_id = o.id
|
||||
WHERE o.user_id = ? AND m.is_read = 0 AND m.sender_type != 'user'
|
||||
''', (session['user_id'],)).fetchone()
|
||||
conn.close()
|
||||
return jsonify({'count': count['count'] if count else 0})
|
||||
|
||||
@app.route('/api/unread_by_order')
|
||||
@login_required
|
||||
def api_unread_by_order():
|
||||
conn = get_db_connection()
|
||||
unread = conn.execute('''
|
||||
SELECT o.id, COUNT(*) as count
|
||||
FROM messages m
|
||||
JOIN orders o ON m.order_id = o.id
|
||||
WHERE o.user_id = ? AND m.is_read = 0 AND m.sender_type != 'user'
|
||||
GROUP BY o.id
|
||||
''', (session['user_id'],)).fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(row) for row in unread])
|
||||
|
||||
@app.route('/api/mark_read', methods=['POST'])
|
||||
@login_required
|
||||
def mark_read():
|
||||
data = request.get_json()
|
||||
order_id = data.get('order_id')
|
||||
conn = get_db_connection()
|
||||
conn.execute('''
|
||||
UPDATE messages SET is_read = 1
|
||||
WHERE order_id = ? AND user_id = ? AND sender_type != 'user'
|
||||
''', (order_id, session['user_id']))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True})
|
||||
|
||||
@app.route('/api/set_timezone', methods=['POST'])
|
||||
def set_timezone():
|
||||
data = request.get_json()
|
||||
if data and 'timezone_offset' in data:
|
||||
session['timezone_offset'] = data['timezone_offset']
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False}), 400
|
||||
|
||||
@app.route('/api/admin_unread_counts')
|
||||
@admin_required
|
||||
def api_admin_unread_counts():
|
||||
conn = get_db_connection()
|
||||
counts = conn.execute('''
|
||||
SELECT order_id, COUNT(*) as count
|
||||
FROM messages
|
||||
WHERE is_read = 0 AND sender_type = 'user'
|
||||
GROUP BY order_id
|
||||
''').fetchall()
|
||||
conn.close()
|
||||
return jsonify({str(row['order_id']): row['count'] for row in counts})
|
||||
|
||||
@app.route('/api/mark_order_read', methods=['POST'])
|
||||
@admin_required
|
||||
def mark_order_read():
|
||||
data = request.get_json()
|
||||
order_id = data.get('order_id')
|
||||
conn = get_db_connection()
|
||||
conn.execute('UPDATE messages SET is_read = 1 WHERE order_id = ? AND sender_type = "user"', (order_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True})
|
||||
|
||||
@app.route('/api/toggle_payment', methods=['POST'])
|
||||
@admin_required
|
||||
def toggle_payment():
|
||||
data = request.get_json()
|
||||
order_id = data.get('order_id')
|
||||
conn = get_db_connection()
|
||||
current = conn.execute('SELECT payment_status FROM orders WHERE id = ?', (order_id,)).fetchone()
|
||||
new_status = 'paid' if current['payment_status'] != 'paid' else 'not_paid'
|
||||
conn.execute('UPDATE orders SET payment_status = ? WHERE id = ?', (new_status, order_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'status': new_status})
|
||||
|
||||
# ---------------------- СТРАНИЦЫ ----------------------
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/about')
|
||||
def about():
|
||||
return render_template('about.html')
|
||||
|
||||
@app.route('/services')
|
||||
def services():
|
||||
conn = get_db_connection()
|
||||
services = conn.execute('SELECT * FROM services').fetchall()
|
||||
conn.close()
|
||||
return render_template('services.html', services=services)
|
||||
|
||||
@app.route('/abonements')
|
||||
def abonements():
|
||||
conn = get_db_connection()
|
||||
abonements = conn.execute('SELECT * FROM abonements').fetchall()
|
||||
conn.close()
|
||||
return render_template('abonements.html', abonements=abonements)
|
||||
|
||||
@app.route('/certificates')
|
||||
def certificates():
|
||||
conn = get_db_connection()
|
||||
certificates = conn.execute('SELECT * FROM certificates').fetchall()
|
||||
conn.close()
|
||||
return render_template('certificates.html', certificates=certificates)
|
||||
|
||||
@app.route('/gallery')
|
||||
def gallery():
|
||||
images_dir = os.path.join(app.static_folder, 'images/gallery')
|
||||
images = []
|
||||
if os.path.exists(images_dir):
|
||||
images = [f for f in os.listdir(images_dir)
|
||||
if f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp'))]
|
||||
return render_template('gallery.html', images=images)
|
||||
|
||||
@app.route('/discounts')
|
||||
def discounts():
|
||||
return render_template('discounts.html')
|
||||
|
||||
# ---------------------- РЕГИСТРАЦИЯ / ВХОД ----------------------
|
||||
@app.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
email = request.form.get('email', '')
|
||||
if email and not is_valid_email(email):
|
||||
flash('Введите корректный email адрес (должен заканчиваться на .ru или .com)', 'danger')
|
||||
return redirect(url_for('register'))
|
||||
conn = get_db_connection()
|
||||
user = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
|
||||
if user:
|
||||
flash('Пользователь с таким именем уже существует', 'danger')
|
||||
conn.close()
|
||||
return redirect(url_for('register'))
|
||||
hashed_password = generate_password_hash(password)
|
||||
conn.execute('INSERT INTO users (username, password, email, role) VALUES (?, ?, ?, ?)',
|
||||
(username, hashed_password, email if email else None, 'user'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash('Регистрация успешна! Теперь вы можете войти', 'success')
|
||||
return redirect(url_for('login'))
|
||||
return render_template('register.html')
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
conn = get_db_connection()
|
||||
user = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
|
||||
conn.close()
|
||||
if user and check_password_hash(user['password'], password):
|
||||
session.permanent = True
|
||||
session['user_id'] = user['id']
|
||||
session['username'] = user['username']
|
||||
session['role'] = user['role']
|
||||
flash(f'Добро пожаловать, {username}!', 'success')
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
flash('Неверное имя пользователя или пароль', 'danger')
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
flash('Вы вышли из системы', 'info')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# ---------------------- ЗАКАЗЫ ----------------------
|
||||
@app.route('/order', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def order():
|
||||
if request.method == 'POST':
|
||||
service_type = request.form['service_type']
|
||||
service_id = request.form.get('service_id')
|
||||
service_name = request.form['service_name']
|
||||
date = request.form.get('date')
|
||||
time = request.form.get('time')
|
||||
persons = request.form.get('persons', 1)
|
||||
price = request.form.get('price', 0)
|
||||
comment = request.form.get('comment', '')
|
||||
try:
|
||||
persons = int(float(persons)) if service_type != 'certificate' else 1
|
||||
if persons < 1: persons = 1
|
||||
if persons > 20: persons = 20
|
||||
except:
|
||||
persons = 1
|
||||
conn = get_db_connection()
|
||||
final_price = int(price)
|
||||
discount_applied = False
|
||||
discount_amount = 0
|
||||
original_price = int(price)
|
||||
if service_type != 'certificate':
|
||||
duration_minutes = 0
|
||||
duration_str = ''
|
||||
if service_type == 'service':
|
||||
service = conn.execute('SELECT duration FROM services WHERE id = ?', (service_id,)).fetchone()
|
||||
if service:
|
||||
duration_str = service['duration']
|
||||
if 'минут' in duration_str:
|
||||
duration_minutes = int(duration_str.split()[0])
|
||||
elif 'час' in duration_str:
|
||||
hours = int(duration_str.split()[0])
|
||||
duration_minutes = hours * 60
|
||||
try:
|
||||
hour = int(time.split(':')[0])
|
||||
minute = int(time.split(':')[1])
|
||||
start_minutes = hour * 60 + minute
|
||||
end_minutes = start_minutes + duration_minutes
|
||||
if start_minutes < 540:
|
||||
flash('Заказы принимаются с 9:00', 'danger')
|
||||
conn.close()
|
||||
return redirect(url_for('order'))
|
||||
if end_minutes > 1200:
|
||||
max_hour = (1200 - duration_minutes) // 60
|
||||
max_minute = (1200 - duration_minutes) % 60
|
||||
flash(f'Услуга длится {duration_str}. Необходимо закончить до 20:00. Максимальное время начала: {max_hour}:{max_minute:02d}', 'danger')
|
||||
conn.close()
|
||||
return redirect(url_for('order'))
|
||||
except:
|
||||
flash('Неверный формат времени', 'danger')
|
||||
conn.close()
|
||||
return redirect(url_for('order'))
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
if date < today:
|
||||
flash('Нельзя выбрать прошедшую дату', 'danger')
|
||||
conn.close()
|
||||
return redirect(url_for('order'))
|
||||
if persons >= 4 and service_type == 'service':
|
||||
discount_amount = int(int(price) * 0.15)
|
||||
final_price = int(int(price) * 0.85)
|
||||
discount_applied = True
|
||||
conn.execute('''
|
||||
INSERT INTO orders
|
||||
(user_id, service_type, service_name, service_id, date, time, persons, price, comment, discount_applied, discount_amount, original_price, payment_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'not_paid')
|
||||
''', (session['user_id'], service_type, service_name, service_id, date, time, persons, final_price, comment, discount_applied, discount_amount, original_price))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if discount_applied:
|
||||
flash(f'Заказ успешно создан! Применена семейная скидка 15%! Итоговая цена: {final_price} руб.', 'success')
|
||||
else:
|
||||
flash('Заказ успешно создан!', 'success')
|
||||
return redirect(url_for('my_orders'))
|
||||
return render_template('order_form.html')
|
||||
|
||||
@app.route('/my-orders')
|
||||
@login_required
|
||||
def my_orders():
|
||||
sort = request.args.get('sort', 'newest')
|
||||
status = request.args.get('status', 'all')
|
||||
conn = get_db_connection()
|
||||
query = 'SELECT * FROM orders WHERE user_id = ?'
|
||||
params = [session['user_id']]
|
||||
if status != 'all':
|
||||
query += ' AND status = ?'
|
||||
params.append(status)
|
||||
if sort == 'newest':
|
||||
query += ' ORDER BY created_at DESC'
|
||||
elif sort == 'oldest':
|
||||
query += ' ORDER BY created_at ASC'
|
||||
elif sort == 'price_high':
|
||||
query += ' ORDER BY price DESC'
|
||||
elif sort == 'price_low':
|
||||
query += ' ORDER BY price ASC'
|
||||
orders = conn.execute(query, params).fetchall()
|
||||
orders_list = []
|
||||
for order in orders:
|
||||
order_dict = dict(order)
|
||||
unread = conn.execute('''
|
||||
SELECT COUNT(*) as count FROM messages
|
||||
WHERE order_id = ? AND is_read = 0 AND sender_type != 'user'
|
||||
''', (order['id'],)).fetchone()
|
||||
order_dict['unread_count'] = unread['count'] if unread else 0
|
||||
orders_list.append(order_dict)
|
||||
conn.close()
|
||||
return render_template('my_orders.html', orders=orders_list, sort=sort, status=status)
|
||||
|
||||
# ---------------------- ОТЗЫВЫ ----------------------
|
||||
@app.route('/reviews', methods=['GET', 'POST'])
|
||||
def reviews():
|
||||
if request.method == 'POST':
|
||||
if 'user_id' not in session:
|
||||
flash('Пожалуйста, войдите в систему, чтобы оставить отзыв', 'warning')
|
||||
return redirect(url_for('login'))
|
||||
order_id = request.form.get('order_id')
|
||||
rating = int(request.form['rating'])
|
||||
comment = request.form['comment']
|
||||
conn = get_db_connection()
|
||||
existing_review = conn.execute('SELECT * FROM reviews WHERE user_id = ? AND order_id = ?',
|
||||
(session['user_id'], order_id)).fetchone()
|
||||
if existing_review:
|
||||
flash('Вы уже оставляли отзыв на этот заказ', 'danger')
|
||||
conn.close()
|
||||
return redirect(url_for('reviews'))
|
||||
order = conn.execute('SELECT * FROM orders WHERE id = ? AND user_id = ? AND status = "completed"',
|
||||
(order_id, session['user_id'])).fetchone()
|
||||
if not order:
|
||||
flash('Вы можете оставить отзыв только на выполненные заказы', 'danger')
|
||||
conn.close()
|
||||
return redirect(url_for('reviews'))
|
||||
conn.execute('''
|
||||
INSERT INTO reviews (user_id, order_id, username, rating, comment, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending')
|
||||
''', (session['user_id'], order_id, session['username'], rating, comment))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash('Ваш отзыв отправлен на модерацию!', 'success')
|
||||
return redirect(url_for('reviews'))
|
||||
sort = request.args.get('sort', 'newest')
|
||||
rating_filter = request.args.get('rating', 'all')
|
||||
conn = get_db_connection()
|
||||
completed_orders = []
|
||||
if 'user_id' in session:
|
||||
completed_orders = conn.execute('''
|
||||
SELECT o.* FROM orders o
|
||||
LEFT JOIN reviews r ON o.id = r.order_id AND r.user_id = ?
|
||||
WHERE o.user_id = ? AND o.status = 'completed' AND r.id IS NULL
|
||||
ORDER BY o.created_at DESC
|
||||
''', (session['user_id'], session['user_id'])).fetchall()
|
||||
query = 'SELECT * FROM reviews WHERE status = "approved"'
|
||||
params = []
|
||||
if rating_filter != 'all':
|
||||
query += ' AND rating = ?'
|
||||
params.append(rating_filter)
|
||||
if sort == 'newest':
|
||||
query += ' ORDER BY created_at DESC'
|
||||
elif sort == 'oldest':
|
||||
query += ' ORDER BY created_at ASC'
|
||||
elif sort == 'rating_high':
|
||||
query += ' ORDER BY rating DESC'
|
||||
elif sort == 'rating_low':
|
||||
query += ' ORDER BY rating ASC'
|
||||
approved_reviews = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return render_template('reviews.html', reviews=approved_reviews,
|
||||
completed_orders=completed_orders,
|
||||
sort=sort, rating_filter=rating_filter)
|
||||
|
||||
# ---------------------- ЧАТ ----------------------
|
||||
@app.route('/chat/<int:order_id>')
|
||||
@login_required
|
||||
def chat(order_id):
|
||||
conn = get_db_connection()
|
||||
order = conn.execute('SELECT * FROM orders WHERE id = ?', (order_id,)).fetchone()
|
||||
if not order:
|
||||
flash('Заказ не найден', 'danger')
|
||||
return redirect(url_for('my_orders'))
|
||||
if session['user_id'] != order['user_id'] and session.get('role') != 'admin':
|
||||
flash('Доступ запрещен', 'danger')
|
||||
return redirect(url_for('index'))
|
||||
if session.get('role') == 'admin':
|
||||
conn.execute('UPDATE messages SET is_read = 1 WHERE order_id = ? AND sender_type = "user"', (order_id,))
|
||||
messages = conn.execute('''
|
||||
SELECT m.*,
|
||||
CASE
|
||||
WHEN m.sender_type = 'user' THEN u.username
|
||||
WHEN m.sender_type = 'admin' THEN a.username
|
||||
ELSE 'Система'
|
||||
END as sender_name
|
||||
FROM messages m
|
||||
LEFT JOIN users u ON m.user_id = u.id
|
||||
LEFT JOIN users a ON m.admin_id = a.id
|
||||
WHERE m.order_id = ?
|
||||
ORDER BY m.created_at ASC
|
||||
''', (order_id,)).fetchall()
|
||||
conn.close()
|
||||
return render_template('chat.html', order=order, messages=messages)
|
||||
|
||||
@app.route('/send_message', methods=['POST'])
|
||||
@login_required
|
||||
def send_message():
|
||||
order_id = request.form['order_id']
|
||||
message = request.form['message']
|
||||
if not message or not message.strip():
|
||||
return jsonify({'error': 'Сообщение не может быть пустым'}), 400
|
||||
conn = get_db_connection()
|
||||
order = conn.execute('SELECT * FROM orders WHERE id = ?', (order_id,)).fetchone()
|
||||
if not order:
|
||||
conn.close()
|
||||
return jsonify({'error': 'Заказ не найден'}), 404
|
||||
if session['user_id'] != order['user_id'] and session.get('role') != 'admin':
|
||||
conn.close()
|
||||
return jsonify({'error': 'Доступ запрещен'}), 403
|
||||
if session.get('role') == 'admin':
|
||||
sender_type = 'admin'
|
||||
admin_id = session['user_id']
|
||||
user_id = order['user_id']
|
||||
else:
|
||||
sender_type = 'user'
|
||||
user_id = session['user_id']
|
||||
admin_id = None
|
||||
conn.execute('''
|
||||
INSERT INTO messages (order_id, user_id, admin_id, message, sender_type)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (order_id, user_id, admin_id, message.strip(), sender_type))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'message': 'Сообщение отправлено'})
|
||||
|
||||
# ---------------------- АДМИНКА ----------------------
|
||||
@app.route('/admin')
|
||||
@admin_required
|
||||
def admin_panel():
|
||||
return render_template('admin/index.html')
|
||||
|
||||
@app.route('/admin/orders')
|
||||
@admin_required
|
||||
def admin_orders():
|
||||
sort = request.args.get('sort', 'newest')
|
||||
status = request.args.get('status', 'all')
|
||||
conn = get_db_connection()
|
||||
query = '''
|
||||
SELECT o.*, u.username,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.order_id = o.id AND m.is_read = 0 AND m.sender_type != 'admin') as unread_count
|
||||
FROM orders o
|
||||
LEFT JOIN users u ON o.user_id = u.id
|
||||
WHERE 1=1
|
||||
'''
|
||||
params = []
|
||||
if status != 'all':
|
||||
query += ' AND o.status = ?'
|
||||
params.append(status)
|
||||
if sort == 'newest':
|
||||
query += ' ORDER BY o.created_at DESC'
|
||||
elif sort == 'oldest':
|
||||
query += ' ORDER BY o.created_at ASC'
|
||||
elif sort == 'price_high':
|
||||
query += ' ORDER BY o.price DESC'
|
||||
elif sort == 'price_low':
|
||||
query += ' ORDER BY o.price ASC'
|
||||
orders = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return render_template('admin/orders.html', orders=orders, sort=sort, status=status)
|
||||
|
||||
@app.route('/admin/orders/<int:order_id>/update', methods=['POST'])
|
||||
@admin_required
|
||||
def update_order(order_id):
|
||||
status = request.form['status']
|
||||
sort = request.args.get('sort', 'newest')
|
||||
status_filter = request.args.get('status', 'all')
|
||||
conn = get_db_connection()
|
||||
order = conn.execute('SELECT user_id FROM orders WHERE id = ?', (order_id,)).fetchone()
|
||||
conn.execute('UPDATE orders SET status = ? WHERE id = ?', (status, order_id))
|
||||
if order:
|
||||
conn.execute('''
|
||||
INSERT INTO messages (order_id, user_id, admin_id, message, sender_type)
|
||||
VALUES (?, ?, ?, ?, 'system')
|
||||
''', (order_id, order['user_id'], session['user_id'], f'Статус заказа изменен на: {status}'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash('Статус заказа обновлен', 'success')
|
||||
return redirect(url_for('admin_orders', sort=sort, status=status_filter))
|
||||
|
||||
@app.route('/admin/orders/<int:order_id>/delete', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_order(order_id):
|
||||
sort = request.args.get('sort', 'newest')
|
||||
status_filter = request.args.get('status', 'all')
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM messages WHERE order_id = ?', (order_id,))
|
||||
conn.execute('DELETE FROM orders WHERE id = ?', (order_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash('Заказ удален', 'success')
|
||||
return redirect(url_for('admin_orders', sort=sort, status=status_filter))
|
||||
|
||||
@app.route('/admin/reviews')
|
||||
@admin_required
|
||||
def admin_reviews():
|
||||
sort = request.args.get('sort', 'newest')
|
||||
status = request.args.get('status', 'all')
|
||||
rating = request.args.get('rating', 'all')
|
||||
conn = get_db_connection()
|
||||
query = 'SELECT * FROM reviews WHERE 1=1'
|
||||
params = []
|
||||
if status != 'all':
|
||||
query += ' AND status = ?'
|
||||
params.append(status)
|
||||
if rating != 'all':
|
||||
query += ' AND rating = ?'
|
||||
params.append(rating)
|
||||
if sort == 'newest':
|
||||
query += ' ORDER BY created_at DESC'
|
||||
elif sort == 'oldest':
|
||||
query += ' ORDER BY created_at ASC'
|
||||
elif sort == 'rating_high':
|
||||
query += ' ORDER BY rating DESC'
|
||||
elif sort == 'rating_low':
|
||||
query += ' ORDER BY rating ASC'
|
||||
reviews = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return render_template('admin/reviews.html', reviews=reviews, sort=sort, status=status, rating=rating)
|
||||
|
||||
@app.route('/admin/reviews/<int:review_id>/moderate', methods=['POST'])
|
||||
@admin_required
|
||||
def moderate_review(review_id):
|
||||
status = request.form['status']
|
||||
sort = request.args.get('sort', 'newest')
|
||||
status_filter = request.args.get('status', 'all')
|
||||
rating_filter = request.args.get('rating', 'all')
|
||||
conn = get_db_connection()
|
||||
conn.execute('UPDATE reviews SET status = ? WHERE id = ?', (status, review_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash('Отзыв обработан', 'success')
|
||||
return redirect(url_for('admin_reviews', sort=sort, status=status_filter, rating=rating_filter))
|
||||
|
||||
@app.route('/admin/reviews/<int:review_id>/delete', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_review(review_id):
|
||||
sort = request.args.get('sort', 'newest')
|
||||
status_filter = request.args.get('status', 'all')
|
||||
rating_filter = request.args.get('rating', 'all')
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM reviews WHERE id = ?', (review_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash('Отзыв удален', 'success')
|
||||
return redirect(url_for('admin_reviews', sort=sort, status=status_filter, rating=rating_filter))
|
||||
|
||||
@app.route('/admin/users')
|
||||
@admin_required
|
||||
def admin_users():
|
||||
sort = request.args.get('sort', 'newest')
|
||||
role = request.args.get('role', 'all')
|
||||
conn = get_db_connection()
|
||||
query = 'SELECT * FROM users WHERE 1=1'
|
||||
params = []
|
||||
if role != 'all':
|
||||
query += ' AND role = ?'
|
||||
params.append(role)
|
||||
if sort == 'newest':
|
||||
query += ' ORDER BY created_at DESC'
|
||||
elif sort == 'oldest':
|
||||
query += ' ORDER BY created_at ASC'
|
||||
elif sort == 'name_asc':
|
||||
query += ' ORDER BY username ASC'
|
||||
elif sort == 'name_desc':
|
||||
query += ' ORDER BY username DESC'
|
||||
users = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
return render_template('admin/users.html', users=users, sort=sort, role=role)
|
||||
|
||||
@app.route('/admin/users/<int:user_id>/delete', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
sort = request.args.get('sort', 'newest')
|
||||
role_filter = request.args.get('role', 'all')
|
||||
if user_id == session['user_id']:
|
||||
flash('Нельзя удалить самого себя', 'danger')
|
||||
return redirect(url_for('admin_users', sort=sort, role=role_filter))
|
||||
conn = get_db_connection()
|
||||
conn.execute('DELETE FROM messages WHERE user_id = ?', (user_id,))
|
||||
conn.execute('DELETE FROM orders WHERE user_id = ?', (user_id,))
|
||||
conn.execute('DELETE FROM reviews WHERE user_id = ?', (user_id,))
|
||||
conn.execute('DELETE FROM users WHERE id = ?', (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash('Пользователь удален', 'success')
|
||||
return redirect(url_for('admin_users', sort=sort, role=role_filter))
|
||||
|
||||
@app.route('/admin/users/<int:user_id>/make-admin', methods=['POST'])
|
||||
@admin_required
|
||||
def make_admin(user_id):
|
||||
sort = request.args.get('sort', 'newest')
|
||||
role_filter = request.args.get('role', 'all')
|
||||
conn = get_db_connection()
|
||||
conn.execute('UPDATE users SET role = "admin" WHERE id = ?', (user_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flash('Пользователь назначен администратором', 'success')
|
||||
return redirect(url_for('admin_users', sort=sort, role=role_filter))
|
||||
|
||||
@app.route('/admin/messages')
|
||||
@admin_required
|
||||
def admin_messages():
|
||||
conn = get_db_connection()
|
||||
messages = conn.execute('''
|
||||
SELECT m.*, o.id as order_id, u.username as user_name
|
||||
FROM messages m
|
||||
JOIN orders o ON m.order_id = o.id
|
||||
JOIN users u ON o.user_id = u.id
|
||||
WHERE m.sender_type = 'user' AND m.is_read = 0
|
||||
ORDER BY m.created_at DESC
|
||||
''').fetchall()
|
||||
conn.close()
|
||||
return render_template('admin/messages.html', messages=messages)
|
||||
|
||||
# ---------------------- ЗАПУСК ----------------------
|
||||
if __name__ == '__main__':
|
||||
conn = sqlite3.connect('horse_farm.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(orders)")
|
||||
existing = [col[1] for col in cursor.fetchall()]
|
||||
for col in ['discount_applied', 'discount_amount', 'original_price', 'payment_status']:
|
||||
if col not in existing:
|
||||
cursor.execute(f"ALTER TABLE orders ADD COLUMN {col} DEFAULT 'not_paid' if col == 'payment_status' else 0")
|
||||
print(f"Добавлена колонка {col}")
|
||||
cursor.execute("PRAGMA table_info(reviews)")
|
||||
if 'order_id' not in [c[1] for c in cursor.fetchall()]:
|
||||
cursor.execute("ALTER TABLE reviews ADD COLUMN order_id INTEGER")
|
||||
cursor.execute("PRAGMA table_info(messages)")
|
||||
if 'is_read' not in [c[1] for c in cursor.fetchall()]:
|
||||
cursor.execute("ALTER TABLE messages ADD COLUMN is_read BOOLEAN DEFAULT 0")
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='messages'")
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('''
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER,
|
||||
user_id INTEGER,
|
||||
admin_id INTEGER,
|
||||
message TEXT NOT NULL,
|
||||
sender_type TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_read BOOLEAN DEFAULT 0
|
||||
)
|
||||
''')
|
||||
cursor.execute("SELECT * FROM users WHERE username = 'admin'")
|
||||
if not cursor.fetchone():
|
||||
hashed = generate_password_hash('admin123')
|
||||
cursor.execute("INSERT INTO users (username, password, email, role) VALUES (?, ?, ?, ?)",
|
||||
('admin', hashed, 'admin@koniydvor.ru', 'admin'))
|
||||
print("Создан администратор: admin / admin123")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
app.run(debug=True)
|
||||
108
create_tables.py
Normal file
108
create_tables.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import sqlite3
|
||||
|
||||
def create_tables():
|
||||
conn = sqlite3.connect('horse_farm.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Таблица для услуг
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
price INTEGER,
|
||||
duration TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Таблица для абонементов
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS abonements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
price INTEGER,
|
||||
description TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Таблица для подарочных сертификатов
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS certificates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
price INTEGER,
|
||||
description TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Таблица для пользователей
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
email TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# Таблица для заказов
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
service_type TEXT NOT NULL,
|
||||
service_name TEXT NOT NULL,
|
||||
service_id INTEGER,
|
||||
date TEXT NOT NULL,
|
||||
time TEXT NOT NULL,
|
||||
persons INTEGER DEFAULT 1,
|
||||
price INTEGER,
|
||||
status TEXT DEFAULT 'pending',
|
||||
comment TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Таблица для отзывов (добавляем поле order_id)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
order_id INTEGER,
|
||||
username TEXT,
|
||||
rating INTEGER CHECK(rating >= 1 AND rating <= 5),
|
||||
comment TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (order_id) REFERENCES orders (id),
|
||||
UNIQUE(user_id, order_id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Таблица для сообщений
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER,
|
||||
user_id INTEGER,
|
||||
admin_id INTEGER,
|
||||
message TEXT NOT NULL,
|
||||
sender_type TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_read BOOLEAN DEFAULT 0,
|
||||
FOREIGN KEY (order_id) REFERENCES orders (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (admin_id) REFERENCES users (id)
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("Таблицы успешно созданы!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
create_tables()
|
||||
BIN
horse_farm.db
Normal file
BIN
horse_farm.db
Normal file
Binary file not shown.
134
load_date.py
Normal file
134
load_date.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import sqlite3
|
||||
import pandas as pd
|
||||
import os
|
||||
import re
|
||||
|
||||
def extract_price(price_str):
|
||||
"""Извлекает число из строки с ценой"""
|
||||
if pd.isna(price_str):
|
||||
return 0
|
||||
# Ищем число в строке (может быть "500 руб." или "500")
|
||||
numbers = re.findall(r'\d+', str(price_str))
|
||||
return int(numbers[0]) if numbers else 0
|
||||
|
||||
def find_column(columns, possible_names):
|
||||
"""Ищет колонку по возможным названиям"""
|
||||
for col in columns:
|
||||
col_lower = col.lower()
|
||||
for name in possible_names:
|
||||
if name in col_lower:
|
||||
return col
|
||||
return columns[0] # возвращаем первую колонку если ничего не найдено
|
||||
|
||||
def load_from_excel_to_db():
|
||||
"""Загрузка данных из Excel файлов в базу данных"""
|
||||
|
||||
# Подключаемся к БД
|
||||
conn = sqlite3.connect('horse_farm.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Очищаем таблицы перед загрузкой
|
||||
cursor.execute("DELETE FROM services")
|
||||
cursor.execute("DELETE FROM abonements")
|
||||
cursor.execute("DELETE FROM certificates")
|
||||
|
||||
# Сбрасываем автоинкремент
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name='services'")
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name='abonements'")
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name='certificates'")
|
||||
|
||||
# Загрузка услуг из Excel
|
||||
services_file = 'resource/services.xlsx'
|
||||
if os.path.exists(services_file):
|
||||
df_services = pd.read_excel(services_file)
|
||||
print(f"Загружаем услуги из {services_file}")
|
||||
print(f"Колонки: {list(df_services.columns)}")
|
||||
|
||||
# Определяем колонки
|
||||
name_col = find_column(df_services.columns, ['name', 'название', 'имя'])
|
||||
desc_col = find_column(df_services.columns, ['desc', 'опис', 'описание'])
|
||||
price_col = find_column(df_services.columns, ['price', 'цен', 'стоим'])
|
||||
duration_col = find_column(df_services.columns, ['duration', 'длит', 'врем'])
|
||||
|
||||
print(f"Используем колонки: name='{name_col}', description='{desc_col}', price='{price_col}', duration='{duration_col}'")
|
||||
|
||||
for _, row in df_services.iterrows():
|
||||
price = extract_price(row[price_col])
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO services (name, description, price, duration)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (
|
||||
str(row[name_col]),
|
||||
str(row[desc_col]) if pd.notna(row[desc_col]) else '',
|
||||
price,
|
||||
str(row[duration_col]) if pd.notna(row[duration_col]) else ''
|
||||
))
|
||||
print(f"Загружено {len(df_services)} услуг")
|
||||
else:
|
||||
print(f"Файл {services_file} не найден")
|
||||
|
||||
# Загрузка абонементов из Excel
|
||||
abonements_file = 'resource/abonements.xlsx'
|
||||
if os.path.exists(abonements_file):
|
||||
df_abonements = pd.read_excel(abonements_file)
|
||||
print(f"\nЗагружаем абонементы из {abonements_file}")
|
||||
print(f"Колонки: {list(df_abonements.columns)}")
|
||||
|
||||
# Определяем колонки
|
||||
name_col = find_column(df_abonements.columns, ['name', 'название', 'имя'])
|
||||
price_col = find_column(df_abonements.columns, ['price', 'цен', 'стоим'])
|
||||
desc_col = find_column(df_abonements.columns, ['desc', 'опис', 'описание'])
|
||||
|
||||
print(f"Используем колонки: name='{name_col}', price='{price_col}', description='{desc_col}'")
|
||||
|
||||
for _, row in df_abonements.iterrows():
|
||||
price = extract_price(row[price_col])
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO abonements (name, price, description)
|
||||
VALUES (?, ?, ?)
|
||||
''', (
|
||||
str(row[name_col]),
|
||||
price,
|
||||
str(row[desc_col]) if pd.notna(row[desc_col]) else ''
|
||||
))
|
||||
print(f"Загружено {len(df_abonements)} абонементов")
|
||||
else:
|
||||
print(f"Файл {abonements_file} не найден")
|
||||
|
||||
# Загрузка сертификатов из Excel
|
||||
certificates_file = 'resource/certificates.xlsx'
|
||||
if os.path.exists(certificates_file):
|
||||
df_certificates = pd.read_excel(certificates_file)
|
||||
print(f"\nЗагружаем сертификаты из {certificates_file}")
|
||||
print(f"Колонки: {list(df_certificates.columns)}")
|
||||
|
||||
# Определяем колонки
|
||||
name_col = find_column(df_certificates.columns, ['name', 'название', 'имя'])
|
||||
price_col = find_column(df_certificates.columns, ['price', 'цен', 'стоим'])
|
||||
desc_col = find_column(df_certificates.columns, ['desc', 'опис', 'описание'])
|
||||
|
||||
print(f"Используем колонки: name='{name_col}', price='{price_col}', description='{desc_col}'")
|
||||
|
||||
for _, row in df_certificates.iterrows():
|
||||
price = extract_price(row[price_col])
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO certificates (name, price, description)
|
||||
VALUES (?, ?, ?)
|
||||
''', (
|
||||
str(row[name_col]),
|
||||
price,
|
||||
str(row[desc_col]) if pd.notna(row[desc_col]) else ''
|
||||
))
|
||||
print(f"Загружено {len(df_certificates)} сертификатов")
|
||||
else:
|
||||
print(f"Файл {certificates_file} не найден")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("\nЗагрузка данных из Excel завершена!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
load_from_excel_to_db()
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
Flask==2.3.3
|
||||
pandas==2.0.3
|
||||
openpyxl==3.1.2
|
||||
werkzeug==3.1.6
|
||||
flask-login==0.6.3
|
||||
BIN
resource/abonements.xlsx
Normal file
BIN
resource/abonements.xlsx
Normal file
Binary file not shown.
BIN
resource/certificates.xlsx
Normal file
BIN
resource/certificates.xlsx
Normal file
Binary file not shown.
BIN
resource/services.xlsx
Normal file
BIN
resource/services.xlsx
Normal file
Binary file not shown.
1151
static/css/style.css
Normal file
1151
static/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
static/images/background.jpg
Normal file
BIN
static/images/background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
BIN
static/images/gallery/зимние сани.jpg
Normal file
BIN
static/images/gallery/зимние сани.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
BIN
static/images/gallery/катание на санях.jpg
Normal file
BIN
static/images/gallery/катание на санях.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
BIN
static/images/gallery/прогулка по лесу.jpg
Normal file
BIN
static/images/gallery/прогулка по лесу.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
BIN
static/images/gallery/прогулка.jpg
Normal file
BIN
static/images/gallery/прогулка.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 355 KiB |
BIN
static/images/gallery/прогулочка.jpg
Normal file
BIN
static/images/gallery/прогулочка.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
14
templates/abonements.html
Normal file
14
templates/abonements.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Абонементы</h2>
|
||||
<div class="abonements-list">
|
||||
{% for abonement in abonements %}
|
||||
<div class="abonement-item">
|
||||
<h3>{{ abonement.name }}</h3>
|
||||
<p>{{ abonement.description }}</p>
|
||||
<p class="price">{{ abonement.price }} рублей</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
178
templates/about.html
Normal file
178
templates/about.html
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.about-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #fef9f0 0%, #fff4e6 100%);
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.about-hero h2 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.about-hero p {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
margin: 2rem 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.about-content h3 {
|
||||
color: #8B5A2B;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.4rem;
|
||||
position: relative;
|
||||
padding-left: 1rem;
|
||||
border-left: 4px solid #d4a373;
|
||||
}
|
||||
|
||||
.about-content h3:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.about-content p {
|
||||
line-height: 1.8;
|
||||
color: #444;
|
||||
margin-bottom: 1rem;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.about-content ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.about-content li {
|
||||
padding: 0.5rem 0;
|
||||
padding-left: 1.8rem;
|
||||
position: relative;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.about-content li::before {
|
||||
content: "🐴";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #d4a373;
|
||||
}
|
||||
|
||||
.about-quote {
|
||||
background: linear-gradient(135deg, #2c1810 0%, #4a2c2c 100%);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
margin: 2rem 0;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.about-quote::before {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 10px;
|
||||
font-size: 80px;
|
||||
opacity: 0.1;
|
||||
font-family: serif;
|
||||
}
|
||||
|
||||
.about-quote::after {
|
||||
content: '"';
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 10px;
|
||||
font-size: 80px;
|
||||
opacity: 0.1;
|
||||
font-family: serif;
|
||||
}
|
||||
|
||||
.about-quote p {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.about-quote .author {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.about-hero h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.about-content h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.about-quote p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="about-hero">
|
||||
<h2>🐴 О нашей базе "Конный двор"</h2>
|
||||
<p>Место, где лошади и люди находят друг друга</p>
|
||||
</div>
|
||||
|
||||
<div class="about-content">
|
||||
<h3>Кто мы такие?</h3>
|
||||
<p>"Конный двор" — это уютная конно-спортивная база, расположенная в живописном месте Республики Башкортостан. Мы создали это место для тех, кто хочет прикоснуться к миру лошадей, научиться верховой езде или просто отдохнуть на природе в компании этих удивительных животных.</p>
|
||||
|
||||
<h3>Наша миссия</h3>
|
||||
<p>Мы стремимся подарить каждому посетителю радость общения с лошадьми, помочь раскрыть красоту и пользу верховой езды, создать комфортные условия для занятий и прогулок. Наши инструкторы — профессионалы с многолетним опытом, которые найдут подход как к взрослым, так и к детям.</p>
|
||||
|
||||
<h3>Что мы предлагаем?</h3>
|
||||
<ul>
|
||||
<li>Обучение верховой езде для начинающих и опытных всадников</li>
|
||||
<li>Конные прогулки по живописному лесу</li>
|
||||
<li>Зимние прогулки в санях</li>
|
||||
<li>Экскурсии с рассказом о лошадях и уходе за ними</li>
|
||||
<li>Подарочные сертификаты и абонементы</li>
|
||||
</ul>
|
||||
|
||||
<h3>Почему выбирают нас?</h3>
|
||||
<ul>
|
||||
<li>Опытные инструкторы с педагогическим образованием</li>
|
||||
<li>Добрые, ухоженные и обученные лошади</li>
|
||||
<li>Живописные маршруты для прогулок</li>
|
||||
<li>Индивидуальный подход к каждому ученику</li>
|
||||
<li>Доступные цены и гибкая система скидок</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="about-quote">
|
||||
<p>Лошадь — это зеркало души человека. Приходя к нам, вы не просто учитесь ездить верхом, вы учитесь понимать, чувствовать и доверять. И это меняет жизнь.</p>
|
||||
<div class="author">— Основатель базы "Конный двор"</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
27
templates/admin/index.html
Normal file
27
templates/admin/index.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Админ панель</h2>
|
||||
|
||||
<div class="admin-dashboard">
|
||||
<div class="admin-menu">
|
||||
<div class="admin-card">
|
||||
<h3>📦 Заказы</h3>
|
||||
<p>Управление заказами пользователей</p>
|
||||
<a href="{{ url_for('admin_orders') }}" class="btn btn-primary">Перейти</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h3>💬 Отзывы</h3>
|
||||
<p>Модерация отзывов</p>
|
||||
<a href="{{ url_for('admin_reviews') }}" class="btn btn-primary">Перейти</a>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<h3>👥 Пользователи</h3>
|
||||
<p>Управление пользователями</p>
|
||||
<a href="{{ url_for('admin_users') }}" class="btn btn-primary">Перейти</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
25
templates/admin/messages.html
Normal file
25
templates/admin/messages.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Новые сообщения от пользователей</h2>
|
||||
|
||||
{% if messages %}
|
||||
<div class="admin-table">
|
||||
{% for msg in messages %}
|
||||
<div class="review-card" style="margin-bottom: 1rem;">
|
||||
<div class="review-header">
|
||||
<strong style="color: white;">Заказ #{{ msg.order_id }}</strong>
|
||||
<span style="color: white;">От: {{ msg.user_name }}</span>
|
||||
<span style="color: white;">{{ msg.created_at }}</span>
|
||||
</div>
|
||||
<div class="review-comment" style="margin: 0.5rem 0; color: white;">{{ msg.message }}</div>
|
||||
<div class="order-actions" style="margin-top: 0.5rem;">
|
||||
<a href="{{ url_for('chat', order_id=msg.order_id) }}" class="btn-primary" style="border-radius: 50px; text-decoration: none;">Ответить</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Новых сообщений нет.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
170
templates/admin/orders.html
Normal file
170
templates/admin/orders.html
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Управление заказами</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="filters">
|
||||
<h3>Фильтры и сортировка</h3>
|
||||
<div class="filter-bar">
|
||||
<label>Сортировка:</label>
|
||||
<select onchange="window.location.href='?sort='+this.value+'&status={{ status }}'">
|
||||
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Новые сначала</option>
|
||||
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Старые сначала</option>
|
||||
<option value="price_high" {% if sort == 'price_high' %}selected{% endif %}>Дорогие сначала</option>
|
||||
<option value="price_low" {% if sort == 'price_low' %}selected{% endif %}>Дешевые сначала</option>
|
||||
</select>
|
||||
<label>Статус:</label>
|
||||
<select onchange="window.location.href='?sort={{ sort }}&status='+this.value">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>Все</option>
|
||||
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Ожидает</option>
|
||||
<option value="confirmed" {% if status == 'confirmed' %}selected{% endif %}>Подтвержден</option>
|
||||
<option value="completed" {% if status == 'completed' %}selected{% endif %}>Выполнен</option>
|
||||
<option value="cancelled" {% if status == 'cancelled' %}selected{% endif %}>Отменен</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Услуга</th>
|
||||
<th>Дата</th>
|
||||
<th>Время</th>
|
||||
<th>Цена</th>
|
||||
<th>Скидка</th>
|
||||
<th>Оплата</th>
|
||||
<th>Статус</th>
|
||||
<th>Сообщения</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="orders-table-body">
|
||||
{% for order in orders %}
|
||||
<tr id="order-row-{{ order.id }}">
|
||||
<td>{{ order.id }}</td>
|
||||
<td>{{ order.username or 'Гость' }}</td>
|
||||
<td>{{ order.service_name }}</td>
|
||||
<td>{{ order.date or '-' }}</td>
|
||||
<td>{{ order.time or '-' }}</td>
|
||||
<td>{{ order.price }} руб.</td>
|
||||
<td>
|
||||
{% if order.discount_applied %}
|
||||
<span style="color: #28a745;">{{ order.discount_amount }} руб.</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="payment-status-cell" data-order-id="{{ order.id }}">
|
||||
{% if order.payment_status == 'paid' %}
|
||||
<span style="color: #28a745;">✅ Оплачено</span>
|
||||
{% else %}
|
||||
<span style="color: #ffc107;">⚠️ Не оплачено</span>
|
||||
{% endif %}
|
||||
<button class="toggle-payment-btn" data-order-id="{{ order.id }}" style="background: none; border: none; cursor: pointer; margin-left: 5px;">🔄</button>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status status-{{ order.status }}">
|
||||
{% if order.status == 'pending' %}Ожидает
|
||||
{% elif order.status == 'confirmed' %}Подтвержден
|
||||
{% elif order.status == 'completed' %}Выполнен
|
||||
{% elif order.status == 'cancelled' %}Отменен
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td class="unread-cell" data-order-id="{{ order.id }}">
|
||||
{% if order.unread_count > 0 %}
|
||||
<span class="unread-badge" style="background: #dc3545; color: white; border-radius: 50%; padding: 2px 8px; font-size: 12px;">{{ order.unread_count }}</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('chat', order_id=order.id) }}" class="chat-link btn-primary btn-sm" data-order-id="{{ order.id }}" style="border-radius: 50px; padding: 0.3rem 0.8rem; text-decoration: none;">Чат</a>
|
||||
<form method="POST" action="{{ url_for('update_order', order_id=order.id) }}?sort={{ sort }}&status={{ status }}" style="display: inline;">
|
||||
<select name="status" onchange="this.form.submit()" style="border-radius: 50px; padding: 0.3rem;">
|
||||
<option value="pending" {% if order.status == 'pending' %}selected{% endif %}>Ожидает</option>
|
||||
<option value="confirmed" {% if order.status == 'confirmed' %}selected{% endif %}>Подтвержден</option>
|
||||
<option value="completed" {% if order.status == 'completed' %}selected{% endif %}>Выполнен</option>
|
||||
<option value="cancelled" {% if order.status == 'cancelled' %}selected{% endif %}>Отменен</option>
|
||||
</select>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('delete_order', order_id=order.id) }}?sort={{ sort }}&status={{ status }}" style="display: inline;" onsubmit="return confirm('Удалить заказ?')">
|
||||
<button type="submit" class="btn-danger btn-sm" style="border-radius: 50px; padding: 0.3rem 0.8rem;">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Переключение статуса оплаты
|
||||
document.querySelectorAll('.toggle-payment-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const orderId = this.dataset.orderId;
|
||||
const response = await fetch('/api/toggle_payment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order_id: orderId })
|
||||
});
|
||||
const data = await response.json();
|
||||
const cell = document.querySelector(`.payment-status-cell[data-order-id="${orderId}"]`);
|
||||
if (data.status === 'paid') {
|
||||
cell.innerHTML = '<span style="color: #28a745;">✅ Оплачено</span> <button class="toggle-payment-btn" data-order-id="' + orderId + '" style="background: none; border: none; cursor: pointer; margin-left: 5px;">🔄</button>';
|
||||
} else {
|
||||
cell.innerHTML = '<span style="color: #ffc107;">⚠️ Не оплачено</span> <button class="toggle-payment-btn" data-order-id="' + orderId + '" style="background: none; border: none; cursor: pointer; margin-left: 5px;">🔄</button>';
|
||||
}
|
||||
document.querySelectorAll('.toggle-payment-btn').forEach(btn => {
|
||||
btn.removeEventListener('click', this);
|
||||
btn.addEventListener('click', arguments.callee);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function updateUnreadCounts() {
|
||||
try {
|
||||
const response = await fetch('/api/admin_unread_counts');
|
||||
const data = await response.json();
|
||||
for (const [orderId, count] of Object.entries(data)) {
|
||||
const cell = document.querySelector(`.unread-cell[data-order-id="${orderId}"]`);
|
||||
if (cell) {
|
||||
if (count > 0) {
|
||||
cell.innerHTML = `<span class="unread-badge" style="background: #dc3545; color: white; border-radius: 50%; padding: 2px 8px; font-size: 12px;">${count}</span>`;
|
||||
} else {
|
||||
cell.innerHTML = '—';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления:', error);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.chat-link').forEach(link => {
|
||||
link.addEventListener('click', async function(e) {
|
||||
const orderId = this.dataset.orderId;
|
||||
await fetch('/api/mark_order_read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order_id: orderId })
|
||||
});
|
||||
const cell = document.querySelector(`.unread-cell[data-order-id="${orderId}"]`);
|
||||
if (cell) cell.innerHTML = '—';
|
||||
});
|
||||
});
|
||||
|
||||
setInterval(updateUnreadCounts, 10000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
314
templates/admin/reviews.html
Normal file
314
templates/admin/reviews.html
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Модерация отзывов</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="filters">
|
||||
<h3>Фильтры и сортировка</h3>
|
||||
<div class="filter-bar">
|
||||
<label>Сортировка:</label>
|
||||
<select onchange="window.location.href='?sort='+this.value+'&status={{ status }}&rating={{ rating }}'">
|
||||
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Новые сначала</option>
|
||||
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Старые сначала</option>
|
||||
<option value="rating_high" {% if sort == 'rating_high' %}selected{% endif %}>Высокий рейтинг</option>
|
||||
<option value="rating_low" {% if sort == 'rating_low' %}selected{% endif %}>Низкий рейтинг</option>
|
||||
</select>
|
||||
|
||||
<label>Статус:</label>
|
||||
<select onchange="window.location.href='?sort={{ sort }}&status='+this.value+'&rating={{ rating }}'">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>Все</option>
|
||||
<option value="pending" {% if status == 'pending' %}selected{% endif %}>На модерации</option>
|
||||
<option value="approved" {% if status == 'approved' %}selected{% endif %}>Одобрены</option>
|
||||
<option value="rejected" {% if status == 'rejected' %}selected{% endif %}>Отклонены</option>
|
||||
</select>
|
||||
|
||||
<label>Оценка:</label>
|
||||
<select onchange="window.location.href='?sort={{ sort }}&status={{ status }}&rating='+this.value">
|
||||
<option value="all" {% if rating == 'all' %}selected{% endif %}>Все оценки</option>
|
||||
<option value="5" {% if rating == '5' %}selected{% endif %}>⭐⭐⭐⭐⭐</option>
|
||||
<option value="4" {% if rating == '4' %}selected{% endif %}>⭐⭐⭐⭐</option>
|
||||
<option value="3" {% if rating == '3' %}selected{% endif %}>⭐⭐⭐</option>
|
||||
<option value="2" {% if rating == '2' %}selected{% endif %}>⭐⭐</option>
|
||||
<option value="1" {% if rating == '1' %}selected{% endif %}>⭐</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Заказ</th>
|
||||
<th>Оценка</th>
|
||||
<th>Отзыв</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for review in reviews %}
|
||||
<tr class="review-row {% if review.status == 'pending' %}row-pending{% endif %}">
|
||||
<td>{{ review.id }}</td>
|
||||
<td><strong>{{ review.username }}</strong></td>
|
||||
<td>Заказ #{{ review.order_id }}</td>
|
||||
<td>
|
||||
<div class="rating">
|
||||
{% for i in range(5) %}
|
||||
{% if i < review.rating %}
|
||||
★
|
||||
{% else %}
|
||||
☆
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="review-comment">
|
||||
<div class="comment-text">{{ review.comment }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ review.status }}">
|
||||
{% if review.status == 'pending' %}На модерации
|
||||
{% elif review.status == 'approved' %}Одобрен
|
||||
{% elif review.status == 'rejected' %}Отклонен
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ review.created_at[:16] }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('moderate_review', review_id=review.id) }}?sort={{ sort }}&status={{ status }}&rating={{ rating }}" style="display: inline;">
|
||||
<select name="status" onchange="this.form.submit()" class="status-select">
|
||||
<option value="pending" {% if review.status == 'pending' %}selected{% endif %}>На модерации</option>
|
||||
<option value="approved" {% if review.status == 'approved' %}selected{% endif %}>Одобрить</option>
|
||||
<option value="rejected" {% if review.status == 'rejected' %}selected{% endif %}>Отклонить</option>
|
||||
</select>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('delete_review', review_id=review.id) }}?sort={{ sort }}&status={{ status }}&rating={{ rating }}" style="display: inline;" onsubmit="return confirm('Удалить отзыв?')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not reviews %}
|
||||
<div class="empty-state">
|
||||
<p>Нет отзывов для отображения</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.filters h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: #4a2c2c;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-bar label {
|
||||
font-weight: bold;
|
||||
color: #4a2c2c;
|
||||
}
|
||||
|
||||
.filter-bar select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.admin-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background-color: #4a2c2c;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.row-pending {
|
||||
background-color: #fff3e0;
|
||||
}
|
||||
|
||||
.rating {
|
||||
color: #ffc107;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.review-comment {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
word-wrap: break-word;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: #ffc107;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-rejected {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
padding: 0.3rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
display: inline-block;
|
||||
margin: 0.25rem 0;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-bar select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.review-comment {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
55
templates/admin/users.html
Normal file
55
templates/admin/users.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Управление пользователями</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="admin-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя пользователя</th>
|
||||
<th>Email</th>
|
||||
<th>Роль</th>
|
||||
<th>Дата регистрации</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.email or '-' }}</td>
|
||||
<td>
|
||||
{% if user.role == 'admin' %}
|
||||
<span class="badge badge-admin">Админ</span>
|
||||
{% else %}
|
||||
<span class="badge badge-user">Пользователь</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at[:10] }}</td>
|
||||
<td>
|
||||
{% if user.role != 'admin' %}
|
||||
<form method="POST" action="{{ url_for('make_admin', user_id=user.id) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-warning btn-sm">Сделать админом</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('delete_user', user_id=user.id) }}" style="display: inline;" onsubmit="return confirm('Удалить пользователя?')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
137
templates/base.html
Normal file
137
templates/base.html
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Конный двор - Конно-спортивная база</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Playfair+Display:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<script>
|
||||
function setUserTimezone() {
|
||||
const timezoneOffset = new Date().getTimezoneOffset();
|
||||
fetch('/api/set_timezone', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ timezone_offset: -timezoneOffset })
|
||||
}).catch(() => {});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', setUserTimezone);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<header>
|
||||
<h1>🐎 Конно-спортивная база "Конный двор" 🐎</h1>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('index') }}">Главная</a></li>
|
||||
<li><a href="{{ url_for('about') }}">О себе</a></li>
|
||||
<li><a href="{{ url_for('services') }}">Услуги</a></li>
|
||||
<li><a href="{{ url_for('abonements') }}">Абонементы</a></li>
|
||||
<li><a href="{{ url_for('certificates') }}">Сертификаты</a></li>
|
||||
<li><a href="{{ url_for('gallery') }}">Фотогалерея</a></li>
|
||||
<li><a href="{{ url_for('discounts') }}">Скидки</a></li>
|
||||
<li><a href="{{ url_for('reviews') }}">Отзывы</a></li>
|
||||
{% if session.user_id %}
|
||||
<li><a href="{{ url_for('order') }}">Заказать</a></li>
|
||||
<li><a href="{{ url_for('my_orders') }}" id="messages-link">
|
||||
Мои заказы
|
||||
<span id="unread-badge" style="background: #dc3545; color: white; border-radius: 50%; padding: 2px 6px; font-size: 10px; display: none;">0</span>
|
||||
</a></li>
|
||||
{% if session.role == 'admin' %}
|
||||
<li><a href="{{ url_for('admin_panel') }}">Админ панель</a></li>
|
||||
<li><a href="{{ url_for('admin_messages') }}"> Сообщения</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('logout') }}">Выйти ({{ session.username }})</a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('login') }}">Вход</a></li>
|
||||
<li><a href="{{ url_for('register') }}">Регистрация</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-content">
|
||||
<div class="footer-section">
|
||||
<h4>Контакты</h4>
|
||||
<p>📞 +7 962 545 066</p>
|
||||
<p>✉️ info@koniydvor.ru</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>Адрес</h4>
|
||||
<p>Республика Башкортостан, Белорецкий район,<br>ул. Благой ключ 101</p>
|
||||
</div>
|
||||
<div class="footer-section">
|
||||
<h4>Часы работы</h4>
|
||||
<p>Ежедневно: 9:00 – 20:00</p>
|
||||
<p>Без выходных</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2026 Конно-спортивная база "Конный двор"</p>
|
||||
<p class="team-credit">🐎 Разработано командой Ганвестzxc 🐎</p>
|
||||
<p class="footer-joke" style="font-size: 0.6rem; opacity: 0.5; margin-top: 0.5rem;">
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* базовые стили — уже есть в style.css, здесь только самые основные на всякий случай */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Montserrat', sans-serif; background: url('/static/images/background.jpg') center/cover fixed; }
|
||||
body::before { content: ''; position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: -1; }
|
||||
.wrapper { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
header { background: rgba(44,24,16,0.92); border-bottom: 2px solid #d4a373; padding: 1rem 0; position: sticky; top: 0; z-index: 1000; }
|
||||
header h1 { font-family: 'Playfair Display', serif; font-size: 2rem; text-align: center; color: white; text-shadow: 2px 2px 4px rgba(0,0,0,0.3); }
|
||||
nav ul { display: flex; justify-content: center; flex-wrap: wrap; gap: 0.5rem; list-style: none; margin-top: 1rem; }
|
||||
nav a { color: white; text-decoration: none; padding: 0.6rem 1.2rem; border-radius: 50px; background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.2); transition: 0.2s; }
|
||||
nav a:hover { background: #d4a373; color: #2c1810; transform: translateY(-2px); }
|
||||
.content { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; width: 100%; flex: 1; }
|
||||
h2 { font-family: 'Playfair Display', serif; color: #f5cba0; margin-bottom: 1.5rem; font-size: 2rem; border-left: 4px solid #f5cba0; padding-left: 1rem; }
|
||||
footer { background: rgba(30,16,10,0.98); border-top: 3px solid #d4a373; padding: 2rem 0 1rem; margin-top: auto; }
|
||||
.footer-content { max-width: 1200px; margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fit, minmax(250px,1fr)); gap: 1.5rem; padding: 0 1rem; }
|
||||
.footer-section h4 { color: #ffdd99; font-size: 1.25rem; margin-bottom: 1rem; }
|
||||
.footer-section p { color: #f0e2d0; margin: 0.5rem 0; }
|
||||
.footer-bottom { text-align: center; padding: 1.5rem 1rem 0.5rem; border-top: 1px solid rgba(212,163,115,0.4); margin-top: 1.5rem; color: #ddc8aa; }
|
||||
.team-credit { font-size: 0.75rem; margin-top: 0.5rem; color: #c9a87c; }
|
||||
@media (max-width: 768px) {
|
||||
header h1 { font-size: 1.2rem; }
|
||||
nav a { padding: 0.3rem 0.6rem; font-size: 0.85rem; }
|
||||
h2 { font-size: 1.5rem; }
|
||||
.footer-content { grid-template-columns: 1fr; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function checkUnreadMessages() {
|
||||
fetch('/api/unread_count')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const badge = document.getElementById('unread-badge');
|
||||
if (badge && data.count > 0) {
|
||||
badge.textContent = data.count;
|
||||
badge.style.display = 'inline-block';
|
||||
} else if (badge) badge.style.display = 'none';
|
||||
})
|
||||
.catch(e => console.error(e));
|
||||
}
|
||||
{% if session.user_id %}
|
||||
setInterval(checkUnreadMessages, 10000);
|
||||
checkUnreadMessages();
|
||||
{% endif %}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
templates/certificates.html
Normal file
14
templates/certificates.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Подарочные сертификаты</h2>
|
||||
<div class="certificates-list">
|
||||
{% for certificate in certificates %}
|
||||
<div class="certificate-item">
|
||||
<h3>{{ certificate.name }}</h3>
|
||||
<p>{{ certificate.description }}</p>
|
||||
<p class="price">{{ certificate.price }} рублей</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
237
templates/chat.html
Normal file
237
templates/chat.html
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.chat-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.order-info {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.order-info h3 {
|
||||
color: #f5cba0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.order-info p {
|
||||
color: white;
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
|
||||
.order-info strong {
|
||||
color: #f5cba0;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 0.8rem;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 12px;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.message-user {
|
||||
background: linear-gradient(135deg, #f5cba0 0%, #d4a373 100%);
|
||||
color: #2c1810;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message-admin {
|
||||
background: rgba(44, 24, 16, 0.85);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-system {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: #f5cba0;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-pending { background: #ffc107; color: #856404; }
|
||||
.status-confirmed { background: #17a2b8; color: white; }
|
||||
.status-completed { background: #28a745; color: white; }
|
||||
.status-cancelled { background: #dc3545; color: white; }
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f5cba0 0%, #d4a373 100%);
|
||||
color: #2c1810;
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #ffdd99 0%, #f5cba0 100%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.message {
|
||||
max-width: 95%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>Чат по заказу #{{ order.id }}</h2>
|
||||
|
||||
<div class="chat-container">
|
||||
<div class="order-info">
|
||||
<h3>Информация о заказе</h3>
|
||||
<p><strong>Услуга:</strong> {{ order.service_name }}</p>
|
||||
<p><strong>Дата:</strong> {{ order.date }} в {{ order.time }}</p>
|
||||
<p><strong>Количество человек:</strong> {{ order.persons }}</p>
|
||||
<p><strong>Цена:</strong>
|
||||
{% if order.discount_applied %}
|
||||
<span style="text-decoration: line-through; color: #aaa;">{{ order.original_price }} руб.</span>
|
||||
<span style="color: #28a745;">{{ order.price }} руб.</span>
|
||||
<span style="background: #28a745; color: white; padding: 0.2rem 0.5rem; border-radius: 20px; font-size: 0.7rem; margin-left: 0.5rem;">Скидка 15%</span>
|
||||
{% else %}
|
||||
{{ order.price }} руб.
|
||||
{% endif %}
|
||||
</p>
|
||||
<p><strong>Статус:</strong> <span class="status status-{{ order.status }}">{{ order.status }}</span></p>
|
||||
{% if order.comment %}
|
||||
<p><strong>Ваш комментарий:</strong> {{ order.comment }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="chat-messages" id="chatMessages">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="message {% if message.sender_type == 'system' %}message-system{% elif message.sender_type == 'admin' %}message-admin{% else %}message-user{% endif %}">
|
||||
<div class="message-header">
|
||||
<strong>
|
||||
{% if message.sender_type == 'system' %}
|
||||
📢 Система
|
||||
{% elif message.sender_type == 'admin' %}
|
||||
👑 Администратор
|
||||
{% else %}
|
||||
👤 {{ message.sender_name }}
|
||||
{% endif %}
|
||||
</strong>
|
||||
<span class="message-time">{{ message.created_at }}</span>
|
||||
</div>
|
||||
<div class="message-text">{{ message.message }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="message message-system">
|
||||
<div class="message-text">Нет сообщений. Напишите что-нибудь...</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="chat-input">
|
||||
<form id="messageForm">
|
||||
<input type="hidden" id="order_id" value="{{ order.id }}">
|
||||
<textarea id="message" placeholder="Введите сообщение..." rows="3" required></textarea>
|
||||
<button type="submit" class="btn-primary">Отправить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Отмечаем сообщения как прочитанные
|
||||
fetch('/api/mark_read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ order_id: {{ order.id }} })
|
||||
});
|
||||
|
||||
// Отправка сообщения
|
||||
document.getElementById('messageForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const orderId = document.getElementById('order_id').value;
|
||||
const message = document.getElementById('message').value.trim();
|
||||
|
||||
if (!message) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('order_id', orderId);
|
||||
formData.append('message', message);
|
||||
|
||||
const response = await fetch('/send_message', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
document.getElementById('message').value = '';
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка при отправке сообщения');
|
||||
}
|
||||
});
|
||||
|
||||
// Автоскролл вниз
|
||||
const chatMessages = document.getElementById('chatMessages');
|
||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||
|
||||
// Обновляем чат каждые 5 секунд
|
||||
setInterval(() => {
|
||||
location.reload();
|
||||
}, 100000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
66
templates/discounts.html
Normal file
66
templates/discounts.html
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Скидки и специальные предложения</h2>
|
||||
|
||||
<div class="discounts-section">
|
||||
<div class="discounts-intro">
|
||||
<p>Мы хотим, чтобы общение с лошадьми было доступным для всех! Поэтому у нас действует гибкая система скидок и специальных предложений.</p>
|
||||
</div>
|
||||
|
||||
<div class="discounts-grid">
|
||||
<div class="discount-card">
|
||||
<div class="discount-icon">🎂</div>
|
||||
<h3>Скидка в честь дня рождения</h3>
|
||||
<div class="discount-value">10%</div>
|
||||
<p>При предъявлении документа, подтверждающего день рождения (скидка действует в день рождения и 3 дня после)</p>
|
||||
</div>
|
||||
|
||||
<div class="discount-card">
|
||||
<div class="discount-icon">👶</div>
|
||||
<h3>Детям до 5 лет</h3>
|
||||
<div class="discount-value">Бесплатно</div>
|
||||
<p>Прогулки в санях для детей до 5 лет - бесплатно в сопровождении родителей</p>
|
||||
</div>
|
||||
|
||||
<div class="discount-card">
|
||||
<div class="discount-icon">👨👩👧👦</div>
|
||||
<h3>Семейные прогулки</h3>
|
||||
<div class="discount-value">15%</div>
|
||||
<p>При заказе прогулки для семьи из 4-х человек и более</p>
|
||||
</div>
|
||||
|
||||
<div class="discount-card">
|
||||
<div class="discount-icon">📅</div>
|
||||
<h3>Абонементы</h3>
|
||||
<div class="discount-value">Экономия до 20%</div>
|
||||
<p>При покупке абонемента вы экономите до 20% от стоимости разовых занятий</p>
|
||||
</div>
|
||||
|
||||
<div class="discount-card">
|
||||
<div class="discount-icon">🎓</div>
|
||||
<h3>Студентам и школьникам</h3>
|
||||
<div class="discount-value">10%</div>
|
||||
<p>При предъявлении студенческого билета или справки из школы</p>
|
||||
</div>
|
||||
|
||||
<div class="discount-card">
|
||||
<div class="discount-icon">👴</div>
|
||||
<h3>Пенсионерам</h3>
|
||||
<div class="discount-value">10%</div>
|
||||
<p>При предъявлении пенсионного удостоверения</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="discounts-note">
|
||||
<h3>Важно!</h3>
|
||||
<p>Скидки не суммируются. Вы можете выбрать наиболее выгодное для вас предложение.
|
||||
Для получения скидки необходимо предъявить подтверждающий документ перед началом прогулки или занятия.</p>
|
||||
</div>
|
||||
|
||||
<div class="special-offer">
|
||||
<h3>Специальное предложение для именинников!</h3>
|
||||
<p>В свой день рождения вы можете прийти на прогулку с компанией до 3-х человек и получить скидку 10% для всей компании!</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
34
templates/gallery.html
Normal file
34
templates/gallery.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Фотогалерея</h2>
|
||||
|
||||
<div class="gallery-intro">
|
||||
<p>Здесь вы можете увидеть фотографии нашей базы, лошадей и счастливых посетителей. Приходите к нам, чтобы создать свои яркие воспоминания!</p>
|
||||
</div>
|
||||
|
||||
<div class="gallery-grid">
|
||||
{% if images %}
|
||||
{% for image in images %}
|
||||
<div class="gallery-item">
|
||||
<img src="{{ url_for('static', filename='images/gallery/' + image) }}" alt="Фото с базы Конный двор">
|
||||
<p class="gallery-caption">{{ image.replace('_', ' ').replace('-', ' ').split('.')[0] }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="gallery-item placeholder">
|
||||
<div class="gallery-placeholder">
|
||||
<span class="placeholder-icon">📷</span>
|
||||
<p>Фото скоро появятся</p>
|
||||
</div>
|
||||
<p class="gallery-caption">Добавьте фотографии в папку static/images/gallery/</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if images %}
|
||||
<div class="gallery-note">
|
||||
<p>Всего фотографий: {{ images|length }}. Хотите поделиться своими фотографиями? Отмечайте нас в социальных сетях с хештегом #КонныйДворБелорецк</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
58
templates/index.html
Normal file
58
templates/index.html
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
color: white !important;
|
||||
font-size: 2.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
max-width: 800px;
|
||||
margin: 1.5rem auto;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.8;
|
||||
color: white !important;
|
||||
text-shadow: 1px 1px 2px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.about-text p {
|
||||
color: white !important;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<section class="hero">
|
||||
<h2>Добро пожаловать в "Конный двор"!</h2>
|
||||
<div class="about-text">
|
||||
<p>Это база где вы сможете поближе познакомиться с лошадьми, обучиться навыкам верховной езды, подышать свежим воздухом.</p>
|
||||
<p>Наша база занимается проведением прогулок верхом и в санях, а также обучением навыкам верховной езды. Приходите к нам и вы откроете для себя удивительный мир лошадей и отдыха!</p>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
31
templates/login.html
Normal file
31
templates/login.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<h2>Вход в систему</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Имя пользователя:</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Войти</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-link">Нет аккаунта? <a href="{{ url_for('register') }}">Зарегистрироваться</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
281
templates/my_orders.html
Normal file
281
templates/my_orders.html
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.orders-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 2rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
.orders-header h2 { margin-bottom: 0; }
|
||||
.orders-stats {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.orders-stats .stat-badge {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 20px;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.orders-filters {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: 1rem;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-bar label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #f5cba0;
|
||||
}
|
||||
.filter-bar select {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
.orders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.order-card {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.order-card:hover {
|
||||
transform: translateX(5px);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.order-header {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.order-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #f5cba0;
|
||||
}
|
||||
.order-details {
|
||||
padding: 1rem;
|
||||
}
|
||||
.order-details p {
|
||||
margin: 0.5rem 0;
|
||||
color: white;
|
||||
}
|
||||
.order-details strong {
|
||||
color: #f5cba0;
|
||||
min-width: 120px;
|
||||
display: inline-block;
|
||||
}
|
||||
.discount-badge {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.7rem;
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.original-price {
|
||||
text-decoration: line-through;
|
||||
color: #aaa;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
.payment-info {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.payment-note {
|
||||
font-size: 0.7rem;
|
||||
color: #ccc;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.order-actions {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.order-actions .btn-primary {
|
||||
background: #f5cba0;
|
||||
color: #2c1810;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
padding: 0.5rem 1rem;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.order-actions .btn-primary:hover {
|
||||
background: #ffdd99;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.status {
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-pending { background: #ffc107; color: #856404; }
|
||||
.status-confirmed { background: #17a2b8; color: white; }
|
||||
.status-completed { background: #28a745; color: white; }
|
||||
.status-cancelled { background: #dc3545; color: white; }
|
||||
.unread-badge {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.empty-orders {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.empty-orders .icon { font-size: 3rem; margin-bottom: 0.5rem; }
|
||||
.empty-orders h3 { color: #f5cba0; margin-bottom: 0.5rem; }
|
||||
.empty-orders p { color: white; }
|
||||
@media (max-width: 768px) {
|
||||
.orders-header { flex-direction: column; align-items: flex-start; }
|
||||
.order-details strong { min-width: auto; display: inline; }
|
||||
.filter-bar { flex-direction: column; align-items: flex-start; }
|
||||
.filter-bar select { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="orders-header">
|
||||
<h2>Мои заказы</h2>
|
||||
<div class="orders-stats">
|
||||
{% set total = orders|length %}
|
||||
{% set pending = orders|selectattr('status', 'equalto', 'pending')|list|length %}
|
||||
{% set confirmed = orders|selectattr('status', 'equalto', 'confirmed')|list|length %}
|
||||
{% set completed = orders|selectattr('status', 'equalto', 'completed')|list|length %}
|
||||
<span class="stat-badge">Всего: {{ total }}</span>
|
||||
<span class="stat-badge">Ожидают: {{ pending }}</span>
|
||||
<span class="stat-badge">Подтверждены: {{ confirmed }}</span>
|
||||
<span class="stat-badge">Выполнены: {{ completed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="orders-filters">
|
||||
<div class="filter-bar">
|
||||
<label>Сортировка:</label>
|
||||
<select onchange="window.location.href='?sort='+this.value+'&status={{ status }}'">
|
||||
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Новые сначала</option>
|
||||
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Старые сначала</option>
|
||||
<option value="price_high" {% if sort == 'price_high' %}selected{% endif %}>Дорогие сначала</option>
|
||||
<option value="price_low" {% if sort == 'price_low' %}selected{% endif %}>Дешевые сначала</option>
|
||||
</select>
|
||||
<label>Статус:</label>
|
||||
<select onchange="window.location.href='?sort={{ sort }}&status='+this.value">
|
||||
<option value="all" {% if status == 'all' %}selected{% endif %}>Все</option>
|
||||
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Ожидает</option>
|
||||
<option value="confirmed" {% if status == 'confirmed' %}selected{% endif %}>Подтвержден</option>
|
||||
<option value="completed" {% if status == 'completed' %}selected{% endif %}>Выполнен</option>
|
||||
<option value="cancelled" {% if status == 'cancelled' %}selected{% endif %}>Отменен</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if orders %}
|
||||
<div class="orders-list">
|
||||
{% for order in orders %}
|
||||
<div class="order-card">
|
||||
<div class="order-header">
|
||||
<h3>Заказ #{{ order.id }}</h3>
|
||||
<span class="status status-{{ order.status }}">
|
||||
{% if order.status == 'pending' %}Ожидает
|
||||
{% elif order.status == 'confirmed' %}Подтвержден
|
||||
{% elif order.status == 'completed' %}Выполнен
|
||||
{% elif order.status == 'cancelled' %}Отменен
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="order-details">
|
||||
<p><strong>Услуга:</strong> {{ order.service_name }}</p>
|
||||
{% if order.date and order.time %}
|
||||
<p><strong>Дата:</strong> {{ order.date }} в {{ order.time }}</p>
|
||||
{% endif %}
|
||||
<p><strong>Количество человек:</strong> {{ order.persons }}</p>
|
||||
<p><strong>Цена:</strong>
|
||||
{% if order.discount_applied %}
|
||||
<span class="original-price">{{ order.original_price }} руб.</span>
|
||||
<span style="color: #28a745;">{{ order.price }} руб.</span>
|
||||
<span class="discount-badge">Скидка {{ order.discount_amount }} руб. (15%)</span>
|
||||
{% else %}
|
||||
{{ order.price }} руб.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="payment-info">
|
||||
<p><strong>💳 Оплата:</strong>
|
||||
{% if order.payment_status == 'paid' %}
|
||||
<span style="color: #28a745;">✅ Оплачено</span>
|
||||
{% else %}
|
||||
<span style="color: #ffc107;">⚠️ Не оплачено</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p><strong>📞 Реквизиты для оплаты:</strong> +7 962 545 066 (СБП, перевод по номеру телефона)</p>
|
||||
<p><strong>💵 Наличные:</strong> на месте встречи</p>
|
||||
<p class="payment-note">Оплата производится при получении услуги или заранее по номеру телефона. После оплаты администратор подтвердит статус.</p>
|
||||
</div>
|
||||
|
||||
{% if order.comment %}
|
||||
<p><strong>Комментарий:</strong> {{ order.comment }}</p>
|
||||
{% endif %}
|
||||
<p><strong>Дата заказа:</strong> {{ order.created_at }}</p>
|
||||
<div class="order-actions">
|
||||
<a href="{{ url_for('chat', order_id=order.id) }}" class="btn-primary">
|
||||
Чат с поддержкой
|
||||
{% if order.unread_count > 0 %}
|
||||
<span class="unread-badge">{{ order.unread_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-orders">
|
||||
<div class="icon">🐴</div>
|
||||
<h3>У вас пока нет заказов</h3>
|
||||
<p>Хотите прокатиться верхом или обучиться верховой езде?</p>
|
||||
<a href="{{ url_for('order') }}" class="btn-primary" style="display: inline-block; margin-top: 1rem; text-decoration: none;">Сделать первый заказ</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
182
templates/order_form.html
Normal file
182
templates/order_form.html
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Оформление заказа</h2>
|
||||
|
||||
<style>
|
||||
.order-form {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: 2rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: bold; color: #f5cba0; }
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
width: 100%; padding: 0.5rem;
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.9);
|
||||
}
|
||||
.form-group textarea { resize: none; height: 100px; }
|
||||
.price-display {
|
||||
font-size: 1.3rem; font-weight: bold; color: #f5cba0;
|
||||
padding: 0.5rem; background: rgba(0,0,0,0.3);
|
||||
border-radius: 8px; text-align: center;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #f5cba0;
|
||||
color: #2c1810;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-weight: bold;
|
||||
transition: 0.2s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: #ffdd99;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<form method="POST" class="order-form" id="orderForm">
|
||||
<div class="form-group">
|
||||
<label for="service_type">Тип услуги:</label>
|
||||
<select id="service_type" name="service_type" required>
|
||||
<option value="">Выберите тип</option>
|
||||
<option value="service">Услуга</option>
|
||||
<option value="abonement">Абонемент</option>
|
||||
<option value="certificate">Подарочный сертификат</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="service_id">Выберите:</label>
|
||||
<select id="service_id" name="service_id" required disabled>
|
||||
<option value="">Сначала выберите тип услуги</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Цена:</label>
|
||||
<div id="price_display" class="price-display">0 руб.</div>
|
||||
<input type="hidden" id="price" name="price" value="0">
|
||||
<input type="hidden" id="service_name" name="service_name">
|
||||
</div>
|
||||
|
||||
<div id="date-group" class="form-group">
|
||||
<label for="date">Дата:</label>
|
||||
<input type="date" id="date" name="date">
|
||||
<small>Нельзя выбрать прошедшую дату</small>
|
||||
</div>
|
||||
|
||||
<div id="time-group" class="form-group">
|
||||
<label for="time">Время:</label>
|
||||
<input type="time" id="time" name="time">
|
||||
<small id="time_hint">Режим работы: с 9:00 до 20:00</small>
|
||||
</div>
|
||||
|
||||
<div id="persons-group" class="form-group">
|
||||
<label for="persons">Количество человек:</label>
|
||||
<input type="number" id="persons" name="persons" min="1" max="20" step="1" value="1">
|
||||
<small>от 1 до 20. При заказе прогулки для 4+ человек - скидка 15%</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="comment">Комментарий к заказу:</label>
|
||||
<textarea id="comment" name="comment" rows="3" placeholder="Ваши пожелания..."></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Оформить заказ</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
let itemsData = {services: [], abonements: [], certificates: []};
|
||||
|
||||
fetch('/api/services')
|
||||
.then(res => res.json())
|
||||
.then(data => { itemsData = data; })
|
||||
.catch(console.error);
|
||||
|
||||
const typeSelect = document.getElementById('service_type');
|
||||
const serviceSelect = document.getElementById('service_id');
|
||||
const dateGroup = document.getElementById('date-group');
|
||||
const timeGroup = document.getElementById('time-group');
|
||||
const personsGroup = document.getElementById('persons-group');
|
||||
const timeHint = document.getElementById('time_hint');
|
||||
|
||||
typeSelect.addEventListener('change', function() {
|
||||
const type = this.value;
|
||||
if (type === 'certificate') {
|
||||
dateGroup.style.display = 'none';
|
||||
timeGroup.style.display = 'none';
|
||||
personsGroup.style.display = 'none';
|
||||
} else {
|
||||
dateGroup.style.display = 'block';
|
||||
timeGroup.style.display = 'block';
|
||||
personsGroup.style.display = 'block';
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
serviceSelect.disabled = true;
|
||||
serviceSelect.innerHTML = '<option value="">Сначала выберите тип услуги</option>';
|
||||
document.getElementById('price_display').textContent = '0 руб.';
|
||||
document.getElementById('price').value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
let items = [];
|
||||
if (type === 'service') items = itemsData.services;
|
||||
else if (type === 'abonement') items = itemsData.abonements;
|
||||
else if (type === 'certificate') items = itemsData.certificates;
|
||||
|
||||
serviceSelect.disabled = false;
|
||||
serviceSelect.innerHTML = '<option value="">Выберите...</option>';
|
||||
items.forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.id;
|
||||
option.textContent = `${item.name} - ${item.price} руб.`;
|
||||
option.dataset.price = item.price;
|
||||
option.dataset.name = item.name;
|
||||
if (item.duration) option.dataset.duration = item.duration;
|
||||
serviceSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
|
||||
serviceSelect.addEventListener('change', function() {
|
||||
const opt = this.options[this.selectedIndex];
|
||||
if (opt.value) {
|
||||
document.getElementById('price_display').textContent = opt.dataset.price + ' руб.';
|
||||
document.getElementById('price').value = opt.dataset.price;
|
||||
document.getElementById('service_name').value = opt.dataset.name;
|
||||
|
||||
const duration = opt.dataset.duration;
|
||||
if (duration && typeSelect.value === 'service') {
|
||||
let minutes = 0;
|
||||
if (duration.includes('минут')) minutes = parseInt(duration);
|
||||
else if (duration.includes('час')) minutes = parseInt(duration) * 60;
|
||||
if (minutes > 0) {
|
||||
const maxHour = Math.floor((20*60 - minutes) / 60);
|
||||
const maxMin = (20*60 - minutes) % 60;
|
||||
timeHint.innerHTML = `Услуга длится ${duration}. Необходимо закончить до 20:00. Максимальное время начала: ${maxHour}:${maxMin.toString().padStart(2,'0')}`;
|
||||
} else timeHint.innerHTML = 'Режим работы: с 9:00 до 20:00';
|
||||
} else timeHint.innerHTML = 'Режим работы: с 9:00 до 20:00';
|
||||
} else {
|
||||
document.getElementById('price_display').textContent = '0 руб.';
|
||||
document.getElementById('price').value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('date').min = new Date().toISOString().split('T')[0];
|
||||
</script>
|
||||
{% endblock %}
|
||||
120
templates/register.html
Normal file
120
templates/register.html
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.auth-container {
|
||||
max-width: 500px;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.auth-container h2 {
|
||||
color: #f5cba0;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 600;
|
||||
color: #f5cba0;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #f5cba0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #f5cba0 0%, #d4a373 100%);
|
||||
color: #2c1810;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #ffdd99 0%, #f5cba0 100%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.auth-link {
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.auth-link a {
|
||||
color: #f5cba0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
color: #f5cba0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="auth-container">
|
||||
<h2>Регистрация</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">Имя пользователя:</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email (необязательно):</label>
|
||||
<input type="email" id="email" name="email" placeholder="example@mail.ru">
|
||||
<small>Формат: name@domain.ru</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Пароль:</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Зарегистрироваться</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-link">Уже есть аккаунт? <a href="{{ url_for('login') }}">Войти</a></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
297
templates/reviews.html
Normal file
297
templates/reviews.html
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.reviews-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.add-review {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: 1.5rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.add-review h3 {
|
||||
color: #f5cba0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.reviews-list {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: 1.5rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.reviews-list h3 {
|
||||
color: #f5cba0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-weight: 600;
|
||||
color: #f5cba0;
|
||||
}
|
||||
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: none;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.review-card:hover {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.review-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.review-header strong {
|
||||
color: #f5cba0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rating {
|
||||
color: #ffc107;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.review-date {
|
||||
color: #d4a373;
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.review-comment {
|
||||
color: white;
|
||||
line-height: 1.5;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-bar label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #f5cba0;
|
||||
}
|
||||
|
||||
.filter-bar select {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
color: #2c2c2c;
|
||||
}
|
||||
|
||||
.empty-reviews {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
margin-top: 1rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.empty-reviews p {
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #f5cba0 0%, #d4a373 100%);
|
||||
color: #2c1810;
|
||||
padding: 0.6rem 1.2rem;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #ffdd99 0%, #f5cba0 100%);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #f5cba0;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.reviews-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.add-review {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.reviews-list {
|
||||
order: 1;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.filter-bar select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<h2>Отзывы наших посетителей</h2>
|
||||
|
||||
<div class="reviews-section">
|
||||
<!-- Форма добавления отзыва -->
|
||||
<div class="add-review">
|
||||
<h3>Оставить отзыв</h3>
|
||||
{% if session.user_id %}
|
||||
{% if completed_orders %}
|
||||
<form method="POST" class="review-form">
|
||||
<div class="form-group">
|
||||
<label for="order_id">Выберите заказ:</label>
|
||||
<select id="order_id" name="order_id" required>
|
||||
<option value="">Выберите выполненный заказ</option>
|
||||
{% for order in completed_orders %}
|
||||
<option value="{{ order.id }}">
|
||||
Заказ #{{ order.id }} - {{ order.service_name }} ({{ order.date }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="rating">Оценка:</label>
|
||||
<select id="rating" name="rating" required>
|
||||
<option value="5">5 - Отлично</option>
|
||||
<option value="4">4 - Хорошо</option>
|
||||
<option value="3">3 - Удовлетворительно</option>
|
||||
<option value="2">2 - Плохо</option>
|
||||
<option value="1">1 - Ужасно</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="comment">Ваш отзыв:</label>
|
||||
<textarea id="comment" name="comment" rows="4" placeholder="Расскажите о вашем опыте..." required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Отправить отзыв</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="info">У вас нет выполненных заказов, на которые можно оставить отзыв.</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="info">Чтобы оставить отзыв, пожалуйста, <a href="{{ url_for('login') }}" style="color: #f5cba0;">войдите</a> в систему.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Список отзывов -->
|
||||
<div class="reviews-list">
|
||||
<div class="filters">
|
||||
<h3>Отзывы</h3>
|
||||
<div class="filter-bar">
|
||||
<label>Сортировка:</label>
|
||||
<select onchange="window.location.href='?sort='+this.value+'&rating={{ rating_filter }}'">
|
||||
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Новые сначала</option>
|
||||
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Старые сначала</option>
|
||||
<option value="rating_high" {% if sort == 'rating_high' %}selected{% endif %}>По рейтингу (высокий)</option>
|
||||
<option value="rating_low" {% if sort == 'rating_low' %}selected{% endif %}>По рейтингу (низкий)</option>
|
||||
</select>
|
||||
|
||||
<label>Фильтр по оценке:</label>
|
||||
<select onchange="window.location.href='?sort={{ sort }}&rating='+this.value">
|
||||
<option value="all" {% if rating_filter == 'all' %}selected{% endif %}>Все</option>
|
||||
<option value="5" {% if rating_filter == '5' %}selected{% endif %}>5 звезд</option>
|
||||
<option value="4" {% if rating_filter == '4' %}selected{% endif %}>4 звезды</option>
|
||||
<option value="3" {% if rating_filter == '3' %}selected{% endif %}>3 звезды</option>
|
||||
<option value="2" {% if rating_filter == '2' %}selected{% endif %}>2 звезды</option>
|
||||
<option value="1" {% if rating_filter == '1' %}selected{% endif %}>1 звезда</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if reviews %}
|
||||
{% for review in reviews %}
|
||||
<div class="review-card">
|
||||
<div class="review-header">
|
||||
<strong>{{ review.username }}</strong>
|
||||
<div class="rating">
|
||||
{% for i in range(5) %}
|
||||
{% if i < review.rating %}
|
||||
★
|
||||
{% else %}
|
||||
☆
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-date">{{ review.created_at }}</div>
|
||||
<div class="review-comment">{{ review.comment }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-reviews">
|
||||
<p>😊 Пока нет отзывов. Будьте первым!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
15
templates/services.html
Normal file
15
templates/services.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Наши услуги</h2>
|
||||
<div class="services-list">
|
||||
{% for service in services %}
|
||||
<div class="service-item">
|
||||
<h3>{{ service.name }}</h3>
|
||||
<p>{{ service.description }}</p>
|
||||
<p class="price">{{ service.price }} рублей</p>
|
||||
<p class="duration">Длительность: {{ service.duration }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
249
templates/users.html
Normal file
249
templates/users.html
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Управление пользователями</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="filters">
|
||||
<h3>Фильтры и сортировка</h3>
|
||||
<div class="filter-bar">
|
||||
<label>Сортировка:</label>
|
||||
<select onchange="window.location.href='?sort='+this.value+'&role={{ role }}'">
|
||||
<option value="newest" {% if sort == 'newest' %}selected{% endif %}>Новые сначала</option>
|
||||
<option value="oldest" {% if sort == 'oldest' %}selected{% endif %}>Старые сначала</option>
|
||||
<option value="name_asc" {% if sort == 'name_asc' %}selected{% endif %}>Имя (А-Я)</option>
|
||||
<option value="name_desc" {% if sort == 'name_desc' %}selected{% endif %}>Имя (Я-А)</option>
|
||||
</select>
|
||||
|
||||
<label>Роль:</label>
|
||||
<select onchange="window.location.href='?sort={{ sort }}&role='+this.value">
|
||||
<option value="all" {% if role == 'all' %}selected{% endif %}>Все</option>
|
||||
<option value="admin" {% if role == 'admin' %}selected{% endif %}>Администраторы</option>
|
||||
<option value="user" {% if role == 'user' %}selected{% endif %}>Пользователи</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя пользователя</th>
|
||||
<th>Email</th>
|
||||
<th>Роль</th>
|
||||
<th>Дата регистрации</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td><strong>{{ user.username }}</strong></td>
|
||||
<td>{{ user.email or '-' }}</td>
|
||||
<td>
|
||||
<span class="role-badge role-{{ user.role }}">
|
||||
{% if user.role == 'admin' %}👑 Администратор
|
||||
{% else %}👤 Пользователь
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ user.created_at[:16] }}</td>
|
||||
<td>
|
||||
{% if user.role != 'admin' %}
|
||||
<form method="POST" action="{{ url_for('make_admin', user_id=user.id) }}?sort={{ sort }}&role={{ role }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-warning btn-sm">👑 Сделать админом</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('delete_user', user_id=user.id) }}?sort={{ sort }}&role={{ role }}" style="display: inline;" onsubmit="return confirm('Удалить пользователя {{ user.username }}? Все его заказы и отзывы также будут удалены.')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">🗑️ Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not users %}
|
||||
<div class="empty-state">
|
||||
<p>Нет пользователей для отображения</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.filters h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: #4a2c2c;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-bar label {
|
||||
font-weight: bold;
|
||||
color: #4a2c2c;
|
||||
}
|
||||
|
||||
.filter-bar select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
overflow-x: auto;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.admin-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background-color: #4a2c2c;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.admin-table tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.role-admin {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.role-user {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
display: inline-block;
|
||||
margin: 0.25rem;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #ffc107;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: #e0a800;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-bar select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Loading…
Reference in a new issue