Compare commits
5 Commits
v202603100
...
v202603101
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7fa1ae9a | ||
|
|
511a0ebac7 | ||
|
|
97ba13c42a | ||
|
|
1bd5bae90d | ||
|
|
608f16f578 |
@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SEO audit fixes** — sitemap: replaced `/market-score` with `/padelnomics-score`, added `/opportunity-map`, removed `/billing/pricing` (blocked by robots.txt), deduplicated articles query (was producing 4 entries per article instead of 2). Fixed `/market-score` redirect chain (1 hop instead of 2). Moved default OG tags inside `{% block head %}` so child templates replace rather than duplicate them. Added JSON-LD WebPage + BreadcrumbList schema to features, planner, and directory pages. Added meta descriptions to export pages.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Opportunity Score v7 → v8** — better spread and discrimination across the full 0-100 range. Addressable market weight reduced (20→15 pts) with steeper sqrt curve (ceiling 1M, was LN/500K). Economic power reduced (15→10 pts). Supply deficit increased (40→50 pts) with market existence dampener: countries with zero padel venues get max 5 pts supply deficit (factor 0.1), scaling linearly to full credit at 50+ venues. NULL nearest-court distance now treated as 0 (assume nearby) instead of 0.5. Added `country_percentile` output column (PERCENT_RANK within country). Target: P5-P95 spread ≥40 pts (was 22), zero-venue countries avg <30.
|
- **Opportunity Score v7 → v8** — better spread and discrimination across the full 0-100 range. Addressable market weight reduced (20→15 pts) with steeper sqrt curve (ceiling 1M, was LN/500K). Economic power reduced (15→10 pts). Supply deficit increased (40→50 pts) with market existence dampener: countries with zero padel venues get max 5 pts supply deficit (factor 0.1), scaling linearly to full credit at 50+ venues. NULL nearest-court distance now treated as 0 (assume nearby) instead of 0.5. Added `country_percentile` output column (PERCENT_RANK within country). Target: P5-P95 spread ≥40 pts (was 22), zero-venue countries avg <30.
|
||||||
- **Opportunity Score v6 → v7 (calibration fix)** — two fixes for inflated scores in saturated markets. (1) `dim_locations` now sources venue coordinates from `dim_venues` (deduplicated OSM + Playtomic) instead of `stg_padel_courts` (OSM only), making Playtomic-only venues visible to spatial lookups. (2) Country-level supply saturation dampener on the 40-pt supply deficit component: saturated countries (Spain ~4.5/100k) get dampened supply deficit (×0.55 → 22 pts max), emerging markets (Germany ~0.7/100k) are nearly unaffected (×0.93 → ~37 pts).
|
- **Opportunity Score v6 → v7 (calibration fix)** — two fixes for inflated scores in saturated markets. (1) `dim_locations` now sources venue coordinates from `dim_venues` (deduplicated OSM + Playtomic) instead of `stg_padel_courts` (OSM only), making Playtomic-only venues visible to spatial lookups. (2) Country-level supply saturation dampener on the 40-pt supply deficit component: saturated countries (Spain ~4.5/100k) get dampened supply deficit (×0.55 → 22 pts max), emerging markets (Germany ~0.7/100k) are nearly unaffected (×0.93 → ~37 pts).
|
||||||
|
|||||||
@@ -267,48 +267,6 @@ def run_export() -> None:
|
|||||||
send_alert(f"[export] {err}")
|
send_alert(f"[export] {err}")
|
||||||
|
|
||||||
|
|
||||||
_last_seen_head: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def web_code_changed() -> bool:
|
|
||||||
"""True on the first tick after a commit that changed web app code or secrets.
|
|
||||||
|
|
||||||
Compares the current HEAD to the HEAD from the previous tick. On first call
|
|
||||||
after process start (e.g. after os.execv reloads new code), falls back to
|
|
||||||
HEAD~1 so the just-deployed commit is evaluated exactly once.
|
|
||||||
|
|
||||||
Records HEAD before returning so the same commit never triggers twice.
|
|
||||||
"""
|
|
||||||
global _last_seen_head
|
|
||||||
result = subprocess.run(
|
|
||||||
["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=10,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
current_head = result.stdout.strip()
|
|
||||||
|
|
||||||
if _last_seen_head is None:
|
|
||||||
# Fresh process — use HEAD~1 as base (evaluates the newly deployed tag).
|
|
||||||
base_result = subprocess.run(
|
|
||||||
["git", "rev-parse", "HEAD~1"], capture_output=True, text=True, timeout=10,
|
|
||||||
)
|
|
||||||
base = base_result.stdout.strip() if base_result.returncode == 0 else current_head
|
|
||||||
else:
|
|
||||||
base = _last_seen_head
|
|
||||||
|
|
||||||
_last_seen_head = current_head # advance now — won't fire again for this HEAD
|
|
||||||
|
|
||||||
if base == current_head:
|
|
||||||
return False
|
|
||||||
|
|
||||||
diff = subprocess.run(
|
|
||||||
["git", "diff", "--name-only", base, current_head, "--",
|
|
||||||
"web/", "Dockerfile", ".env.prod.sops"],
|
|
||||||
capture_output=True, text=True, timeout=30,
|
|
||||||
)
|
|
||||||
return bool(diff.stdout.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def current_deployed_tag() -> str | None:
|
def current_deployed_tag() -> str | None:
|
||||||
"""Return the highest-version tag pointing at HEAD, or None.
|
"""Return the highest-version tag pointing at HEAD, or None.
|
||||||
|
|
||||||
@@ -360,6 +318,15 @@ def git_pull_and_sync() -> None:
|
|||||||
run_shell("uv sync --all-packages")
|
run_shell("uv sync --all-packages")
|
||||||
# Apply any model changes (FULL→INCREMENTAL, new models, etc.) before re-exec
|
# Apply any model changes (FULL→INCREMENTAL, new models, etc.) before re-exec
|
||||||
run_shell("uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply")
|
run_shell("uv run sqlmesh -p transform/sqlmesh_padelnomics plan prod --auto-apply")
|
||||||
|
# Always redeploy the web app on new tag — blue/green swap is zero-downtime
|
||||||
|
# and Docker layer caching makes no-op builds fast. Previous approach of
|
||||||
|
# diffing HEAD~1 missed changes inside merge commits.
|
||||||
|
logger.info("Deploying web app (blue/green swap)")
|
||||||
|
ok, err = run_shell("./deploy.sh")
|
||||||
|
if ok:
|
||||||
|
send_alert(f"[deploy] {latest} ok")
|
||||||
|
else:
|
||||||
|
send_alert(f"[deploy] {latest} failed: {err}")
|
||||||
# Re-exec so the new code is loaded. os.execv replaces this process in-place;
|
# Re-exec so the new code is loaded. os.execv replaces this process in-place;
|
||||||
# systemd sees it as the same PID and does not restart the unit.
|
# systemd sees it as the same PID and does not restart the unit.
|
||||||
logger.info("Deploy complete — re-execing to load new code")
|
logger.info("Deploy complete — re-execing to load new code")
|
||||||
@@ -408,14 +375,6 @@ def tick() -> None:
|
|||||||
# Export serving tables
|
# Export serving tables
|
||||||
run_export()
|
run_export()
|
||||||
|
|
||||||
# Deploy web app if code changed
|
|
||||||
if os.getenv("SUPERVISOR_GIT_PULL") and web_code_changed():
|
|
||||||
logger.info("Web code changed — deploying")
|
|
||||||
ok, err = run_shell("./deploy.sh")
|
|
||||||
if ok:
|
|
||||||
send_alert("[deploy] ok")
|
|
||||||
else:
|
|
||||||
send_alert(f"[deploy] failed: {err}")
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ all_jsonl AS (
|
|||||||
tenant_id,
|
tenant_id,
|
||||||
slots AS slots_json
|
slots AS slots_json
|
||||||
FROM read_json(
|
FROM read_json(
|
||||||
@LANDING_DIR || '/playtomic/*/*/availability_' || @start_ds || '*.jsonl.gz',
|
@LANDING_DIR || '/playtomic/*/*/availability_*.jsonl.gz',
|
||||||
format = 'newline_delimited',
|
format = 'newline_delimited',
|
||||||
columns = {
|
columns = {
|
||||||
date: 'VARCHAR',
|
date: 'VARCHAR',
|
||||||
@@ -46,6 +46,7 @@ all_jsonl AS (
|
|||||||
filename = true
|
filename = true
|
||||||
)
|
)
|
||||||
WHERE tenant_id IS NOT NULL
|
WHERE tenant_id IS NOT NULL
|
||||||
|
AND CAST(date AS DATE) BETWEEN @start_ds AND @end_ds
|
||||||
),
|
),
|
||||||
raw_resources AS (
|
raw_resources AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ def create_app() -> Quart:
|
|||||||
|
|
||||||
@app.route("/market-score")
|
@app.route("/market-score")
|
||||||
async def legacy_market_score():
|
async def legacy_market_score():
|
||||||
return redirect("/en/market-score", 301)
|
return redirect("/en/padelnomics-score", 301)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Blueprint registration
|
# Blueprint registration
|
||||||
|
|||||||
@@ -6,6 +6,28 @@
|
|||||||
<meta name="description" content="{{ t.dir_page_meta_desc | tformat(count=total_suppliers, countries=total_countries) }}">
|
<meta name="description" content="{{ t.dir_page_meta_desc | tformat(count=total_suppliers, countries=total_countries) }}">
|
||||||
<meta property="og:title" content="{{ t.dir_page_title }} - {{ config.APP_NAME }}">
|
<meta property="og:title" content="{{ t.dir_page_title }} - {{ config.APP_NAME }}">
|
||||||
<meta property="og:description" content="{{ t.dir_page_og_desc | tformat(count=total_suppliers, countries=total_countries) }}">
|
<meta property="og:description" content="{{ t.dir_page_og_desc | tformat(count=total_suppliers, countries=total_countries) }}">
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "{{ t.dir_page_title }} - {{ config.APP_NAME }}",
|
||||||
|
"description": "{{ t.dir_page_meta_desc | tformat(count=total_suppliers, countries=total_countries) }}",
|
||||||
|
"url": "{{ config.BASE_URL }}/{{ lang }}/directory/",
|
||||||
|
"inLanguage": "{{ lang }}",
|
||||||
|
"isPartOf": {"@type": "WebSite", "name": "Padelnomics", "url": "{{ config.BASE_URL }}"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
|
||||||
|
{"@type": "ListItem", "position": 2, "name": "{{ t.dir_page_title }}", "item": "{{ config.BASE_URL }}/{{ lang }}/directory/"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--dir-green: #15803D;
|
--dir-green: #15803D;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
|
{% block paddle %}{% include "_payment_js.html" %}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
<meta name="description" content="{{ t.export_title }}">
|
||||||
<style>
|
<style>
|
||||||
.exp-wrap { max-width: 640px; margin: 0 auto; padding: 3rem 0; }
|
.exp-wrap { max-width: 640px; margin: 0 auto; padding: 3rem 0; }
|
||||||
.exp-hero { text-align: center; margin-bottom: 2rem; }
|
.exp-hero { text-align: center; margin-bottom: 2rem; }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
{% block title %}Business Plan Details — {{ config.APP_NAME }}{% endblock %}
|
{% block title %}Business Plan Details — {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
<meta name="description" content="Business Plan Details — {{ config.APP_NAME }}">
|
||||||
<style>
|
<style>
|
||||||
.bp-wrap { max-width: 680px; margin: 0 auto; padding: 3rem 0; }
|
.bp-wrap { max-width: 680px; margin: 0 auto; padding: 3rem 0; }
|
||||||
.bp-hero { margin-bottom: 2rem; }
|
.bp-hero { margin-bottom: 2rem; }
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ t.export_success_title }} - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.export_success_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<meta name="description" content="{{ t.export_success_title }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center">
|
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center">
|
||||||
<div style="font-size:3rem;margin-bottom:1rem">✓</div>
|
<div style="font-size:3rem;margin-bottom:1rem">✓</div>
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
{% block title %}{{ t.export_waitlist_title }} - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.export_waitlist_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<meta name="description" content="{{ t.export_waitlist_title }} - {{ config.APP_NAME }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
<div class="card max-w-md mx-auto mt-8 text-center">
|
<div class="card max-w-md mx-auto mt-8 text-center">
|
||||||
|
|||||||
@@ -9,6 +9,35 @@
|
|||||||
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/planner.css') }}?v={{ v }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/planner.css') }}?v={{ v }}">
|
||||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "{{ t.planner_page_title }} - {{ config.APP_NAME }}",
|
||||||
|
"description": "{{ t.planner_meta_desc }}",
|
||||||
|
"url": "{{ config.BASE_URL }}/{{ lang }}/planner/",
|
||||||
|
"inLanguage": "{{ lang }}",
|
||||||
|
"isPartOf": {"@type": "WebSite", "name": "Padelnomics", "url": "{{ config.BASE_URL }}"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
|
||||||
|
{"@type": "ListItem", "position": 2, "name": "{{ t.nav_planner }}", "item": "{{ config.BASE_URL }}/{{ lang }}/planner/"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "Padelnomics Padel Court Planner",
|
||||||
|
"applicationCategory": "BusinessApplication",
|
||||||
|
"operatingSystem": "Web",
|
||||||
|
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% macro slider(name, label, min, max, step, value, tip='') %}
|
{% macro slider(name, label, min, max, step, value, tip='') %}
|
||||||
|
|||||||
@@ -7,6 +7,28 @@
|
|||||||
<meta property="og:title" content="{{ t.features_title_prefix }} | {{ config.APP_NAME }}">
|
<meta property="og:title" content="{{ t.features_title_prefix }} | {{ config.APP_NAME }}">
|
||||||
<meta property="og:description" content="{{ t.features_meta_desc }}">
|
<meta property="og:description" content="{{ t.features_meta_desc }}">
|
||||||
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "{{ t.features_title_prefix }} | {{ config.APP_NAME }}",
|
||||||
|
"description": "{{ t.features_meta_desc }}",
|
||||||
|
"url": "{{ config.BASE_URL }}/{{ lang }}/features",
|
||||||
|
"inLanguage": "{{ lang }}",
|
||||||
|
"isPartOf": {"@type": "WebSite", "name": "Padelnomics", "url": "{{ config.BASE_URL }}"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
|
||||||
|
{"@type": "ListItem", "position": 2, "name": "{{ t.features_title_prefix }}", "item": "{{ config.BASE_URL }}/{{ lang }}/features"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ STATIC_PATHS = [
|
|||||||
"/imprint",
|
"/imprint",
|
||||||
"/suppliers",
|
"/suppliers",
|
||||||
"/markets",
|
"/markets",
|
||||||
"/market-score",
|
"/padelnomics-score",
|
||||||
"/planner/",
|
"/planner/",
|
||||||
"/directory/",
|
"/directory/",
|
||||||
|
"/opportunity-map",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -65,16 +66,16 @@ async def _generate_sitemap_xml(base_url: str) -> str:
|
|||||||
for lang in LANGS:
|
for lang in LANGS:
|
||||||
entries.append(_url_entry(f"{base}/{lang}{path}", alternates))
|
entries.append(_url_entry(f"{base}/{lang}{path}", alternates))
|
||||||
|
|
||||||
# Billing pricing — no lang prefix, no hreflang
|
|
||||||
entries.append(_url_entry(f"{base}/billing/pricing", []))
|
|
||||||
|
|
||||||
# Published articles — both lang variants with accurate lastmod.
|
# Published articles — both lang variants with accurate lastmod.
|
||||||
# Exclude noindex articles (thin data) to keep sitemap signal-dense.
|
# Exclude noindex articles (thin data) to keep sitemap signal-dense.
|
||||||
|
# GROUP BY url_path: articles table has one row per language per url_path,
|
||||||
|
# but the for-lang loop already creates both lang entries per path.
|
||||||
articles = await fetch_all(
|
articles = await fetch_all(
|
||||||
"""SELECT url_path, COALESCE(updated_at, published_at) AS lastmod
|
"""SELECT url_path, MAX(COALESCE(updated_at, published_at)) AS lastmod
|
||||||
FROM articles
|
FROM articles
|
||||||
WHERE status = 'published' AND noindex = 0 AND published_at <= datetime('now')
|
WHERE status = 'published' AND noindex = 0 AND published_at <= datetime('now')
|
||||||
ORDER BY published_at DESC
|
GROUP BY url_path
|
||||||
|
ORDER BY MAX(published_at) DESC
|
||||||
LIMIT 25000"""
|
LIMIT 25000"""
|
||||||
)
|
)
|
||||||
for article in articles:
|
for article in articles:
|
||||||
|
|||||||
@@ -29,15 +29,16 @@
|
|||||||
<link rel="alternate" hreflang="de" href="{{ config.BASE_URL }}/de{{ path_suffix }}">
|
<link rel="alternate" hreflang="de" href="{{ config.BASE_URL }}/de{{ path_suffix }}">
|
||||||
<link rel="alternate" hreflang="x-default" href="{{ config.BASE_URL }}/en{{ path_suffix }}">
|
<link rel="alternate" hreflang="x-default" href="{{ config.BASE_URL }}/en{{ path_suffix }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
|
||||||
|
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
|
||||||
|
{% block head %}
|
||||||
<meta property="og:title" content="{{ config.APP_NAME }}">
|
<meta property="og:title" content="{{ config.APP_NAME }}">
|
||||||
<meta property="og:description" content="">
|
<meta property="og:description" content="">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="{{ config.BASE_URL }}{{ request.path }}">
|
<meta property="og:url" content="{{ config.BASE_URL }}{{ request.path }}">
|
||||||
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
|
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
{% endblock %}
|
||||||
|
|
||||||
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
|
|
||||||
{% block head %}{% endblock %}
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="nav-bar" id="main-nav">
|
<nav class="nav-bar" id="main-nav">
|
||||||
|
|||||||
Reference in New Issue
Block a user