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

- 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:
Maciej Pienczyn 2026-02-02 22:26:44 +01:00
parent d52aaaba9c
commit 5fd5140763
15 changed files with 2073 additions and 11 deletions

View File

@ -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

View File

@ -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:*)"
]
}
}

View File

View File

@ -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

View File

@ -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

View 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()

View 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

View 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()

View File

@ -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
# ============================================================

View 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;

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}