fix supplier dashboard UX, Paddle integration, and dev tooling

Supplier dashboard: impersonate redirects to supplier dashboard when user
owns a supplier, sidenav active tab updates on click, listing preview
capped at 420px, insufficient-credits error shows balance and buy CTA,
boost buy buttons resolve Paddle price IDs server-side.

Dev tooling: dev_run.sh kills child processes on Ctrl-C (process
substitution fix), syncs Paddle products to DB on each run,
setup_paddle.py gains --sync mode and fixes Duration/Interval SDK change.

Seed data: each claimed supplier gets its own owner user and subscription.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-18 19:40:06 +01:00
parent 891b875cd1
commit 0fe5ab1259
11 changed files with 212 additions and 64 deletions

View File

@@ -13,6 +13,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Supplier impersonation** — "Impersonate Owner" button on admin supplier
detail page; reuses existing `admin.impersonate` route, shown only when
the supplier has a `claimed_by` user
- **Seed data: supplier owner accounts** — each claimed supplier now gets its
own user + active subscription for realistic impersonation testing
### Fixed
- **Insufficient credits UX** — unlock error now shows credit balance, cost,
and a "Buy Credits" CTA linking to Boost & Upsells tab (was a collapsed
red error message with no action)
- **Supplier dashboard sidenav** — active tab highlight now updates on click
(was only correct on initial page load)
- **Boost/credit buy buttons used wrong price IDs** — dashboard boosts tab
passed internal keys (`boost_logo`, `credits_25`) as Paddle `priceId`
instead of resolved Paddle price IDs; buttons now show "Not configured"
when Paddle products aren't set up
- **Listing preview card stretched full width** — constrained to 420px to
match actual directory card proportions
- **Impersonate redirects to supplier dashboard** — impersonating a user who
owns a supplier now lands on `/suppliers/dashboard` instead of the generic
demand-side dashboard
### Added — Programmatic SEO: Content Generation Engine
- **Database migration 0010** — `published_scenarios`, `article_templates`,

View File

@@ -59,6 +59,12 @@ info "Seeding development data"
uv run python -m padelnomics.scripts.seed_dev_data
ok "Dev data seeded"
if [ -n "$PADDLE_API_KEY" ]; then
info "Syncing Paddle products to DB"
uv run python -m padelnomics.scripts.setup_paddle --sync
ok "Paddle products synced"
fi
info "Building CSS"
make css-build
ok "CSS built"

View File

@@ -311,8 +311,14 @@ async def impersonate(user_id: int):
# Store admin session so we can return
session["admin_impersonating"] = True
session["user_id"] = user_id
# Redirect to supplier dashboard if user owns a supplier, otherwise generic
supplier = await fetch_one(
"SELECT id FROM suppliers WHERE claimed_by = ? LIMIT 1", (user_id,)
)
await flash(f"Now impersonating {user['email']}. Return to admin to stop.", "warning")
if supplier:
return redirect(url_for("suppliers.dashboard"))
return redirect(url_for("dashboard.index"))

View File

