add programmatic SEO content engine with article generation pipeline and tests

Adds a complete content generation system for producing SEO articles
at scale with embedded financial scenario widgets. Includes DB schema
(published_scenarios, article_templates, template_data, articles with
FTS5), bulk generation pipeline with staggered publish dates, admin CRUD
for templates/scenarios/articles, public markets hub with HTMX filtering,
catch-all article serving from pre-rendered static HTML, sitemap
integration, and 94 pytest tests covering the full stack.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-18 16:40:11 +01:00
parent 0b218f35ca
commit 61bf855103
33 changed files with 3733 additions and 3 deletions

View File

@@ -6,6 +6,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Added — Programmatic SEO: Content Generation Engine
- **Database migration 0010** — `published_scenarios`, `article_templates`,
`template_data`, `articles` tables with FTS5 full-text search on articles;
content-sync triggers for INSERT/UPDATE/DELETE
- **Article template system** — parameterized content recipes with
`input_schema` (JSON field definitions), `url_pattern`, `title_pattern`,
`body_template` (Markdown + Jinja2); supports multiple content types
(only `calculator` built now)
- **Template data rows** — per-city/region input data that feeds the
generation pipeline; manual add or bulk CSV upload
- **Bulk generation pipeline** — `POST /admin/templates/<id>/generate`
computes financial scenarios from data rows via `validate_state()` +
`calc()`, renders Markdown with baked scenario cards, writes static HTML
to `data/content/_build/`, creates articles with staggered publish dates
- **Published scenarios** — standalone financial widgets with pre-computed
`calc_json`; embeddable in articles via `[scenario:slug]` markers with
section variants (`:capex`, `:operating`, `:cashflow`, `:returns`, `:full`)
- **Scenario widget templates** — 6 partial templates (summary, CAPEX
breakdown, revenue & OPEX, 5-year projection, returns & financing,
full combined) with dark navy header bar, Commit Mono numbers, responsive
metric grids, and "Try with your own numbers" CTA
- **Static file rendering** — articles rendered to `data/content/_build/`
as pre-baked HTML; DB stores metadata only, no body content in DB
- **Content blueprint** — catch-all route serves published articles by
`url_path`; registered last to avoid path collisions
- **Markets hub** (`/markets`) — public search + country/region filter page
for discovering articles; HTMX live filtering via FTS5 queries
- **Admin template CRUD** — list, create, edit, delete article templates;
view/add/upload/delete data rows; bulk generation form with start date
and articles-per-day controls
- **Admin scenario CRUD** — list, create (with curated calculator form),
edit + recalculate, delete, preview all widget sections
- **Admin article management** — list with status badges (draft/published/
scheduled), create manual articles (Markdown textarea), edit, delete,
publish/unpublish toggle, rebuild single or all articles
- **Rebuild system** — `POST /admin/articles/<id>/rebuild` and
`/admin/rebuild-all` re-render article HTML from source (template+data
or markdown file) with fresh scenario `calc_json`
- **Article detail page** — SEO meta tags (description, og:title,
og:description, og:type=article, og:image, canonical), article
typography styles, bottom CTA linking to planner
- **Scenario widget CSS** — `.scenario-widget` component styles in
`input.css` with responsive table scroll and metric grid collapse;
`.article-body` typography for headings, paragraphs, lists, blockquotes,
tables, code blocks
- **`slugify()` utility** — added to `core.py` for URL-safe slug generation
- **`mistune` dependency** — Markdown → HTML rendering for articles
- **Sitemap** — `/markets` and all published articles added to
`/sitemap.xml` (only articles with `published_at <= now`)
- **Footer** — "Markets" link added to Product column
- **Admin dashboard** — Templates, Scenarios, Articles quick-link buttons
- **Path collision prevention** — `RESERVED_PREFIXES` validation rejects
article `url_path` values that conflict with existing routes
### Added ### Added
- **Dev setup script** (`scripts/dev_setup.sh`) — interactive bootstrap that - **Dev setup script** (`scripts/dev_setup.sh`) — interactive bootstrap that
checks prerequisites, installs deps, creates `.env` with auto-generated checks prerequisites, installs deps, creates `.env` with auto-generated

View File

@@ -12,6 +12,7 @@ dependencies = [
"jinja2>=3.1.0", "jinja2>=3.1.0",
"hypercorn>=0.17.0", "hypercorn>=0.17.0",
"paddle-python-sdk>=1.13.0", "paddle-python-sdk>=1.13.0",
"mistune>=3.0.0",
"resend>=2.22.0", "resend>=2.22.0",
"weasyprint>=68.1", "weasyprint>=68.1",
] ]

View File

