fix(content): slug transliteration, article links, country overview ranking
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -19,6 +19,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
- Dashboard boost buttons converted from inline `Paddle.Checkout.open()` to server round-trip via `/billing/checkout/item` endpoint
|
- Dashboard boost buttons converted from inline `Paddle.Checkout.open()` to server round-trip via `/billing/checkout/item` endpoint
|
||||||
- Stripe Tax add-on handles EU VAT (must be enabled in Stripe Dashboard)
|
- Stripe Tax add-on handles EU VAT (must be enabled in Stripe Dashboard)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **City slug transliteration** — replaced broken inline `REGEXP_REPLACE(LOWER(...), '[^a-z0-9]+', '-')` with new `@slugify` SQLMesh macro that uses `STRIP_ACCENTS` + `ß→ss` pre-replacement. Fixes: `Düsseldorf` → `dusseldorf` (was `d-sseldorf`), `Überlingen` → `uberlingen` (was `-berlingen`). Applied to `dim_venues`, `dim_cities`, `dim_locations`. Python `slugify()` in `core.py` updated to match.
|
||||||
|
- **B2B article market links** — added missing language prefix (`/markets/germany` → `/de/markets/germany` and `/en/markets/germany`). Without the prefix, Quart interpreted `markets` as a language code → 500 error.
|
||||||
|
- **Country overview top-5 city list** — changed ranking from raw `market_score DESC` (which inflated tiny towns with high density scores) to `padel_venue_count DESC` for top cities and `population DESC` for top opportunity cities. Germany now shows Berlin, Hamburg, München instead of Überlingen, Schwaigern.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **CRO overhaul — homepage and supplier landing pages** — rewrote all copy from feature-focused ("60+ variables", "6 analysis tabs") to outcome-focused JTBD framing ("Invest in Padel with Confidence, Not Guesswork"). Based on JTBD analysis: the visitor's job is confidence committing €200K+, not "plan faster."
|
- **CRO overhaul — homepage and supplier landing pages** — rewrote all copy from feature-focused ("60+ variables", "6 analysis tabs") to outcome-focused JTBD framing ("Invest in Padel with Confidence, Not Guesswork"). Based on JTBD analysis: the visitor's job is confidence committing €200K+, not "plan faster."
|
||||||
- **Homepage hero**: new headline, description, and trust-building bullets (bank-ready metrics, real market data, free/no-signup)
|
- **Homepage hero**: new headline, description, and trust-building bullets (bank-ready metrics, real market data, free/no-signup)
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ Before committing to a site search in any city, calibrate where it sits on this
|
|||||||
|
|
||||||
Padelnomics tracks venue density, booking platform utilisation, and demographic fit for cities across Europe. Use the country market overview to read the maturity stage of your target city before evaluating individual sites.
|
Padelnomics tracks venue density, booking platform utilisation, and demographic fit for cities across Europe. Use the country market overview to read the maturity stage of your target city before evaluating individual sites.
|
||||||
|
|
||||||
[→ View market data by country](/markets/germany)
|
[→ View market data by country](/en/markets/germany)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ Bevor Sie in einer Stadt konkret nach Objekten suchen, sollten Sie deren Marktre
|
|||||||
|
|
||||||
Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografische Kennzahlen für Städte europaweit. Den aktuellen Marktüberblick für Ihr Zielland finden Sie hier:
|
Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografische Kennzahlen für Städte europaweit. Den aktuellen Marktüberblick für Ihr Zielland finden Sie hier:
|
||||||
|
|
||||||
[→ Marktüberblick nach Land](/markets/germany)
|
[→ Marktüberblick nach Land](/de/markets/germany)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,21 @@ def normalize_eurostat_nuts(evaluator, code_col) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@macro()
|
||||||
|
def slugify(evaluator, col) -> str:
|
||||||
|
"""URL-safe slug: lowercase → ß→ss → strip accents → non-alnum to dashes → trim.
|
||||||
|
|
||||||
|
Usage in SQL: @slugify(city) AS city_slug
|
||||||
|
"""
|
||||||
|
c = str(col)
|
||||||
|
return (
|
||||||
|
f"TRIM(REGEXP_REPLACE("
|
||||||
|
f"LOWER(STRIP_ACCENTS(REPLACE(LOWER({c}), 'ß', 'ss'))), "
|
||||||
|
f"'[^a-z0-9]+', '-'"
|
||||||
|
f"), '-')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@macro()
|
@macro()
|
||||||
def infer_country_from_coords(evaluator, lat_col, lon_col) -> str:
|
def infer_country_from_coords(evaluator, lat_col, lon_col) -> str:
|
||||||
"""Infer ISO country code from lat/lon using bounding boxes for 8 European markets.
|
"""Infer ISO country code from lat/lon using bounding boxes for 8 European markets.
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ venue_cities AS (
|
|||||||
SELECT
|
SELECT
|
||||||
country_code,
|
country_code,
|
||||||
city AS city_name,
|
city AS city_name,
|
||||||
-- Lowercase before regex so uppercase letters aren't stripped to '-'
|
@slugify(city) AS city_slug,
|
||||||
LOWER(REGEXP_REPLACE(LOWER(city), '[^a-z0-9]+', '-')) AS city_slug,
|
|
||||||
COUNT(*) AS padel_venue_count,
|
COUNT(*) AS padel_venue_count,
|
||||||
AVG(lat) AS centroid_lat,
|
AVG(lat) AS centroid_lat,
|
||||||
AVG(lon) AS centroid_lon
|
AVG(lon) AS centroid_lon
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ locations AS (
|
|||||||
geoname_id,
|
geoname_id,
|
||||||
city_name AS location_name,
|
city_name AS location_name,
|
||||||
-- URL-safe location slug
|
-- URL-safe location slug
|
||||||
LOWER(REGEXP_REPLACE(LOWER(city_name), '[^a-z0-9]+', '-')) AS location_slug,
|
@slugify(city_name) AS location_slug,
|
||||||
country_code,
|
country_code,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ SELECT
|
|||||||
indoor_court_count,
|
indoor_court_count,
|
||||||
outdoor_court_count,
|
outdoor_court_count,
|
||||||
-- Conformed city key: enables deterministic joins to dim_cities / venue_pricing_benchmarks
|
-- Conformed city key: enables deterministic joins to dim_cities / venue_pricing_benchmarks
|
||||||
LOWER(REGEXP_REPLACE(LOWER(COALESCE(city, '')), '[^a-z0-9]+', '-')) AS city_slug,
|
@slugify(COALESCE(city, '')) AS city_slug,
|
||||||
extracted_date
|
extracted_date
|
||||||
FROM ranked
|
FROM ranked
|
||||||
QUALIFY ROW_NUMBER() OVER (
|
QUALIFY ROW_NUMBER() OVER (
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ SELECT
|
|||||||
SUM(padel_venue_count) AS total_venues,
|
SUM(padel_venue_count) AS total_venues,
|
||||||
ROUND(AVG(market_score), 1) AS avg_market_score,
|
ROUND(AVG(market_score), 1) AS avg_market_score,
|
||||||
MAX(market_score) AS top_city_market_score,
|
MAX(market_score) AS top_city_market_score,
|
||||||
-- Top 5 cities by market score for internal linking (DuckDB list slice syntax)
|
-- Top 5 cities by venue count (prominence), then score for internal linking
|
||||||
LIST(city_slug ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_slugs,
|
LIST(city_slug ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_slugs,
|
||||||
LIST(city_name ORDER BY market_score DESC NULLS LAST)[1:5] AS top_city_names,
|
LIST(city_name ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_names,
|
||||||
-- Opportunity score aggregates (NULL-safe: cities without geoname_id match excluded from AVG)
|
-- Opportunity score aggregates (NULL-safe: cities without geoname_id match excluded from AVG)
|
||||||
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
|
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
|
||||||
MAX(opportunity_score) AS top_opportunity_score,
|
MAX(opportunity_score) AS top_opportunity_score,
|
||||||
-- Top 5 cities by opportunity score (may differ from top market score cities)
|
-- Top 5 opportunity cities by population (prominence), then opportunity score
|
||||||
LIST(city_slug ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs,
|
LIST(city_slug ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs,
|
||||||
LIST(city_name ORDER BY opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_names,
|
LIST(city_name ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_names,
|
||||||
-- Pricing medians across cities (NULL when no Playtomic coverage in country)
|
-- Pricing medians across cities (NULL when no Playtomic coverage in country)
|
||||||
ROUND(MEDIAN(median_hourly_rate), 0) AS median_hourly_rate,
|
ROUND(MEDIAN(median_hourly_rate), 0) AS median_hourly_rate,
|
||||||
ROUND(MEDIAN(median_peak_rate), 0) AS median_peak_rate,
|
ROUND(MEDIAN(median_peak_rate), 0) AS median_peak_rate,
|
||||||
|
|||||||
@@ -767,9 +767,14 @@ async def get_all_paddle_prices() -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def slugify(text: str, max_length_chars: int = 80) -> str:
|
def slugify(text: str, max_length_chars: int = 80) -> str:
|
||||||
"""Convert text to URL-safe slug."""
|
"""Convert text to URL-safe slug.
|
||||||
|
|
||||||
|
Pre-replaces ß→ss before NFKD normalization so output matches the SQL
|
||||||
|
@slugify macro (which uses DuckDB STRIP_ACCENTS + REPLACE).
|
||||||
|
"""
|
||||||
|
text = text.lower().replace("ß", "ss")
|
||||||
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||||
text = re.sub(r"[^\w\s-]", "", text.lower())
|
text = re.sub(r"[^\w\s-]", "", text)
|
||||||
text = re.sub(r"[-\s]+", "-", text).strip("-")
|
text = re.sub(r"[-\s]+", "-", text).strip("-")
|
||||||
return text[:max_length_chars]
|
return text[:max_length_chars]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user