@@ -336,15 +336,52 @@ def main():
supplier_ids[s["slug"]] = cursor.lastrowid
print(f" {s['name']} -> id={cursor.lastrowid}")
# 3. Claim 2 suppliers to dev user (growth + pro)
print("\nClaiming PadelTech GmbH and CourtBuild Spain to dev@localhost...")
for slug in ("padeltech-gmbh", "courtbuild-spain"):
# 3. Claim paid suppliers — each gets its own owner user + subscription
print("\nClaiming paid suppliers with owner accounts...")
claimed_suppliers = [
("padeltech-gmbh", "supplier_pro", "hans@padeltech.example.com", "Hans Weber"),
("courtbuild-spain", "supplier_growth", "maria@courtbuild.example.com", "Maria Garcia"),
("desert-padel-fze", "supplier_pro", "ahmed@desertpadel.example.com", "Ahmed Al-Rashid"),
]
period_end = (now + timedelta(days=30)).isoformat()
for slug, plan, email, name in claimed_suppliers:
sid = supplier_ids.get(slug)
if sid:
conn.execute(
"UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL",
(dev_user_id, now.isoformat(), sid),
if not sid:
continue
# Create or find owner user
existing_owner = conn.execute(
"SELECT id FROM users WHERE email = ?", (email,)
).fetchone()
if existing_owner:
owner_id = existing_owner["id"]
else:
cursor = conn.execute(
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
(email, name, now.isoformat()),
)
owner_id = cursor.lastrowid
# Claim the supplier
conn.execute(
"UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL",
(owner_id, now.isoformat(), sid),
)
# Create active subscription
existing_sub = conn.execute(
"SELECT id FROM subscriptions WHERE user_id = ?", (owner_id,)
).fetchone()
if not existing_sub:
conn.execute(
"""INSERT INTO subscriptions
(user_id, plan, status, paddle_customer_id, paddle_subscription_id,
current_period_end, created_at)
VALUES (?, ?, 'active', ?, ?, ?, ?)""",
(owner_id, plan, f"ctm_dev_{slug}", f"sub_dev_{slug}",
period_end, now.isoformat()),
)
print(f" {slug} -> owner {email} ({plan})")
# 4. Seed leads
print(f"\nSeeding {len(LEADS)} leads...")
@@ -376,24 +413,23 @@ def main():
# 5. Add credit ledger entries for claimed suppliers
print("\nAdding credit ledger entries...")
for slug in ("padeltech-gmbh", "courtbuild-spain"):
for slug in ("padeltech-gmbh", "courtbuild-spain", "desert-padel-fze"):
sid = supplier_ids.get(slug)
if not sid:
continue
is_pro = slug in ("padeltech-gmbh", "desert-padel-fze")
monthly = 100 if is_pro else 30
# Monthly allocation
conn.execute(
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
VALUES (?, ?, ?, 'monthly_allocation', 'Monthly refill', ?)""",
(sid, 100 if slug == "padeltech-gmbh" else 30,
100 if slug == "padeltech-gmbh" else 30,
(now - timedelta(days=28)).isoformat()),
(sid, monthly, monthly, (now - timedelta(days=28)).isoformat()),
)
# Admin adjustment
conn.execute(
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
VALUES (?, ?, ?, 'admin_adjustment', 'Welcome bonus', ?)""",
(sid, 10, 110 if slug == "padeltech-gmbh" else 40,
(now - timedelta(days=25)).isoformat()),
(sid, 10, monthly + 10, (now - timedelta(days=25)).isoformat()),
)
print(f" {slug}: 2 ledger entries")

View File

@@ -1,12 +1,9 @@
"""
Create all Paddle products, prices, and webhook notification destination.
Create or sync Paddle products, prices, and webhook notification destination.
Run once per environment (sandbox, then production).
Creates products in Paddle, writes IDs to the paddle_products DB table,
and sets up a webhook notification destination (writing the secret to .env).
Usage:
uv run python -m padelnomics.scripts.setup_paddle
Commands:
uv run python -m padelnomics.scripts.setup_paddle # first-time: create products + webhook
uv run python -m padelnomics.scripts.setup_paddle --sync # re-populate DB from existing Paddle products
"""
import os
@@ -23,7 +20,8 @@ from paddle_billing.Entities.NotificationSettings.NotificationSettingType import
from paddle_billing.Entities.Shared import CurrencyCode, Money, TaxCategory
from paddle_billing.Resources.NotificationSettings.Operations import CreateNotificationSetting
from paddle_billing.Resources.Prices.Operations import CreatePrice
from paddle_billing.Resources.Products.Operations import CreateProduct
from paddle_billing.Resources.Products.Operations import CreateProduct, ListProducts
from paddle_billing.Resources.Products.Operations.List.Includes import Includes
load_dotenv()
@@ -37,6 +35,8 @@ if not PADDLE_API_KEY:
sys.exit(1)
# Maps our internal key -> product name in Paddle.
# The name is used to match existing products on sync.
PRODUCTS = [
# Subscriptions
{
@@ -159,31 +159,73 @@ PRODUCTS = [
},
]
# Build name -> spec lookup
_PRODUCT_BY_NAME = {p["name"]: p for p in PRODUCTS}
def main():
env = Environment.SANDBOX if PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
paddle = PaddleClient(PADDLE_API_KEY, options=Options(env))
def _open_db():
db_path = DATABASE_PATH
if not Path(db_path).exists():
print(f"ERROR: Database not found at {db_path}. Run migrations first.")
sys.exit(1)
conn = sqlite3.connect(db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def _write_product(conn, key, product_id, price_id, name, price_cents, billing_type):
conn.execute(
"""INSERT OR REPLACE INTO paddle_products
(key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(key, product_id, price_id, name, price_cents, "EUR", billing_type),
)
def sync(paddle, conn):
"""Fetch existing products from Paddle and re-populate paddle_products table."""
print(f"Syncing products from Paddle ({PADDLE_ENVIRONMENT})...\n")
products = paddle.products.list(ListProducts(includes=[Includes.Prices]))
matched = 0
for product in products:
spec = _PRODUCT_BY_NAME.get(product.name)
if not spec:
continue
if not product.prices or len(product.prices) == 0:
print(f" SKIP {spec['key']}: no prices on {product.id}")
continue
# Use the first active price
price = product.prices[0]
_write_product(
conn, spec["key"], product.id, price.id,
spec["name"], spec["price"], spec["billing_type"],
)
matched += 1
print(f" {spec['key']}: {product.id} / {price.id}")
conn.commit()
if matched == 0:
print("\nNo matching products found in Paddle. Run without --sync first.")
else:
print(f"\n{matched}/{len(PRODUCTS)} products synced to DB")
def create(paddle, conn):
"""Create new products and prices in Paddle, write to DB, set up webhook."""
print(f"Creating products in {PADDLE_ENVIRONMENT}...\n")
for spec in PRODUCTS:
# Create product
product = paddle.products.create(CreateProduct(
name=spec["name"],
tax_category=TaxCategory.Standard,
))
print(f" Product: {spec['name']} -> {product.id}")
# Create price
price_kwargs = {
"description": spec["name"],
"product_id": product.id,
@@ -191,31 +233,24 @@ def main():
}
if spec["billing_type"] == "subscription":
from paddle_billing.Entities.Shared import TimePeriod
price_kwargs["billing_cycle"] = TimePeriod(interval="month", frequency=1)
from paddle_billing.Entities.Shared import Duration, Interval
price_kwargs["billing_cycle"] = Duration(interval=Interval.Month, frequency=1)
price = paddle.prices.create(CreatePrice(**price_kwargs))
print(f" Price: {spec['key']} = {price.id}")
# Write to DB
conn.execute(
"""INSERT OR REPLACE INTO paddle_products
(key, paddle_product_id, paddle_price_id, name, price_cents, currency, billing_type)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(spec["key"], product.id, price.id, spec["name"],
spec["price"], "EUR", spec["billing_type"]),
_write_product(
conn, spec["key"], product.id, price.id,
spec["name"], spec["price"], spec["billing_type"],
)
conn.commit()
conn.close()
print(f"\n✓ All products written to {db_path}")
print(f"\n✓ All products written to DB")
# -- Notification destination (webhook) -----------------------------------
webhook_url = f"{BASE_URL}/billing/webhook/paddle"
# Events the webhook handler actually processes
subscribed_events = [
EventTypeName.SubscriptionActivated,
EventTypeName.SubscriptionUpdated,
@@ -242,31 +277,43 @@ def main():
print(f" ID: {notification_setting.id}")
print(f" Secret: {webhook_secret}")
# Write webhook secret and notification setting ID to .env
env_path = Path(".env")
env_vars = {
"PADDLE_WEBHOOK_SECRET": webhook_secret,
"PADDLE_NOTIFICATION_SETTING_ID": notification_setting.id,
}
if env_path.exists():
env_text = env_path.read_text()
env_text = re.sub(
r"^PADDLE_WEBHOOK_SECRET=.*$",
f"PADDLE_WEBHOOK_SECRET={webhook_secret}",
env_text,
flags=re.MULTILINE,
)
env_text = re.sub(
r"^PADDLE_NOTIFICATION_SETTING_ID=.*$",
f"PADDLE_NOTIFICATION_SETTING_ID={notification_setting.id}",
env_text,
flags=re.MULTILINE,
)
for key, value in env_vars.items():
pattern = rf"^{key}=.*$"
replacement = f"{key}={value}"
if re.search(pattern, env_text, flags=re.MULTILINE):
env_text = re.sub(pattern, replacement, env_text, flags=re.MULTILINE)
else:
env_text = env_text.rstrip("\n") + f"\n{replacement}\n"
env_path.write_text(env_text)
print(f"\n✓ PADDLE_WEBHOOK_SECRET and PADDLE_NOTIFICATION_SETTING_ID written to .env")
else:
print(f"\n Add to .env:")
print(f" PADDLE_WEBHOOK_SECRET={webhook_secret}")
print(f" PADDLE_NOTIFICATION_SETTING_ID={notification_setting.id}")
for key, value in env_vars.items():
print(f" {key}={value}")
print("\nDone. dev_run.sh will start ngrok and update the webhook URL automatically.")
def main():
env = Environment.SANDBOX if PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
paddle = PaddleClient(PADDLE_API_KEY, options=Options(env))
conn = _open_db()
try:
if "--sync" in sys.argv:
sync(paddle, conn)
else:
create(paddle, conn)
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@@ -415,7 +415,8 @@ async def unlock_lead(lead_id: int):
except InsufficientCredits as e:
return await render_template(
"suppliers/partials/lead_card_error.html",
error=f"Not enough credits. You have {e.balance}, need {e.required}.",
balance=e.balance,
required=e.required,
lead_id=lead_id,
)
except ValueError as e:
@@ -660,6 +661,13 @@ async def dashboard_boosts():
(supplier["id"],),
)
# Resolve Paddle price IDs for buy buttons
price_ids = {}
for b in BOOST_OPTIONS:
price_ids[b["key"]] = await get_paddle_price(b["key"])
for cp in CREDIT_PACK_OPTIONS:
price_ids[cp["key"]] = await get_paddle_price(cp["key"])
return await render_template(
"suppliers/partials/dashboard_boosts.html",
supplier=supplier,
@@ -667,4 +675,5 @@ async def dashboard_boosts():
boost_options=BOOST_OPTIONS,
credit_packs=CREDIT_PACK_OPTIONS,
plan_features=PLAN_FEATURES,
price_ids=price_ids,
)

View File

@@ -58,7 +58,7 @@
<span class="dash-sidebar__tier dash-sidebar__tier--{{ supplier.tier }}">{{ supplier.tier | upper }}</span>
</div>
<nav class="dash-nav">
<nav class="dash-nav" id="dash-nav">
<a href="{{ url_for('suppliers.dashboard', tab='overview') }}"
hx-get="{{ url_for('suppliers.dashboard_overview') }}"
hx-target="#dashboard-content"
@@ -96,6 +96,15 @@
</div>
</aside>
<script>
document.getElementById("dash-nav").addEventListener("click", function(e) {
var link = e.target.closest("a");
if (!link) return;
this.querySelectorAll("a").forEach(function(a) { a.classList.remove("active"); });
link.classList.add("active");
});
</script>
<!-- Main content area -->
<main class="dash-main" id="dashboard-content"
hx-get="{% if active_tab == 'leads' %}{{ url_for('suppliers.dashboard_leads') }}{% elif active_tab == 'listing' %}{{ url_for('suppliers.dashboard_listing') }}{% elif active_tab == 'boosts' %}{{ url_for('suppliers.dashboard_boosts') }}{% else %}{{ url_for('suppliers.dashboard_overview') }}{% endif %}"

View File

@@ -100,10 +100,14 @@
</div>
<div style="text-align:right">
<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:'{{ b.key }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
onclick="Paddle.Checkout.open({items:[{priceId:'{{ price_ids[b.key] }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
Activate
</button>
{% else %}
<span style="font-size:0.6875rem;color:#94A3B8">Not configured</span>
{% endif %}
</div>
</div>
{% endif %}
@@ -119,10 +123,14 @@
<div class="bst-credit-card__amount">{{ cp.amount }}</div>
<div class="bst-credit-card__label">credits</div>
<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:'{{ cp.key }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
onclick="Paddle.Checkout.open({items:[{priceId:'{{ price_ids[cp.key] }}',quantity:1}],customData:{supplier_id:'{{ supplier.id }}'}})">
Buy
</button>
{% else %}
<span style="font-size:0.6875rem;color:#94A3B8">Not configured</span>
{% endif %}
</div>
{% endfor %}
</div>

View File

@@ -1,5 +1,5 @@
<style>
.lst-preview { background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem; margin-bottom: 1.5rem; }
.lst-preview { background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem; margin-bottom: 1.5rem; max-width: 420px; }
.lst-preview h3 { font-size: 0.9375rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; }
.lst-card {
border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.25rem;

View File

@@ -1,3 +1,7 @@
<div class="lf-card">
<div class="lf-error">{{ error }}</div>
<div class="lf-card" style="border-color:#FCA5A5">
<div style="text-align:center;padding:0.75rem 0">
<p style="font-size:0.8125rem;font-weight:600;color:#DC2626;margin:0 0 4px">Not enough credits</p>
<p style="font-size:0.75rem;color:#64748B;margin:0 0 12px">You have <strong>{{ balance }}</strong> credits, this lead costs <strong>{{ required }}</strong>.</p>
<a href="{{ url_for('suppliers.dashboard', tab='boosts') }}" class="lf-unlock-btn" style="display:inline-block;text-decoration:none">Buy Credits</a>
</div>
</div>

View File

@@ -28,6 +28,9 @@
{% 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",
@@ -36,6 +39,8 @@
}
}
});
{% else %}
console.warn("Paddle: PADDLE_CLIENT_TOKEN not configured");
{% endif %}
</script>
@@ -51,7 +56,7 @@
</div>
<!-- Center: logo -->
<a href="{{ url_for('dashboard.index') if user else url_for('public.landing') }}" class="nav-logo">
<a href="{{ url_for('public.landing') }}" class="nav-logo">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}">
</a>