mirror of
https://github.com/Michatec/MiniFaceBook.git
synced 2026-05-30 18:02:40 +02:00
feat(shop): implement CSRF protection and improve UI/UX
- Add CSRF token validation to the shop purchase process to prevent cross-site request forgery. - Implement a unique constraint on `UserShopItem` to prevent duplicate purchases of the same item. - Refactor the shop template with a modern, responsive grid layout and improved visual feedback for owned items. - Enhance CSS with better dark/light mode support, including improved navbar styling and scrollbar customization. - Add `.env_example` and update documentation for environment variable setup. - Integrate `python-dotenv` for environment variable management. - Improve logging configuration for the application. - Update `.gitignore` to include `venv/` and `.env`. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# You can change this file, to your preferred Settings.
|
||||||
|
|
||||||
|
PORT=YOUR PORT
|
||||||
+2
-3
@@ -10,11 +10,10 @@ migrations
|
|||||||
*.env
|
*.env
|
||||||
.vscode
|
.vscode
|
||||||
routes/__pycache__
|
routes/__pycache__
|
||||||
tools
|
|
||||||
*.pot
|
*.pot
|
||||||
*.mo
|
*.mo
|
||||||
routes/oauth.py
|
routes/oauth.py
|
||||||
static/profile_pics
|
static/profile_pics
|
||||||
static/uploads
|
static/uploads
|
||||||
commands.txt
|
venv/
|
||||||
py-to-exemfc.json
|
.env
|
||||||
@@ -33,12 +33,21 @@ MiniFacebook is a minimalist social network built with [Flask](https://flask.pal
|
|||||||
pip install -r requirments.txt
|
pip install -r requirments.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Start**
|
3. **Setup the .env file**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mv .env_example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
And you can change this file, to your preferred Settings.
|
||||||
|
⚠️ You need to set up a PORT
|
||||||
|
|
||||||
|
4. **Start**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
python main.py
|
python main.py
|
||||||
```
|
```
|
||||||
4. **Optional:**
|
5. **Optional:**
|
||||||
|
|
||||||
Go to routes/example_oauth.py
|
Go to routes/example_oauth.py
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from flask import Flask, request, render_template, redirect, url_for, flash, abort, jsonify
|
from flask import Flask, request, render_template, redirect, url_for, flash, abort, jsonify, current_app, session, has_request_context
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_login import LoginManager, login_required, current_user
|
from flask_login import LoginManager, login_required, current_user
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
@@ -20,8 +20,14 @@ try:
|
|||||||
from routes.oauth import oauth
|
from routes.oauth import oauth
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import os
|
import os, sys
|
||||||
|
|
||||||
|
logger = logging.getLogger('waitress')
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.addHandler(logging.StreamHandler(sys.stdout))
|
||||||
|
|
||||||
__mapper_args__ = {"confirm_deleted_rows": False}
|
__mapper_args__ = {"confirm_deleted_rows": False}
|
||||||
|
|
||||||
@@ -66,40 +72,12 @@ app.register_blueprint(friends_bp)
|
|||||||
app.register_blueprint(noti_bp)
|
app.register_blueprint(noti_bp)
|
||||||
app.register_blueprint(credits_bp)
|
app.register_blueprint(credits_bp)
|
||||||
|
|
||||||
with app.app_context():
|
|
||||||
if db.session.query(ShopItem).count() == 0:
|
|
||||||
db.session.add(ShopItem(
|
|
||||||
name="Premium Account",
|
|
||||||
description="Exclusive features and content.",
|
|
||||||
price=100,
|
|
||||||
icon="bi-star"
|
|
||||||
))
|
|
||||||
db.session.add(ShopItem(
|
|
||||||
name="Gold Profile Frame",
|
|
||||||
description="Adds a golden profile frame to your profile.",
|
|
||||||
price=50,
|
|
||||||
icon="bi-person-bounding-box"
|
|
||||||
))
|
|
||||||
db.session.add(ShopItem(
|
|
||||||
name="Extra Upload Slot",
|
|
||||||
description="Become able to upload more files.",
|
|
||||||
price=130,
|
|
||||||
icon="bi-cloud-upload"
|
|
||||||
))
|
|
||||||
db.session.add(ShopItem(
|
|
||||||
name="More Types",
|
|
||||||
description="More types for your posts. Limit: 500 types per post.",
|
|
||||||
price=80,
|
|
||||||
icon="bi-megaphone"
|
|
||||||
))
|
|
||||||
db.session.commit()
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_locale():
|
def get_locale():
|
||||||
|
if has_request_context():
|
||||||
lang = request.cookies.get('lang')
|
lang = request.cookies.get('lang')
|
||||||
if lang in ['de', 'en']:
|
if lang in ['de', 'en']:
|
||||||
return lang
|
return lang
|
||||||
|
return None
|
||||||
|
|
||||||
babel.init_app(app, locale_selector=get_locale)
|
babel.init_app(app, locale_selector=get_locale)
|
||||||
|
|
||||||
@@ -188,23 +166,48 @@ def setup():
|
|||||||
@app.route('/shop', methods=['GET', 'POST'])
|
@app.route('/shop', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def shop():
|
def shop():
|
||||||
items = db.session.query(ShopItem).all()
|
if 'shop_csrf_token' not in session:
|
||||||
message = None
|
session['shop_csrf_token'] = os.urandom(24).hex()
|
||||||
|
csrf_token_value = session['shop_csrf_token']
|
||||||
|
|
||||||
|
items = db.session.query(ShopItem).order_by(ShopItem.price.asc()).all()
|
||||||
owned_ids = [usi.item_id for usi in current_user.shop_items]
|
owned_ids = [usi.item_id for usi in current_user.shop_items]
|
||||||
|
user_points = current_user.reward_points()
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
if request.form.get('csrf_token') != session.get('shop_csrf_token'):
|
||||||
|
logger.warning(f"CSRF token mismatch for user {current_user.id} in shop")
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
try:
|
||||||
item_id = int(request.form['item_id'])
|
item_id = int(request.form['item_id'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
flash(_('Invalid item selected.'), 'danger')
|
||||||
|
return redirect(url_for('shop'))
|
||||||
|
|
||||||
item = db.session.get(ShopItem, item_id)
|
item = db.session.get(ShopItem, item_id)
|
||||||
if item_id in owned_ids:
|
|
||||||
message = _("Already purchased!")
|
if not item:
|
||||||
elif item and current_user.reward_points() >= item.price:
|
flash(_('Item not found.'), 'danger')
|
||||||
|
elif item_id in owned_ids:
|
||||||
|
flash(_('Already purchased!'), 'warning')
|
||||||
|
elif user_points < item.price:
|
||||||
|
flash(_('Not enough points! You need %(needed)d more.', needed=item.price - user_points), 'danger')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
db.session.add(Reward(user_id=current_user.id, type=f'buy_{item.name}', points=-item.price))
|
db.session.add(Reward(user_id=current_user.id, type=f'buy_{item.name}', points=-item.price))
|
||||||
db.session.add(UserShopItem(user_id=current_user.id, item_id=item.id))
|
db.session.add(UserShopItem(user_id=current_user.id, item_id=item.id))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
message = _(f"Purchased: {item.name}")
|
session['shop_csrf_token'] = os.urandom(24).hex()
|
||||||
owned_ids.append(item_id)
|
flash(_('Successfully purchased: %(item_name)s', item_name=item.name), 'success')
|
||||||
else:
|
return redirect(url_for('shop'))
|
||||||
message = _("Not enough points!")
|
except Exception as e:
|
||||||
return render_template('shop.html', items=items, message=message, owned_ids=owned_ids)
|
db.session.rollback()
|
||||||
|
logger.error(f"Shop purchase failed for user {current_user.id}: {e}")
|
||||||
|
flash(_('Purchase failed. Please try again.'), 'danger')
|
||||||
|
|
||||||
|
return render_template('shop.html', items=items, owned_ids=owned_ids,
|
||||||
|
user_points=user_points, csrf_token_value=csrf_token_value)
|
||||||
|
|
||||||
@app.errorhandler(403)
|
@app.errorhandler(403)
|
||||||
def forbidden(error):
|
def forbidden(error):
|
||||||
@@ -217,9 +220,85 @@ def not_found(error):
|
|||||||
return redirect(url_for('post.feed'))
|
return redirect(url_for('post.feed'))
|
||||||
return render_template('index.html'), 200
|
return render_template('index.html'), 200
|
||||||
|
|
||||||
|
def print_error(message):
|
||||||
|
print(f"\033[91m[ERROR] {message}\033[0m")
|
||||||
|
|
||||||
|
def print_loading(message):
|
||||||
|
colors = ["\033[91m", "\033[93m", "\033[92m", "\033[96m", "\033[94m", "\033[95m"]
|
||||||
|
color = colors[hash(message) % len(colors)]
|
||||||
|
print(f"{color}◆ {message}...\033[0m")
|
||||||
|
|
||||||
|
|
||||||
|
def print_success(message):
|
||||||
|
colors = ["\033[92m", "\033[96m", "\033[94m", "\033[95m"]
|
||||||
|
color = colors[hash(message) % len(colors)]
|
||||||
|
print(f"{color}✓ {message}\033[0m")
|
||||||
|
|
||||||
|
def print_rainbow_separator():
|
||||||
|
rainbow = "\033[91m▆\033[93m▆\033[92m▆\033[96m▆\033[94m▆\033[95m▆\033[0m"
|
||||||
|
print(f" {rainbow * 12}")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
print_loading("Starting MiniFaceBook...")
|
||||||
|
print_rainbow_separator()
|
||||||
|
print_loading("Initializing database")
|
||||||
try:
|
try:
|
||||||
serve(app, host="0.0.0.0", port=80, threads=12)
|
with app.app_context():
|
||||||
print("Serving connections from port 80....")
|
if db.session.query(ShopItem).count() == 0:
|
||||||
|
shop_items = [
|
||||||
|
{
|
||||||
|
'name': _('Premium Account'),
|
||||||
|
'description': _('Exclusive features and content.'),
|
||||||
|
'price': 100,
|
||||||
|
'icon': 'bi-star'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': _('Gold Profile Frame'),
|
||||||
|
'description': _('Adds a golden profile frame to your profile.'),
|
||||||
|
'price': 50,
|
||||||
|
'icon': 'bi-person-bounding-box'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': _('Extra Upload Slot'),
|
||||||
|
'description': _('Become able to upload more files.'),
|
||||||
|
'price': 130,
|
||||||
|
'icon': 'bi-cloud-upload'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': _('More Types'),
|
||||||
|
'description': _('More types for your posts. Limit: 500 types per post.'),
|
||||||
|
'price': 80,
|
||||||
|
'icon': 'bi-megaphone'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
for item_data in shop_items:
|
||||||
|
item = ShopItem(**item_data)
|
||||||
|
db.session.add(item)
|
||||||
|
db.session.commit()
|
||||||
|
print_success("Database initialized successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Database initialization failed: {e}")
|
||||||
|
print_error("Please check your database configuration and ensure the database is accessible.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print_loading("Loading environment variables")
|
||||||
|
try:
|
||||||
|
load_dotenv()
|
||||||
|
port = os.environ.get('PORT')
|
||||||
|
print_success("Environment variables loaded successfully.")
|
||||||
|
except Exception as e:
|
||||||
|
print_error(f"Failed to load environment variables: {e}")
|
||||||
|
print_error("Please set the environment variables!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print_loading(f"Using port {port}")
|
||||||
|
print_rainbow_separator()
|
||||||
|
print_loading("Starting server with Waitress")
|
||||||
|
|
||||||
|
try:
|
||||||
|
print_success(f"Server started successfully with Waitress at the port {port}.")
|
||||||
|
serve(app, host="0.0.0.0", port=port, threads=12, connection_limit=1000)
|
||||||
except:
|
except:
|
||||||
app.run(debug=True, host="0.0.0.0", port=80)
|
print_error(f"Failed to start with Waitress, falling back to Flask's built-in server at port {port}. This is not recommended for production use.")
|
||||||
|
app.run(debug=True, host="0.0.0.0", port=port)
|
||||||
@@ -120,6 +120,8 @@ class UserShopItem(db.Model):
|
|||||||
bought_at = db.Column(db.DateTime, default=datetime.now)
|
bought_at = db.Column(db.DateTime, default=datetime.now)
|
||||||
item = db.relationship('ShopItem')
|
item = db.relationship('ShopItem')
|
||||||
|
|
||||||
|
__table_args__ = (db.UniqueConstraint('user_id', 'item_id', name='unique_user_item_purchase'),)
|
||||||
|
|
||||||
class SupportRequest(db.Model):
|
class SupportRequest(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ waitress
|
|||||||
authlib
|
authlib
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
requests
|
requests
|
||||||
|
dotenv
|
||||||
+89
-13
@@ -5,6 +5,16 @@ body {
|
|||||||
cursor: url("/static/icons/custom-cursor.png"), auto;
|
cursor: url("/static/icons/custom-cursor.png"), auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body *::selection {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
input::selection, textarea::selection {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
body input, body textarea {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
|
||||||
canvas {
|
canvas {
|
||||||
background: black;
|
background: black;
|
||||||
border: 1px solid white;
|
border: 1px solid white;
|
||||||
@@ -14,12 +24,31 @@ canvas {
|
|||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #2563eb !important;
|
color: #2563eb !important;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Navbar in dark mode */
|
||||||
|
body.dark-mode .navbar {
|
||||||
|
background: #1f2937 !important;
|
||||||
|
border-color: #374151 !important;
|
||||||
|
}
|
||||||
|
body.dark-mode .navbar .nav-link {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
body.dark-mode .navbar .nav-link:hover {
|
||||||
|
color: #60a5fa !important;
|
||||||
|
}
|
||||||
|
body.dark-mode .navbar-toggler {
|
||||||
|
border-color: #4b5563 !important;
|
||||||
|
}
|
||||||
|
body.dark-mode .navbar-toggler-icon {
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 30 30' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='rgba(229,231,235,0.9)' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E") !important;
|
||||||
|
}
|
||||||
.profile-pic {
|
.profile-pic {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@@ -120,29 +149,65 @@ canvas {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Light mode scrollbar */
|
||||||
|
body.light-mode ::-webkit-scrollbar {
|
||||||
|
background: #d1d5db;
|
||||||
|
}
|
||||||
|
body.light-mode ::-webkit-scrollbar-thumb {
|
||||||
|
background: #9ca3af;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Light Mode (default) */
|
/* Light Mode (default) */
|
||||||
body, .card, .navbar, .list-group-item, .table, .form-control, .form-select {
|
body, .card, .navbar, .list-group-item, .table, .form-control, .form-select {
|
||||||
transition: background 0.3s, color 0.3s;
|
transition: background 0.3s, color 0.3s;
|
||||||
}
|
}
|
||||||
body.light-mode {
|
body.light-mode {
|
||||||
background: linear-gradient(135deg, #0f5b86 0%, #0245aabb 100%);
|
background: linear-gradient(135deg, #f0f2f5 0%, #e8ecf0 100%);
|
||||||
color: #222;
|
color: #222;
|
||||||
}
|
}
|
||||||
body.light-mode .card,
|
body.light-mode .card,
|
||||||
body.light-mode .navbar,
|
|
||||||
body.light-mode .list-group-item,
|
body.light-mode .list-group-item,
|
||||||
body.light-mode .table {
|
body.light-mode .table {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #222;
|
color: #222;
|
||||||
}
|
}
|
||||||
|
body.light-mode .navbar {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
border-color: #e5e7eb !important;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
body.light-mode .navbar .nav-link {
|
||||||
|
color: #374151 !important;
|
||||||
|
}
|
||||||
|
body.light-mode .navbar .nav-link:hover {
|
||||||
|
color: #2563eb !important;
|
||||||
|
}
|
||||||
body.light-mode .form-control,
|
body.light-mode .form-control,
|
||||||
body.light-mode .form-select {
|
body.light-mode .form-select {
|
||||||
background: #f8fafc;
|
background: #fff;
|
||||||
color: #222;
|
color: #222;
|
||||||
|
border-color: #d1d5db;
|
||||||
|
}
|
||||||
|
body.light-mode .form-control:focus,
|
||||||
|
body.light-mode .form-select:focus {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 2px #2563eb22;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.light-mode li button {
|
body.light-mode li button {
|
||||||
color: #0011ff !important;
|
color: #2563eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links in light mode */
|
||||||
|
body.light-mode a,
|
||||||
|
body.light-mode a:visited {
|
||||||
|
color: #2563eb !important;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
body.light-mode a:hover {
|
||||||
|
color: #1d4ed8 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark Mode */
|
/* Dark Mode */
|
||||||
@@ -155,9 +220,6 @@ body.dark-mode li button {
|
|||||||
color: #0099f1 !important;
|
color: #0099f1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.light-mode p {
|
|
||||||
color: #000000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.dark-mode .card,
|
body.dark-mode .card,
|
||||||
body.dark-mode .navbar,
|
body.dark-mode .navbar,
|
||||||
@@ -177,9 +239,14 @@ body.dark-mode .form-control:focus,
|
|||||||
body.dark-mode .form-select:focus {
|
body.dark-mode .form-select:focus {
|
||||||
border-color: #2563eb !important;
|
border-color: #2563eb !important;
|
||||||
box-shadow: 0 0 0 2px #2563eb55 !important;
|
box-shadow: 0 0 0 2px #2563eb55 !important;
|
||||||
background: #23272f !important;
|
background: #1f2937 !important;
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.light-mode .form-control:focus,
|
||||||
|
body.light-mode .form-select:focus {
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
body.dark-mode .btn,
|
body.dark-mode .btn,
|
||||||
body.dark-mode .btn-primary,
|
body.dark-mode .btn-primary,
|
||||||
body.dark-mode .btn-success,
|
body.dark-mode .btn-success,
|
||||||
@@ -226,10 +293,10 @@ body.dark-mode .profile-pic {
|
|||||||
body.dark-mode .table th,
|
body.dark-mode .table th,
|
||||||
body.dark-mode .table td {
|
body.dark-mode .table td {
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
background: #23272f !important;
|
background: #1f2937 !important;
|
||||||
}
|
}
|
||||||
body.dark-mode .list-group-item {
|
body.dark-mode .list-group-item {
|
||||||
background: #23272f !important;
|
background: #1f2937 !important;
|
||||||
color: #e5e7eb !important;
|
color: #e5e7eb !important;
|
||||||
box-shadow: 0 1px 4px rgba(37,99,235,0.08) !important;
|
box-shadow: 0 1px 4px rgba(37,99,235,0.08) !important;
|
||||||
}
|
}
|
||||||
@@ -271,17 +338,26 @@ body.dark-mode .navbar-toggler-icon {
|
|||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.light-mode .footer {
|
||||||
|
background: #f8fafc !important;
|
||||||
|
border-top: 1px solid #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode .footer {
|
body.dark-mode .footer {
|
||||||
background: #181a1b !important;
|
background: #1f2937 !important;
|
||||||
border-top: 1px solid #23272f !important;
|
border-top: 1px solid #374151 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer .text-muted, .footer a {
|
.footer .text-muted, .footer a {
|
||||||
color: #6c757d !important;
|
color: #6c757d !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.light-mode .footer .text-muted, body.light-mode .footer a {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
body.dark-mode .footer .text-muted, body.dark-mode .footer a {
|
body.dark-mode .footer .text-muted, body.dark-mode .footer a {
|
||||||
color: #b0b8c1 !important;
|
color: #9ca3af !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex flex-column min-vh-100 {{ theme_class }}">
|
<body class="d-flex flex-column min-vh-100 {{ theme_class }}">
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
<nav class="navbar navbar-expand-lg bg-light" id="main-navbar">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="{{ url_for('index') }}">
|
<a class="navbar-brand" href="{{ url_for('index') }}">
|
||||||
<i class="bi bi-people-fill me-2"></i>MiniFacebook
|
<i class="bi bi-people-fill me-2"></i>MiniFacebook
|
||||||
|
|||||||
+100
-16
@@ -1,33 +1,117 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ _('Shop') }}{% endblock %}
|
{% block title %}{{ _('Shop') }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="container-fluid px-0">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h2><i class="bi bi-shop me-2"></i>{{ _('Shop') }}</h2>
|
<h2><i class="bi bi-shop me-2"></i>{{ _('Shop') }}</h2>
|
||||||
<p>{{ _('Deine Reward-Punkte:') }} <b>{{ current_user.reward_points() }}</b></p>
|
<div class="d-flex align-items-center gap-3">
|
||||||
{% if message %}
|
<div class="points-display bg-gradient-primary text-white px-4 py-2 rounded-3 shadow-sm">
|
||||||
<div class="alert alert-info">{{ message }}</div>
|
<i class="bi bi-coin me-2"></i>
|
||||||
{% endif %}
|
<span>{{ _('Your Points:') }}</span>
|
||||||
<div class="row">
|
<strong class="ms-1">{{ user_points }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-lg-4 col-md-6">
|
||||||
<div class="card shadow-sm">
|
<div class="card h-100 shadow-sm border-0 {% if item.id in owned_ids %}owned-bg{% endif %}">
|
||||||
<div class="card-body text-center">
|
<div class="card-body d-flex flex-column">
|
||||||
<i class="bi {{ item.icon }} display-4 mb-2"></i>
|
<div class="text-center mb-3">
|
||||||
<h5>{{ item.name }}</h5>
|
<div class="icon-wrapper text-white rounded-circle mx-auto mb-2 d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
|
||||||
<p>{{ item.description }}</p>
|
<i class="bi {{ item.icon }} display-4"></i>
|
||||||
<p><b>{{ item.price }}</b> {{ _('Points') }}</p>
|
</div>
|
||||||
|
<h5 class="card-title mb-1">{{ item.name }}</h5>
|
||||||
|
<small class="text-muted">{{ item.description }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="price-tag">
|
||||||
|
<span class="h4 text-primary mb-0">{{ item.price }}</span>
|
||||||
|
<small class="text-muted">{{ _('Points') }}</small>
|
||||||
|
</div>
|
||||||
{% if item.id in owned_ids %}
|
{% if item.id in owned_ids %}
|
||||||
<button class="btn btn-secondary" disabled>{{ _('Bought') }}</button>
|
<span class="badge bg-success rounded-pill px-3 py-2">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>{{ _('Owned') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.id in owned_ids %}
|
||||||
|
<button class="btn btn-outline-secondary w-100" disabled>
|
||||||
|
<i class="bi bi-bag-check me-2"></i>{{ _('Already Purchased') }}
|
||||||
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post">
|
{% if user_points >= item.price %}
|
||||||
|
<form method="post" class="d-grid">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token_value }}">
|
||||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
<button class="btn btn-success" {% if current_user.reward_points() < item.price %}disabled{% endif %}>
|
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||||
<i class="bi bi-cart"></i> {{ _('Buy') }}
|
<i class="bi bi-cart-plus me-2"></i>{{ _('Buy Now') }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<button class="btn btn-outline-secondary w-100" disabled title="{{ _('Not enough points') }}">
|
||||||
|
<i class="bi bi-coin me-2"></i>{{ _('Need %(needed)d more points', needed=item.price - user_points) }}
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if not items %}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-inbox display-1 text-muted mb-3"></i>
|
||||||
|
<h4>{{ _('No items available') }}</h4>
|
||||||
|
<p class="text-muted">{{ _('Check back later for new items in the shop!') }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.points-display {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0,0,0,0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.owned-bg {
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
border: 2px solid #28a745 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-tag .h4 {
|
||||||
|
color: #0d6efd;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user