@@ -1,14 +1,18 @@
""" """
Admin domain: password-protected admin panel for managing users, tasks, etc. Admin domain: password-protected admin panel for managing users, tasks, etc.
""" """
import csv
import io
import json
import secrets import secrets
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
import mistune
from quart import Blueprint, flash, redirect, render_template, request, session, url_for from quart import Blueprint, flash, redirect, render_template, request, session, url_for
from ..core import config, csrf_protect, execute, fetch_all, fetch_one, transaction from ..core import config, csrf_protect, execute, execute_many, fetch_all, fetch_one, slugify, transaction
# Blueprint with its own template folder # Blueprint with its own template folder
bp = Blueprint( bp = Blueprint(
@@ -851,3 +855,836 @@ async def feedback():
LIMIT 200""" LIMIT 200"""
) )
return await render_template("admin/feedback.html", feedback_list=feedback_list) return await render_template("admin/feedback.html", feedback_list=feedback_list)
# =============================================================================
# Article Template Management
# =============================================================================
@bp.route("/templates")
@admin_required
async def templates():
"""List article templates."""
template_list = await fetch_all(
"SELECT * FROM article_templates ORDER BY created_at DESC"
)
# Attach data row counts
for t in template_list:
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ?", (t["id"],)
)
t["data_count"] = row["cnt"] if row else 0
row = await fetch_one(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NOT NULL",
(t["id"],),
)
t["generated_count"] = row["cnt"] if row else 0
return await render_template("admin/templates.html", templates=template_list)
@bp.route("/templates/new", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def template_new():
"""Create a new article template."""
if request.method == "POST":
form = await request.form
name = form.get("name", "").strip()
template_slug = form.get("slug", "").strip() or slugify(name)
content_type = form.get("content_type", "calculator")
input_schema = form.get("input_schema", "[]").strip()
url_pattern = form.get("url_pattern", "").strip()
title_pattern = form.get("title_pattern", "").strip()
meta_description_pattern = form.get("meta_description_pattern", "").strip()
body_template = form.get("body_template", "").strip()
if not name or not url_pattern or not title_pattern or not body_template:
await flash("Name, URL pattern, title pattern, and body template are required.", "error")
return await render_template("admin/template_form.html", data=dict(form), editing=False)
# Validate input_schema is valid JSON
try:
json.loads(input_schema)
except json.JSONDecodeError:
await flash("Input schema must be valid JSON.", "error")
return await render_template("admin/template_form.html", data=dict(form), editing=False)
existing = await fetch_one(
"SELECT 1 FROM article_templates WHERE slug = ?", (template_slug,)
)
if existing:
await flash(f"Slug '{template_slug}' already exists.", "error")
return await render_template("admin/template_form.html", data=dict(form), editing=False)
template_id = await execute(
"""INSERT INTO article_templates
(name, slug, content_type, input_schema, url_pattern,
title_pattern, meta_description_pattern, body_template)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(name, template_slug, content_type, input_schema, url_pattern,
title_pattern, meta_description_pattern, body_template),
)
await flash(f"Template '{name}' created.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
return await render_template("admin/template_form.html", data={}, editing=False)
@bp.route("/templates/<int:template_id>/edit", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def template_edit(template_id: int):
"""Edit an article template."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
if request.method == "POST":
form = await request.form
name = form.get("name", "").strip()
input_schema = form.get("input_schema", "[]").strip()
url_pattern = form.get("url_pattern", "").strip()
title_pattern = form.get("title_pattern", "").strip()
meta_description_pattern = form.get("meta_description_pattern", "").strip()
body_template = form.get("body_template", "").strip()
if not name or not url_pattern or not title_pattern or not body_template:
await flash("Name, URL pattern, title pattern, and body template are required.", "error")
return await render_template(
"admin/template_form.html", data=dict(form), editing=True, template_id=template_id,
)
try:
json.loads(input_schema)
except json.JSONDecodeError:
await flash("Input schema must be valid JSON.", "error")
return await render_template(
"admin/template_form.html", data=dict(form), editing=True, template_id=template_id,
)
now = datetime.utcnow().isoformat()
await execute(
"""UPDATE article_templates
SET name = ?, input_schema = ?, url_pattern = ?,
title_pattern = ?, meta_description_pattern = ?,
body_template = ?, updated_at = ?
WHERE id = ?""",
(name, input_schema, url_pattern, title_pattern,
meta_description_pattern, body_template, now, template_id),
)
await flash("Template updated.", "success")
return redirect(url_for("admin.templates"))
return await render_template(
"admin/template_form.html", data=dict(template), editing=True, template_id=template_id,
)
@bp.route("/templates/<int:template_id>/delete", methods=["POST"])
@admin_required
@csrf_protect
async def template_delete(template_id: int):
"""Delete an article template."""
await execute("DELETE FROM article_templates WHERE id = ?", (template_id,))
await flash("Template deleted.", "success")
return redirect(url_for("admin.templates"))
# =============================================================================
# Template Data Management
# =============================================================================
@bp.route("/templates/<int:template_id>/data")
@admin_required
async def template_data(template_id: int):
"""View data rows for a template."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
data_rows = await fetch_all(
"""SELECT td.*, a.title as article_title, a.url_path as article_url,
ps.slug as scenario_slug
FROM template_data td
LEFT JOIN articles a ON a.id = td.article_id
LEFT JOIN published_scenarios ps ON ps.id = td.scenario_id
WHERE td.template_id = ?
ORDER BY td.created_at DESC""",
(template_id,),
)
# Pre-parse data_json for display in template
for row in data_rows:
try:
row["parsed_data"] = json.loads(row["data_json"])
except (json.JSONDecodeError, TypeError):
row["parsed_data"] = {}
schema = json.loads(template["input_schema"])
return await render_template(
"admin/template_data.html",
template=template,
data_rows=data_rows,
schema=schema,
)
@bp.route("/templates/<int:template_id>/data/add", methods=["POST"])
@admin_required
@csrf_protect
async def template_data_add(template_id: int):
"""Add a single data row."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
form = await request.form
schema = json.loads(template["input_schema"])
data = {}
for field in schema:
val = form.get(field["name"], "").strip()
if field.get("field_type") in ("number", "float"):
try:
data[field["name"]] = float(val) if val else 0
except ValueError:
data[field["name"]] = 0
else:
data[field["name"]] = val
await execute(
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
(template_id, json.dumps(data)),
)
await flash("Data row added.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
@bp.route("/templates/<int:template_id>/data/upload", methods=["POST"])
@admin_required
@csrf_protect
async def template_data_upload(template_id: int):
"""Bulk upload data rows from CSV."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
files = await request.files
csv_file = files.get("csv_file")
if not csv_file:
await flash("No CSV file uploaded.", "error")
return redirect(url_for("admin.template_data", template_id=template_id))
content = (await csv_file.read()).decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(content))
rows_added = 0
for row in reader:
data = {k.strip(): v.strip() for k, v in row.items() if k and v}
if data:
await execute(
"INSERT INTO template_data (template_id, data_json) VALUES (?, ?)",
(template_id, json.dumps(data)),
)
rows_added += 1
await flash(f"{rows_added} data rows imported from CSV.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
@bp.route("/templates/<int:template_id>/data/<int:data_id>/delete", methods=["POST"])
@admin_required
@csrf_protect
async def template_data_delete(template_id: int, data_id: int):
"""Delete a single data row."""
await execute("DELETE FROM template_data WHERE id = ? AND template_id = ?", (data_id, template_id))
await flash("Data row deleted.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
# =============================================================================
# Bulk Generation
# =============================================================================
def _render_jinja_string(template_str: str, context: dict) -> str:
"""Render a Jinja2 template string with the given context."""
from jinja2 import Environment
env = Environment()
tmpl = env.from_string(template_str)
return tmpl.render(**context)
@bp.route("/templates/<int:template_id>/generate", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def template_generate(template_id: int):
"""Bulk-generate scenarios + articles from template data."""
template = await fetch_one("SELECT * FROM article_templates WHERE id = ?", (template_id,))
if not template:
await flash("Template not found.", "error")
return redirect(url_for("admin.templates"))
pending_count = await fetch_one(
"SELECT COUNT(*) as cnt FROM template_data WHERE template_id = ? AND article_id IS NULL",
(template_id,),
)
pending = pending_count["cnt"] if pending_count else 0
if request.method == "POST":
form = await request.form
start_date_str = form.get("start_date", "")
articles_per_day = int(form.get("articles_per_day", 2) or 2)
if not start_date_str:
start_date = date.today()
else:
start_date = date.fromisoformat(start_date_str)
assert articles_per_day > 0, "articles_per_day must be positive"
generated = await _generate_from_template(template, start_date, articles_per_day)
await flash(f"Generated {generated} articles with staggered publish dates.", "success")
return redirect(url_for("admin.template_data", template_id=template_id))
return await render_template(
"admin/generate_form.html",
template=template,
pending_count=pending,
today=date.today().isoformat(),
)
async def _generate_from_template(template: dict, start_date: date, articles_per_day: int) -> int:
"""Generate scenarios + articles for all un-generated data rows."""
from ..content.routes import bake_scenario_cards, is_reserved_path, BUILD_DIR
from ..planner.calculator import DEFAULTS, calc, validate_state
data_rows = await fetch_all(
"SELECT * FROM template_data WHERE template_id = ? AND article_id IS NULL",
(template["id"],),
)
publish_date = start_date
published_today = 0
generated = 0
for row in data_rows:
data = json.loads(row["data_json"])
# Separate calc fields from display fields
calc_overrides = {k: v for k, v in data.items() if k in DEFAULTS}
state = validate_state(calc_overrides)
d = calc(state)
# Build scenario slug
city_slug = data.get("city_slug", str(row["id"]))
scenario_slug = template["slug"] + "-" + city_slug
# Court config label
dbl = state.get("dblCourts", 0)
sgl = state.get("sglCourts", 0)
court_config = f"{dbl} double + {sgl} single"
# Create published scenario
scenario_id = await execute(
"""INSERT OR IGNORE INTO published_scenarios
(slug, title, subtitle, location, country, venue_type, ownership,
court_config, state_json, calc_json, template_data_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
scenario_slug,
data.get("city", scenario_slug),
data.get("subtitle", ""),
data.get("city", ""),
data.get("country", state.get("country", "")),
state.get("venue", "indoor"),
state.get("own", "rent"),
court_config,
json.dumps(state),
json.dumps(d),
row["id"],
),
)
if not scenario_id:
# Slug already exists, fetch existing
existing = await fetch_one(
"SELECT id FROM published_scenarios WHERE slug = ?", (scenario_slug,)
)
scenario_id = existing["id"] if existing else None
if not scenario_id:
continue
# Fill template patterns
data["scenario_slug"] = scenario_slug
title = _render_jinja_string(template["title_pattern"], data)
url_path = _render_jinja_string(template["url_pattern"], data)
article_slug = city_slug
meta_desc = ""
if template["meta_description_pattern"]:
meta_desc = _render_jinja_string(template["meta_description_pattern"], data)
# Validate url_path
if is_reserved_path(url_path):
continue
# Render body
body_md = _render_jinja_string(template["body_template"], data)
body_html = mistune.html(body_md)
body_html = await bake_scenario_cards(body_html)
# Write to disk
BUILD_DIR.mkdir(parents=True, exist_ok=True)
build_path = BUILD_DIR / f"{article_slug}.html"
build_path.write_text(body_html)
# Stagger publish date
publish_dt = datetime(publish_date.year, publish_date.month, publish_date.day, 8, 0, 0)
# Create article
article_id = await execute(
"""INSERT OR IGNORE INTO articles
(url_path, slug, title, meta_description, country, region,
status, published_at, template_data_id)
VALUES (?, ?, ?, ?, ?, ?, 'published', ?, ?)""",
(
url_path, article_slug, title, meta_desc,
data.get("country", ""), data.get("region", ""),
publish_dt.isoformat(), row["id"],
),
)
if article_id:
# Link data row
now = datetime.utcnow().isoformat()
await execute(
"UPDATE template_data SET scenario_id = ?, article_id = ?, updated_at = ? WHERE id = ?",
(scenario_id, article_id, now, row["id"]),
)
generated += 1
# Stagger dates
published_today += 1
if published_today >= articles_per_day:
published_today = 0
publish_date += timedelta(days=1)
return generated
# =============================================================================
# Published Scenario Management
# =============================================================================
SCENARIO_FORM_FIELDS = [
"title", "slug", "subtitle", "location", "country",
"venue", "own", "dblCourts", "sglCourts",
"ratePeak", "rateOffPeak", "rateSingle", "peakPct", "hoursPerDay", "utilTarget",
"rentSqm", "electricity", "heating", "staff", "insurance", "maintenance", "cleaning", "marketing",
"courtCostDbl", "courtCostSgl", "hallCostSqm", "landPriceSqm", "contingencyPct", "fitout",
"loanPct", "interestRate", "loanTerm",
]
@bp.route("/scenarios")
@admin_required
async def scenarios():
"""List published scenarios."""
scenario_list = await fetch_all(
"SELECT * FROM published_scenarios ORDER BY created_at DESC"
)
return await render_template("admin/scenarios.html", scenarios=scenario_list)
@bp.route("/scenarios/new", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def scenario_new():
"""Create a published scenario manually."""
from ..planner.calculator import DEFAULTS, calc, validate_state
if request.method == "POST":
form = await request.form
title = form.get("title", "").strip()
scenario_slug = form.get("slug", "").strip() or slugify(title)
subtitle = form.get("subtitle", "").strip()
location = form.get("location", "").strip()
country = form.get("country", "").strip()
if not title or not location or not country:
await flash("Title, location, and country are required.", "error")
return await render_template(
"admin/scenario_form.html", data=dict(form), editing=False, defaults=DEFAULTS,
)
# Build calc state from form
calc_overrides = {}
for key in DEFAULTS:
val = form.get(key, "")
if val != "":
calc_overrides[key] = val
state = validate_state(calc_overrides)
d = calc(state)
dbl = state.get("dblCourts", 0)
sgl = state.get("sglCourts", 0)
court_config = f"{dbl} double + {sgl} single"
scenario_id = await execute(
"""INSERT INTO published_scenarios
(slug, title, subtitle, location, country, venue_type, ownership,
court_config, state_json, calc_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
scenario_slug, title, subtitle, location, country,
state.get("venue", "indoor"), state.get("own", "rent"),
court_config, json.dumps(state), json.dumps(d),
),
)
await flash(f"Scenario '{title}' created.", "success")
return redirect(url_for("admin.scenarios"))
return await render_template(
"admin/scenario_form.html", data={}, editing=False, defaults=DEFAULTS,
)
@bp.route("/scenarios/<int:scenario_id>/edit", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def scenario_edit(scenario_id: int):
"""Edit a published scenario."""
from ..planner.calculator import DEFAULTS, calc, validate_state
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
if not scenario:
await flash("Scenario not found.", "error")
return redirect(url_for("admin.scenarios"))
if request.method == "POST":
form = await request.form
title = form.get("title", "").strip()
subtitle = form.get("subtitle", "").strip()
location = form.get("location", "").strip()
country = form.get("country", "").strip()
calc_overrides = {}
for key in DEFAULTS:
val = form.get(key, "")
if val != "":
calc_overrides[key] = val
state = validate_state(calc_overrides)
d = calc(state)
dbl = state.get("dblCourts", 0)
sgl = state.get("sglCourts", 0)
court_config = f"{dbl} double + {sgl} single"
now = datetime.utcnow().isoformat()
await execute(
"""UPDATE published_scenarios
SET title = ?, subtitle = ?, location = ?, country = ?,
venue_type = ?, ownership = ?, court_config = ?,
state_json = ?, calc_json = ?, updated_at = ?
WHERE id = ?""",
(
title, subtitle, location, country,
state.get("venue", "indoor"), state.get("own", "rent"),
court_config, json.dumps(state), json.dumps(d), now, scenario_id,
),
)
await flash("Scenario updated and recalculated.", "success")
return redirect(url_for("admin.scenarios"))
# Merge scenario metadata + state for the form
state = json.loads(scenario["state_json"])
data = {
"title": scenario["title"],
"slug": scenario["slug"],
"subtitle": scenario["subtitle"] or "",
"location": scenario["location"],
"country": scenario["country"],
**state,
}
return await render_template(
"admin/scenario_form.html", data=data, editing=True, scenario_id=scenario_id, defaults=DEFAULTS,
)
@bp.route("/scenarios/<int:scenario_id>/delete", methods=["POST"])
@admin_required
@csrf_protect
async def scenario_delete(scenario_id: int):
"""Delete a published scenario."""
await execute("DELETE FROM published_scenarios WHERE id = ?", (scenario_id,))
await flash("Scenario deleted.", "success")
return redirect(url_for("admin.scenarios"))
@bp.route("/scenarios/<int:scenario_id>/preview")
@admin_required
async def scenario_preview(scenario_id: int):
"""Preview a rendered scenario card."""
scenario = await fetch_one("SELECT * FROM published_scenarios WHERE id = ?", (scenario_id,))
if not scenario:
await flash("Scenario not found.", "error")
return redirect(url_for("admin.scenarios"))
d = json.loads(scenario["calc_json"])
s = json.loads(scenario["state_json"])
return await render_template(
"admin/scenario_preview.html", scenario=scenario, d=d, s=s,
)
# =============================================================================
# Article Management
# =============================================================================
@bp.route("/articles")
@admin_required
async def articles():
"""List all articles."""
article_list = await fetch_all(
"SELECT * FROM articles ORDER BY created_at DESC"
)
return await render_template("admin/articles.html", articles=article_list)
@bp.route("/articles/new", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def article_new():
"""Create a manual article."""
from ..content.routes import bake_scenario_cards, is_reserved_path, BUILD_DIR
if request.method == "POST":
form = await request.form
title = form.get("title", "").strip()
article_slug = form.get("slug", "").strip() or slugify(title)
url_path = form.get("url_path", "").strip() or ("/" + article_slug)
meta_description = form.get("meta_description", "").strip()
og_image_url = form.get("og_image_url", "").strip()
country = form.get("country", "").strip()
region = form.get("region", "").strip()
body = form.get("body", "").strip()
status = form.get("status", "draft")
published_at = form.get("published_at", "").strip()
if not title or not body:
await flash("Title and body are required.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False)
if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False)
# Render markdown → HTML with scenario cards baked in
body_html = mistune.html(body)
body_html = await bake_scenario_cards(body_html)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
build_path = BUILD_DIR / f"{article_slug}.html"
build_path.write_text(body_html)
# Save markdown source
md_dir = Path("data/content/articles")
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article_slug}.md").write_text(body)
pub_dt = published_at or datetime.utcnow().isoformat()
article_id = await execute(
"""INSERT INTO articles
(url_path, slug, title, meta_description, og_image_url,
country, region, status, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(url_path, article_slug, title, meta_description, og_image_url,
country, region, status, pub_dt),
)
await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles"))
return await render_template("admin/article_form.html", data={}, editing=False)
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
@admin_required
@csrf_protect
async def article_edit(article_id: int):
"""Edit a manual article."""
from ..content.routes import bake_scenario_cards, is_reserved_path, BUILD_DIR
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
if not article:
await flash("Article not found.", "error")
return redirect(url_for("admin.articles"))
if request.method == "POST":
form = await request.form
title = form.get("title", "").strip()
url_path = form.get("url_path", "").strip()
meta_description = form.get("meta_description", "").strip()
og_image_url = form.get("og_image_url", "").strip()
country = form.get("country", "").strip()
region = form.get("region", "").strip()
body = form.get("body", "").strip()
status = form.get("status", article["status"])
published_at = form.get("published_at", "").strip()
if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template(
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id,
)
# Re-render if body provided
if body:
body_html = mistune.html(body)
body_html = await bake_scenario_cards(body_html)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
md_dir = Path("data/content/articles")
md_dir.mkdir(parents=True, exist_ok=True)
(md_dir / f"{article['slug']}.md").write_text(body)
now = datetime.utcnow().isoformat()
pub_dt = published_at or article["published_at"]
await execute(
"""UPDATE articles
SET title = ?, url_path = ?, meta_description = ?, og_image_url = ?,
country = ?, region = ?, status = ?, published_at = ?, updated_at = ?
WHERE id = ?""",
(title, url_path, meta_description, og_image_url,
country, region, status, pub_dt, now, article_id),
)
await flash("Article updated.", "success")
return redirect(url_for("admin.articles"))
# Load markdown source if available
md_path = Path("data/content/articles") / f"{article['slug']}.md"
body = md_path.read_text() if md_path.exists() else ""
data = {**dict(article), "body": body}
return await render_template(
"admin/article_form.html", data=data, editing=True, article_id=article_id,
)
@bp.route("/articles/<int:article_id>/delete", methods=["POST"])
@admin_required
@csrf_protect
async def article_delete(article_id: int):
"""Delete an article."""
article = await fetch_one("SELECT slug FROM articles WHERE id = ?", (article_id,))
if article:
# Clean up files
from ..content.routes import BUILD_DIR
build_path = BUILD_DIR / f"{article['slug']}.html"
if build_path.exists():
build_path.unlink()
md_path = Path("data/content/articles") / f"{article['slug']}.md"
if md_path.exists():
md_path.unlink()
await execute("DELETE FROM articles WHERE id = ?", (article_id,))
await flash("Article deleted.", "success")
return redirect(url_for("admin.articles"))
@bp.route("/articles/<int:article_id>/publish", methods=["POST"])
@admin_required
@csrf_protect
async def article_publish(article_id: int):
"""Toggle article status between draft and published."""
article = await fetch_one("SELECT status FROM articles WHERE id = ?", (article_id,))
if not article:
await flash("Article not found.", "error")
return redirect(url_for("admin.articles"))
new_status = "published" if article["status"] == "draft" else "draft"
now = datetime.utcnow().isoformat()
await execute(
"UPDATE articles SET status = ?, updated_at = ? WHERE id = ?",
(new_status, now, article_id),
)
await flash(f"Article status changed to {new_status}.", "success")
return redirect(url_for("admin.articles"))
@bp.route("/articles/<int:article_id>/rebuild", methods=["POST"])
@admin_required
@csrf_protect
async def article_rebuild(article_id: int):
"""Re-render an article's HTML from source."""
await _rebuild_article(article_id)
await flash("Article rebuilt.", "success")
return redirect(url_for("admin.articles"))
@bp.route("/rebuild-all", methods=["POST"])
@admin_required
@csrf_protect
async def rebuild_all():
"""Re-render all articles."""
articles = await fetch_all("SELECT id FROM articles")
count = 0
for a in articles:
await _rebuild_article(a["id"])
count += 1
await flash(f"Rebuilt {count} articles.", "success")
return redirect(url_for("admin.articles"))
async def _rebuild_article(article_id: int):
"""Re-render a single article from its source (template+data or markdown)."""
from ..content.routes import bake_scenario_cards, BUILD_DIR
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
if not article:
return
if article["template_data_id"]:
# Generated article: re-render from template + data
td = await fetch_one(
"""SELECT td.*, at.body_template, at.title_pattern, at.meta_description_pattern
FROM template_data td
JOIN article_templates at ON at.id = td.template_id
WHERE td.id = ?""",
(article["template_data_id"],),
)
if not td:
return
data = json.loads(td["data_json"])
# Re-fetch scenario for fresh calc_json
if td["scenario_id"]:
scenario = await fetch_one(
"SELECT slug FROM published_scenarios WHERE id = ?", (td["scenario_id"],)
)
if scenario:
data["scenario_slug"] = scenario["slug"]
body_md = _render_jinja_string(td["body_template"], data)
body_html = mistune.html(body_md)
else:
# Manual article: re-render from markdown file
md_path = Path("data/content/articles") / f"{article['slug']}.md"
if not md_path.exists():
return
body_html = mistune.html(md_path.read_text())
body_html = await bake_scenario_cards(body_html)
BUILD_DIR.mkdir(parents=True, exist_ok=True)
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div style="max-width: 48rem; margin: 0 auto;">
<a href="{{ url_for('admin.articles') }}" class="text-sm text-slate">&larr; Back to articles</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article</h1>
<form method="post" class="card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="title">Title</label>
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
</div>
<div>
<label class="form-label" for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
placeholder="auto-generated from title" {% if editing %}readonly{% endif %}>
</div>
</div>
<div class="mb-4">
<label class="form-label" for="url_path">URL Path</label>
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}" class="form-input"
placeholder="e.g. /padel-court-cost-miami">
<p class="form-hint">Defaults to /slug. Must not conflict with existing routes.</p>
</div>
<div class="mb-4">
<label class="form-label" for="meta_description">Meta Description</label>
<input type="text" id="meta_description" name="meta_description" value="{{ data.get('meta_description', '') }}"
class="form-input" maxlength="160">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="country">Country</label>
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}" class="form-input"
placeholder="e.g. US">
</div>
<div>
<label class="form-label" for="region">Region</label>
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}" class="form-input"
placeholder="e.g. North America">
</div>
<div>
<label class="form-label" for="og_image_url">OG Image URL</label>
<input type="text" id="og_image_url" name="og_image_url" value="{{ data.get('og_image_url', '') }}" class="form-input">
</div>
</div>
<div class="mb-4">
<label class="form-label" for="body">Body (Markdown)</label>
<textarea id="body" name="body" rows="20" class="form-input"
style="font-family: var(--font-mono); font-size: 0.8125rem;" {% if not editing %}required{% endif %}>{{ data.get('body', '') }}</textarea>
<p class="form-hint">Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full</p>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="status">Status</label>
<select id="status" name="status" class="form-input">
<option value="draft" {% if data.get('status') == 'draft' %}selected{% endif %}>Draft</option>
<option value="published" {% if data.get('status') == 'published' %}selected{% endif %}>Published</option>
</select>
</div>
<div>
<label class="form-label" for="published_at">Publish Date</label>
<input type="datetime-local" id="published_at" name="published_at"
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}" class="form-input">
<p class="form-hint">Leave blank for now. Future date = scheduled.</p>
</div>
</div>
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button>
</form>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Articles</h1>
<p class="text-slate text-sm">{{ articles | length }} article{{ 's' if articles | length != 1 }}</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.article_new') }}" class="btn">New Article</a>
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline" onclick="return confirm('Rebuild all articles?')">Rebuild All</button>
</form>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
</div>
</header>
<div class="card">
{% if articles %}
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>URL</th>
<th>Status</th>
<th>Published</th>
<th>Source</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in articles %}
<tr>
<td>{{ a.title }}</td>
<td class="mono text-sm">{{ a.url_path }}</td>
<td>
{% if a.status == 'published' %}
{% if a.published_at and a.published_at > now.isoformat() %}
<span class="badge-warning">Scheduled</span>
{% else %}
<span class="badge-success">Published</span>
{% endif %}
{% else %}
<span class="badge">Draft</span>
{% endif %}
</td>
<td class="mono text-sm">{{ a.published_at[:10] if a.published_at else '-' }}</td>
<td class="text-sm">{% if a.template_data_id %}Generated{% else %}Manual{% endif %}</td>
<td class="text-right" style="white-space: nowrap;">
<form method="post" action="{{ url_for('admin.article_publish', article_id=a.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">{% if a.status == 'published' %}Unpublish{% else %}Publish{% endif %}</button>
</form>
<a href="{{ url_for('admin.article_edit', article_id=a.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.article_rebuild', article_id=a.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm">Rebuild</button>
</form>
<form method="post" action="{{ url_for('admin.article_delete', article_id=a.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this article?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No articles yet. Create one or generate from a template.</p>
{% endif %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Generate Articles - {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div style="max-width: 32rem; margin: 0 auto;">
<a href="{{ url_for('admin.template_data', template_id=template.id) }}" class="text-sm text-slate">&larr; Back to {{ template.name }}</a>
<h1 class="text-2xl mt-4 mb-2">Generate Articles</h1>
<p class="text-slate text-sm mb-6">{{ pending_count }} pending data row{{ 's' if pending_count != 1 }} ready to generate.</p>
{% if pending_count == 0 %}
<div class="card">
<p class="text-slate text-sm">All data rows have already been generated. Add more data rows first.</p>
</div>
{% else %}
<form method="post" class="card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label class="form-label" for="start_date">Start Date</label>
<input type="date" id="start_date" name="start_date" value="{{ today }}" class="form-input" required>
<p class="form-hint">First batch of articles will be published on this date.</p>
</div>
<div class="mb-4">
<label class="form-label" for="articles_per_day">Articles Per Day</label>
<input type="number" id="articles_per_day" name="articles_per_day" value="2" min="1" max="50" class="form-input" required>
<p class="form-hint">How many articles to publish per day. Remaining articles get staggered to following days.</p>
</div>
<div class="card" style="background: var(--color-soft-white); border: 1px dashed var(--color-mid-gray); margin-bottom: 1rem;">
<p class="text-sm text-slate">
This will generate <strong class="text-navy">{{ pending_count }}</strong> articles
over <strong class="text-navy" id="days-estimate">{{ ((pending_count + 1) // 2) }}</strong> days,
each with its own financial scenario computed from the data row's input values.
</p>
</div>
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate {{ pending_count }} articles? This cannot be undone.')">
Generate {{ pending_count }} Articles
</button>
</form>
{% endif %}
</div>
</main>
{% endblock %}

View File

@@ -94,7 +94,7 @@
</div> </div>
<!-- Quick Links --> <!-- Quick Links -->
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem" class="mb-10"> <div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem" class="mb-4">
<a href="{{ url_for('admin.leads') }}" class="btn text-center">Leads</a> <a href="{{ url_for('admin.leads') }}" class="btn text-center">Leads</a>
<a href="{{ url_for('admin.suppliers') }}" class="btn text-center">Suppliers</a> <a href="{{ url_for('admin.suppliers') }}" class="btn text-center">Suppliers</a>
<a href="{{ url_for('admin.users') }}" class="btn-outline text-center">All Users</a> <a href="{{ url_for('admin.users') }}" class="btn-outline text-center">All Users</a>
@@ -102,6 +102,11 @@
<a href="{{ url_for('admin.feedback') }}" class="btn-outline text-center">Feedback</a> <a href="{{ url_for('admin.feedback') }}" class="btn-outline text-center">Feedback</a>
<a href="{{ url_for('dashboard.index') }}" class="btn-outline text-center">View as User</a> <a href="{{ url_for('dashboard.index') }}" class="btn-outline text-center">View as User</a>
</div> </div>
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem" class="mb-10">
<a href="{{ url_for('admin.templates') }}" class="btn-outline text-center">Templates</a>
<a href="{{ url_for('admin.scenarios') }}" class="btn-outline text-center">Scenarios</a>
<a href="{{ url_for('admin.articles') }}" class="btn-outline text-center">Articles</a>
</div>
<div class="grid-2"> <div class="grid-2">
<!-- Recent Users --> <!-- Recent Users -->

View File

@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Scenario - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div style="max-width: 48rem; margin: 0 auto;">
<a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">&larr; Back to scenarios</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Published Scenario</h1>
<form method="post" class="card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Metadata -->
<h3 class="text-base font-semibold mb-3">Metadata</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-6">
<div>
<label class="form-label">Title</label>
<input type="text" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
</div>
<div>
<label class="form-label">Slug</label>
<input type="text" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
placeholder="auto-generated" {% if editing %}readonly{% endif %}>
</div>
<div>
<label class="form-label">Subtitle</label>
<input type="text" name="subtitle" value="{{ data.get('subtitle', '') }}" class="form-input">
</div>
<div>
<label class="form-label">Location</label>
<input type="text" name="location" value="{{ data.get('location', '') }}" class="form-input" required placeholder="e.g. Miami">
</div>
<div>
<label class="form-label">Country</label>
<input type="text" name="country" value="{{ data.get('country', '') }}" class="form-input" required placeholder="e.g. US">
</div>
</div>
<!-- Venue -->
<h3 class="text-base font-semibold mb-3">Venue</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 1rem;" class="mb-6">
<div>
<label class="form-label">Venue Type</label>
<select name="venue" class="form-input">
<option value="indoor" {% if data.get('venue') == 'indoor' %}selected{% endif %}>Indoor</option>
<option value="outdoor" {% if data.get('venue') == 'outdoor' %}selected{% endif %}>Outdoor</option>
</select>
</div>
<div>
<label class="form-label">Ownership</label>
<select name="own" class="form-input">
<option value="rent" {% if data.get('own') == 'rent' %}selected{% endif %}>Rent</option>
<option value="buy" {% if data.get('own') == 'buy' %}selected{% endif %}>Buy</option>
</select>
</div>
<div>
<label class="form-label">Double Courts</label>
<input type="number" name="dblCourts" value="{{ data.get('dblCourts', defaults.dblCourts) }}" class="form-input" min="0">
</div>
<div>
<label class="form-label">Single Courts</label>
<input type="number" name="sglCourts" value="{{ data.get('sglCourts', defaults.sglCourts) }}" class="form-input" min="0">
</div>
</div>
<!-- Pricing -->
<h3 class="text-base font-semibold mb-3">Pricing</h3>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;" class="mb-6">
<div>
<label class="form-label">Peak Rate (&euro;/hr)</label>
<input type="number" name="ratePeak" value="{{ data.get('ratePeak', defaults.ratePeak) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Off-Peak Rate (&euro;/hr)</label>
<input type="number" name="rateOffPeak" value="{{ data.get('rateOffPeak', defaults.rateOffPeak) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Single Rate (&euro;/hr)</label>
<input type="number" name="rateSingle" value="{{ data.get('rateSingle', defaults.rateSingle) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Peak %</label>
<input type="number" name="peakPct" value="{{ data.get('peakPct', defaults.peakPct) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Hours/Day</label>
<input type="number" name="hoursPerDay" value="{{ data.get('hoursPerDay', defaults.hoursPerDay) }}" class="form-input">
</div>
<div>
<label class="form-label">Utilization Target (%)</label>
<input type="number" name="utilTarget" value="{{ data.get('utilTarget', defaults.utilTarget) }}" class="form-input" step="any">
</div>
</div>
<!-- OPEX -->
<h3 class="text-base font-semibold mb-3">Operating Costs (monthly)</h3>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem;" class="mb-6">
<div>
<label class="form-label">Rent/m&sup2;</label>
<input type="number" name="rentSqm" value="{{ data.get('rentSqm', defaults.rentSqm) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Electricity</label>
<input type="number" name="electricity" value="{{ data.get('electricity', defaults.electricity) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Heating</label>
<input type="number" name="heating" value="{{ data.get('heating', defaults.heating) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Staff</label>
<input type="number" name="staff" value="{{ data.get('staff', defaults.staff) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Insurance</label>
<input type="number" name="insurance" value="{{ data.get('insurance', defaults.insurance) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Maintenance</label>
<input type="number" name="maintenance" value="{{ data.get('maintenance', defaults.maintenance) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Cleaning</label>
<input type="number" name="cleaning" value="{{ data.get('cleaning', defaults.cleaning) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Marketing</label>
<input type="number" name="marketing" value="{{ data.get('marketing', defaults.marketing) }}" class="form-input" step="any">
</div>
</div>
<!-- CAPEX -->
<h3 class="text-base font-semibold mb-3">Capital Costs</h3>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;" class="mb-6">
<div>
<label class="form-label">Court Cost (dbl)</label>
<input type="number" name="courtCostDbl" value="{{ data.get('courtCostDbl', defaults.courtCostDbl) }}" class="form-input">
</div>
<div>
<label class="form-label">Court Cost (sgl)</label>
<input type="number" name="courtCostSgl" value="{{ data.get('courtCostSgl', defaults.courtCostSgl) }}" class="form-input">
</div>
<div>
<label class="form-label">Hall Cost/m&sup2;</label>
<input type="number" name="hallCostSqm" value="{{ data.get('hallCostSqm', defaults.hallCostSqm) }}" class="form-input">
</div>
<div>
<label class="form-label">Land Price/m&sup2;</label>
<input type="number" name="landPriceSqm" value="{{ data.get('landPriceSqm', defaults.landPriceSqm) }}" class="form-input">
</div>
<div>
<label class="form-label">Contingency (%)</label>
<input type="number" name="contingencyPct" value="{{ data.get('contingencyPct', defaults.contingencyPct) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Fit-out</label>
<input type="number" name="fitout" value="{{ data.get('fitout', defaults.fitout) }}" class="form-input">
</div>
</div>
<!-- Financing -->
<h3 class="text-base font-semibold mb-3">Financing</h3>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;" class="mb-6">
<div>
<label class="form-label">Loan %</label>
<input type="number" name="loanPct" value="{{ data.get('loanPct', defaults.loanPct) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Interest Rate (%)</label>
<input type="number" name="interestRate" value="{{ data.get('interestRate', defaults.interestRate) }}" class="form-input" step="any">
</div>
<div>
<label class="form-label">Loan Term (years)</label>
<input type="number" name="loanTerm" value="{{ data.get('loanTerm', defaults.loanTerm) }}" class="form-input">
</div>
</div>
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update &amp; Recalculate{% else %}Create Scenario{% endif %}</button>
</form>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Preview: {{ scenario.title }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div style="max-width: 48rem; margin: 0 auto;">
<div class="flex justify-between items-center mb-6">
<a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">&larr; Back to scenarios</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=scenario.id) }}" class="btn-outline btn-sm">Edit</a>
</div>
<h1 class="text-2xl mb-2">{{ scenario.title }}</h1>
{% if scenario.subtitle %}
<p class="text-slate mb-6">{{ scenario.subtitle }}</p>
{% endif %}
<h2 class="text-lg mb-4">Summary</h2>
{% include "partials/scenario_summary.html" %}
<h2 class="text-lg mb-4 mt-8">CAPEX Breakdown</h2>
{% include "partials/scenario_capex.html" %}
<h2 class="text-lg mb-4 mt-8">Revenue &amp; Operating Costs</h2>
{% include "partials/scenario_operating.html" %}
<h2 class="text-lg mb-4 mt-8">Cash Flow Projection</h2>
{% include "partials/scenario_cashflow.html" %}
<h2 class="text-lg mb-4 mt-8">Returns &amp; Financing</h2>
{% include "partials/scenario_returns.html" %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Published Scenarios</h1>
<p class="text-slate text-sm">{{ scenarios | length }} scenario{{ 's' if scenarios | length != 1 }}</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.scenario_new') }}" class="btn">New Scenario</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
</div>
</header>
<div class="card">
{% if scenarios %}
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Slug</th>
<th>Location</th>
<th>Config</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in scenarios %}
<tr>
<td>{{ s.title }}</td>
<td class="mono text-sm">{{ s.slug }}</td>
<td>{{ s.location }}, {{ s.country }}</td>
<td class="text-sm">{{ s.venue_type | capitalize }} · {{ s.court_config }}</td>
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
<td class="text-right">
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this scenario?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No published scenarios yet.</p>
{% endif %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Data: {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8">
<div>
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a>
<h1 class="text-2xl mt-2">{{ template.name }}</h1>
<p class="text-slate text-sm">{{ data_rows | length }} data row{{ 's' if data_rows | length != 1 }} · <span class="mono">{{ template.slug }}</span></p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.template_generate', template_id=template.id) }}" class="btn">Generate Articles</a>
<a href="{{ url_for('admin.template_edit', template_id=template.id) }}" class="btn-outline">Edit Template</a>
</div>
</header>
<!-- Add Single Row -->
<div class="card mb-6">
<h3 class="text-base font-semibold mb-4">Add Data Row</h3>
<form method="post" action="{{ url_for('admin.template_data_add', template_id=template.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem;" class="mb-4">
{% for field in schema %}
<div>
<label class="form-label">{{ field.label }}</label>
<input type="{{ 'number' if field.get('field_type') in ('number', 'float') else 'text' }}"
name="{{ field.name }}" class="form-input"
{% if field.get('field_type') == 'float' %}step="any"{% endif %}
{% if field.get('required') %}required{% endif %}>
</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-sm">Add Row</button>
</form>
</div>
<!-- CSV Upload -->
<div class="card mb-6">
<h3 class="text-base font-semibold mb-4">Bulk Upload (CSV)</h3>
<form method="post" action="{{ url_for('admin.template_data_upload', template_id=template.id) }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="flex items-end gap-3">
<div style="flex: 1;">
<label class="form-label" for="csv_file">CSV File</label>
<input type="file" id="csv_file" name="csv_file" accept=".csv" class="form-input" required>
</div>
<button type="submit" class="btn btn-sm">Upload</button>
</div>
<p class="form-hint mt-1">CSV headers should match field names: {{ schema | map(attribute='name') | join(', ') }}</p>
</form>
</div>
<!-- Data Rows -->
<div class="card">
<h3 class="text-base font-semibold mb-4">Data Rows</h3>
{% if data_rows %}
<div class="scenario-widget__table-wrap">
<table class="table">
<thead>
<tr>
<th>#</th>
{% for field in schema[:5] %}
<th>{{ field.label }}</th>
{% endfor %}
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for row in data_rows %}
<tr>
<td class="mono text-sm">{{ row.id }}</td>
{% for field in schema[:5] %}
<td class="text-sm">{{ row.parsed_data.get(field.name, '') }}</td>
{% endfor %}
<td>
{% if row.article_id %}
<span class="badge-success">Generated</span>
{% if row.article_url %}
<a href="{{ row.article_url }}" class="text-xs ml-1">View</a>
{% endif %}
{% else %}
<span class="badge-warning">Pending</span>
{% endif %}
</td>
<td class="text-right">
<form method="post" action="{{ url_for('admin.template_data_delete', template_id=template.id, data_id=row.id) }}" class="m-0" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this data row?')">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-slate text-sm">No data rows yet. Add some above or upload a CSV.</p>
{% endif %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Template - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<div style="max-width: 48rem; margin: 0 auto;">
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">&larr; Back to templates</a>
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article Template</h1>
<form method="post" class="card">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
<div>
<label class="form-label" for="name">Name</label>
<input type="text" id="name" name="name" value="{{ data.get('name', '') }}" class="form-input" required>
</div>
<div>
<label class="form-label" for="slug">Slug</label>
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
placeholder="auto-generated from name" {% if editing %}readonly{% endif %}>
</div>
</div>
<div class="mb-4">
<label class="form-label" for="content_type">Content Type</label>
<select id="content_type" name="content_type" class="form-input" {% if editing %}disabled{% endif %}>
<option value="calculator" {% if data.get('content_type') == 'calculator' %}selected{% endif %}>Calculator</option>
<option value="map" {% if data.get('content_type') == 'map' %}selected{% endif %}>Map (future)</option>
</select>
</div>
<div class="mb-4">
<label class="form-label" for="input_schema">Input Schema (JSON)</label>
<textarea id="input_schema" name="input_schema" rows="6" class="form-input" style="font-family: var(--font-mono); font-size: 0.8125rem;">{{ data.get('input_schema', '[{"name": "city", "label": "City", "field_type": "text", "required": true}, {"name": "city_slug", "label": "City Slug", "field_type": "text", "required": true}, {"name": "country", "label": "Country", "field_type": "text", "required": true}, {"name": "region", "label": "Region", "field_type": "text", "required": false}]') }}</textarea>
<p class="form-hint">JSON array of field definitions: [{name, label, field_type, required}]</p>
</div>
<div class="mb-4">
<label class="form-label" for="url_pattern">URL Pattern</label>
<input type="text" id="url_pattern" name="url_pattern" value="{{ data.get('url_pattern', '') }}"
class="form-input" placeholder="/padel-court-cost-{{ '{{' }} city_slug {{ '}}' }}" required>
<p class="form-hint">Jinja2 template string. Use {{ '{{' }} variable {{ '}}' }} placeholders from data rows.</p>
</div>
<div class="mb-4">
<label class="form-label" for="title_pattern">Title Pattern</label>
<input type="text" id="title_pattern" name="title_pattern" value="{{ data.get('title_pattern', '') }}"
class="form-input" placeholder="Padel Center Cost in {{ '{{' }} city {{ '}}' }}" required>
</div>
<div class="mb-4">
<label class="form-label" for="meta_description_pattern">Meta Description Pattern</label>
<input type="text" id="meta_description_pattern" name="meta_description_pattern"
value="{{ data.get('meta_description_pattern', '') }}" class="form-input"
placeholder="How much does it cost to build a padel center in {{ '{{' }} city {{ '}}' }}?">
</div>
<div class="mb-4">
<label class="form-label" for="body_template">Body Template (Markdown + Jinja2)</label>
<textarea id="body_template" name="body_template" rows="20" class="form-input"
style="font-family: var(--font-mono); font-size: 0.8125rem;" required>{{ data.get('body_template', '') }}</textarea>
<p class="form-hint">Markdown with {{ '{{' }} variable {{ '}}' }} placeholders. Use [scenario:{{ '{{' }} scenario_slug {{ '}}' }}] to embed financial widgets. Sections: [scenario:slug:capex], [scenario:slug:operating], [scenario:slug:cashflow], [scenario:slug:returns], [scenario:slug:full].</p>
</div>
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update{% else %}Create{% endif %} Template</button>
</form>
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Article Templates - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-2xl">Article Templates</h1>
<p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }}</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('admin.template_new') }}" class="btn">New Template</a>
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
</div>
</header>
<div class="card">
{% if templates %}
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Type</th>
<th>Data Rows</th>
<th>Generated</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in templates %}
<tr>
<td><a href="{{ url_for('admin.template_data', template_id=t.id) }}">{{ t.name }}</a></td>
<td class="mono text-sm">{{ t.slug }}</td>
<td><span class="badge">{{ t.content_type }}</span></td>
<td class="mono">{{ t.data_count }}</td>
<td class="mono">{{ t.generated_count }}</td>
<td class="text-right">
<a href="{{ url_for('admin.template_edit', template_id=t.id) }}" class="btn-outline btn-sm">Edit</a>
<a href="{{ url_for('admin.template_generate', template_id=t.id) }}" class="btn btn-sm">Generate</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-slate text-sm">No templates yet. Create one to get started.</p>
{% endif %}
</div>
</main>
{% endblock %}

View File

@@ -80,6 +80,7 @@ def create_app() -> Quart:
from .admin.routes import bp as admin_bp from .admin.routes import bp as admin_bp
from .auth.routes import bp as auth_bp from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp from .billing.routes import bp as billing_bp
from .content.routes import bp as content_bp
from .dashboard.routes import bp as dashboard_bp from .dashboard.routes import bp as dashboard_bp
from .directory.routes import bp as directory_bp from .directory.routes import bp as directory_bp
from .leads.routes import bp as leads_bp from .leads.routes import bp as leads_bp
@@ -96,6 +97,7 @@ def create_app() -> Quart:
app.register_blueprint(directory_bp) app.register_blueprint(directory_bp)
app.register_blueprint(suppliers_bp) app.register_blueprint(suppliers_bp)
app.register_blueprint(admin_bp) app.register_blueprint(admin_bp)
app.register_blueprint(content_bp) # last — catch-all route
# Request ID tracking # Request ID tracking
setup_request_id(app) setup_request_id(app)

View File

@@ -0,0 +1,204 @@
"""
Content domain: public article serving, markets hub, scenario widget rendering.
"""
import json
import re
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
from markupsafe import Markup
from quart import Blueprint, abort, render_template, request
from ..core import fetch_all, fetch_one
bp = Blueprint(
"content",
__name__,
template_folder=str(Path(__file__).parent / "templates"),
)
BUILD_DIR = Path("data/content/_build")
RESERVED_PREFIXES = (
"/admin", "/auth", "/planner", "/billing", "/dashboard",
"/directory", "/leads", "/suppliers", "/health",
"/sitemap", "/static", "/markets", "/features",
"/terms", "/privacy", "/about", "/feedback",
)
SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]')
SECTION_TEMPLATES = {
None: "partials/scenario_summary.html",
"capex": "partials/scenario_capex.html",
"operating": "partials/scenario_operating.html",
"cashflow": "partials/scenario_cashflow.html",
"returns": "partials/scenario_returns.html",
"full": "partials/scenario_full.html",
}
# Standalone Jinja2 env for baking scenario cards into static HTML.
# Does not require a Quart app context.
_TEMPLATE_DIR = Path(__file__).parent / "templates"
_bake_env = Environment(loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=True)
def is_reserved_path(url_path: str) -> bool:
"""Check if a url_path starts with a reserved prefix."""
clean = "/" + url_path.strip("/")
return any(clean.startswith(p) for p in RESERVED_PREFIXES)
async def bake_scenario_cards(html: str) -> str:
"""Replace [scenario:slug] and [scenario:slug:section] markers with rendered HTML."""
matches = list(SCENARIO_RE.finditer(html))
if not matches:
return html
# Batch-fetch all referenced scenarios
slugs = list({m.group(1) for m in matches})
placeholders = ",".join("?" * len(slugs))
rows = await fetch_all(
f"SELECT * FROM published_scenarios WHERE slug IN ({placeholders})",
tuple(slugs),
)
scenarios = {row["slug"]: row for row in rows}
for match in reversed(matches):
slug = match.group(1)
section = match.group(2)
scenario = scenarios.get(slug)
if not scenario:
continue
template_name = SECTION_TEMPLATES.get(section)
if not template_name:
continue
calc_data = json.loads(scenario["calc_json"])
state_data = json.loads(scenario["state_json"])
tmpl = _bake_env.get_template(template_name)
card_html = tmpl.render(scenario=scenario, d=calc_data, s=state_data)
html = html[:match.start()] + card_html + html[match.end():]
return html
# =============================================================================
# Markets Hub
# =============================================================================
@bp.route("/markets")
async def markets():
"""Hub page: search + country/region filter for articles."""
q = request.args.get("q", "").strip()
country = request.args.get("country", "")
region = request.args.get("region", "")
countries = await fetch_all(
"""SELECT DISTINCT country FROM articles
WHERE country IS NOT NULL AND country != ''
AND status = 'published' AND published_at <= datetime('now')
ORDER BY country"""
)
regions = await fetch_all(
"""SELECT DISTINCT region FROM articles
WHERE region IS NOT NULL AND region != ''
AND status = 'published' AND published_at <= datetime('now')
ORDER BY region"""
)
articles = await _filter_articles(q, country, region)
return await render_template(
"markets.html",
articles=articles,
countries=[c["country"] for c in countries],
regions=[r["region"] for r in regions],
current_q=q,
current_country=country,
current_region=region,
)
@bp.route("/markets/results")
async def market_results():
"""HTMX partial: filtered article cards."""
q = request.args.get("q", "").strip()
country = request.args.get("country", "")
region = request.args.get("region", "")
articles = await _filter_articles(q, country, region)
return await render_template("partials/market_results.html", articles=articles)
async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
"""Query published articles with optional FTS + country/region filters."""
if q:
# FTS query
wheres = ["articles_fts MATCH ?"]
params: list = [q]
if country:
wheres.append("a.country = ?")
params.append(country)
if region:
wheres.append("a.region = ?")
params.append(region)
where = " AND ".join(wheres)
return await fetch_all(
f"""SELECT a.* FROM articles a
JOIN articles_fts ON articles_fts.rowid = a.id
WHERE {where}
AND a.status = 'published' AND a.published_at <= datetime('now')
ORDER BY a.published_at DESC
LIMIT 100""",
tuple(params),
)
else:
wheres = ["status = 'published'", "published_at <= datetime('now')"]
params = []
if country:
wheres.append("country = ?")
params.append(country)
if region:
wheres.append("region = ?")
params.append(region)
where = " AND ".join(wheres)
return await fetch_all(
f"""SELECT * FROM articles WHERE {where}
ORDER BY published_at DESC LIMIT 100""",
tuple(params),
)
# =============================================================================
# Catch-all Article Serving (must be registered LAST)
# =============================================================================
@bp.route("/<path:url_path>")
async def article_page(url_path: str):
"""Serve a published article by its url_path."""
clean_path = "/" + url_path.strip("/")
article = await fetch_one(
"""SELECT * FROM articles
WHERE url_path = ? AND status = 'published'
AND published_at <= datetime('now')""",
(clean_path,),
)
if not article:
abort(404)
build_path = BUILD_DIR / f"{article['slug']}.html"
if not build_path.exists():
abort(404)
body_html = build_path.read_text()
return await render_template(
"article_detail.html",
article=article,
body_html=Markup(body_html),
)

View File

@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}{{ article.title }} - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="{{ article.meta_description or '' }}">
<meta property="og:title" content="{{ article.title }}">
<meta property="og:description" content="{{ article.meta_description or '' }}">
<meta property="og:type" content="article">
{% if article.og_image_url %}
<meta property="og:image" content="{{ article.og_image_url }}">
{% endif %}
<link rel="canonical" href="{{ config.BASE_URL }}{{ article.url_path }}">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<article class="article-content" style="max-width: 48rem; margin: 0 auto;">
<header class="mb-8">
<h1 class="text-3xl mb-2">{{ article.title }}</h1>
<p class="text-sm text-slate">
{% if article.published_at %}{{ article.published_at[:10] }} · {% endif %}Padelnomics Research
</p>
</header>
<div class="article-body">
{{ body_html }}
</div>
<footer class="mt-12 pt-8 border-t border-light-gray">
<div class="card" style="text-align: center; padding: 2rem;">
<h3 class="text-xl mb-2">Run Your Own Numbers</h3>
<p class="text-slate text-sm mb-4">Use our free financial planner to model a padel center with your own assumptions.</p>
<a href="{{ url_for('planner.index') }}" class="btn">Open the Planner</a>
</div>
</footer>
</article>
</main>
{% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Padel Markets - {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Padel court cost analysis and market data for cities worldwide. Real financial scenarios with local data.">
<meta property="og:title" content="Padel Markets - {{ config.APP_NAME }}">
<meta property="og:description" content="Explore padel court costs, revenue projections, and investment returns by city.">
<link rel="canonical" href="{{ config.BASE_URL }}/markets">
{% endblock %}
{% block content %}
<main class="container-page py-12">
<header class="mb-8">
<h1 class="text-3xl mb-2">Padel Markets</h1>
<p class="text-slate">Cost analysis and financial projections for padel centers worldwide.</p>
</header>
<!-- Filters -->
<div class="card mb-8">
<div style="display: grid; grid-template-columns: 1fr auto auto; gap: 1rem; align-items: end;">
<div>
<label class="form-label" for="market-q">Search</label>
<input type="text" id="market-q" name="q" value="{{ current_q }}" placeholder="Search articles..."
class="form-input"
hx-get="{{ url_for('content.market_results') }}"
hx-target="#market-results"
hx-trigger="input changed delay:300ms"
hx-include="#market-country, #market-region">
</div>
<div>
<label class="form-label" for="market-country">Country</label>
<select id="market-country" name="country" class="form-input"
hx-get="{{ url_for('content.market_results') }}"
hx-target="#market-results"
hx-trigger="change"
hx-include="#market-q, #market-region">
<option value="">All Countries</option>
{% for c in countries %}
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label" for="market-region">Region</label>
<select id="market-region" name="region" class="form-input"
hx-get="{{ url_for('content.market_results') }}"
hx-target="#market-results"
hx-trigger="change"
hx-include="#market-q, #market-country">
<option value="">All Regions</option>
{% for r in regions %}
<option value="{{ r }}" {% if r == current_region %}selected{% endif %}>{{ r }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Results -->
<div id="market-results">
{% include "partials/market_results.html" %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,27 @@
{% if articles %}
<div class="grid-3">
{% for article in articles %}
<a href="{{ article.url_path }}" class="card" style="text-decoration: none; display: block;">
<h3 class="text-base font-semibold text-navy mb-1">{{ article.title }}</h3>
{% if article.meta_description %}
<p class="text-sm text-slate mb-3" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">{{ article.meta_description }}</p>
{% endif %}
<div class="flex items-center gap-2">
{% if article.country %}
<span class="badge">{{ article.country }}</span>
{% endif %}
{% if article.region %}
<span class="text-xs text-slate">{{ article.region }}</span>
{% endif %}
{% if article.published_at %}
<span class="text-xs text-slate ml-auto mono">{{ article.published_at[:10] }}</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="card text-center">
<p class="text-slate text-sm">No articles found. Try adjusting your filters.</p>
</div>
{% endif %}

View File

@@ -0,0 +1,66 @@
<div class="scenario-widget scenario-capex">
<div class="scenario-widget__header">
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
<span class="scenario-widget__config">Investment Breakdown</span>
</div>
<div class="scenario-widget__body">
<div class="scenario-widget__table-wrap">
<table class="scenario-widget__table">
<thead>
<tr>
<th>Item</th>
<th class="text-right">Amount</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for item in d.capexItems %}
<tr>
<td>{{ item.name }}</td>
<td class="text-right mono">&euro;{{ "{:,.0f}".format(item.amount) }}</td>
<td class="text-sm text-slate">{{ item.info }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td><strong>Total CAPEX</strong></td>
<td class="text-right mono"><strong>&euro;{{ "{:,.0f}".format(d.capex) }}</strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="scenario-widget__metrics" style="margin-top: 1rem;">
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Per Court</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.capexPerCourt) }}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Per m&sup2;</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.capexPerSqm) }}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">LTV</span>
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ltv * 100) }}%</span>
</div>
</div>
<div class="scenario-widget__metrics">
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Equity</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.equity) }}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Loan</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.loanAmount) }}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Total</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.capex) }}</span>
</div>
</div>
</div>
<div class="scenario-widget__cta">
<a href="/planner/">Try with your own numbers &rarr;</a>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<div class="scenario-widget scenario-cashflow">
<div class="scenario-widget__header">
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
<span class="scenario-widget__config">{{ s.holdYears }}-Year Projection</span>
</div>
<div class="scenario-widget__body">
<div class="scenario-widget__table-wrap">
<table class="scenario-widget__table">
<thead>
<tr>
<th></th>
{% for a in d.annuals %}
<th class="text-right">Year {{ a.year }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr>
<td>Revenue</td>
{% for a in d.annuals %}
<td class="text-right mono">&euro;{{ "{:,.0f}".format(a.revenue) }}</td>
{% endfor %}
</tr>
<tr>
<td>EBITDA</td>
{% for a in d.annuals %}
<td class="text-right mono">&euro;{{ "{:,.0f}".format(a.ebitda) }}</td>
{% endfor %}
</tr>
<tr>
<td>Debt Service</td>
{% for a in d.annuals %}
<td class="text-right mono">&euro;{{ "{:,.0f}".format(a.ds) }}</td>
{% endfor %}
</tr>
<tr>
<td><strong>Net Cash Flow</strong></td>
{% for a in d.annuals %}
<td class="text-right mono"><strong>&euro;{{ "{:,.0f}".format(a.ncf) }}</strong></td>
{% endfor %}
</tr>
<tr>
<td>Cumulative NCF</td>
{% set cum = namespace(total=-d.capex) %}
{% for a in d.annuals %}
{% set cum.total = cum.total + a.ncf %}
<td class="text-right mono {% if cum.total >= 0 %}text-accent{% else %}text-danger{% endif %}">&euro;{{ "{:,.0f}".format(cum.total) }}</td>
{% endfor %}
</tr>
</tbody>
<tfoot>
<tr>
<td>DSCR</td>
{% for entry in d.dscr %}
<td class="text-right mono">{{ "{:.2f}".format(entry.dscr) }}x</td>
{% endfor %}
</tr>
</tfoot>
</table>
</div>
</div>
<div class="scenario-widget__cta">
<a href="/planner/">Try with your own numbers &rarr;</a>
</div>
</div>

View File

@@ -0,0 +1,5 @@
{% include "partials/scenario_summary.html" %}
{% include "partials/scenario_capex.html" %}
{% include "partials/scenario_operating.html" %}
{% include "partials/scenario_cashflow.html" %}
{% include "partials/scenario_returns.html" %}

View File

@@ -0,0 +1,91 @@
<div class="scenario-widget scenario-operating">
<div class="scenario-widget__header">
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
<span class="scenario-widget__config">Revenue &amp; Operating Costs</span>
</div>
<div class="scenario-widget__body">
<h4 class="scenario-widget__section-title">Revenue Model</h4>
<div class="scenario-widget__metrics">
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Weighted Rate</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:.0f}".format(d.weightedRate) }}/hr</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Utilization Target</span>
<span class="scenario-widget__metric-value">{{ s.utilTarget }}%</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Booked Hours/mo</span>
<span class="scenario-widget__metric-value">{{ "{:,.0f}".format(d.bookedHoursMonth) }}</span>
</div>
</div>
<h4 class="scenario-widget__section-title">Monthly OPEX</h4>
<div class="scenario-widget__table-wrap">
<table class="scenario-widget__table">
<thead>
<tr>
<th>Item</th>
<th class="text-right">Monthly</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{% for item in d.opexItems %}
<tr>
<td>{{ item.name }}</td>
<td class="text-right mono">&euro;{{ "{:,.0f}".format(item.amount) }}</td>
<td class="text-sm text-slate">{{ item.info }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td><strong>Total Monthly OPEX</strong></td>
<td class="text-right mono"><strong>&euro;{{ "{:,.0f}".format(d.opex) }}</strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<h4 class="scenario-widget__section-title">Monthly Summary</h4>
<div class="scenario-widget__table-wrap">
<table class="scenario-widget__table">
<tbody>
<tr>
<td>Gross Revenue</td>
<td class="text-right mono">&euro;{{ "{:,.0f}".format(d.grossRevMonth) }}</td>
</tr>
<tr>
<td>Booking Fees</td>
<td class="text-right mono">-&euro;{{ "{:,.0f}".format(d.feeDeduction) }}</td>
</tr>
<tr>
<td>Net Revenue</td>
<td class="text-right mono">&euro;{{ "{:,.0f}".format(d.netRevMonth) }}</td>
</tr>
<tr>
<td>Operating Costs</td>
<td class="text-right mono">-&euro;{{ "{:,.0f}".format(d.opex) }}</td>
</tr>
<tr>
<td><strong>EBITDA</strong></td>
<td class="text-right mono"><strong>&euro;{{ "{:,.0f}".format(d.ebitdaMonth) }}</strong></td>
</tr>
<tr>
<td>Debt Service</td>
<td class="text-right mono">-&euro;{{ "{:,.0f}".format(d.monthlyPayment) }}</td>
</tr>
<tr>
<td><strong>Net Cash Flow</strong></td>
<td class="text-right mono"><strong>&euro;{{ "{:,.0f}".format(d.netCFMonth) }}</strong></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="scenario-widget__cta">
<a href="/planner/">Try with your own numbers &rarr;</a>
</div>
</div>

View File

@@ -0,0 +1,72 @@
<div class="scenario-widget scenario-returns">
<div class="scenario-widget__header">
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
<span class="scenario-widget__config">Returns &amp; Financing</span>
</div>
<div class="scenario-widget__body">
<h4 class="scenario-widget__section-title">Return Metrics</h4>
<div class="scenario-widget__metrics">
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">IRR ({{ s.holdYears }}yr)</span>
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.irr * 100) }}%</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">MOIC</span>
<span class="scenario-widget__metric-value">{{ "{:.2f}".format(d.moic) }}x</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Payback</span>
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} months{% else %}N/A{% endif %}</span>
</div>
</div>
<div class="scenario-widget__metrics">
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Cash-on-Cash</span>
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.cashOnCash * 100) }}%</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Yield on Cost</span>
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.yieldOnCost * 100) }}%</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">EBITDA Margin</span>
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ebitdaMargin * 100) }}%</span>
</div>
</div>
<h4 class="scenario-widget__section-title">Exit Analysis</h4>
<div class="scenario-widget__metrics">
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Exit Value ({{ s.exitMultiple }}x EBITDA)</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.exitValue) }}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Remaining Loan</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.remainingLoan) }}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Net Exit</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.netExit) }}</span>
</div>
</div>
<h4 class="scenario-widget__section-title">Financing</h4>
<div class="scenario-widget__metrics">
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Loan Amount</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.loanAmount) }}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Rate / Term</span>
<span class="scenario-widget__metric-value">{{ s.interestRate }}% / {{ s.loanTerm }}yr</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Monthly Payment</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.monthlyPayment) }}</span>
</div>
</div>
</div>
<div class="scenario-widget__cta">
<a href="/planner/">Try with your own numbers &rarr;</a>
</div>
</div>

