Compare commits

...

10 Commits

Author SHA1 Message Date
Deeman
48401bd2af feat(articles): rewrite B2B article CTAs — directory → /quote form
All checks were successful
CI / test (push) Successful in 50s
CI / tag (push) Successful in 3s
All 12 hall-building articles now link to /quote (leads.quote_request).
Previously: 2 had broken directory prose, 4 had unlinked planner mentions,
4 had broken [→ placeholder] links, 2 had scenario cards but no CTA link.

- Group 1 (bauen/build-guide): replace directory section with quote CTA
- Group 2 (kosten/risiken): link planner refs, append quote CTA
- Group 3 (finanzierung): append quote CTA after scenario card
- Group 4 (standort/businessplan): fix broken [→] links to /de|en/planner,
  append quote CTA

CTA copy is contextual per article. Light-blue banner pattern, .btn class.
B2C gear articles unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:55:28 +01:00
Deeman
cd02726d4c chore(changelog): document B2B article CTA rewrite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:55:20 +01:00
Deeman
fbc259cafa fix(articles): fix broken CTA links + add /quote CTA in location and business plan articles
- padel-standort-analyse-de, padel-hall-location-guide-en:
  fix [→ ...] placeholders to /de/planner and /en/planner
  append quote CTA "Standort gefunden? Angebote einholen"
- padel-business-plan-bank-de, padel-business-plan-bank-requirements-en:
  fix [→ Businessplan erstellen] / [→ Generate your business plan] to planner
  append quote CTA "Bankfähige Zahlen plus passende Baupartner"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:39:59 +01:00
Deeman
992e448c18 fix(articles): add /quote CTA after scenario card in financing articles
Appends contextual quote CTA block to padel-halle-finanzierung-de.md
and padel-hall-financing-germany-en.md after the scenario card embed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:29:01 +01:00
Deeman
777a4af505 fix(articles): add /quote CTA + planner links in cost and risk articles
- padel-halle-kosten-de, padel-hall-cost-guide-en: link planner ref,
  append quote CTA "Zahlen prüfen — Angebote einholen"
- padel-halle-risiken-de, padel-hall-investment-risks-en: link planner
  in sensitivity tab mention, append quote CTA on risk management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:18:46 +01:00
Deeman
2c8c662e9e fix(articles): replace directory CTA with /quote in build guides
Removes the broken "find suppliers" directory section from
padel-halle-bauen-de.md and padel-hall-build-guide-en.md.
Replaces with a contextual light-blue quote CTA block linking to /quote.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 14:17:28 +01:00
Deeman
34f8e45204 merge(articles): iframe preview + collapsible meta + word count 2026-03-02 12:09:04 +01:00
Deeman
6b9187f420 fix(articles): iframe preview + collapsible meta + word count
Replace the auto-escaped `{{ body_html }}` div (showed raw HTML tags)
with a sandboxed `<iframe srcdoc>` pattern matching the email preview.
Both the initial page load and the HTMX live-update endpoint now build
a full `preview_doc` document embedding the public CSS and wrapping
content in `<div class="article-body">` — pixel-perfect against the
live article, admin styles fully isolated.

Also:
- Delete ~65 lines of redundant `.preview-body` custom CSS
- Add "Meta ▾" toolbar toggle to collapse/expand metadata strip
- Add word count footer in the editor pane (updates on input)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 12:01:16 +01:00
Deeman
94d92328b8 merge: fix article .md lookup + lighter editor
All checks were successful
CI / test (push) Successful in 51s
CI / tag (push) Successful in 3s
2026-03-02 11:47:13 +01:00
Deeman
100e200c3b fix(articles): find .md by slug scan + lighter editor theme
Two fixes:
- _find_article_md() scans _ARTICLES_DIR for files whose frontmatter
  slug matches, so padel-halle-bauen-de.md is found for slug
  'padel-halle-bauen'. The previous exact-name lookup missed any file
  where the filename ≠ slug (e.g. {slug}-{lang}.md naming convention).
