From 0fe5ab1259a2876a0da4ee563a0f384b9e1bba61 Mon Sep 17 00:00:00 2001 From: Deeman Date: Wed, 18 Feb 2026 19:40:06 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 18 +++ padelnomics/scripts/dev_run.sh | 6 + padelnomics/src/padelnomics/admin/routes.py | 8 +- .../src/padelnomics/scripts/seed_dev_data.py | 62 +++++++-- .../src/padelnomics/scripts/setup_paddle.py | 131 ++++++++++++------ .../src/padelnomics/suppliers/routes.py | 11 +- .../templates/suppliers/dashboard.html | 11 +- .../suppliers/partials/dashboard_boosts.html | 12 +- .../suppliers/partials/dashboard_listing.html | 2 +- .../suppliers/partials/lead_card_error.html | 8 +- .../src/padelnomics/templates/base.html | 7 +- 11 files changed, 212 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8583824..1522a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, diff --git a/padelnomics/scripts/dev_run.sh b/padelnomics/scripts/dev_run.sh index fe75817..d1ad062 100755 --- a/padelnomics/scripts/dev_run.sh +++ b/padelnomics/scripts/dev_run.sh @@ -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" diff --git a/padelnomics/src/padelnomics/admin/routes.py b/padelnomics/src/padelnomics/admin/routes.py index c4a1d1e..c0ca40d 100644 --- a/padelnomics/src/padelnomics/admin/routes.py +++ b/padelnomics/src/padelnomics/admin/routes.py @@ -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")) diff --git a/padelnomics/src/padelnomics/scripts/seed_dev_data.py b/padelnomics/src/padelnomics/scripts/seed_dev_data.py index 58f8e9d..f63b708 100644 --- a/padelnomics/src/padelnomics/scripts/seed_dev_data.py +++ b/padelnomics/src/padelnomics/scripts/seed_dev_data.py @@ -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") diff --git a/padelnomics/src/padelnomics/scripts/setup_paddle.py b/padelnomics/src/padelnomics/scripts/setup_paddle.py index 8cd60eb..6e50409 100644 --- a/padelnomics/src/padelnomics/scripts/setup_paddle.py +++ b/padelnomics/src/padelnomics/scripts/setup_paddle.py @@ -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() diff --git a/padelnomics/src/padelnomics/suppliers/routes.py b/padelnomics/src/padelnomics/suppliers/routes.py index 60ffc5b..b87b2e4 100644 --- a/padelnomics/src/padelnomics/suppliers/routes.py +++ b/padelnomics/src/padelnomics/suppliers/routes.py @@ -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, ) diff --git a/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html b/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html index d0b0f10..3c73dd4 100644 --- a/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html +++ b/padelnomics/src/padelnomics/suppliers/templates/suppliers/dashboard.html @@ -58,7 +58,7 @@ {{ supplier.tier | upper }} -