feat: Add member benefits module with WisprFlow affiliate
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- Add Benefit and BenefitClick models for tracking affiliate offers - Create /korzysci blueprint with admin-only access (test mode) - Add admin panel at /admin/benefits for managing offers - Include WisprFlow as first benefit with branded link ref.wisprflow.ai/norda - Add QR code support for printed materials - Track clicks with user attribution and analytics Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d52aaaba9c
commit
5fd5140763
@ -8,6 +8,34 @@ Opcjonalny argument określa typ backupu:
|
||||
- `/backup db` - tylko baza danych
|
||||
- `/backup snapshot` - snapshot VM w Proxmox
|
||||
- `/backup restore` - przywracanie (interaktywne)
|
||||
- `/backup status` - sprawdź status automatycznych backupów
|
||||
|
||||
## System automatycznych backupów
|
||||
|
||||
### Harmonogram (cron na NORDABIZ-01)
|
||||
|
||||
| Typ | Częstotliwość | Godzina | Retencja | Lokalizacja |
|
||||
|-----|---------------|---------|----------|-------------|
|
||||
| Hourly | Co godzinę | :00 | 24h | `/var/backups/nordabiz/hourly/` |
|
||||
| Daily | Codziennie | 02:00 | 30 dni | `/var/backups/nordabiz/daily/` |
|
||||
| Offsite | Codziennie | 04:00 | 30 dni | PBS (10.22.68.127) |
|
||||
| Config | Tygodniowo | Niedziela 03:00 | 4 tyg | `/var/backups/nordabiz/config/` |
|
||||
|
||||
### Sprawdzenie statusu backupów
|
||||
|
||||
```bash
|
||||
# Ostatnie backupy hourly
|
||||
ssh maciejpi@10.22.68.249 "ls -lt /var/backups/nordabiz/hourly/ | head -5"
|
||||
|
||||
# Ostatnie backupy daily
|
||||
ssh maciejpi@10.22.68.249 "ls -lt /var/backups/nordabiz/daily/ | head -5"
|
||||
|
||||
# Sprawdź offsite (PBS)
|
||||
ssh maciejpi@10.22.68.127 "ls -lt /backup/nordabiz/daily/ | head -5"
|
||||
|
||||
# Rozmiar backupów
|
||||
ssh maciejpi@10.22.68.249 "du -sh /var/backups/nordabiz/*"
|
||||
```
|
||||
|
||||
## Kroki do wykonania:
|
||||
|
||||
@ -25,7 +53,7 @@ ssh maciejpi@10.22.68.249 "sudo -u postgres pg_dump nordabiz" > "backups/prod_$(
|
||||
|
||||
Lub backup na serwerze:
|
||||
```bash
|
||||
ssh maciejpi@10.22.68.249 "sudo -u postgres pg_dump nordabiz > /tmp/backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||
ssh maciejpi@10.22.68.249 "sudo -u postgres pg_dump -Fc nordabiz > /tmp/backup_$(date +%Y%m%d_%H%M%S).dump"
|
||||
```
|
||||
|
||||
### 3. Backup plików konfiguracyjnych
|
||||
@ -60,6 +88,11 @@ Pokaż snapshoty VM 249
|
||||
|
||||
### 6. Przywracanie z backupu
|
||||
|
||||
#### Szybkie przywracanie (skrypt DR)
|
||||
```bash
|
||||
ssh maciejpi@10.22.68.249 "sudo /var/www/nordabiznes/scripts/dr-restore.sh /var/backups/nordabiz/hourly/nordabiz_YYYYMMDD_HH.dump"
|
||||
```
|
||||
|
||||
#### DEV (Docker PostgreSQL):
|
||||
```bash
|
||||
docker exec -i nordabiz-postgres psql -U nordabiz_app -d nordabiz < backups/dev_YYYYMMDD_HHMMSS.sql
|
||||
@ -76,22 +109,59 @@ Użyj skill `proxmox-manager`:
|
||||
Przywróć VM 249 ze snapshotu backup_YYYYMMDD
|
||||
```
|
||||
|
||||
## Harmonogram backupów:
|
||||
| Typ | Częstotliwość | Retencja |
|
||||
|-----|---------------|----------|
|
||||
| DB dump | Codziennie | 7 dni |
|
||||
| VM snapshot | Przed deployment | 3 snapshoty |
|
||||
| Pełny backup | Tygodniowo | 4 tygodnie |
|
||||
## Konfiguracja cron (na serwerze PROD)
|
||||
|
||||
### Plik: `/etc/cron.d/nordabiz-backup`
|
||||
|
||||
```bash
|
||||
# Backup co godzinę (retencja 24h)
|
||||
0 * * * * postgres pg_dump -Fc nordabiz > /var/backups/nordabiz/hourly/nordabiz_$(date +\%Y\%m\%d_\%H).dump 2>> /var/log/nordabiznes/backup.log
|
||||
|
||||
# Backup dzienny o 2:00 (retencja 30 dni)
|
||||
0 2 * * * postgres pg_dump -Fc nordabiz > /var/backups/nordabiz/daily/nordabiz_$(date +\%Y\%m\%d).dump 2>> /var/log/nordabiznes/backup.log
|
||||
|
||||
# Cleanup starych hourly (24h)
|
||||
0 3 * * * root find /var/backups/nordabiz/hourly -name "*.dump" -mtime +1 -delete
|
||||
|
||||
# Cleanup starych daily (30 dni)
|
||||
0 3 * * * root find /var/backups/nordabiz/daily -name "*.dump" -mtime +30 -delete
|
||||
```
|
||||
|
||||
### Plik: `/etc/cron.d/nordabiz-offsite`
|
||||
|
||||
```bash
|
||||
# Sync daily backups do PBS o 4:00
|
||||
0 4 * * * root rsync -avz --delete /var/backups/nordabiz/daily/ maciejpi@10.22.68.127:/backup/nordabiz/daily/ 2>> /var/log/nordabiznes/backup.log
|
||||
|
||||
# Sync config do PBS o 4:30
|
||||
30 4 * * * root rsync -avz /var/backups/nordabiz/config/ maciejpi@10.22.68.127:/backup/nordabiz/config/ 2>> /var/log/nordabiznes/backup.log
|
||||
```
|
||||
|
||||
## Disaster Recovery
|
||||
|
||||
**Pełna dokumentacja:** `docs/DR-PLAYBOOK.md`
|
||||
|
||||
### Metryki SLA
|
||||
- **RTO:** 30-60 min
|
||||
- **RPO:** 1 godzina
|
||||
|
||||
### Skrypt restore
|
||||
```bash
|
||||
sudo /var/www/nordabiznes/scripts/dr-restore.sh /path/to/backup.dump
|
||||
```
|
||||
|
||||
## Przechowywanie:
|
||||
- Lokalne: `./backups/` (dodane do .gitignore)
|
||||
- Proxmox: snapshoty na storage lokalnym
|
||||
- Offsite: rozważ rsync do R11-PBS-01
|
||||
- Lokalne DEV: `./backups/` (dodane do .gitignore)
|
||||
- Lokalne PROD: `/var/backups/nordabiz/`
|
||||
- Offsite: PBS (10.22.68.127:/backup/nordabiz/)
|
||||
- Proxmox: snapshoty VM
|
||||
|
||||
## Uwagi:
|
||||
- ZAWSZE rób backup przed większymi zmianami
|
||||
- Testuj przywracanie okresowo
|
||||
- Testuj przywracanie okresowo (co kwartał)
|
||||
- Snapshoty VM są najszybsze do rollbacku
|
||||
- PostgreSQL dump jest przenośny między środowiskami
|
||||
- DEV używa Docker PostgreSQL na localhost:5433
|
||||
- PROD używa PostgreSQL na 10.22.68.249:5432
|
||||
|
||||
Data aktualizacji: 2026-02-02
|
||||
|
||||
@ -47,5 +47,10 @@
|
||||
"commit-commands@claude-plugins-official": true,
|
||||
"context7@claude-plugins-official": true,
|
||||
"code-review@claude-plugins-official": true
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -418,6 +418,24 @@ def register_blueprints(app):
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering membership blueprint: {e}")
|
||||
|
||||
# Benefits blueprint (Member Benefits / Affiliate offers)
|
||||
try:
|
||||
from blueprints.benefits import bp as benefits_bp
|
||||
app.register_blueprint(benefits_bp)
|
||||
logger.info("Registered blueprint: benefits")
|
||||
|
||||
# Create aliases for backward compatibility
|
||||
_create_endpoint_aliases(app, benefits_bp, {
|
||||
'benefits_list': 'benefits.benefits_list',
|
||||
'benefit_detail': 'benefits.benefit_detail',
|
||||
'benefit_redirect': 'benefits.benefit_redirect',
|
||||
})
|
||||
logger.info("Created benefits endpoint aliases")
|
||||
except ImportError as e:
|
||||
logger.debug(f"Blueprint benefits not yet available: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering benefits blueprint: {e}")
|
||||
|
||||
# Phase 6 (continued) + Phase 7-10: Future blueprints will be added here
|
||||
|
||||
|
||||
|
||||
@ -27,3 +27,4 @@ from . import routes_krs_api # noqa: E402, F401
|
||||
from . import routes_companies # noqa: E402, F401
|
||||
from . import routes_people # noqa: E402, F401
|
||||
from . import routes_membership # noqa: E402, F401
|
||||
from . import routes_benefits # noqa: E402, F401
|
||||
|
||||
217
blueprints/admin/routes_benefits.py
Normal file
217
blueprints/admin/routes_benefits.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""
|
||||
Admin Benefits Routes
|
||||
=====================
|
||||
|
||||
Zarządzanie korzyściami członkowskimi (oferty afiliacyjne).
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from flask import render_template, redirect, url_for, flash, request
|
||||
from flask_login import login_required
|
||||
|
||||
from . import bp
|
||||
from database import SessionLocal, Benefit, BenefitClick, SystemRole
|
||||
from utils.decorators import role_required
|
||||
|
||||
|
||||
@bp.route('/benefits')
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_benefits():
|
||||
"""Lista korzyści w panelu admina."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
benefits = db.query(Benefit).order_by(
|
||||
Benefit.is_active.desc(),
|
||||
Benefit.display_order,
|
||||
Benefit.name
|
||||
).all()
|
||||
|
||||
# Statystyki
|
||||
total_clicks = sum(b.click_count or 0 for b in benefits)
|
||||
active_count = sum(1 for b in benefits if b.is_active)
|
||||
|
||||
return render_template(
|
||||
'admin/benefits_list.html',
|
||||
benefits=benefits,
|
||||
total_clicks=total_clicks,
|
||||
active_count=active_count
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/benefits/new', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_benefits_new():
|
||||
"""Dodaj nową korzyść."""
|
||||
if request.method == 'POST':
|
||||
db = SessionLocal()
|
||||
try:
|
||||
benefit = Benefit(
|
||||
name=request.form.get('name'),
|
||||
slug=request.form.get('slug'),
|
||||
short_description=request.form.get('short_description'),
|
||||
description=request.form.get('description'),
|
||||
category=request.form.get('category'),
|
||||
regular_price=request.form.get('regular_price'),
|
||||
member_price=request.form.get('member_price'),
|
||||
discount_description=request.form.get('discount_description'),
|
||||
affiliate_url=request.form.get('affiliate_url'),
|
||||
product_url=request.form.get('product_url'),
|
||||
logo_url=request.form.get('logo_url'),
|
||||
promo_code=request.form.get('promo_code'),
|
||||
promo_code_instructions=request.form.get('promo_code_instructions'),
|
||||
commission_rate=request.form.get('commission_rate'),
|
||||
commission_duration=request.form.get('commission_duration'),
|
||||
partner_platform=request.form.get('partner_platform'),
|
||||
is_featured=request.form.get('is_featured') == 'on',
|
||||
is_active=request.form.get('is_active') == 'on',
|
||||
display_order=int(request.form.get('display_order', 0))
|
||||
)
|
||||
|
||||
# Partner since date
|
||||
partner_since_str = request.form.get('partner_since')
|
||||
if partner_since_str:
|
||||
try:
|
||||
benefit.partner_since = datetime.strptime(partner_since_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
db.add(benefit)
|
||||
db.commit()
|
||||
|
||||
flash(f'Dodano korzyść: {benefit.name}', 'success')
|
||||
return redirect(url_for('admin.admin_benefits'))
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
flash(f'Błąd: {str(e)}', 'error')
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return render_template(
|
||||
'admin/benefits_form.html',
|
||||
benefit=None,
|
||||
categories=Benefit.CATEGORY_CHOICES
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/benefits/<int:benefit_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_benefits_edit(benefit_id):
|
||||
"""Edytuj korzyść."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
benefit = db.query(Benefit).filter(Benefit.id == benefit_id).first()
|
||||
if not benefit:
|
||||
flash('Korzyść nie istnieje.', 'error')
|
||||
return redirect(url_for('admin.admin_benefits'))
|
||||
|
||||
if request.method == 'POST':
|
||||
benefit.name = request.form.get('name')
|
||||
benefit.slug = request.form.get('slug')
|
||||
benefit.short_description = request.form.get('short_description')
|
||||
benefit.description = request.form.get('description')
|
||||
benefit.category = request.form.get('category')
|
||||
benefit.regular_price = request.form.get('regular_price')
|
||||
benefit.member_price = request.form.get('member_price')
|
||||
benefit.discount_description = request.form.get('discount_description')
|
||||
benefit.affiliate_url = request.form.get('affiliate_url')
|
||||
benefit.product_url = request.form.get('product_url')
|
||||
benefit.logo_url = request.form.get('logo_url')
|
||||
benefit.promo_code = request.form.get('promo_code')
|
||||
benefit.promo_code_instructions = request.form.get('promo_code_instructions')
|
||||
benefit.commission_rate = request.form.get('commission_rate')
|
||||
benefit.commission_duration = request.form.get('commission_duration')
|
||||
benefit.partner_platform = request.form.get('partner_platform')
|
||||
benefit.is_featured = request.form.get('is_featured') == 'on'
|
||||
benefit.is_active = request.form.get('is_active') == 'on'
|
||||
benefit.display_order = int(request.form.get('display_order', 0))
|
||||
|
||||
# Partner since date
|
||||
partner_since_str = request.form.get('partner_since')
|
||||
if partner_since_str:
|
||||
try:
|
||||
benefit.partner_since = datetime.strptime(partner_since_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
db.commit()
|
||||
flash(f'Zapisano zmiany: {benefit.name}', 'success')
|
||||
return redirect(url_for('admin.admin_benefits'))
|
||||
|
||||
return render_template(
|
||||
'admin/benefits_form.html',
|
||||
benefit=benefit,
|
||||
categories=Benefit.CATEGORY_CHOICES
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/benefits/<int:benefit_id>/toggle', methods=['POST'])
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_benefits_toggle(benefit_id):
|
||||
"""Włącz/wyłącz korzyść."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
benefit = db.query(Benefit).filter(Benefit.id == benefit_id).first()
|
||||
if benefit:
|
||||
benefit.is_active = not benefit.is_active
|
||||
db.commit()
|
||||
status = 'włączona' if benefit.is_active else 'wyłączona'
|
||||
flash(f'Korzyść {benefit.name} została {status}.', 'success')
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return redirect(url_for('admin.admin_benefits'))
|
||||
|
||||
|
||||
@bp.route('/benefits/<int:benefit_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_benefits_delete(benefit_id):
|
||||
"""Usuń korzyść."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
benefit = db.query(Benefit).filter(Benefit.id == benefit_id).first()
|
||||
if benefit:
|
||||
name = benefit.name
|
||||
db.delete(benefit)
|
||||
db.commit()
|
||||
flash(f'Usunięto korzyść: {name}', 'success')
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return redirect(url_for('admin.admin_benefits'))
|
||||
|
||||
|
||||
@bp.route('/benefits/<int:benefit_id>/clicks')
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN)
|
||||
def admin_benefits_clicks(benefit_id):
|
||||
"""Historia kliknięć dla korzyści."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
benefit = db.query(Benefit).filter(Benefit.id == benefit_id).first()
|
||||
if not benefit:
|
||||
flash('Korzyść nie istnieje.', 'error')
|
||||
return redirect(url_for('admin.admin_benefits'))
|
||||
|
||||
clicks = db.query(BenefitClick).filter(
|
||||
BenefitClick.benefit_id == benefit_id
|
||||
).order_by(
|
||||
BenefitClick.clicked_at.desc()
|
||||
).limit(100).all()
|
||||
|
||||
return render_template(
|
||||
'admin/benefits_clicks.html',
|
||||
benefit=benefit,
|
||||
clicks=clicks
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
15
blueprints/benefits/__init__.py
Normal file
15
blueprints/benefits/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
"""
|
||||
Benefits Blueprint
|
||||
==================
|
||||
|
||||
Korzyści członkowskie - oferty afiliacyjne dla członków Izby.
|
||||
URL prefix: /korzysci
|
||||
|
||||
TRYB TESTOWY: Dostęp tylko dla administratorów.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
bp = Blueprint('benefits', __name__, url_prefix='/korzysci')
|
||||
|
||||
from . import routes # noqa: F401, E402
|
||||
110
blueprints/benefits/routes.py
Normal file
110
blueprints/benefits/routes.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""
|
||||
Benefits Routes
|
||||
===============
|
||||
|
||||
Korzyści członkowskie - oferty afiliacyjne dla członków Izby NORDA.
|
||||
|
||||
TRYB TESTOWY: Dostęp tylko dla administratorów.
|
||||
Po uruchomieniu produkcyjnym zmienić na @member_required.
|
||||
"""
|
||||
|
||||
from flask import render_template, redirect, request, flash, url_for, abort
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from . import bp
|
||||
from database import SessionLocal, Benefit, BenefitClick, SystemRole
|
||||
from utils.decorators import role_required
|
||||
|
||||
|
||||
@bp.route('/')
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN) # TESTOWY: tylko admin
|
||||
def benefits_list():
|
||||
"""Lista korzyści członkowskich."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
benefits = db.query(Benefit).filter(
|
||||
Benefit.is_active == True
|
||||
).order_by(
|
||||
Benefit.is_featured.desc(),
|
||||
Benefit.display_order,
|
||||
Benefit.name
|
||||
).all()
|
||||
|
||||
# Grupuj po kategorii
|
||||
categories = {}
|
||||
for benefit in benefits:
|
||||
cat = benefit.category or 'other'
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(benefit)
|
||||
|
||||
return render_template(
|
||||
'benefits/benefits_list.html',
|
||||
benefits=benefits,
|
||||
categories=categories,
|
||||
category_choices=dict(Benefit.CATEGORY_CHOICES)
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/<slug>')
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN) # TESTOWY: tylko admin
|
||||
def benefit_detail(slug):
|
||||
"""Szczegóły korzyści i przekierowanie na link afiliacyjny."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
benefit = db.query(Benefit).filter(
|
||||
Benefit.slug == slug,
|
||||
Benefit.is_active == True
|
||||
).first()
|
||||
|
||||
if not benefit:
|
||||
abort(404)
|
||||
|
||||
return render_template(
|
||||
'benefits/benefit_detail.html',
|
||||
benefit=benefit
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/<slug>/go')
|
||||
@login_required
|
||||
@role_required(SystemRole.ADMIN) # TESTOWY: tylko admin
|
||||
def benefit_redirect(slug):
|
||||
"""Przekierowanie na link afiliacyjny z logowaniem kliknięcia."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
benefit = db.query(Benefit).filter(
|
||||
Benefit.slug == slug,
|
||||
Benefit.is_active == True
|
||||
).first()
|
||||
|
||||
if not benefit:
|
||||
abort(404)
|
||||
|
||||
if not benefit.affiliate_url:
|
||||
flash('Link do tej oferty nie jest jeszcze dostępny.', 'warning')
|
||||
return redirect(url_for('benefits.benefits_list'))
|
||||
|
||||
# Loguj kliknięcie
|
||||
click = BenefitClick(
|
||||
benefit_id=benefit.id,
|
||||
user_id=current_user.id if current_user.is_authenticated else None,
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get('User-Agent', '')[:500]
|
||||
)
|
||||
db.add(click)
|
||||
|
||||
# Zwiększ licznik
|
||||
benefit.click_count = (benefit.click_count or 0) + 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return redirect(benefit.affiliate_url)
|
||||
finally:
|
||||
db.close()
|
||||
97
database.py
97
database.py
@ -4435,6 +4435,103 @@ class CompanyDataRequest(Base):
|
||||
return f"<CompanyDataRequest {self.id} [{self.status}] NIP:{self.nip}>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# MEMBER BENEFITS
|
||||
# ============================================================
|
||||
|
||||
class Benefit(Base):
|
||||
"""
|
||||
Korzyści członkowskie - oferty afiliacyjne dla członków Izby.
|
||||
Zniżki na licencje, subskrypcje, narzędzia SaaS itp.
|
||||
"""
|
||||
__tablename__ = 'benefits'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
# Podstawowe info
|
||||
name = Column(String(100), nullable=False)
|
||||
slug = Column(String(100), unique=True, nullable=False)
|
||||
short_description = Column(String(200)) # Na kartę
|
||||
description = Column(Text) # Pełny opis
|
||||
category = Column(String(50)) # np. 'productivity', 'ai', 'marketing'
|
||||
|
||||
# Ceny i oferta
|
||||
regular_price = Column(String(50)) # np. "$10/mies"
|
||||
member_price = Column(String(50)) # np. "$8/mies" lub "10% zniżki"
|
||||
discount_description = Column(String(100)) # np. "10% zniżki dla członków"
|
||||
|
||||
# Linki
|
||||
affiliate_url = Column(String(500)) # Link afiliacyjny
|
||||
product_url = Column(String(500)) # Link do strony produktu
|
||||
logo_url = Column(String(500)) # Logo produktu
|
||||
|
||||
# Kod promocyjny (opcjonalny)
|
||||
promo_code = Column(String(50))
|
||||
promo_code_instructions = Column(Text) # Jak użyć kodu
|
||||
|
||||
# QR Code (generowany przez Dub)
|
||||
qr_code_url = Column(String(500)) # URL do obrazka QR
|
||||
|
||||
# Prowizja (dla admina)
|
||||
commission_rate = Column(String(50)) # np. "25%"
|
||||
commission_duration = Column(String(50)) # np. "12 miesięcy"
|
||||
partner_platform = Column(String(100)) # np. "Dub Partners"
|
||||
partner_since = Column(Date)
|
||||
|
||||
# Status
|
||||
is_featured = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
display_order = Column(Integer, default=0)
|
||||
|
||||
# Statystyki
|
||||
click_count = Column(Integer, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Constants
|
||||
CATEGORY_CHOICES = [
|
||||
('productivity', 'Produktywność'),
|
||||
('ai', 'Sztuczna Inteligencja'),
|
||||
('marketing', 'Marketing'),
|
||||
('finance', 'Finanse'),
|
||||
('communication', 'Komunikacja'),
|
||||
('design', 'Design'),
|
||||
('development', 'Development'),
|
||||
('other', 'Inne'),
|
||||
]
|
||||
|
||||
@property
|
||||
def category_label(self):
|
||||
return dict(self.CATEGORY_CHOICES).get(self.category, self.category)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Benefit {self.id} {self.name}>"
|
||||
|
||||
|
||||
class BenefitClick(Base):
|
||||
"""
|
||||
Śledzenie kliknięć w linki afiliacyjne.
|
||||
"""
|
||||
__tablename__ = 'benefit_clicks'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
benefit_id = Column(Integer, ForeignKey('benefits.id', ondelete='CASCADE'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||||
|
||||
clicked_at = Column(DateTime, default=datetime.now)
|
||||
ip_address = Column(String(45)) # IPv6 support
|
||||
user_agent = Column(String(500))
|
||||
|
||||
# Relationships
|
||||
benefit = relationship('Benefit', backref='clicks')
|
||||
user = relationship('User')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BenefitClick {self.id} benefit={self.benefit_id}>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DATABASE INITIALIZATION
|
||||
# ============================================================
|
||||
|
||||
98
database/migrations/024_add_benefits_tables.sql
Normal file
98
database/migrations/024_add_benefits_tables.sql
Normal file
@ -0,0 +1,98 @@
|
||||
-- Migration: Add benefits tables for member affiliate offers
|
||||
-- Date: 2026-02-02
|
||||
-- Author: Claude
|
||||
|
||||
-- Create benefits table
|
||||
CREATE TABLE IF NOT EXISTS benefits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
short_description VARCHAR(200),
|
||||
description TEXT,
|
||||
category VARCHAR(50),
|
||||
regular_price VARCHAR(50),
|
||||
member_price VARCHAR(50),
|
||||
discount_description VARCHAR(100),
|
||||
affiliate_url VARCHAR(500),
|
||||
product_url VARCHAR(500),
|
||||
logo_url VARCHAR(500),
|
||||
promo_code VARCHAR(50),
|
||||
promo_code_instructions TEXT,
|
||||
commission_rate VARCHAR(50),
|
||||
commission_duration VARCHAR(50),
|
||||
partner_platform VARCHAR(100),
|
||||
partner_since DATE,
|
||||
is_featured BOOLEAN DEFAULT FALSE,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
display_order INTEGER DEFAULT 0,
|
||||
click_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create benefit_clicks table for tracking
|
||||
CREATE TABLE IF NOT EXISTS benefit_clicks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
benefit_id INTEGER NOT NULL REFERENCES benefits(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent VARCHAR(500)
|
||||
);
|
||||
|
||||
-- Create index for benefit_clicks
|
||||
CREATE INDEX IF NOT EXISTS idx_benefit_clicks_benefit_id ON benefit_clicks(benefit_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_benefit_clicks_clicked_at ON benefit_clicks(clicked_at);
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL ON TABLE benefits TO nordabiz_app;
|
||||
GRANT ALL ON TABLE benefit_clicks TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE benefits_id_seq TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE benefit_clicks_id_seq TO nordabiz_app;
|
||||
|
||||
-- Insert WisprFlow as first benefit
|
||||
INSERT INTO benefits (
|
||||
name,
|
||||
slug,
|
||||
short_description,
|
||||
description,
|
||||
category,
|
||||
regular_price,
|
||||
member_price,
|
||||
affiliate_url,
|
||||
product_url,
|
||||
commission_rate,
|
||||
commission_duration,
|
||||
partner_platform,
|
||||
partner_since,
|
||||
is_featured,
|
||||
is_active,
|
||||
display_order
|
||||
) VALUES (
|
||||
'WisprFlow',
|
||||
'wispr-flow',
|
||||
'AI do transkrypcji i notatek ze spotkań. Oszczędź czas na notowaniu.',
|
||||
'WisprFlow to narzędzie AI, które automatycznie transkrybuje Twoje spotkania i tworzy inteligentne notatki. Idealne dla przedsiębiorców, którzy chcą skupić się na rozmowie zamiast na pisaniu notatek.
|
||||
|
||||
Funkcje:
|
||||
- Automatyczna transkrypcja w czasie rzeczywistym
|
||||
- Inteligentne podsumowania spotkań
|
||||
- Wyciąganie kluczowych punktów i zadań
|
||||
- Integracja z kalendarzem
|
||||
- Wsparcie dla języka polskiego',
|
||||
'productivity',
|
||||
'$10/mies',
|
||||
'$8.50/mies (plan roczny)',
|
||||
'https://ref.wisprflow.ai/norda',
|
||||
'https://wisprflow.ai',
|
||||
'25%',
|
||||
'12 miesięcy',
|
||||
'Dub Partners',
|
||||
'2026-02-02',
|
||||
TRUE,
|
||||
TRUE,
|
||||
1
|
||||
) ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
-- Verification
|
||||
SELECT id, name, slug, is_active FROM benefits;
|
||||
121
templates/admin/benefits_clicks.html
Normal file
121
templates/admin/benefits_clicks.html
Normal file
@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Kliknięcia: {{ benefit.name }} - Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.admin-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.clicks-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.clicks-table th,
|
||||
.clicks-table td {
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.clicks-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-3xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin.admin_benefits') }}" class="back-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
Powrót do listy
|
||||
</a>
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>Kliknięcia: {{ benefit.name }}</h1>
|
||||
<p>Łącznie: {{ benefit.click_count or 0 }} kliknięć</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
{% if clicks %}
|
||||
<table class="clicks-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Użytkownik</th>
|
||||
<th>IP</th>
|
||||
<th>User Agent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for click in clicks %}
|
||||
<tr>
|
||||
<td>{{ click.clicked_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
{% if click.user %}
|
||||
{{ click.user.email }}
|
||||
{% else %}
|
||||
<em>Anonimowy</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ click.ip_address or '-' }}</td>
|
||||
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ click.user_agent[:100] if click.user_agent else '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Brak kliknięć dla tej korzyści.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
342
templates/admin/benefits_form.html
Normal file
342
templates/admin/benefits_form.html
Normal file
@ -0,0 +1,342 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if benefit %}Edytuj{% else %}Dodaj{% endif %} Korzyść - Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.form-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-sm);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-row.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.form-group label .required {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px var(--primary-bg);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group .hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="form-container">
|
||||
<a href="{{ url_for('admin.admin_benefits') }}" class="back-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
Powrót do listy
|
||||
</a>
|
||||
|
||||
<div class="admin-header">
|
||||
<h1>{% if benefit %}Edytuj korzyść: {{ benefit.name }}{% else %}Dodaj nową korzyść{% endif %}</h1>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<!-- Podstawowe info -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">Podstawowe informacje</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Nazwa <span class="required">*</span></label>
|
||||
<input type="text" name="name" value="{{ benefit.name if benefit else '' }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Slug (URL) <span class="required">*</span></label>
|
||||
<input type="text" name="slug" value="{{ benefit.slug if benefit else '' }}" required pattern="[a-z0-9-]+">
|
||||
<span class="hint">Tylko małe litery, cyfry i myślniki, np. wispr-flow</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Kategoria</label>
|
||||
<select name="category">
|
||||
<option value="">-- Wybierz --</option>
|
||||
{% for value, label in categories %}
|
||||
<option value="{{ value }}" {% if benefit and benefit.category == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Kolejność wyświetlania</label>
|
||||
<input type="number" name="display_order" value="{{ benefit.display_order if benefit else 0 }}" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row single">
|
||||
<div class="form-group">
|
||||
<label>Krótki opis (na kartę)</label>
|
||||
<input type="text" name="short_description" value="{{ benefit.short_description if benefit else '' }}" maxlength="200">
|
||||
<span class="hint">Max 200 znaków</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row single">
|
||||
<div class="form-group">
|
||||
<label>Pełny opis</label>
|
||||
<textarea name="description">{{ benefit.description if benefit else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ceny i oferta -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">Ceny i oferta</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Cena regularna</label>
|
||||
<input type="text" name="regular_price" value="{{ benefit.regular_price if benefit else '' }}" placeholder="np. $10/mies">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Cena dla członków</label>
|
||||
<input type="text" name="member_price" value="{{ benefit.member_price if benefit else '' }}" placeholder="np. $8/mies">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row single">
|
||||
<div class="form-group">
|
||||
<label>Opis zniżki</label>
|
||||
<input type="text" name="discount_description" value="{{ benefit.discount_description if benefit else '' }}" placeholder="np. 10% zniżki dla członków">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Kod promocyjny</label>
|
||||
<input type="text" name="promo_code" value="{{ benefit.promo_code if benefit else '' }}" placeholder="np. NORDA10">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Instrukcja użycia kodu</label>
|
||||
<input type="text" name="promo_code_instructions" value="{{ benefit.promo_code_instructions if benefit else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linki -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">Linki</div>
|
||||
|
||||
<div class="form-row single">
|
||||
<div class="form-group">
|
||||
<label>Link afiliacyjny <span class="required">*</span></label>
|
||||
<input type="url" name="affiliate_url" value="{{ benefit.affiliate_url if benefit else '' }}" required>
|
||||
<span class="hint">Link przez który śledzimy polecenia</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Link do strony produktu</label>
|
||||
<input type="url" name="product_url" value="{{ benefit.product_url if benefit else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>URL logo</label>
|
||||
<input type="url" name="logo_url" value="{{ benefit.logo_url if benefit else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prowizja (dla admina) -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">Dane prowizji (wewnętrzne)</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Wysokość prowizji</label>
|
||||
<input type="text" name="commission_rate" value="{{ benefit.commission_rate if benefit else '' }}" placeholder="np. 25%">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Okres prowizji</label>
|
||||
<input type="text" name="commission_duration" value="{{ benefit.commission_duration if benefit else '' }}" placeholder="np. 12 miesięcy">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Platforma partnerska</label>
|
||||
<input type="text" name="partner_platform" value="{{ benefit.partner_platform if benefit else '' }}" placeholder="np. Dub Partners">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Partner od</label>
|
||||
<input type="date" name="partner_since" value="{{ benefit.partner_since.strftime('%Y-%m-%d') if benefit and benefit.partner_since else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">Status</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" name="is_active" id="is_active" {% if not benefit or benefit.is_active %}checked{% endif %}>
|
||||
<label for="is_active">Aktywna (widoczna na stronie)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="checkbox-group">
|
||||
<input type="checkbox" name="is_featured" id="is_featured" {% if benefit and benefit.is_featured %}checked{% endif %}>
|
||||
<label for="is_featured">Polecana (wyróżniona)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('admin.admin_benefits') }}" class="btn btn-secondary">Anuluj</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if benefit %}Zapisz zmiany{% else %}Dodaj korzyść{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
299
templates/admin/benefits_list.html
Normal file
299
templates/admin/benefits_list.html
Normal file
@ -0,0 +1,299 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Korzyści Członkowskie - Admin{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.admin-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.admin-header h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.section {
|
||||
background: var(--surface);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.benefits-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.benefits-table th,
|
||||
.benefits-table td {
|
||||
padding: var(--spacing-md);
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.benefits-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.benefits-table tr:hover {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.benefit-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.benefit-logo-small {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: contain;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.benefit-logo-placeholder-small {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-active { background: #dcfce7; color: #16a34a; }
|
||||
.status-inactive { background: #f3f4f6; color: #6b7280; }
|
||||
.status-featured { background: #dbeafe; color: #2563eb; }
|
||||
|
||||
.category-badge {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
background: var(--primary-bg);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #f0f9ff;
|
||||
color: #0284c7;
|
||||
border: 1px solid #bae6fd;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #e0f2fe;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
background: #fef08a;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #fecaca;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-3xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="admin-header">
|
||||
<h1>Korzyści Członkowskie</h1>
|
||||
<a href="{{ url_for('admin.admin_benefits_new') }}" class="btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Dodaj korzyść
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ benefits|length }}</div>
|
||||
<div class="stat-label">Wszystkich</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ active_count }}</div>
|
||||
<div class="stat-label">Aktywnych</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{{ total_clicks }}</div>
|
||||
<div class="stat-label">Kliknięć</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
{% if benefits %}
|
||||
<table class="benefits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nazwa</th>
|
||||
<th>Kategoria</th>
|
||||
<th>Prowizja</th>
|
||||
<th>Kliknięcia</th>
|
||||
<th>Status</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for benefit in benefits %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="benefit-name-cell">
|
||||
{% if benefit.logo_url %}
|
||||
<img src="{{ benefit.logo_url }}" alt="" class="benefit-logo-small">
|
||||
{% else %}
|
||||
<div class="benefit-logo-placeholder-small">{{ benefit.name[0] }}</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<strong>{{ benefit.name }}</strong>
|
||||
{% if benefit.is_featured %}
|
||||
<span class="status-badge status-featured">Polecane</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="category-badge">{{ benefit.category_label }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if benefit.commission_rate %}
|
||||
{{ benefit.commission_rate }}
|
||||
{% if benefit.commission_duration %}<br><small>przez {{ benefit.commission_duration }}</small>{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ benefit.click_count or 0 }}</td>
|
||||
<td>
|
||||
{% if benefit.is_active %}
|
||||
<span class="status-badge status-active">Aktywna</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-inactive">Nieaktywna</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="actions-cell">
|
||||
<a href="{{ url_for('admin.admin_benefits_edit', benefit_id=benefit.id) }}" class="btn-small btn-edit">Edytuj</a>
|
||||
<form action="{{ url_for('admin.admin_benefits_toggle', benefit_id=benefit.id) }}" method="POST" style="display:inline;">
|
||||
<button type="submit" class="btn-small btn-toggle">
|
||||
{% if benefit.is_active %}Wyłącz{% else %}Włącz{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('admin.admin_benefits_clicks', benefit_id=benefit.id) }}" class="btn-small btn-edit">Kliknięcia</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Brak korzyści. <a href="{{ url_for('admin.admin_benefits_new') }}">Dodaj pierwszą</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
312
templates/benefits/benefit_detail.html
Normal file
312
templates/benefits/benefit_detail.html
Normal file
@ -0,0 +1,312 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ benefit.name }} - Korzyści Członkowskie{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.benefit-detail {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.benefit-detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
padding-bottom: var(--spacing-xl);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.benefit-detail-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-lg);
|
||||
object-fit: contain;
|
||||
background: #f8fafc;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.benefit-detail-logo-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.benefit-detail-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.benefit-detail-title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.benefit-detail-category {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.benefit-detail-pricing {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.benefit-detail-member-price {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.benefit-detail-regular-price {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.benefit-detail-body {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow);
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.benefit-detail-description {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.benefit-detail-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.benefit-detail-section-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.benefit-detail-section-title svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.benefit-promo-code-box {
|
||||
background: #f0f9ff;
|
||||
border: 2px dashed #0ea5e9;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.promo-code-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.promo-code-value {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
color: #0284c7;
|
||||
font-size: var(--font-size-2xl);
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.promo-code-instructions {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.benefit-detail-cta {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.benefit-cta-primary {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-lg);
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.benefit-cta-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.benefit-cta-primary svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.benefit-cta-secondary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: white;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.benefit-cta-secondary:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.commission-info {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-md);
|
||||
margin-top: var(--spacing-xl);
|
||||
font-size: var(--font-size-sm);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.commission-info strong {
|
||||
color: #78350f;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="benefit-detail">
|
||||
<a href="{{ url_for('benefits.benefits_list') }}" class="back-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
</svg>
|
||||
Powrót do listy korzyści
|
||||
</a>
|
||||
|
||||
<div class="benefit-detail-header">
|
||||
{% if benefit.logo_url %}
|
||||
<img src="{{ benefit.logo_url }}" alt="{{ benefit.name }}" class="benefit-detail-logo">
|
||||
{% else %}
|
||||
<div class="benefit-detail-logo-placeholder">{{ benefit.name[0] }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="benefit-detail-info">
|
||||
<h1 class="benefit-detail-title">{{ benefit.name }}</h1>
|
||||
<div class="benefit-detail-category">{{ benefit.category_label }}</div>
|
||||
|
||||
{% if benefit.member_price or benefit.discount_description %}
|
||||
<div class="benefit-detail-pricing">
|
||||
{% if benefit.member_price %}
|
||||
<span class="benefit-detail-member-price">{{ benefit.member_price }}</span>
|
||||
{% endif %}
|
||||
{% if benefit.regular_price %}
|
||||
<span class="benefit-detail-regular-price">{{ benefit.regular_price }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="benefit-detail-body">
|
||||
{% if benefit.description %}
|
||||
<div class="benefit-detail-description">
|
||||
{{ benefit.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if benefit.promo_code %}
|
||||
<div class="benefit-promo-code-box">
|
||||
<div class="promo-code-label">Twój kod rabatowy:</div>
|
||||
<div class="promo-code-value">{{ benefit.promo_code }}</div>
|
||||
{% if benefit.promo_code_instructions %}
|
||||
<div class="promo-code-instructions">{{ benefit.promo_code_instructions }}</div>
|
||||
{% else %}
|
||||
<div class="promo-code-instructions">Użyj tego kodu podczas składania zamówienia.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="benefit-detail-cta">
|
||||
<a href="{{ url_for('benefits.benefit_redirect', slug=benefit.slug) }}" class="benefit-cta-primary" target="_blank" rel="noopener">
|
||||
Skorzystaj z oferty
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{% if benefit.product_url %}
|
||||
<a href="{{ benefit.product_url }}" class="benefit-cta-secondary" target="_blank" rel="noopener">
|
||||
Strona produktu
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Admin info -->
|
||||
{% if current_user.is_admin %}
|
||||
<div class="commission-info">
|
||||
<strong>Info dla admina:</strong>
|
||||
{% if benefit.commission_rate %}Prowizja: {{ benefit.commission_rate }}{% endif %}
|
||||
{% if benefit.commission_duration %} przez {{ benefit.commission_duration }}{% endif %}
|
||||
{% if benefit.partner_platform %} | Platforma: {{ benefit.partner_platform }}{% endif %}
|
||||
{% if benefit.click_count %} | Kliknięć: {{ benefit.click_count }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
357
templates/benefits/benefits_list.html
Normal file
357
templates/benefits/benefits_list.html
Normal file
@ -0,0 +1,357 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Korzyści Członkowskie - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.benefits-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.benefits-header h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.benefits-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.test-mode-banner {
|
||||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.test-mode-banner svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #d97706;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.test-mode-banner .info-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.test-mode-banner .info-title {
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.test-mode-banner .info-text {
|
||||
color: #b45309;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.benefits-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.benefit-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.benefit-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.benefit-card.featured {
|
||||
border-color: var(--primary);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.benefit-card.featured::before {
|
||||
content: 'Polecane';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: -28px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
padding: 4px 36px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.benefit-card-header {
|
||||
padding: var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.benefit-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: contain;
|
||||
background: #f8fafc;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.benefit-logo-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--primary) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.benefit-title-section {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.benefit-title {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.benefit-category {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.benefit-card-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.benefit-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.benefit-pricing {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.benefit-member-price {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.benefit-regular-price {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.benefit-discount-badge {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.benefit-promo-code {
|
||||
background: #f0f9ff;
|
||||
border: 1px dashed #0ea5e9;
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.promo-code-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.promo-code-value {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
color: #0284c7;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.benefit-card-footer {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.benefit-cta {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.benefit-cta:hover {
|
||||
background: var(--primary-dark);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.benefit-cta svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-3xl);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="benefits-header">
|
||||
<h1>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
</svg>
|
||||
Korzyści Członkowskie
|
||||
</h1>
|
||||
<p>Ekskluzywne oferty i zniżki dla członków Izby Przedsiębiorców NORDA</p>
|
||||
</div>
|
||||
|
||||
<!-- Test mode banner -->
|
||||
<div class="test-mode-banner">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
<div class="info-content">
|
||||
<div class="info-title">Tryb testowy</div>
|
||||
<div class="info-text">
|
||||
Ta strona jest widoczna tylko dla administratorów. Po zakończeniu testów zostanie udostępniona wszystkim członkom Izby.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if benefits %}
|
||||
<div class="benefits-grid">
|
||||
{% for benefit in benefits %}
|
||||
<div class="benefit-card {% if benefit.is_featured %}featured{% endif %}">
|
||||
<div class="benefit-card-header">
|
||||
{% if benefit.logo_url %}
|
||||
<img src="{{ benefit.logo_url }}" alt="{{ benefit.name }}" class="benefit-logo">
|
||||
{% else %}
|
||||
<div class="benefit-logo-placeholder">{{ benefit.name[0] }}</div>
|
||||
{% endif %}
|
||||
<div class="benefit-title-section">
|
||||
<div class="benefit-title">{{ benefit.name }}</div>
|
||||
<div class="benefit-category">{{ category_choices.get(benefit.category, benefit.category) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="benefit-card-body">
|
||||
<p class="benefit-description">
|
||||
{{ benefit.short_description or benefit.description[:150] + '...' if benefit.description and benefit.description|length > 150 else benefit.description or 'Brak opisu.' }}
|
||||
</p>
|
||||
|
||||
{% if benefit.member_price or benefit.discount_description %}
|
||||
<div class="benefit-pricing">
|
||||
{% if benefit.member_price %}
|
||||
<span class="benefit-member-price">{{ benefit.member_price }}</span>
|
||||
{% endif %}
|
||||
{% if benefit.regular_price %}
|
||||
<span class="benefit-regular-price">{{ benefit.regular_price }}</span>
|
||||
{% endif %}
|
||||
{% if benefit.discount_description %}
|
||||
<span class="benefit-discount-badge">{{ benefit.discount_description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if benefit.promo_code %}
|
||||
<div class="benefit-promo-code">
|
||||
<span class="promo-code-label">Kod rabatowy:</span>
|
||||
<span class="promo-code-value">{{ benefit.promo_code }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="benefit-card-footer">
|
||||
<a href="{{ url_for('benefits.benefit_redirect', slug=benefit.slug) }}" class="benefit-cta" target="_blank" rel="noopener">
|
||||
Skorzystaj z oferty
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
</svg>
|
||||
<h3>Brak dostępnych ofert</h3>
|
||||
<p>Wkrótce pojawią się tutaj ekskluzywne korzyści dla członków Izby.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue
Block a user