- Editor pane: replace dark navy background with warm off-white (#FEFDFB)
  and dark text so it reads like a document, not a code editor.
2026-03-02 11:43:26 +01:00
16 changed files with 232 additions and 122 deletions

View File

@@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- **B2B article CTAs rewritten — all 12 now link to `/quote`** — zero articles previously linked to the quote lead-capture form. Each article's final section has been updated:
- `padel-halle-bauen-de` / `padel-hall-build-guide-en`: replaced broken "directory" section (no link) with a contextual light-blue quote CTA block
- `padel-halle-kosten-de` / `padel-hall-cost-guide-en`: planner mention linked to `/de/planner` / `/en/planner`; quote CTA block appended
- `padel-halle-risiken-de` / `padel-hall-investment-risks-en`: planner sensitivity-tab mention linked; quote CTA block appended
- `padel-halle-finanzierung-de` / `padel-hall-financing-germany-en`: quote CTA block appended after scenario card embed
- `padel-standort-analyse-de` / `padel-hall-location-guide-en`: fixed broken `[→ Standortanalyse starten]` / `[→ Run a location analysis]` placeholders (no href) to `/de/planner` / `/en/planner`; quote CTA block appended
- `padel-business-plan-bank-de` / `padel-business-plan-bank-requirements-en`: fixed broken `[→ Businessplan erstellen]` / `[→ Generate your business plan]` placeholders to `/de/planner` / `/en/planner`; quote CTA block appended
- CTA copy is contextual per article (not identical boilerplate); uses the light-blue banner pattern (`.btn` class, `#EFF6FF` background) consistent with other generated articles
- **Article editor preview now renders HTML correctly** — replaced the raw `{{ body_html }}` div (which Jinja2 auto-escaped to literal `<h1>...</h1>` text) with a sandboxed `<iframe srcdoc="...">` pattern. The route builds a full `preview_doc` HTML document embedding the public site stylesheet (`/static/css/output.css`) and wraps content in `<div class="article-body">`, so the preview is pixel-perfect against the live article. The `article_preview` POST endpoint uses the same pattern for HTMX live updates. Removed ~65 lines of redundant `.preview-body` custom CSS from the editor template.
### Changed ### Changed
- **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler. - **Semantic compression pass** — applied Casey Muratori's compression workflow (write concrete → observe patterns → compress genuine repetitions) across all three packages. Net result: ~200 lines removed, codebase simpler.
- **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines. - **`count_where()` helper** (`web/core.py`): compresses the `fetch_one("SELECT COUNT(*) ...") + null-check` pattern. Applied across 30+ call sites in admin, suppliers, directory, dashboard, public, and planner routes. Dashboard stats function shrinks from 75 to 25 lines.

View File

@@ -160,4 +160,10 @@ Ein bankfähiger Businessplan steht und fällt mit der Qualität der Finanzdaten
Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland. Der Businessplan-Export enthält alle 13 Gliederungsabschnitte mit automatisch befüllten Finanztabellen, einer KDDB-Berechnung für alle drei Szenarien und einer Übersicht der relevanten KfW-Programme für Ihr Bundesland.
[→ Businessplan erstellen] [→ Businessplan erstellen](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankfähige Zahlen plus passende Baupartner</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Zum überzeugenden Bankgespräch gehören nicht nur solide Zahlen — sondern auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Vorhaben in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -160,4 +160,10 @@ A bankable business plan depends on the quality of the financial model behind it
The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*. The business plan export includes all 13 sections with auto-populated financial tables, a DSCR calculation across all three scenarios, and a summary of applicable KfW and state programs for your *Bundesland*.
[→ Generate your business plan] [→ Generate your business plan](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Complete your bank file — get a build cost estimate</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs a financial model with a real contractor quote. Describe your project — we'll connect you with architects, court suppliers, and MEP specialists who can provide the cost documentation your bank needs. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -331,8 +331,10 @@ Building a padel hall is complex, but it is a solved problem. The failures are n
--- ---
## Find Builders and Suppliers Through Padelnomics ## Find the Right Build Partners
Padelnomics maintains a directory of verified build partners for padel hall projects: architects with sports facility experience, court suppliers, HVAC specialists, and operational consultants. <div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Get quotes from verified build partners</p>
If you're currently in Phase 1 or Phase 2 and looking for the right partners, the directory is the fastest place to start. <p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">From feasibility to court installation: describe your project in a few minutes — we'll connect you with vetted architects, court suppliers, and MEP specialists. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -191,4 +191,10 @@ Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on
The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated. The investors who succeed here are not the ones who found a cheaper build. They are the ones who understood the numbers precisely enough to make the right location and concept decisions early — and to structure their financing before the costs escalated.
**Next step:** Use the Padelnomics Financial Planner to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers. **Next step:** Use the [Padelnomics Financial Planner](/en/planner) to model your specific scenario — your city, your financing mix, your pricing assumptions. The figures in this article are your starting point; your hall deserves a projection built around your actual numbers.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Test your numbers against real market prices</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once your model is in shape, the next step is benchmarking against actual quotes. Describe your project — we'll connect you with build partners who can give you concrete figures for your specific facility. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -179,3 +179,9 @@ Your most powerful tool in every bank meeting: a complete financial model demons
[scenario:padel-halle-6-courts:full] [scenario:padel-halle-6-courts:full]
The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application. The Padelnomics business plan includes a full financing structure overview and use-of-funds breakdown — the exact format your bank needs to evaluate the application.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ready to take financing to the next step?</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">A credible bank application pairs your financial model with a real build cost estimate from a contractor. Describe your project — we'll connect you with build partners who provide the cost documentation lenders expect. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -218,6 +218,12 @@ The investors who succeed long-term in padel aren't the ones who found a risk-fr
## Model the Downside with Padelnomics ## Model the Downside with Padelnomics
The Padelnomics investment planner includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand? The [Padelnomics investment planner](/en/planner) includes a sensitivity analysis tab designed for exactly this kind of scenario work: how does ROI change at 40% vs 65% utilization? What does a six-month construction delay cost in total? What happens to the model when a competitor opens in year three and takes 20% of demand?
Good decisions need an honest model — not just the best-case assumptions. Good decisions need an honest model — not just the best-case assumptions.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Start with the right partners</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Most of the risks in this article are manageable with the right advisors, builders, and specialists on board from day one. Describe your project — we'll connect you with vetted partners who specialize in padel facilities. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -184,4 +184,10 @@ Padelnomics tracks venue density, booking platform utilisation, and demographic
Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it. Padelnomics analyzes market data for your target area: player density, competitive supply, demand signals from booking platform data, and demographic indicators at municipality level. For your candidate sites, Padelnomics produces a catchment area profile and a side-by-side comparison — so the decision is grounded in data rather than a map with a finger pointing at it.
[→ Run a location analysis] [→ Run a location analysis](/en/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Site shortlisted — time to get quotes</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Once a location passes your criteria, the next step is engaging architects and court suppliers. Describe your project — we'll connect you with vetted build partners who can give you concrete figures. Free and non-binding.</p>
<a href="/quote" class="btn">Request a Quote</a>
</div>

View File

@@ -326,8 +326,10 @@ Eine Padelhalle zu bauen ist komplex — aber kein ungelöstes Problem. Die Fehl
--- ---
## Planer und Lieferanten finden ## Die richtigen Baupartner finden
Padelnomics führt ein Verzeichnis verifizierter Baupartner für Padelhallen im DACH-Raum: Architekten mit Sportanlagenerfahrung, Court-Lieferanten, Haustechnikspezialisten und Betriebsberater. <div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Angebote von verifizierten Baupartnern erhalten</p>
Wenn Sie gerade in Phase 1 oder Phase 2 sind und die richtigen Partner suchen, ist das Verzeichnis der schnellste Einstieg. <p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Von der Machbarkeitsstudie bis zum Court-Einbau: Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -199,3 +199,9 @@ Ihr wichtigstes Werkzeug in jedem Bankgespräch: ein vollständiges Finanzmodell
[scenario:padel-halle-6-courts:full] [scenario:padel-halle-6-courts:full]
Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann. Der Padelnomics-Businessplan enthält eine vollständige Finanzierungsstrukturübersicht und eine Mittelverwendungsplanung, die direkt in Ihr Bankgespräch mitgenommen werden kann.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Bankgespräch vorbereiten — Baupartner finden</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Bereit, die Finanzierungsphase anzugehen? Für ein überzeugendes Bankgespräch brauchen Sie auch ein konkretes Angebot von realen Baupartnern. Schildern Sie Ihr Projekt in wenigen Minuten — wir stellen den Kontakt zu Architekten, Court-Lieferanten und Haustechnikspezialisten her, die bankfähige Kalkulationsunterlagen liefern. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -189,4 +189,10 @@ Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,
Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service. Richtig aufgesetzt, stimmt die Wirtschaftlichkeit: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 35 Jahren realistisch. Der deutsche Padel-Markt wächst weiter — aber mit wachsendem Angebot steigen auch die Erwartungen der Spieler und die Anforderungen an Konzept, Lage und Service.
**Nächster Schritt:** Nutzen Sie den Padelnomics Financial Planner, um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut. **Nächster Schritt:** Nutzen Sie den [Padelnomics Financial Planner](/de/planner), um Ihre spezifische Konstellation durchzurechnen — mit Ihrem Standort, Ihrer Finanzierungsstruktur und Ihren Preisannahmen. Die Zahlen in diesem Artikel sind Ihr Ausgangspunkt — Ihre Halle verdient eine Kalkulation, die auf Ihren tatsächlichen Rahmenbedingungen aufbaut.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Zahlen prüfen — Angebote einholen</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Wenn Ihre Kalkulation steht, ist der nächste Schritt die Konfrontation mit realen Marktpreisen. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu Baupartnern her, die konkrete Angebote auf Basis Ihrer Anlage machen können. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -216,6 +216,12 @@ Niemand kann alle Risiken eliminieren. Aber die Investoren, die langfristig erfo
## Die Padelnomics-Investitionsrechnung ## Die Padelnomics-Investitionsrechnung
Der Padelnomics-Planer enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht? Der [Padelnomics-Planer](/de/planner) enthält einen Sensitivitätsanalyse-Tab, der genau diese Szenarien berechenbar macht: Wie verändert sich der ROI bei 40 versus 65 Prozent Auslastung? Was kostet ein sechsmonatiger Bauverzug? Was passiert, wenn ein Wettbewerber in Jahr drei 20 Prozent Ihrer Nachfrage abzieht?
Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen. Gute Entscheidungen brauchen ein ehrliches Modell — nicht nur die besten Annahmen.
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Ihr Projekt mit den richtigen Partnern absichern</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Das beste Risikomanagement beginnt mit der richtigen Auswahl an Planern und Baupartnern. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Architekten, Court-Lieferanten und Haustechnikspezialisten her, die sich auf Padelanlagen spezialisiert haben. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -174,4 +174,10 @@ Padelnomics erfasst Anlagendichte, Buchungsplattform-Auslastung und demografisch
Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig. Padelnomics wertet Marktdaten für Ihr Zielgebiet aus: Spielerdichte, Wettbewerbsdichte, Court-Nachfrage-Indikatoren aus Buchungsplattformdaten und demografische Kennzahlen auf Gemeindeebene. Für Ihre potenziellen Standorte erstellt Padelnomics ein Einzugsgebietsprofil und einen Standortvergleich — so dass die Entscheidung auf einer Datenbasis getroffen werden kann, nicht auf einer Karte mit Fingerzeig.
[→ Standortanalyse starten] [→ Standortanalyse starten](/de/planner)
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1.5rem 2rem;margin:2rem 0;">
<p style="margin:0 0 0.5rem;font-weight:600;color:#0F172A;font-size:1.0625rem;">Den richtigen Standort gefunden? Angebote einholen.</p>
<p style="margin:0 0 1rem;color:#334155;font-size:0.9375rem;">Sobald ein Standort die Kriterien erfüllt, folgt der nächste Schritt: die Kontaktaufnahme mit Architekten und Court-Lieferanten. Schildern Sie Ihr Vorhaben — wir stellen den Kontakt zu geprüften Baupartnern her. Kostenlos und unverbindlich.</p>
<a href="/quote" class="btn">Angebot anfordern</a>
</div>

View File

@@ -2121,6 +2121,27 @@ _ARTICLES_DIR = Path(__file__).parent.parent.parent.parent.parent / "data" / "co
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
def _find_article_md(slug: str) -> Path | None:
"""Return the Path of the .md file whose frontmatter slug matches, or None.
Tries the exact name first ({slug}.md), then scans _ARTICLES_DIR for any
file whose YAML frontmatter contains 'slug: <slug>'. This handles the
common pattern where files are named {slug}-{lang}.md but the frontmatter
slug omits the language suffix.
"""
if not _ARTICLES_DIR.is_dir():
return None
exact = _ARTICLES_DIR / f"{slug}.md"
if exact.exists():
return exact
for path in _ARTICLES_DIR.glob("*.md"):
raw = path.read_text(encoding="utf-8")
m = _FRONTMATTER_RE.match(raw)
if m and f"slug: {slug}" in m.group(1):
return path
return None
async def _sync_static_articles() -> None: async def _sync_static_articles() -> None:
"""Upsert static .md articles from data/content/articles/ into the DB. """Upsert static .md articles from data/content/articles/ into the DB.
@@ -2437,11 +2458,11 @@ async def article_new():
if not title or not body: if not title or not body:
await flash("Title and body are required.", "error") await flash("Title and body are required.", "error")
return await render_template("admin/article_form.html", data=dict(form), editing=False) return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
if is_reserved_path(url_path): if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") 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) return await render_template("admin/article_form.html", data=dict(form), editing=False, preview_doc="")
# Render markdown → HTML with scenario + product cards baked in # Render markdown → HTML with scenario + product cards baked in
body_html = mistune.html(body) body_html = mistune.html(body)
@@ -2474,7 +2495,7 @@ async def article_new():
await flash(f"Article '{title}' created.", "success") await flash(f"Article '{title}' created.", "success")
return redirect(url_for("admin.articles")) return redirect(url_for("admin.articles"))
return await render_template("admin/article_form.html", data={}, editing=False, body_html="") return await render_template("admin/article_form.html", data={}, editing=False, preview_doc="")
@bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"]) @bp.route("/articles/<int:article_id>/edit", methods=["GET", "POST"])
@@ -2510,7 +2531,7 @@ async def article_edit(article_id: int):
if is_reserved_path(url_path): if is_reserved_path(url_path):
await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error") await flash(f"URL path '{url_path}' conflicts with a reserved route.", "error")
return await render_template( return await render_template(
"admin/article_form.html", data=dict(form), editing=True, article_id=article_id, "admin/article_form.html", data=dict(form), editing=True, article_id=article_id, preview_doc="",
) )
# Re-render if body provided # Re-render if body provided
@@ -2544,16 +2565,24 @@ async def article_edit(article_id: int):
# Load markdown source if available (manual or generated) # Load markdown source if available (manual or generated)
from ..content.routes import BUILD_DIR as CONTENT_BUILD_DIR from ..content.routes import BUILD_DIR as CONTENT_BUILD_DIR
md_path = _ARTICLES_DIR / f"{article['slug']}.md" md_path = _find_article_md(article["slug"])
if not md_path.exists(): if md_path is None:
lang = article["language"] or "en" lang = article["language"] or "en"
md_path = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md" fallback = CONTENT_BUILD_DIR / lang / "md" / f"{article['slug']}.md"
raw = md_path.read_text() if md_path.exists() else "" md_path = fallback if fallback.exists() else None
raw = md_path.read_text() if md_path else ""
# Strip YAML frontmatter so only the prose body appears in the editor # Strip YAML frontmatter so only the prose body appears in the editor
m = _FRONTMATTER_RE.match(raw) m = _FRONTMATTER_RE.match(raw)
body = raw[m.end():].lstrip("\n") if m else raw body = raw[m.end():].lstrip("\n") if m else raw
body_html = mistune.html(body) if body else "" body_html = mistune.html(body) if body else ""
css_url = url_for("static", filename="css/output.css")
preview_doc = (
f"<!doctype html><html><head>"
f"<link rel='stylesheet' href='{css_url}'>"
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
) if body_html else ""
data = {**dict(article), "body": body} data = {**dict(article), "body": body}
return await render_template( return await render_template(
@@ -2561,7 +2590,7 @@ async def article_edit(article_id: int):
data=data, data=data,
editing=True, editing=True,
article_id=article_id, article_id=article_id,
body_html=body_html, preview_doc=preview_doc,
) )
@@ -2570,13 +2599,19 @@ async def article_edit(article_id: int):
@csrf_protect @csrf_protect
async def article_preview(): async def article_preview():
"""Render markdown body to HTML for the live editor preview panel.""" """Render markdown body to HTML for the live editor preview panel."""
from markupsafe import Markup
form = await request.form form = await request.form
body = form.get("body", "") body = form.get("body", "")
m = _FRONTMATTER_RE.match(body) m = _FRONTMATTER_RE.match(body)
body = body[m.end():].lstrip("\n") if m else body body = body[m.end():].lstrip("\n") if m else body
body_html = Markup(mistune.html(body)) if body else "" body_html = mistune.html(body) if body else ""
return await render_template("admin/partials/article_preview.html", body_html=body_html) css_url = url_for("static", filename="css/output.css")
preview_doc = (
f"<!doctype html><html><head>"
f"<link rel='stylesheet' href='{css_url}'>"
f"<style>html,body{{margin:0;padding:0}}body{{padding:2rem 2.5rem}}</style>"
f"</head><body><div class='article-body'>{body_html}</div></body></html>"
) if body_html else ""
return await render_template("admin/partials/article_preview.html", preview_doc=preview_doc)
@bp.route("/articles/<int:article_id>/delete", methods=["POST"]) @bp.route("/articles/<int:article_id>/delete", methods=["POST"])

View File

@@ -156,8 +156,8 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.375rem 0.875rem; padding: 0.375rem 0.875rem;
background: #1E293B; background: #F8FAFC;
border-bottom: 1px solid #0F172A; border-bottom: 1px solid #E2E8F0;
flex-shrink: 0; flex-shrink: 0;
} }
.ae-pane--preview .ae-pane__header { .ae-pane--preview .ae-pane__header {
@@ -171,13 +171,11 @@
letter-spacing: 0.09em; letter-spacing: 0.09em;
color: #94A3B8; color: #94A3B8;
} }
.ae-pane--editor .ae-pane__label { color: #475569; }
.ae-pane__hint { .ae-pane__hint {
font-size: 0.625rem; font-size: 0.625rem;
font-family: var(--font-mono); font-family: var(--font-mono);
color: #475569; color: #94A3B8;
} }
.ae-pane--preview .ae-pane__hint { color: #94A3B8; }
/* The markdown textarea */ /* The markdown textarea */
.ae-editor { .ae-editor {
@@ -185,96 +183,62 @@
resize: none; resize: none;
border: none; border: none;
outline: none; outline: none;
padding: 1.125rem 1.25rem; padding: 1.5rem 2rem;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.8125rem; font-size: 0.875rem;
line-height: 1.75; line-height: 1.8;
background: #0F172A; background: #FEFDFB;
color: #CBD5E1; color: #1E293B;
caret-color: #3B82F6; caret-color: #1D4ED8;
tab-size: 2; tab-size: 2;
} }
.ae-editor::placeholder { color: #334155; } .ae-editor::placeholder { color: #CBD5E1; }
.ae-editor:focus { outline: none; } .ae-editor:focus { outline: none; }
/* Preview pane */ /* Preview pane — iframe fills the content area */
.ae-preview { #ae-preview-content {
flex: 1; flex: 1;
overflow-y: auto; display: flex;
padding: 1.5rem 2rem; min-height: 0;
background: #fff;
} }
/* Preview typography — maps rendered markdown */
.ae-preview .preview-body { max-width: 42rem; }
.ae-preview .preview-body h1 {
font-family: var(--font-display);
font-size: 1.625rem; font-weight: 700;
color: #0F172A; margin: 0 0 1rem;
line-height: 1.25;
}
.ae-preview .preview-body h2 {
font-family: var(--font-display);
font-size: 1.25rem; font-weight: 600;
color: #0F172A; margin: 1.75rem 0 0.625rem;
}
.ae-preview .preview-body h3 {
font-size: 1.0625rem; font-weight: 600;
color: #0F172A; margin: 1.25rem 0 0.5rem;
}
.ae-preview .preview-body h4 {
font-size: 0.9375rem; font-weight: 600;
color: #334155; margin: 1rem 0 0.375rem;
}
.ae-preview .preview-body p { margin: 0 0 0.875rem; color: #1E293B; line-height: 1.75; }
.ae-preview .preview-body ul,
.ae-preview .preview-body ol { margin: 0 0 0.875rem 1.375rem; color: #1E293B; }
.ae-preview .preview-body li { margin: 0.3rem 0; line-height: 1.65; }
.ae-preview .preview-body code {
font-family: var(--font-mono); font-size: 0.8125rem;
background: #F1F5F9; color: #1D4ED8;
padding: 0.1rem 0.35rem; border-radius: 3px;
}
.ae-preview .preview-body pre {
background: #0F172A; color: #CBD5E1;
padding: 1rem 1.125rem; border-radius: 6px;
overflow-x: auto; margin: 0 0 0.875rem;
font-size: 0.8125rem; line-height: 1.65;
}
.ae-preview .preview-body pre code {
background: none; color: inherit; padding: 0;
}
.ae-preview .preview-body blockquote {
border-left: 3px solid #1D4ED8;
padding-left: 1rem; margin: 0 0 0.875rem;
color: #475569;
}
.ae-preview .preview-body a { color: #1D4ED8; }
.ae-preview .preview-body hr {
border: none; border-top: 1px solid #E2E8F0;
margin: 1.5rem 0;
}
.ae-preview .preview-body strong { font-weight: 600; color: #0F172A; }
.ae-preview .preview-body table {
width: 100%; border-collapse: collapse;
font-size: 0.875rem; margin: 0 0 0.875rem;
}
.ae-preview .preview-body th {
background: #F8FAFC; font-weight: 600;
padding: 0.5rem 0.75rem; text-align: left;
border: 1px solid #E2E8F0; color: #0F172A;
}
.ae-preview .preview-body td {
padding: 0.5rem 0.75rem;
border: 1px solid #E2E8F0; color: #1E293B;
}
.ae-preview .preview-body tr:nth-child(even) td { background: #F8FAFC; }
.preview-placeholder { .preview-placeholder {
font-size: 0.875rem; font-size: 0.875rem;
color: #94A3B8; color: #94A3B8;
font-style: italic; font-style: italic;
margin: 0; margin: 1.5rem 2rem;
}
/* Collapsible metadata */
.ae-meta--collapsed { display: none; }
.ae-toolbar__toggle {
font-size: 0.75rem;
font-weight: 600;
color: #64748B;
background: none;
border: 1px solid #E2E8F0;
border-radius: 4px;
padding: 0.25rem 0.6rem;
cursor: pointer;
flex-shrink: 0;
}
.ae-toolbar__toggle:hover { color: #0F172A; border-color: #94A3B8; }
/* Word count footer */
.ae-footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0.25rem 0.875rem;
background: #F8FAFC;
border-top: 1px solid #E2E8F0;
flex-shrink: 0;
}
.ae-wordcount {
font-size: 0.625rem;
font-family: var(--font-mono);
color: #94A3B8;
} }
/* HTMX loading indicator — htmx toggles .htmx-request on the element */ /* HTMX loading indicator — htmx toggles .htmx-request on the element */
@@ -283,9 +247,15 @@
color: #94A3B8; color: #94A3B8;
font-family: var(--font-mono); font-family: var(--font-mono);
opacity: 0; opacity: 0;
transition: opacity 0.15s; transition: opacity 0.2s;
} }
.ae-loading.htmx-request { opacity: 1; } .ae-loading.htmx-request { opacity: 1; }
/* Responsive: stack on narrow screens */
@media (max-width: 900px) {
.ae-split { grid-template-columns: 1fr; }
.ae-pane--preview { display: none; }
}
</style> </style>
{% endblock %} {% endblock %}
@@ -304,6 +274,8 @@
{{ data.get('status', 'draft') }} {{ data.get('status', 'draft') }}
</span> </span>
{% endif %} {% endif %}
<button type="button" class="ae-toolbar__toggle"
onclick="document.querySelector('.ae-meta').classList.toggle('ae-meta--collapsed')">Meta ▾</button>
<button form="ae-form" type="submit" class="btn btn-sm"> <button form="ae-form" type="submit" class="btn btn-sm">
{% if editing %}Save Changes{% else %}Create Article{% endif %} {% if editing %}Save Changes{% else %}Create Article{% endif %}
</button> </button>
@@ -396,6 +368,9 @@
hx-include="[name=csrf_token]" hx-include="[name=csrf_token]"
hx-indicator="#ae-loading" hx-indicator="#ae-loading"
>{{ data.get('body', '') }}</textarea> >{{ data.get('body', '') }}</textarea>
<div class="ae-footer">
<span id="ae-wordcount" class="ae-wordcount">0 words</span>
</div>
</div> </div>
<!-- Right — Rendered preview --> <!-- Right — Rendered preview -->
@@ -404,14 +379,17 @@
<span class="ae-pane__label">Preview</span> <span class="ae-pane__label">Preview</span>
<span id="ae-loading" class="ae-loading">Rendering…</span> <span id="ae-loading" class="ae-loading">Rendering…</span>
</div> </div>
<div class="ae-preview"> <div id="ae-preview-content" style="flex:1;display:flex;min-height:0;">
<div id="ae-preview-content"> {% if preview_doc %}
{% if body_html %} <iframe
<div class="preview-body">{{ body_html }}</div> srcdoc="{{ preview_doc | e }}"
{% else %} style="flex:1;width:100%;border:none;display:block;"
<p class="preview-placeholder">Start writing to see a preview.</p> sandbox="allow-same-origin"
{% endif %} title="Article preview"
</div> ></iframe>
{% else %}
<p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %}
</div> </div>
</div> </div>
@@ -419,4 +397,17 @@
</form> </form>
</div> </div>
<script>
(function () {
var textarea = document.getElementById('body');
var counter = document.getElementById('ae-wordcount');
function updateCount() {
var text = textarea.value.trim();
var count = text ? text.split(/\s+/).length : 0;
counter.textContent = count + (count === 1 ? ' word' : ' words');
}
textarea.addEventListener('input', updateCount);
updateCount();
}());
</script>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,12 @@
{% if body_html %} {# HTMX partial: sandboxed iframe showing a rendered article preview.
<div class="preview-body">{{ body_html }}</div> Rendered by POST /admin/articles/preview. #}
{% if preview_doc %}
<iframe
srcdoc="{{ preview_doc | e }}"
style="flex:1;width:100%;border:none;display:block;"
sandbox="allow-same-origin"
title="Article preview"
></iframe>
{% else %} {% else %}
<p class="preview-placeholder">Start writing to see a preview.</p> <p class="preview-placeholder">Start writing to see a preview.</p>
{% endif %} {% endif %}