View File

@@ -0,0 +1,39 @@
<div class="scenario-widget scenario-summary">
<div class="scenario-widget__header">
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
<span class="scenario-widget__config">{{ scenario.venue_type | capitalize }} · {{ scenario.court_config }}</span>
</div>
<div class="scenario-widget__body">
<div class="scenario-widget__metrics">
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Total CAPEX</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.capex) }}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Monthly EBITDA</span>
<span class="scenario-widget__metric-value">&euro;{{ "{:,.0f}".format(d.ebitdaMonth) }}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">IRR ({{ s.holdYears }}yr)</span>
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.irr * 100) }}%</span>
</div>
</div>
<div class="scenario-widget__metrics">
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Payback</span>
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} months{% else %}N/A{% endif %}</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">Cash-on-Cash</span>
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.cashOnCash * 100) }}%</span>
</div>
<div class="scenario-widget__metric">
<span class="scenario-widget__metric-label">EBITDA Margin</span>
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ebitdaMargin * 100) }}%</span>
</div>
</div>
</div>
<div class="scenario-widget__cta">
<a href="/planner/">Try with your own numbers &rarr;</a>
</div>
</div>

View File

@@ -4,7 +4,9 @@ Core infrastructure: database, config, email, and shared utilities.
import hashlib import hashlib
import hmac import hmac
import os import os
import re
import secrets import secrets
import unicodedata
from contextvars import ContextVar from contextvars import ContextVar
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import wraps from functools import wraps
@@ -373,3 +375,15 @@ async def get_all_paddle_prices() -> dict[str, str]:
"""Load all Paddle price IDs as a {key: price_id} dict.""" """Load all Paddle price IDs as a {key: price_id} dict."""
rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products") rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products")
return {r["key"]: r["paddle_price_id"] for r in rows} return {r["key"]: r["paddle_price_id"] for r in rows}
# =============================================================================
# Text Utilities
# =============================================================================
def slugify(text: str, max_length_chars: int = 80) -> str:
"""Convert text to URL-safe slug."""
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
text = re.sub(r"[^\w\s-]", "", text.lower())
text = re.sub(r"[-\s]+", "-", text).strip("-")
return text[:max_length_chars]

