feat: cookie consent banner, defer Paddle.js to checkout pages

- Add cookie consent banner (_cookie_banner.html) — fixed bottom bar with
  "Accept all" and "Manage preferences" (toggles for Essential/Functional);
  consent stored in cookie_consent cookie (1 year); no-JS = only essential
  cookies set (privacy-safe default)
- Add "Manage Cookies" link to footer Legal section to re-open the banner
- Extract Paddle.js init into _paddle.html partial; add {% block paddle %}
  to base.html (empty by default); override on export, supplier signup, and
  supplier dashboard pages — Paddle.js no longer loads on every page visit
- Gate ab_test() on functional cookie consent: variant picked per-request
  always, but ab_* cookie only persisted when visitor has consented
- Update privacy policy section 6: full cookie disclosure (essential,
  functional, payment categories + Umami cookieless note); fix "Plausible"
  → "Umami" in service providers list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-20 01:48:08 +01:00
parent 23ca28613e
commit a7b38339a6
9 changed files with 175 additions and 32 deletions

View File

@@ -464,13 +464,25 @@ def slugify(text: str, max_length_chars: int = 80) -> str:
# A/B Testing
# =============================================================================
def _has_functional_consent() -> bool:
"""Return True if the visitor has accepted functional cookies."""
return "functional" in request.cookies.get("cookie_consent", "")
def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
"""Assign visitor to an A/B test variant via cookie, tag Umami pageviews."""
"""Assign visitor to an A/B test variant, tag Umami pageviews.
Only persists the variant cookie when the visitor has given functional
cookie consent. Without consent a random variant is picked per-request
(so the page renders fine and Umami is tagged), but no cookie is set.
"""
def decorator(f):
@wraps(f)
async def wrapper(*args, **kwargs):
cookie_key = f"ab_{experiment}"
assigned = request.cookies.get(cookie_key)
has_consent = _has_functional_consent()
assigned = request.cookies.get(cookie_key) if has_consent else None
if assigned not in variants:
assigned = random.choice(variants)
@@ -478,7 +490,8 @@ def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
g.ab_tag = f"{experiment}-{assigned}"
response = await make_response(await f(*args, **kwargs))
response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
if has_consent:
response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
return response
return wrapper
return decorator

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}Export Business Plan - {{ config.APP_NAME }}{% endblock %}
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
{% block head %}
<style>

View File

@@ -45,7 +45,7 @@
<h2 class="text-lg mb-2">3. Information Sharing</h2>
<p>We do not sell your personal information. We may share information with:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Service providers (Resend for email, Plausible for privacy-friendly analytics)</li>
<li>Service providers (Resend for email, Umami for privacy-friendly analytics, Paddle for payment processing)</li>
<li>Law enforcement when required by law</li>
</ul>
</section>
@@ -60,9 +60,27 @@
<p>We implement industry-standard security measures including encryption, secure sessions, and regular backups.</p>
</section>
<section>
<section id="cookies">
<h2 class="text-lg mb-2">6. Cookies</h2>
<p>We use essential cookies for session management. We do not use tracking or advertising cookies.</p>
<p>We use the following cookies. You can manage your preferences at any time via the "Manage Cookies" link in the footer.</p>
<p class="mt-3 font-semibold text-sm">Essential (always active)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>session</strong> — Keeps you signed in. Secure, HttpOnly, expires after 30 days of inactivity.</li>
<li><strong>cookie_consent</strong> — Stores your cookie preferences. Expires after 1 year.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Functional (require consent)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>ab_*</strong> — Assigns you to an A/B test variant to help us improve the site. Expires after 30 days. Only set if you accept functional cookies.</li>
</ul>
<p class="mt-3 font-semibold text-sm">Payment (checkout only)</p>
<ul class="list-disc pl-6 mt-1 space-y-1">
<li><strong>Paddle.com cookies</strong> — Set by our payment provider only on pages where you initiate a purchase. Strictly necessary for processing payments. See <a href="https://www.paddle.com/legal/privacy" target="_blank" rel="noopener" style="text-decoration:underline">Paddle's Privacy Policy</a>.</li>
</ul>
<p class="mt-3">We use <a href="https://umami.is" target="_blank" rel="noopener" style="text-decoration:underline">Umami</a> for website analytics. Umami is cookieless and does not track individual users across sessions.</p>
</section>
<section>

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}Supplier Dashboard - {{ config.APP_NAME }}{% endblock %}
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
{% block head %}
<style>

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %}
{% block title %}Supplier Signup - {{ config.APP_NAME }}{% endblock %}
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
{% block head %}
<style>

