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:
18
CHANGELOG.md
18
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
|
- **Supplier impersonation** — "Impersonate Owner" button on admin supplier
|
||||||
detail page; reuses existing `admin.impersonate` route, shown only when
|
detail page; reuses existing `admin.impersonate` route, shown only when
|
||||||
the supplier has a `claimed_by` user
|
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
|
### Added — Programmatic SEO: Content Generation Engine
|
||||||
- **Database migration 0010** — `published_scenarios`, `article_templates`,
|
- **Database migration 0010** — `published_scenarios`, `article_templates`,
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ info "Seeding development data"
|
|||||||
uv run python -m padelnomics.scripts.seed_dev_data
|
uv run python -m padelnomics.scripts.seed_dev_data
|
||||||
ok "Dev data seeded"
|
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"
|
info "Building CSS"
|
||||||
make css-build
|
make css-build
|
||||||
ok "CSS built"
|
ok "CSS built"
|
||||||
|
|||||||
@@ -312,7 +312,13 @@ async def impersonate(user_id: int):
|
|||||||
session["admin_impersonating"] = True
|
session["admin_impersonating"] = True
|
||||||
session["user_id"] = user_id
|
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")
|
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"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -336,16 +336,53 @@ def main():
|
|||||||
supplier_ids[s["slug"]] = cursor.lastrowid
|
supplier_ids[s["slug"]] = cursor.lastrowid
|
||||||
print(f" {s['name']} -> id={cursor.lastrowid}")
|
print(f" {s['name']} -> id={cursor.lastrowid}")
|
||||||
|
|
||||||
# 3. Claim 2 suppliers to dev user (growth + pro)
|
# 3. Claim paid suppliers — each gets its own owner user + subscription
|
||||||
print("\nClaiming PadelTech GmbH and CourtBuild Spain to dev@localhost...")
|
print("\nClaiming paid suppliers with owner accounts...")
|
||||||
for slug in ("padeltech-gmbh", "courtbuild-spain"):
|
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)
|
sid = supplier_ids.get(slug)
|
||||||
if 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(
|
conn.execute(
|
||||||
"UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL",
|
"UPDATE suppliers SET claimed_by = ?, claimed_at = ? WHERE id = ? AND claimed_by IS NULL",
|
||||||
(dev_user_id, now.isoformat(), sid),
|
(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
|
# 4. Seed leads
|
||||||
print(f"\nSeeding {len(LEADS)} leads...")
|
print(f"\nSeeding {len(LEADS)} leads...")
|
||||||
lead_ids = []
|
lead_ids = []
|
||||||
@@ -376,24 +413,23 @@ def main():
|
|||||||
|
|
||||||
# 5. Add credit ledger entries for claimed suppliers
|
# 5. Add credit ledger entries for claimed suppliers
|
||||||
print("\nAdding credit ledger entries...")
|
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)
|
sid = supplier_ids.get(slug)
|
||||||
if not sid:
|
if not sid:
|
||||||
continue
|
continue
|
||||||
|
is_pro = slug in ("padeltech-gmbh", "desert-padel-fze")
|
||||||
|
monthly = 100 if is_pro else 30
|
||||||
# Monthly allocation
|
# Monthly allocation
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
|
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
|
||||||
VALUES (?, ?, ?, 'monthly_allocation', 'Monthly refill', ?)""",
|
VALUES (?, ?, ?, 'monthly_allocation', 'Monthly refill', ?)""",
|
||||||
(sid, 100 if slug == "padeltech-gmbh" else 30,
|
(sid, monthly, monthly, (now - timedelta(days=28)).isoformat()),
|
||||||
100 if slug == "padeltech-gmbh" else 30,
|
|
||||||
(now - timedelta(days=28)).isoformat()),
|
|
||||||
)
|
)
|
||||||
# Admin adjustment
|
# Admin adjustment
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
|
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
|
||||||
VALUES (?, ?, ?, 'admin_adjustment', 'Welcome bonus', ?)""",
|
VALUES (?, ?, ?, 'admin_adjustment', 'Welcome bonus', ?)""",
|
||||||
(sid, 10, 110 if slug == "padeltech-gmbh" else 40,
|
(sid, 10, monthly + 10, (now - timedelta(days=25)).isoformat()),
|
||||||
(now - timedelta(days=25)).isoformat()),
|
|
||||||
)
|
)
|
||||||
print(f" {slug}: 2 ledger entries")
|
print(f" {slug}: 2 ledger entries")
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
Commands:
|
||||||
Creates products in Paddle, writes IDs to the paddle_products DB table,
|
uv run python -m padelnomics.scripts.setup_paddle # first-time: create products + webhook
|
||||||
and sets up a webhook notification destination (writing the secret to .env).
|
uv run python -m padelnomics.scripts.setup_paddle --sync # re-populate DB from existing Paddle products
|
||||||
|
|
||||||
Usage:
|
|
||||||
uv run python -m padelnomics.scripts.setup_paddle
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
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.Entities.Shared import CurrencyCode, Money, TaxCategory
|
||||||
from paddle_billing.Resources.NotificationSettings.Operations import CreateNotificationSetting
|
from paddle_billing.Resources.NotificationSettings.Operations import CreateNotificationSetting
|
||||||
from paddle_billing.Resources.Prices.Operations import CreatePrice
|
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()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -37,6 +35,8 @@ if not PADDLE_API_KEY:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Maps our internal key -> product name in Paddle.
|
||||||
|
# The name is used to match existing products on sync.
|
||||||
PRODUCTS = [
|
PRODUCTS = [
|
||||||
# Subscriptions
|
# 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
|
db_path = DATABASE_PATH
|
||||||
if not Path(db_path).exists():
|
if not Path(db_path).exists():
|
||||||
print(f"ERROR: Database not found at {db_path}. Run migrations first.")
|
print(f"ERROR: Database not found at {db_path}. Run migrations first.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA foreign_keys=ON")
|
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")
|
print(f"Creating products in {PADDLE_ENVIRONMENT}...\n")
|
||||||
|
|
||||||
for spec in PRODUCTS:
|
for spec in PRODUCTS:
|
||||||
# Create product
|
|
||||||
product = paddle.products.create(CreateProduct(
|
product = paddle.products.create(CreateProduct(
|
||||||
name=spec["name"],
|
name=spec["name"],
|
||||||
tax_category=TaxCategory.Standard,
|
tax_category=TaxCategory.Standard,
|
||||||
))
|
))
|
||||||
print(f" Product: {spec['name']} -> {product.id}")
|
print(f" Product: {spec['name']} -> {product.id}")
|
||||||
|
|
||||||
# Create price
|
|
||||||
price_kwargs = {
|
price_kwargs = {
|
||||||
"description": spec["name"],
|
"description": spec["name"],
|
||||||
"product_id": product.id,
|
"product_id": product.id,
|
||||||
@@ -191,31 +233,24 @@ def main():
|
|||||||
}
|
}
|
||||||
|
|
||||||
if spec["billing_type"] == "subscription":
|
if spec["billing_type"] == "subscription":
|
||||||
from paddle_billing.Entities.Shared import TimePeriod
|
from paddle_billing.Entities.Shared import Duration, Interval
|
||||||
price_kwargs["billing_cycle"] = TimePeriod(interval="month", frequency=1)
|
price_kwargs["billing_cycle"] = Duration(interval=Interval.Month, frequency=1)
|
||||||
|
|
||||||
price = paddle.prices.create(CreatePrice(**price_kwargs))
|
price = paddle.prices.create(CreatePrice(**price_kwargs))
|
||||||
print(f" Price: {spec['key']} = {price.id}")
|
print(f" Price: {spec['key']} = {price.id}")
|
||||||
|
|
||||||
# Write to DB
|
_write_product(
|
||||||
conn.execute(
|
conn, spec["key"], product.id, price.id,
|
||||||
"""INSERT OR REPLACE INTO paddle_products
|
spec["name"], spec["price"], spec["billing_type"],
|
||||||
(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"]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
print(f"\n✓ All products written to DB")
|
||||||
|
|
||||||
print(f"\n✓ All products written to {db_path}")
|
|
||||||
|
|
||||||
# -- Notification destination (webhook) -----------------------------------
|
# -- Notification destination (webhook) -----------------------------------
|
||||||
|
|
||||||
webhook_url = f"{BASE_URL}/billing/webhook/paddle"
|
webhook_url = f"{BASE_URL}/billing/webhook/paddle"
|
||||||
|
|
||||||
# Events the webhook handler actually processes
|
|
||||||
subscribed_events = [
|
subscribed_events = [
|
||||||
EventTypeName.SubscriptionActivated,
|
EventTypeName.SubscriptionActivated,
|
||||||
EventTypeName.SubscriptionUpdated,
|
EventTypeName.SubscriptionUpdated,
|
||||||
@@ -242,31 +277,43 @@ def main():
|
|||||||
print(f" ID: {notification_setting.id}")
|
print(f" ID: {notification_setting.id}")
|
||||||
print(f" Secret: {webhook_secret}")
|
print(f" Secret: {webhook_secret}")
|
||||||
|
|
||||||
# Write webhook secret and notification setting ID to .env
|
|
||||||
env_path = Path(".env")
|
env_path = Path(".env")
|
||||||
|
env_vars = {
|
||||||
|
"PADDLE_WEBHOOK_SECRET": webhook_secret,
|
||||||
|
"PADDLE_NOTIFICATION_SETTING_ID": notification_setting.id,
|
||||||
|
}
|
||||||
if env_path.exists():
|
if env_path.exists():
|
||||||
env_text = env_path.read_text()
|
env_text = env_path.read_text()
|
||||||
env_text = re.sub(
|
for key, value in env_vars.items():
|
||||||
r"^PADDLE_WEBHOOK_SECRET=.*$",
|
pattern = rf"^{key}=.*$"
|
||||||
f"PADDLE_WEBHOOK_SECRET={webhook_secret}",
|
replacement = f"{key}={value}"
|
||||||
env_text,
|
if re.search(pattern, env_text, flags=re.MULTILINE):
|
||||||
flags=re.MULTILINE,
|
env_text = re.sub(pattern, replacement, env_text, flags=re.MULTILINE)
|
||||||
)
|
else:
|
||||||
env_text = re.sub(
|
env_text = env_text.rstrip("\n") + f"\n{replacement}\n"
|
||||||
r"^PADDLE_NOTIFICATION_SETTING_ID=.*$",
|
|
||||||
f"PADDLE_NOTIFICATION_SETTING_ID={notification_setting.id}",
|
|
||||||
env_text,
|
|
||||||
flags=re.MULTILINE,
|
|
||||||
)
|
|
||||||
env_path.write_text(env_text)
|
env_path.write_text(env_text)
|
||||||
print(f"\n✓ PADDLE_WEBHOOK_SECRET and PADDLE_NOTIFICATION_SETTING_ID written to .env")
|
print(f"\n✓ PADDLE_WEBHOOK_SECRET and PADDLE_NOTIFICATION_SETTING_ID written to .env")
|
||||||
else:
|
else:
|
||||||
print(f"\n Add to .env:")
|
print(f"\n Add to .env:")
|
||||||
print(f" PADDLE_WEBHOOK_SECRET={webhook_secret}")
|
for key, value in env_vars.items():
|
||||||
print(f" PADDLE_NOTIFICATION_SETTING_ID={notification_setting.id}")
|
print(f" {key}={value}")
|
||||||
|
|
||||||
print("\nDone. dev_run.sh will start ngrok and update the webhook URL automatically.")
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -415,7 +415,8 @@ async def unlock_lead(lead_id: int):
|
|||||||
except InsufficientCredits as e:
|
except InsufficientCredits as e:
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"suppliers/partials/lead_card_error.html",
|
"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,
|
lead_id=lead_id,
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -660,6 +661,13 @@ async def dashboard_boosts():
|
|||||||
(supplier["id"],),
|
(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(
|
return await render_template(
|
||||||
"suppliers/partials/dashboard_boosts.html",
|
"suppliers/partials/dashboard_boosts.html",
|
||||||
supplier=supplier,
|
supplier=supplier,
|
||||||
@@ -667,4 +675,5 @@ async def dashboard_boosts():
|
|||||||
boost_options=BOOST_OPTIONS,
|
boost_options=BOOST_OPTIONS,
|
||||||
credit_packs=CREDIT_PACK_OPTIONS,
|
credit_packs=CREDIT_PACK_OPTIONS,
|
||||||
plan_features=PLAN_FEATURES,
|
plan_features=PLAN_FEATURES,
|
||||||
|
price_ids=price_ids,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<span class="dash-sidebar__tier dash-sidebar__tier--{{ supplier.tier }}">{{ supplier.tier | upper }}</span>
|
<span class="dash-sidebar__tier dash-sidebar__tier--{{ supplier.tier }}">{{ supplier.tier | upper }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="dash-nav">
|
<nav class="dash-nav" id="dash-nav">
|
||||||
<a href="{{ url_for('suppliers.dashboard', tab='overview') }}"
|
<a href="{{ url_for('suppliers.dashboard', tab='overview') }}"
|
||||||
hx-get="{{ url_for('suppliers.dashboard_overview') }}"
|
hx-get="{{ url_for('suppliers.dashboard_overview') }}"
|
||||||
hx-target="#dashboard-content"
|
hx-target="#dashboard-content"
|
||||||
@@ -96,6 +96,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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 content area -->
|
||||||
<main class="dash-main" id="dashboard-content"
|
<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 %}"
|
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 %}"
|
||||||
|
|||||||
@@ -100,10 +100,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="text-align:right">
|
<div style="text-align:right">
|
||||||
<div class="bst-boost__price">€{{ b.price }}/mo</div>
|
<div class="bst-boost__price">€{{ b.price }}/mo</div>
|
||||||
|
{% if price_ids.get(b.key) %}
|
||||||
<button type="button" class="bst-buy-btn"
|
<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
|
Activate
|
||||||
</button>
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size:0.6875rem;color:#94A3B8">Not configured</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -119,10 +123,14 @@
|
|||||||
<div class="bst-credit-card__amount">{{ cp.amount }}</div>
|
<div class="bst-credit-card__amount">{{ cp.amount }}</div>
|
||||||
<div class="bst-credit-card__label">credits</div>
|
<div class="bst-credit-card__label">credits</div>
|
||||||
<div class="bst-credit-card__price">€{{ cp.price }}</div>
|
<div class="bst-credit-card__price">€{{ cp.price }}</div>
|
||||||
|
{% if price_ids.get(cp.key) %}
|
||||||
<button type="button" class="bst-buy-btn"
|
<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
|
Buy
|
||||||
</button>
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size:0.6875rem;color:#94A3B8">Not configured</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<style>
|
<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-preview h3 { font-size: 0.9375rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; }
|
||||||
.lst-card {
|
.lst-card {
|
||||||
border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.25rem;
|
border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.25rem;
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
<div class="lf-card">
|
<div class="lf-card" style="border-color:#FCA5A5">
|
||||||
<div class="lf-error">{{ error }}</div>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
{% if config.PADDLE_CLIENT_TOKEN %}
|
{% if config.PADDLE_CLIENT_TOKEN %}
|
||||||
Paddle.Initialize({
|
Paddle.Initialize({
|
||||||
token: "{{ config.PADDLE_CLIENT_TOKEN }}",
|
token: "{{ config.PADDLE_CLIENT_TOKEN }}",
|
||||||
|
eventCallback: function(ev) {
|
||||||
|
if (ev.name === "checkout.error") console.error("Paddle checkout error:", ev.data);
|
||||||
|
},
|
||||||
checkout: {
|
checkout: {
|
||||||
settings: {
|
settings: {
|
||||||
displayMode: "overlay",
|
displayMode: "overlay",
|
||||||
@@ -36,6 +39,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
{% else %}
|
||||||
|
console.warn("Paddle: PADDLE_CLIENT_TOKEN not configured");
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: logo -->
|
<!-- 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 }}">
|
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user