feat(billing): A5 — dual-path JS templates for Paddle overlay / Stripe redirect

- New _payment_js.html: conditionally loads Paddle.js or nothing (Stripe
  uses server-side Checkout Session). Provides startCheckout() helper.
- All checkout templates use _payment_js.html instead of _paddle.html
- export.html, signup_step_4.html: Paddle.Checkout.open() → startCheckout()
- dashboard_boosts.html: inline onclick → buyItem() with server round-trip
- New /billing/checkout/item endpoint for single-item purchases (boosts, credits)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-03 15:31:52 +01:00
parent 7af9b2c82c
commit 8f0a56079f
7 changed files with 108 additions and 15 deletions

View File

@@ -175,6 +175,32 @@ async def checkout(plan: str):
return jsonify(payload)
@bp.route("/checkout/item", methods=["POST"])
@login_required
async def checkout_item():
"""Return checkout JSON for a single item (boost, credit pack, etc.).
Used by dashboard boost/credit buttons that need a server round-trip
for Stripe (Checkout Session creation) and work with Paddle overlay too.
Expects JSON body: {price_key, custom_data, success_url?}
"""
body = await request.get_json(silent=True) or {}
price_key = body.get("price_key", "")
custom_data = body.get("custom_data", {})
success_url = body.get("success_url", f"{config.BASE_URL}/suppliers/dashboard?tab=boosts")
price_id = await get_price_id(price_key)
if not price_id:
return jsonify({"error": "Product not configured."}), 400
payload = _provider().build_checkout_payload(
price_id=price_id,
custom_data=custom_data,
success_url=success_url,
)
return jsonify(payload)
@bp.route("/manage", methods=["POST"])
@login_required
async def manage():

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ t.export_title }} - {{ config.APP_NAME }}{% endblock %}
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
{% block head %}
<style>
@@ -133,11 +133,7 @@ document.getElementById('export-form').addEventListener('submit', async function
btn.textContent = '{{ t.export_btn }}';
return;
}
Paddle.Checkout.open({
items: data.items,
customData: data.customData,
settings: data.settings,
});
startCheckout(data);
btn.disabled = false;
btn.textContent = '{{ t.export_btn }}';
} catch (err) {

View File

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

View File

@@ -102,7 +102,7 @@
<div class="bst-boost__price">&euro;{{ b.price }}/mo</div>
{% if price_ids.get(b.key) %}
<button type="button" class="bst-buy-btn"
onclick="Paddle.Checkout.open({items:[{priceId:'{{ price_ids[b.key] }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
onclick="buyItem('{{ b.key }}', {supplier_id:'{{ supplier.id }}'}, this)">
{{ t.sd_bst_activate }}
</button>
{% else %}
@@ -125,7 +125,7 @@
<div class="bst-credit-card__price">&euro;{{ cp.price }}</div>
{% if price_ids.get(cp.key) %}
<button type="button" class="bst-buy-btn"
onclick="Paddle.Checkout.open({items:[{priceId:'{{ price_ids[cp.key] }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
onclick="buyItem('{{ cp.key }}', {supplier_id:'{{ supplier.id }}'}, this)">
{{ t.sd_bst_buy }}
</button>
{% else %}
@@ -160,3 +160,27 @@
</div>
</div>
</div>
<script>
function buyItem(priceKey, customData, btn) {
var label = btn.textContent;
btn.disabled = true;
btn.textContent = '...';
fetch('/billing/checkout/item', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({price_key: priceKey, custom_data: customData}),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) { alert(data.error); }
else { startCheckout(data); }
btn.disabled = false;
btn.textContent = label;
})
.catch(function() {
btn.disabled = false;
btn.textContent = label;
});
}
</script>

View File

@@ -124,11 +124,7 @@
btn.textContent = {{ t.sup_step4_checkout | tojson }};
return;
}
Paddle.Checkout.open({
items: result.data.items,
customData: result.data.customData,
settings: result.data.settings
});
startCheckout(result.data);
btn.disabled = false;
btn.textContent = {{ t.sup_step4_checkout | tojson }};
})

View File

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

View File

@@ -0,0 +1,51 @@
{# Payment JS — conditionally loads provider SDK on checkout pages.
Include via {% block paddle %}{% include "_payment_js.html" %}{% endblock %}
Paddle: loads Paddle.js SDK + initializes overlay checkout.
Stripe: no SDK needed (server-side Checkout Session + redirect). #}
{% if config.PAYMENT_PROVIDER == "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>
{% endif %}
<script>
/**
* startCheckout — dual-path checkout handler.
* Paddle: opens overlay with items/customData/settings from response JSON.
* Stripe: redirects to checkout_url from response JSON.
*/
function startCheckout(data) {
if (data.checkout_url) {
window.location.href = data.checkout_url;
} else if (data.items) {
Paddle.Checkout.open({
items: data.items,
customData: data.customData,
settings: data.settings,
});
}
}
</script>