View File

@@ -316,3 +316,102 @@ CREATE TABLE IF NOT EXISTS feedback (
is_read INTEGER NOT NULL DEFAULT 0, is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- =============================================================================
-- Content / Programmatic SEO
-- =============================================================================
-- Published scenarios (generated financial widgets for articles)
CREATE TABLE IF NOT EXISTS published_scenarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
location TEXT NOT NULL,
country TEXT NOT NULL,
venue_type TEXT NOT NULL DEFAULT 'indoor',
ownership TEXT NOT NULL DEFAULT 'rent',
court_config TEXT NOT NULL,
state_json TEXT NOT NULL,
calc_json TEXT NOT NULL,
template_data_id INTEGER REFERENCES template_data(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_pub_scenarios_slug ON published_scenarios(slug);
-- Article templates (content recipes)
CREATE TABLE IF NOT EXISTS article_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content_type TEXT NOT NULL DEFAULT 'calculator',
input_schema TEXT NOT NULL,
url_pattern TEXT NOT NULL,
title_pattern TEXT NOT NULL,
meta_description_pattern TEXT,
body_template TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_article_templates_slug ON article_templates(slug);
-- Template data (per-city/region input rows)
CREATE TABLE IF NOT EXISTS template_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL REFERENCES article_templates(id),
data_json TEXT NOT NULL,
scenario_id INTEGER REFERENCES published_scenarios(id),
article_id INTEGER REFERENCES articles(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_template_data_template ON template_data(template_id);
-- Articles (generated or manual)
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_path TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
meta_description TEXT,
country TEXT,
region TEXT,
og_image_url TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT,
template_data_id INTEGER REFERENCES template_data(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path);
CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at);
-- FTS5 full-text search for articles
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, meta_description, country, region,
content='articles', content_rowid='id'
);
-- Keep FTS in sync with articles table
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END;
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
END;
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END;

View File

@@ -0,0 +1,103 @@
"""Programmatic SEO: article templates, template data, published scenarios, articles + FTS."""
def up(conn):
conn.execute("""
CREATE TABLE IF NOT EXISTS published_scenarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
subtitle TEXT,
location TEXT NOT NULL,
country TEXT NOT NULL,
venue_type TEXT NOT NULL DEFAULT 'indoor',
ownership TEXT NOT NULL DEFAULT 'rent',
court_config TEXT NOT NULL,
state_json TEXT NOT NULL,
calc_json TEXT NOT NULL,
template_data_id INTEGER REFERENCES template_data(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_pub_scenarios_slug ON published_scenarios(slug)")
conn.execute("""
CREATE TABLE IF NOT EXISTS article_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content_type TEXT NOT NULL DEFAULT 'calculator',
input_schema TEXT NOT NULL,
url_pattern TEXT NOT NULL,
title_pattern TEXT NOT NULL,
meta_description_pattern TEXT,
body_template TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_article_templates_slug ON article_templates(slug)")
conn.execute("""
CREATE TABLE IF NOT EXISTS template_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL REFERENCES article_templates(id),
data_json TEXT NOT NULL,
scenario_id INTEGER REFERENCES published_scenarios(id),
article_id INTEGER REFERENCES articles(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_template_data_template ON template_data(template_id)")
conn.execute("""
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url_path TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
meta_description TEXT,
country TEXT,
region TEXT,
og_image_url TEXT,
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT,
template_data_id INTEGER REFERENCES template_data(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)")
conn.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, meta_description, country, region,
content='articles', content_rowid='id'
)
""")
# FTS sync triggers
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
END
""")
conn.execute("""
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
END
""")

View File

@@ -107,7 +107,18 @@ async def sitemap():
f"{base}/billing/pricing", f"{base}/billing/pricing",
f"{base}/terms", f"{base}/terms",
f"{base}/privacy", f"{base}/privacy",
f"{base}/markets",
] ]
# Add published articles (only those with published_at <= now)
articles = await fetch_all(
"""SELECT url_path FROM articles
WHERE status = 'published' AND published_at <= datetime('now')
ORDER BY published_at DESC"""
)
for article in articles:
urls.append(f"{base}{article['url_path']}")
xml = '<?xml version="1.0" encoding="UTF-8"?>\n' xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
for url in urls: for url in urls:

View File

@@ -280,4 +280,115 @@
.htmx-request.htmx-indicator { .htmx-request.htmx-indicator {
display: inline; display: inline;
} }
/* ── Scenario Widgets ── */
.scenario-widget {
@apply border border-light-gray rounded-2xl overflow-hidden mb-6 bg-white shadow-sm;
}
.scenario-widget__header {
@apply flex justify-between items-center px-4 py-3 text-sm font-semibold text-white;
background: var(--color-navy);
}
.scenario-widget__location {
@apply uppercase tracking-wider text-xs;
}
.scenario-widget__config {
@apply text-xs font-normal opacity-80;
}
.scenario-widget__body {
@apply p-4;
}
.scenario-widget__metrics {
@apply grid gap-4 mb-4;
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 640px) {
.scenario-widget__metrics {
grid-template-columns: repeat(2, 1fr);
}
}
.scenario-widget__metric {
@apply text-center;
}
.scenario-widget__metric-label {
@apply block text-xs text-slate mb-0.5;
}
.scenario-widget__metric-value {
@apply block text-lg font-bold font-mono text-navy;
}
.scenario-widget__section-title {
@apply text-sm font-semibold text-charcoal mb-2 mt-4 pb-1 border-b border-light-gray;
}
.scenario-widget__table-wrap {
@apply overflow-x-auto;
}
.scenario-widget__table {
@apply w-full text-sm;
}
.scenario-widget__table th {
@apply text-left px-3 py-1.5 text-xs font-semibold text-slate uppercase tracking-wider border-b border-light-gray;
}
.scenario-widget__table td {
@apply px-3 py-1.5 border-b border-light-gray text-slate-dark;
}
.scenario-widget__table tfoot td,
.scenario-widget__table tfoot th {
@apply border-t-2 border-light-gray font-semibold;
}
.scenario-widget__cta {
@apply px-4 py-3 text-center border-t border-light-gray bg-soft-white;
}
.scenario-widget__cta a {
@apply text-sm font-semibold text-electric hover:text-electric-hover;
}
/* ── Article Body Typography ── */
.article-body h2 {
@apply text-2xl mt-10 mb-4;
}
.article-body h3 {
@apply text-xl mt-8 mb-3;
}
.article-body h4 {
@apply text-lg mt-6 mb-2;
}
.article-body p {
@apply text-base leading-relaxed mb-4 text-slate-dark;
}
.article-body ul, .article-body ol {
@apply mb-4 pl-6;
}
.article-body ul {
@apply list-disc;
}
.article-body ol {
@apply list-decimal;
}
.article-body li {
@apply mb-1 text-slate-dark;
}
.article-body blockquote {
@apply border-l-4 border-electric pl-4 italic text-slate my-6;
}
.article-body table {
@apply w-full text-sm mb-6;
}
.article-body table th {
@apply text-left px-3 py-2 text-xs font-semibold text-slate uppercase border-b-2 border-light-gray;
}
.article-body table td {
@apply px-3 py-2 border-b border-light-gray text-slate-dark;
}
.article-body code {
@apply font-mono text-sm bg-light-gray px-1 py-0.5 rounded;
}
.article-body pre {
@apply bg-navy text-white p-4 rounded-xl mb-4 overflow-x-auto;
}
.article-body pre code {
@apply bg-transparent p-0;
}
.article-body a {
@apply text-electric underline hover:text-electric-hover;
}
} }

