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
|
||||
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`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 %}"
|
||||
|
||||
@@ -100,10 +100,14 @@
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div class="bst-boost__price">€{{ 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">€{{ 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user