View File

@@ -0,0 +1,96 @@
{# Cookie consent banner — included in base.html before </body>.
JS-driven: hidden by default, shown only if cookie_consent cookie is absent.
No-JS behaviour: banner stays hidden → only essential cookies are set (privacy-safe default). #}
<div id="cookie-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:200;background:#fff;border-top:1px solid #E2E8F0">
<div class="container-page" style="padding-top:0.875rem;padding-bottom:0.875rem">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;flex-wrap:wrap">
<div style="flex:1;min-width:260px">
<p style="font-size:0.8125rem;color:#475569;margin:0 0 4px">
We use cookies for essential site functions.
<a href="{{ url_for('public.privacy') }}#cookies" style="text-decoration:underline;color:#1D4ED8">Learn more</a>
</p>
<!-- Preferences panel, collapsed by default -->
<div id="cookie-prefs" hidden
style="margin-top:0.75rem;padding:0.75rem;border:1px solid #E2E8F0;border-radius:10px;background:#F8FAFC">
<!-- Essential row (locked) -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">
<span style="font-size:0.8125rem;font-weight:600;color:#1E293B">Essential</span>
<span style="font-size:0.75rem;color:#16A34A;font-weight:600">Always on</span>
</div>
<p style="font-size:0.75rem;color:#64748B;margin:0 0 10px">Session management. Cannot be disabled.</p>
<!-- Functional row (toggle) -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px">
<span style="font-size:0.8125rem;font-weight:600;color:#1E293B">Functional</span>
<!-- Toggle switch -->
<label style="position:relative;display:inline-block;width:36px;height:20px;cursor:pointer;flex-shrink:0">
<input type="checkbox" id="cookie-functional" style="opacity:0;width:0;height:0;position:absolute">
<span id="cookie-track" style="position:absolute;inset:0;background:#CBD5E1;border-radius:20px;transition:background 0.2s"></span>
<span id="cookie-dot" style="position:absolute;top:2px;left:2px;width:16px;height:16px;background:#fff;border-radius:50%;transition:transform 0.2s;box-shadow:0 1px 3px rgba(0,0,0,.2)"></span>
</label>
</div>
<p style="font-size:0.75rem;color:#64748B;margin:0">A/B testing to improve the site experience.</p>
</div>
</div>
<!-- Action buttons -->
<div style="display:flex;gap:8px;flex-shrink:0;align-items:flex-start;padding-top:2px">
<button type="button" id="cookie-manage-btn"
style="font-size:0.8125rem;padding:7px 14px;border:1px solid #E2E8F0;border-radius:10px;background:white;cursor:pointer;color:#475569;font-family:inherit;white-space:nowrap">
Manage preferences
</button>
<button type="button" id="cookie-accept-btn" class="btn"
style="font-size:0.8125rem;padding:7px 14px;white-space:nowrap">
Accept all
</button>
</div>
</div>
</div>
</div>
<script>
(function () {
var COOKIE_NAME = 'cookie_consent';
var MAX_AGE = 365 * 24 * 60 * 60;
var banner = document.getElementById('cookie-banner');
// Show only if no consent cookie exists yet
if (document.cookie.split(';').some(function (c) {
return c.trim().startsWith(COOKIE_NAME + '=');
})) return;
banner.style.display = 'block';
var prefs = document.getElementById('cookie-prefs');
var checkbox = document.getElementById('cookie-functional');
var track = document.getElementById('cookie-track');
var dot = document.getElementById('cookie-dot');
var manageBtn = document.getElementById('cookie-manage-btn');
var acceptBtn = document.getElementById('cookie-accept-btn');
var panelOpen = false;
function updateToggle() {
track.style.background = checkbox.checked ? '#1D4ED8' : '#CBD5E1';
dot.style.transform = checkbox.checked ? 'translateX(16px)' : 'translateX(0)';
}
checkbox.addEventListener('change', updateToggle);
manageBtn.addEventListener('click', function () {
if (!panelOpen) {
prefs.hidden = false;
manageBtn.textContent = 'Save preferences';
panelOpen = true;
} else {
var cats = ['essential'];
if (checkbox.checked) cats.push('functional');
setConsent(cats.join(','));
}
});
acceptBtn.addEventListener('click', function () {
setConsent('essential,functional');
});
function setConsent(value) {
document.cookie = COOKIE_NAME + '=' + value + ';path=/;max-age=' + MAX_AGE + ';SameSite=Lax';
banner.style.display = 'none';
}
}());
</script>

View File

@@ -0,0 +1,26 @@
{# Paddle.js — include only on pages with checkout flows via {% block paddle %}. #}
<script defer src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
{% if config.PADDLE_ENVIRONMENT == "sandbox" %}
Paddle.Environment.set("sandbox");
{% endif %}
{% if config.PADDLE_CLIENT_TOKEN %}
Paddle.Initialize({
token: "{{ config.PADDLE_CLIENT_TOKEN }}",
eventCallback: function(ev) {
if (ev.name === "checkout.error") console.error("Paddle checkout error:", ev.data);
},
checkout: {
settings: {
displayMode: "overlay",
theme: "light",
locale: "en",
}
}
});
{% else %}
console.warn("Paddle: PADDLE_CLIENT_TOKEN not configured");
{% endif %}
});
</script>