View File

@@ -122,6 +122,7 @@
<ul class="space-y-1 text-sm"> <ul class="space-y-1 text-sm">
<li><a href="{{ url_for('planner.index') }}">Planner</a></li> <li><a href="{{ url_for('planner.index') }}">Planner</a></li>
<li><a href="{{ url_for('directory.index') }}">Supplier Directory</a></li> <li><a href="{{ url_for('directory.index') }}">Supplier Directory</a></li>
<li><a href="{{ url_for('content.markets') }}">Markets</a></li>
<li><a href="{{ url_for('public.suppliers') }}">For Suppliers</a></li> <li><a href="{{ url_for('public.suppliers') }}">For Suppliers</a></li>
</ul> </ul>
</div> </div>

File diff suppressed because it is too large Load Diff

11
padelnomics/uv.lock generated
View File

@@ -633,6 +633,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
] ]
[[package]]
name = "mistune"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" },
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.0"
@@ -664,6 +673,7 @@ dependencies = [
{ name = "hypercorn" }, { name = "hypercorn" },
{ name = "itsdangerous" }, { name = "itsdangerous" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "mistune" },
{ name = "paddle-python-sdk" }, { name = "paddle-python-sdk" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "quart" }, { name = "quart" },
@@ -688,6 +698,7 @@ requires-dist = [
{ name = "hypercorn", specifier = ">=0.17.0" }, { name = "hypercorn", specifier = ">=0.17.0" },
{ name = "itsdangerous", specifier = ">=2.1.0" }, { name = "itsdangerous", specifier = ">=2.1.0" },
{ name = "jinja2", specifier = ">=3.1.0" }, { name = "jinja2", specifier = ">=3.1.0" },
{ name = "mistune", specifier = ">=3.0.0" },
{ name = "paddle-python-sdk", specifier = ">=1.13.0" }, { name = "paddle-python-sdk", specifier = ">=1.13.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "quart", specifier = ">=0.19.0" }, { name = "quart", specifier = ">=0.19.0" },