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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Supplier Dashboard - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Supplier Signup - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
|
||||
96
padelnomics/src/padelnomics/templates/_cookie_banner.html
Normal file
96
padelnomics/src/padelnomics/templates/_cookie_banner.html
Normal 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>
|
||||
26
padelnomics/src/padelnomics/templates/_paddle.html
Normal file
26
padelnomics/src/padelnomics/templates/_paddle.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user