View File

@@ -19,32 +19,8 @@
<!-- Umami Analytics -->
<script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"{% if ab_tag %} data-tag="{{ ab_tag }}"{% endif %}></script>
<!-- Paddle.js -->
<script defer src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
{% if config.PADDLE_ENVIRONMENT == "sandbox" %}
Paddle.Environment.set("sandbox");
{% endif %}
{% if config.PADDLE_CLIENT_TOKEN %}
Paddle.Initialize({
token: "{{ config.PADDLE_CLIENT_TOKEN }}",
eventCallback: function(ev) {
if (ev.name === "checkout.error") console.error("Paddle checkout error:", ev.data);
},
checkout: {
settings: {
displayMode: "overlay",
theme: "light",
locale: "en",
}
}
});
{% else %}
console.warn("Paddle: PADDLE_CLIENT_TOKEN not configured");
{% endif %}
});
</script>
<!-- Paddle.js (only on checkout pages via block override) -->
{% block paddle %}{% endblock %}
<!-- SEO defaults (child templates may override via block head) -->
<link rel="canonical" href="{{ config.BASE_URL }}{{ request.path }}">
@@ -168,6 +144,7 @@
<li><a href="{{ url_for('public.terms') }}">Terms</a></li>
<li><a href="{{ url_for('public.privacy') }}">Privacy</a></li>
<li><a href="{{ url_for('public.imprint') }}">Imprint</a></li>
<li><a href="#" onclick="document.cookie='cookie_consent=;path=/;max-age=0';location.reload();return false">Manage Cookies</a></li>
</ul>
</div>
<div>
@@ -203,5 +180,7 @@
</script>
{% block scripts %}{% endblock %}
{% include "_cookie_banner.html" %}
</body>
</html>