Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86be044116 | ||
|
|
5de0676f44 | ||
|
|
8a921ee18a | ||
|
|
81ec8733c7 | ||
|
|
07d8ea1c0e | ||
|
|
370fc1f70b | ||
|
|
e0c3f38c0a | ||
|
|
f9faa02683 | ||
|
|
109da23902 | ||
|
|
34065fa2ac | ||
|
|
d1a10ff243 | ||
|
|
5f48449d25 | ||
|
|
b7e44ac5b3 | ||
|
|
c2dfefcc1e | ||
|
|
e9d1b74618 | ||
|
|
4b5c237bee | ||
|
|
8c4a4078f9 | ||
|
|
5f756a2ba5 | ||
|
|
4ac17af503 | ||
|
|
0984657e72 | ||
|
|
73547ec876 | ||
|
|
129ca26143 | ||
|
|
9ea4ff55fa | ||
|
|
8a91fc752b | ||
|
|
4783067c6e | ||
|
|
c1e1f42aad | ||
|
|
ecd1cdd27a | ||
|
|
24ec7060b3 | ||
|
|
5c22ea9780 | ||
|
|
aee3733b49 | ||
|
|
1fdd2d07a4 | ||
|
|
2214d7a58f | ||
|
|
0f360fd230 | ||
|
|
bc7e40b531 | ||
|
|
ef85d3bb36 | ||
|
|
4d45b99cd8 | ||
|
|
b5db9d16b9 | ||
|
|
2e149fc1db |
@@ -17,9 +17,9 @@ jobs:
|
||||
- run: uv run pytest web/tests/ -x -q -p no:faulthandler
|
||||
- run: uv run ruff check web/src/ web/tests/
|
||||
|
||||
# Creates v<N> tag after tests pass. The on-server supervisor polls for new
|
||||
# tags every 60s and deploys automatically. No SSH keys or deploy credentials
|
||||
# needed in CI — only the built-in github.token.
|
||||
# Creates a v{YYYYMMDDHHMM} tag after tests pass on master.
|
||||
# The on-server supervisor polls for new tags every 60s and deploys
|
||||
# automatically. No SSH keys or deploy credentials needed in CI.
|
||||
tag:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,5 +32,6 @@ jobs:
|
||||
run: |
|
||||
git config user.name "CI"
|
||||
git config user.email "ci@noreply"
|
||||
git tag "v${{ github.run_number }}"
|
||||
git push origin "v${{ github.run_number }}"
|
||||
TAG="v$(date -u +%Y%m%d%H%M)"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Affiliate product system** — "Wirecutter for padel" editorial affiliate cards embedded in articles via `[product:slug]` and `[product-group:category]` markers, baked at build time into static HTML. `/go/<slug>` click-tracking redirect (302, GDPR-compliant daily-rotated IP hash). Admin CRUD (`/admin/affiliate`) with live preview, inline status toggle, HTMX search/filter. Click stats dashboard (pure CSS bar chart, top products/articles/retailers). 10 German equipment review article scaffolds seeded.
|
||||
- `web/src/padelnomics/migrations/versions/0026_affiliate_products.py`: `affiliate_products` + `affiliate_clicks` tables; `UNIQUE(slug, language)` constraint mirrors articles schema
|
||||
- `web/src/padelnomics/affiliate.py`: `get_product()`, `get_products_by_category()`, `get_all_products()`, `log_click()`, `hash_ip()`, `get_click_stats()`, `get_click_counts()`, `get_distinct_retailers()`
|
||||
- `web/src/padelnomics/content/routes.py`: `PRODUCT_RE`, `PRODUCT_GROUP_RE`, `bake_product_cards()` — chained after `bake_scenario_cards()` in `generate_articles()` and `preview_article()`
|
||||
- `web/src/padelnomics/app.py`: `/go/<slug>` route with rate limiting (60/min per IP) and referer-based article/language extraction
|
||||
- `web/src/padelnomics/admin/routes.py`: affiliate CRUD routes + `bake_product_cards()` chained in article rebuild flows
|
||||
- New templates: `partials/product_card.html`, `partials/product_group.html`, `admin/affiliate_products.html`, `admin/affiliate_form.html`, `admin/affiliate_dashboard.html`, `admin/partials/affiliate_results.html`, `admin/partials/affiliate_row.html`
|
||||
- `locales/en.json` + `locales/de.json`: 6 new affiliate i18n keys
|
||||
- `data/content/articles/`: 10 new German equipment review scaffolds (rackets, balls, shoes, accessories, gifts)
|
||||
- 26 tests in `web/tests/test_affiliate.py`
|
||||
|
||||
### Added
|
||||
- **Three-tier proxy system** for extraction pipeline: free (Webshare auto-fetched) → datacenter (`PROXY_URLS_DATACENTER`) → residential (`PROXY_URLS_RESIDENTIAL`). Webshare free proxies are now auto-fetched from their download API on each run — no more manually copying stale proxy lists.
|
||||
- `proxy.py`: added `fetch_webshare_proxies()` (stdlib urllib, bounded read + timeout), `load_proxy_tiers()` (assembles N tiers from env), generalised `make_tiered_cycler()` to accept `list[list[str]]` with N-level escalation. Exposes `is_exhausted()`, `active_tier_index()`, `tier_count()`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Padelnomics — Project Tracker
|
||||
|
||||
> Move tasks across columns as you work. Add new tasks at the top of the relevant column.
|
||||
> Last updated: 2026-02-27 (Phase 2b — EU NUTS-2 spatial join + US state income).
|
||||
> Last updated: 2026-02-28 (Affiliate product system — editorial gear cards + click tracking).
|
||||
|
||||
---
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
- [x] **pSEO article noindex** — `noindex` column on articles (migration 0025), `NOINDEX_THRESHOLDS` per-template lambdas in `content/__init__.py`, robots meta tag in `article_detail.html`, sitemap exclusion, pSEO dashboard count card + article row badge; 20 tests
|
||||
- [x] **group_key static article grouping** — migration 0020 adds `group_key TEXT` column; `_sync_static_articles()` auto-upserts `data/content/articles/*.md` on admin page load; `_get_article_list_grouped()` groups by `COALESCE(group_key, url_path)` so EN/DE static cornerstones pair into one row
|
||||
- [x] **Email-gated report PDF** — `reports/` blueprint with email capture gate + PDF download; premium WeasyPrint PDF (full-bleed navy cover, Padelnomics wordmark watermark, gold/teal accents); `make report-pdf` target; EN + DE i18n (26 keys, native German); state-of-padel report moved to `data/content/reports/`
|
||||
- [x] **Affiliate product system** — "Wirecutter for padel" editorial gear cards embedded in articles via `[product:slug]` / `[product-group:category]` markers, baked at build time; `/go/<slug>` click-tracking redirect (302, GDPR daily-rotated IP hash, rate-limited); admin CRUD with live preview, HTMX filter/search, status toggle; click stats dashboard (pure CSS charts); 10 German equipment review article scaffolds; 26 tests
|
||||
|
||||
### SEO & Legal
|
||||
- [x] Sitemap (both language variants, `<lastmod>` on all entries)
|
||||
@@ -243,7 +244,6 @@
|
||||
|
||||
### Marketing & Content
|
||||
- [ ] LinkedIn presence (ongoing — founder posts, thought leadership)
|
||||
- [ ] "Wirecutter for padel" affiliate site (racket reviews, gear guides)
|
||||
- [ ] "The Padel Business Report" newsletter
|
||||
- [ ] Equipment supplier affiliate partnerships (€500–1,000/lead or 5%)
|
||||
- [ ] Padel podcasts (guest appearances)
|
||||
|
||||
88
data/content/articles/beste-padelschlaeger-de.md
Normal file
88
data/content/articles/beste-padelschlaeger-de.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: "Die besten Padelschläger 2026: Unser ausführlicher Vergleich"
|
||||
slug: beste-padelschlaeger-de
|
||||
language: de
|
||||
url_path: /beste-padelschlaeger-2026
|
||||
meta_description: "Welcher Padelschläger ist der beste 2026? Wir haben die wichtigsten Modelle für Anfänger, Fortgeschrittene und Profis getestet und verglichen."
|
||||
---
|
||||
|
||||
# Die besten Padelschläger 2026: Unser ausführlicher Vergleich
|
||||
|
||||
<!-- TODO: Einleitung mit Hauptkeyword und USP dieser Seite (200–300 Wörter) -->
|
||||
|
||||
Wer einen neuen Padelschläger kaufen will, steht vor einer unüberschaubaren Auswahl. Mehr als 50 Marken, Hunderte von Modellen — und kein einziges unabhängiges Testlabor. Wir haben die meistverkauften und meistempfohlenen Schläger zusammengetragen und nach drei Kriterien bewertet: Spielgefühl, Haltbarkeit und Preis-Leistungs-Verhältnis.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Top-Empfehlungen
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Testsieger im Detail
|
||||
|
||||
<!-- TODO: Ausführliche Besprechung der Top 3–5 Modelle, je 300–500 Wörter pro Schläger -->
|
||||
|
||||
### Platz 1: [Produktname einfügen]
|
||||
|
||||
[product:platzhalter-schlaeger-1-amazon]
|
||||
|
||||
<!-- TODO: Erfahrungsbericht + Vor- und Nachteile im Prosatext -->
|
||||
|
||||
### Platz 2: [Produktname einfügen]
|
||||
|
||||
[product:platzhalter-schlaeger-2-amazon]
|
||||
|
||||
### Platz 3: [Produktname einfügen]
|
||||
|
||||
[product:platzhalter-schlaeger-3-amazon]
|
||||
|
||||
---
|
||||
|
||||
## So haben wir getestet
|
||||
|
||||
<!-- TODO: Kurze Beschreibung der Testmethodik (2–3 Absätze) -->
|
||||
|
||||
---
|
||||
|
||||
## Kaufberatung: Welcher Schläger passt zu mir?
|
||||
|
||||
<!-- TODO: Entscheidungsbaum / Tabelle nach Spielertyp -->
|
||||
|
||||
| Spielertyp | Empfohlene Form | Empfohlenes Gewicht |
|
||||
|---|---|---|
|
||||
| Anfänger | Rund | 355–365 g |
|
||||
| Allspieler | Tropfen | 360–370 g |
|
||||
| Fortgeschrittener | Diamant | 365–380 g |
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie oft sollte man einen Padelschläger wechseln?</summary>
|
||||
|
||||
<!-- TODO: Antwort (50–100 Wörter) -->
|
||||
|
||||
Bei regelmäßigem Spielen (2–3 Mal pro Woche) empfehlen wir einen Wechsel alle 12 bis 18 Monate. Der größte Qualitätsverlust entsteht nicht durch sichtbare Schäden, sondern durch den Abbau der Schaumstoffkerns, der das Spielgefühl verändert.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Was kostet ein guter Padelschläger?</summary>
|
||||
|
||||
<!-- TODO: Preisklassen-Überblick -->
|
||||
|
||||
Gute Einstiegsschläger gibt es ab 50 Euro. Für Fortgeschrittene empfehlen wir 100–200 Euro, für ambitionierte Spieler 200–350 Euro. Über 400 Euro kostet nur das Pro-Segment, das für die meisten Freizeitspieler überdimensioniert ist.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Runder oder Diamant-Schläger — was ist besser?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Runde Schläger verzeihen mehr Fehlschläge und eignen sich für Anfänger und defensive Spieler. Diamant-Schläger liefern mehr Power und werden von Angriffsspielern bevorzugt. Für die meisten Freizeitspieler ist eine Tropfen- oder runde Form die sicherere Wahl.
|
||||
|
||||
</details>
|
||||
69
data/content/articles/padel-ausruestung-anfaenger-de.md
Normal file
69
data/content/articles/padel-ausruestung-anfaenger-de.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: "Padel-Ausrüstung für Anfänger: Was brauche ich wirklich?"
|
||||
slug: padel-ausruestung-anfaenger-de
|
||||
language: de
|
||||
url_path: /padel-ausruestung-anfaenger
|
||||
meta_description: "Was braucht man für Padel? Unser Ausrüstungsguide für Einsteiger — von Schläger und Schuhen bis zur Schutztasche. Was ist unverzichtbar, was ist Luxus?"
|
||||
---
|
||||
|
||||
# Padel-Ausrüstung für Anfänger: Was brauche ich wirklich?
|
||||
|
||||
<!-- TODO: Einleitung — klare Orientierung für Einsteiger -->
|
||||
|
||||
Padel ist im Vergleich zu vielen anderen Sportarten günstig einzusteigen. Wer zum ersten Mal auf den Court geht, braucht eigentlich nur drei Dinge: einen Schläger, die richtigen Schuhe und Bälle. Der Rest ist komfortsteigerndes Zubehör — notwendig wird es erst, wenn man ernsthafter spielt.
|
||||
|
||||
---
|
||||
|
||||
## Die unverzichtbare Grundausstattung
|
||||
|
||||
### 1. Schläger
|
||||
|
||||
[product:platzhalter-anfaenger-schlaeger-amazon]
|
||||
|
||||
<!-- TODO: 1–2 Absätze zum Einstiegsschläger -->
|
||||
|
||||
### 2. Schuhe
|
||||
|
||||
[product:platzhalter-padelschuh-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### 3. Bälle
|
||||
|
||||
[product:platzhalter-ball-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Was kann ich mir zunächst sparen?
|
||||
|
||||
<!-- TODO: Schläger-Tasche, Griffband, Sportbrille — wann sinnvoll? -->
|
||||
|
||||
---
|
||||
|
||||
## Das komplette Anfänger-Set: Unsere Empfehlung
|
||||
|
||||
[product-group:accessory]
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie viel kostet ein komplettes Padel-Starterpaket?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Für rund 150 Euro bekommt man einen soliden Anfängerschläger (60–90 €), passende Padelschuhe (50–70 €) und eine Dose Bälle (6–10 €). Alles darüber hinaus ist optional.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Kann ich mit geliehener Ausrüstung starten?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Ja, für die ersten Einheiten ist das sinnvoll. Die meisten Padel-Center verleihen Schläger für 2–5 Euro pro Einheit. Wer mehr als 3–4 Mal spielen will, lohnt sich ein eigener Schläger — schon allein wegen des vertrauten Spielgefühls.
|
||||
|
||||
</details>
|
||||
@@ -91,6 +91,8 @@ Die Bilanz am ersten Betriebstag: Aktiva (Anlagevermögen nach CAPEX, Anfangsliq
|
||||
|
||||
## KfW-Förderprogramme für Padelhallen
|
||||
|
||||
Abschnitt 9 des Gliederungsrahmens verlangt: Welche Förderprogramme wurden geprüft? Hier ist die Antwort, die Ihr Businessplan liefern muss.
|
||||
|
||||
Die KfW bietet mehrere Programme, die für Padelhallen-Projekte relevant sein können. Wichtig: KfW-Kredite werden nicht direkt bei der KfW beantragt, sondern über die Hausbank. Die Hausbank leitet den Antrag weiter und trägt einen Teil des Ausfallrisikos mit — was erklärt, warum sie ein starkes Eigeninteresse an der Qualität des Businessplans hat.
|
||||
|
||||
**KfW Unternehmerkredit (037/047)**
|
||||
@@ -129,7 +131,7 @@ Was passiert, wenn die Auslastung 10 Prozentpunkte unter Plan liegt? Wenn die Ba
|
||||
|
||||
### 4. Unvollständiger CAPEX
|
||||
|
||||
Häufig unterschätzt: Nebenkosten des Baus (Architektenhonorar, Baunebenkosten, Baugenehmigungsgebühren), Working Capital für die Anlaufphase (3–6 Monate Betriebskosten als Puffer), Kosten der Betriebsaufnahme (Marketing, Erstausstattung, Versicherungen vor Eröffnung), Unvorhergesehenes (Bankstandard: 10 Prozent Contingency auf den Rohbau). Wer diese Positionen vergisst, finanziert sich zu knapp — und die Bank bemerkt es.
|
||||
Häufig unterschätzt: Nebenkosten des Baus (Architektenhonorar, Baunebenkosten, Baugenehmigungsgebühren), Working Capital für die Anlaufphase (3–6 Monate Betriebskosten als Puffer), Kosten der Betriebsaufnahme (Marketing, Erstausstattung, Versicherungen vor Eröffnung), Unvorhergesehenes (Mindestpuffer: 10 Prozent auf den Rohbau — bei Sportstättenumbauten realistisch eher 15–20 Prozent). Wer diese Positionen vergisst, finanziert sich zu knapp — und die Bank bemerkt es.
|
||||
|
||||
### 5. KfW nicht adressiert
|
||||
|
||||
@@ -148,7 +150,7 @@ Fragen, die Sie sich vor der Bürgschaftsübernahme stellen sollten:
|
||||
- Gibt es Vermögenswerte, die ich herauslösen kann (z.B. durch Schenkung an Ehepartner vor Gründung — hier unbedingt Rechtsberatung einholen, da Anfechtungsrisiken bestehen)?
|
||||
- Wie viele Monate Verlustbetrieb kann ich aus eigenen Mitteln abfedern?
|
||||
|
||||
Wer diese Fragen beantwortet hat, hat das Projekt ernst genommen.
|
||||
Wer diese Fragen beantwortet hat, hat das Projekt ernst genommen. Das spüren Banken.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ The formula:
|
||||
DSCR = operating cash flow ÷ annual debt service (interest + principal)
|
||||
```
|
||||
|
||||
The standard in German SME lending: **1.2 to 1.5x**. For every €1 of debt service, the project needs to generate €1.20–1.50 of cash flow. Below 1.2x, you'll either face rejection or be asked to inject more equity. A plan that doesn't make the DSCR calculation transparent forces the loan officer to do the math himself — and he'll be more conservative than you.
|
||||
The standard in German SME lending: **1.2 to 1.5x**. For every €1 of debt service, the project needs to generate €1.20–1.50 of cash flow. Below 1.2x, you'll either face rejection or be asked to inject more equity. A plan that doesn't make the DSCR calculation transparent forces the loan officer to do the math himself — and they'll be more conservative than you.
|
||||
|
||||
The other hard constraint is **equity contribution** (*Eigenkapitalquote*): banks typically expect the founder to put in 20–30% of total investment. KfW subsidy programs can partly substitute for equity (more on that below), but they never replace it entirely. Coming to the table with 10% equity rarely works.
|
||||
|
||||
@@ -89,6 +89,8 @@ The balance sheet on Day 1: assets (fixed assets after CAPEX, opening cash) vers
|
||||
|
||||
## KfW Subsidy Programs for Padel Hall Projects
|
||||
|
||||
Section 9 of the business plan framework above asks which financing programs have been evaluated. Here's the answer your plan needs to provide.
|
||||
|
||||
KfW (Germany's state development bank) offers several programs relevant to padel hall construction and launch. One crucial operational detail: KfW loans are not applied for directly at KfW. They're applied for through your *Hausbank* (house bank), which passes the application to KfW and shares a portion of the default risk. This is precisely why your Hausbank cares so much about the quality of your business plan — they're on the hook too.
|
||||
|
||||
**KfW Unternehmerkredit (programs 037/047)**
|
||||
@@ -109,7 +111,7 @@ Each German state (*Bundesland*) runs its own SME and startup lending programs t
|
||||
- Hamburg: IFB Hamburg
|
||||
- Saxony: Sächsische Aufbaubank (SAB)
|
||||
|
||||
These programs are overlooked in the majority of business plans we've reviewed — despite the fact that combining them with KfW can meaningfully reduce the equity burden.
|
||||
These programs are overlooked in the majority of business plans we've reviewed — even though combining them with KfW can meaningfully reduce the equity burden.
|
||||
|
||||
---
|
||||
|
||||
@@ -129,7 +131,7 @@ What happens if utilization comes in 10 percentage points below plan? If constru
|
||||
|
||||
### 4. Incomplete CAPEX
|
||||
|
||||
Frequently underestimated items: architect and engineering fees, permitting fees and costs of the *Baugenehmigung* (building permit), working capital for the ramp-up period (3–6 months of operating costs), pre-opening expenses (marketing, initial inventory, pre-opening insurance), and contingency (the industry standard is 10% of raw construction costs). Forget these, and you're underfunded from Day 1.
|
||||
Frequently underestimated items: architect and engineering fees, permitting fees and costs of the *Baugenehmigung* (building permit), working capital for the ramp-up period (3–6 months of operating costs), pre-opening expenses (marketing, initial inventory, pre-opening insurance), and contingency (minimum 10% of raw construction costs — 15–20% is more realistic for sports hall conversions). Forget these, and you're underfunded from Day 1.
|
||||
|
||||
### 5. No mention of KfW or subsidy programs
|
||||
|
||||
@@ -148,7 +150,7 @@ Questions worth answering before you proceed:
|
||||
- Are there assets that could be structured outside the exposure (specialist legal advice is essential here, as pre-signing asset transfers can be challenged under German insolvency law)?
|
||||
- How many months of operating losses could I absorb from personal resources?
|
||||
|
||||
A founder who has worked through these questions has taken the project seriously. That comes across in a bank conversation.
|
||||
A founder who has worked through these questions has taken the project seriously. Banks can tell.
|
||||
|
||||
---
|
||||
|
||||
|
||||
67
data/content/articles/padel-geschenke-de.md
Normal file
67
data/content/articles/padel-geschenke-de.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Padel-Geschenke: Die besten Ideen für Padelbegeisterte"
|
||||
slug: padel-geschenke-de
|
||||
language: de
|
||||
url_path: /padel-geschenke
|
||||
meta_description: "Padel-Geschenke für Geburtstage, Weihnachten oder als Überraschung. Von der günstigen Kleinigkeit bis zum hochwertigen Schläger — für jeden Budget."
|
||||
---
|
||||
|
||||
# Padel-Geschenke: Die besten Ideen für Padelbegeisterte
|
||||
|
||||
<!-- TODO: Einleitung — Padel boomt, Geschenkideen gefragt -->
|
||||
|
||||
Padel ist der am schnellsten wachsende Sport Europas — und viele haben gerade erst damit begonnen. Wer einem Padel-Fan ein Geschenk machen will, steht vor der Frage: Was fehlt ihm noch? Dieser Guide listet die besten Ideen nach Preisklassen, vom kleinen Mitbringsel bis zum Wunschschläger.
|
||||
|
||||
---
|
||||
|
||||
## Geschenke unter 15 Euro
|
||||
|
||||
[product-group:grip]
|
||||
|
||||
<!-- TODO: Griffband, Bälle, kleine Accessoires -->
|
||||
|
||||
---
|
||||
|
||||
## Geschenke unter 50 Euro
|
||||
|
||||
[product-group:accessory]
|
||||
|
||||
<!-- TODO: Sporttasche, Cover, Trainingszubehör -->
|
||||
|
||||
---
|
||||
|
||||
## Geschenke unter 100 Euro
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
[product:platzhalter-schuh-amazon]
|
||||
|
||||
---
|
||||
|
||||
## Das perfekte Geschenk: Ein neuer Schläger
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
<!-- TODO: Hinweis auf Wunschliste / Amazon-Wunschliste-Tipp -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie finde ich heraus, welcher Schläger passt?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Fragen Sie die beschenkte Person nach ihrem aktuellen Modell oder lassen Sie sie aus einer Empfehlungsliste wählen. Schläger sind sehr persönlich — eine Gutscheinkarte für einen Fachhandel ist oft die sicherste Option.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Gibt es Padel-Geschenksets?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Einige Marken bieten Starter-Sets an (Schläger + Bälle + Cover). Diese sind im Vergleich zum Einzelkauf oft günstiger und eignen sich als Komplett-Einstiegsgeschenk für Neuspieler.
|
||||
|
||||
</details>
|
||||
@@ -31,7 +31,7 @@ Steps 1–5 Steps 6–11 Steps 12–16 Steps 17–20 Step
|
||||
|
||||
## Phase 1: Feasibility and Concept (Months 1–3)
|
||||
|
||||
This is the most important phase and the one where projects most often go wrong in one of two directions: either stopping too early because the first obstacle looks daunting, or moving too fast because enthusiasm outpaces analysis. Rigorous work here prevents expensive corrections later.
|
||||
This is the most important phase — and where projects most often go wrong in one of two directions: stopping too early because the first obstacle looks daunting, or moving too fast because enthusiasm outpaces analysis. Rigorous work here prevents expensive corrections later.
|
||||
|
||||
### Step 1: Market Research
|
||||
|
||||
@@ -49,7 +49,7 @@ Good market research won't guarantee success, but it will protect you from the m
|
||||
|
||||
Your market research should drive your concept. How many courts? Which customer segments — competitive recreational players, club training, corporate wellness, broad community use? What service level — a pure booking facility or a full-concept venue with lounge, bar, pro shop, and coaching program?
|
||||
|
||||
Every decision here cascades into investment requirements, operating costs, and revenue potential. Nail this down before moving to site selection.
|
||||
Every decision here cascades into investment requirements, operating costs, and revenue potential. Nail the concept before moving to site selection.
|
||||
|
||||
### Step 3: Location Scouting
|
||||
|
||||
@@ -125,7 +125,7 @@ Approach lenders with your full business plan. Typical capital structure for pad
|
||||
- 50–70% debt (bank loan)
|
||||
- 30–50% equity (own funds, silent partners, shareholder loans)
|
||||
|
||||
What lenders will require: a credible financial model, collateral, your track record, and — almost universally for single-asset leisure facilities — personal guarantees from principal shareholders. See the companion article on investment risks for a full treatment of personal guarantee exposure.
|
||||
What lenders will require: a credible financial model, collateral, your track record, and — almost universally for single-asset leisure facilities — personal guarantees from principal shareholders. The companion article on investment risks covers personal guarantee exposure in full.
|
||||
|
||||
Investigate public funding programs: development bank loans, regional sports infrastructure grants, and municipal co-investment schemes can reduce either equity requirements or interest burden. This research is worth several hours of your time.
|
||||
|
||||
@@ -256,6 +256,8 @@ Patterns emerge when you observe padel hall projects across a market over time.
|
||||
|
||||
**Projects that succeed long-term** treat all three phases — planning, build, and opening — with equal rigor, and invest early and consistently in community and repeat customers.
|
||||
|
||||
Building a padel hall is complex, but it is a solved problem. The failures are nearly always the same failures. So are the successes.
|
||||
|
||||
---
|
||||
|
||||
## Find Builders and Suppliers Through Padelnomics
|
||||
|
||||
@@ -9,11 +9,11 @@ cornerstone: C2
|
||||
|
||||
# How Much Does It Cost to Open a Padel Hall in Germany? Complete 2026 CAPEX Breakdown
|
||||
|
||||
Anyone who has started researching padel hall investment in Germany has encountered the same frustrating non-answer: "it depends." And it genuinely does — total project costs for a six-court indoor facility range from **€930,000 to €1.9 million**, a span wide enough to make planning feel impossible.
|
||||
Anyone researching padel hall investment in Germany hits the same frustrating non-answer: "it depends." And it genuinely does — total project costs for a six-court indoor facility range from **€930,000 to €1.9 million**, a span wide enough to make planning feel impossible.
|
||||
|
||||
But that range is not noise. It reflects specific, quantifiable decisions: whether you're fitting out an existing warehouse or building from scratch, whether you're in Munich or Leipzig, whether you want panorama glass courts or standard construction. Once you understand where the variance lives, the numbers become plannable.
|
||||
|
||||
This article gives you the complete picture: itemized CAPEX, city-by-city rent and booking rates, a full operating cost breakdown, a three-year P&L projection, and the key metrics your bank will want to see. All figures are based on real German market data from 2025–2026. By the end, you should be able to build a credible first-pass financial model for your specific scenario — and walk into a lender conversation with confidence.
|
||||
This article gives you the complete picture: itemized CAPEX, city-by-city rent and booking rates, a full operating cost breakdown, a three-year P&L projection, and the key metrics your bank will want to see. All figures are based on real German market data from 2025–2026. By the end, you'll have everything you need to build a credible first-pass financial model for your specific scenario — and walk into a lender conversation with confidence.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,7 +21,7 @@ This article gives you the complete picture: itemized CAPEX, city-by-city rent a
|
||||
|
||||
The single largest driver of CAPEX variance is construction. Converting a suitable existing warehouse — one that already has the necessary ceiling height (8–9 m clear) and adequate structural load — costs vastly less than a ground-up build or a complete gut-renovation. This line item alone accounts for €400,000 to €800,000 of the total budget.
|
||||
|
||||
Location adds another layer of variance. The same 2,000 sqm hall costs 40–60% more to rent in Munich than in Leipzig. That gap shows up not just in annual OPEX but in the lease deposit and the working capital reserve you need to fund the ramp-up — both of which are part of your initial CAPEX.
|
||||
Location adds another layer of variance. The same 2,000 sqm hall costs 40–60% more to rent in Munich than in Leipzig across comparable market tiers — at the extremes, the gap is considerably wider. That difference runs through every budget line: not just annual rent, but the lease deposit and working capital reserve needed at launch, both part of your initial CAPEX.
|
||||
|
||||
For a **six-court indoor facility** with solid but not extravagant fit-out, the realistic planning figure is **€1.2–1.5 million all-in**. Projects that come in below that typically either benefited from an exceptional real estate deal or — more often — undercounted one of the three most expensive items: construction, HVAC, and the operating reserve.
|
||||
|
||||
@@ -56,6 +56,8 @@ For a **six-court indoor facility** with solid but not extravagant fit-out, the
|
||||
|
||||
## Commercial Rent by German City
|
||||
|
||||
Construction and courts consume most of your initial budget. What determines long-term viability is what you pay every month: rent.
|
||||
|
||||
A six-court facility with changing rooms, a reception area, and a lounge requires **1,500–2,500 sqm** of floor space. Current industrial/warehouse lease rates across major German cities:
|
||||
|
||||
| City | Rent €/sqm/month | Typical monthly cost (2,000 sqm) |
|
||||
@@ -77,7 +79,7 @@ One structural note: German commercial landlords typically require lease terms o
|
||||
|
||||
## Court Hire Rates: What the Market Will Bear
|
||||
|
||||
Booking rates vary significantly by city and time slot. The following figures are drawn from platform data and direct market surveys:
|
||||
Revenue potential tracks location almost as closely as rent does. The following booking rates are drawn from platform data and direct market surveys:
|
||||
|
||||
| City | Off-Peak (€/hr) | Peak (€/hr) | Confidence |
|
||||
|---|---|---|---|
|
||||
@@ -113,6 +115,8 @@ Operating cost projections are where business plans most often diverge from real
|
||||
| Admin, accounting, legal | €20,000 | €22,000 | €24,000 |
|
||||
| **Total OPEX** | **€490,000** | **€530,000** | **€566,000** |
|
||||
|
||||
Note: the rent line reflects a well-positioned facility in a mid-tier city. For Munich or Berlin, adjust upward using the city rent table above — and recalibrate your revenue assumptions accordingly.
|
||||
|
||||
**Staffing** is the line that most first-time operators get wrong. Five FTEs is a genuine minimum for professional operations — reception, court management, a coach, administration. In Germany, employer social security contributions add roughly 20% on top of gross wages. €200k in Year 1 for a five-person team is lean, not generous.
|
||||
|
||||
**Energy** depends heavily on the building envelope. An older warehouse with poor insulation and an oversized, inefficient HVAC installation can run 30–50% higher than the figures shown here. Commissioning a quick energy audit before signing the lease is cheap insurance.
|
||||
@@ -167,13 +171,13 @@ On an €800k loan at 5% over 10 years, annual debt service is approximately €
|
||||
|
||||
## What Lenders Actually Look For
|
||||
|
||||
A padel hall is an unusual asset class for most bank credit officers. What moves a credit committee is not enthusiasm for the sport — it is the rigor of the financial documentation.
|
||||
A padel hall is an unfamiliar asset class for most bank credit officers. They have no mental model for court utilization rates or booking yield — and that is actually an opportunity. What moves a credit committee is not enthusiasm for the sport. It is the rigor of the financial documentation. Arrive with clean numbers and you stand out from the start.
|
||||
|
||||
**DSCR of 1.2–1.5x minimum.** Lenders want operating cash flow to cover debt service with a 20–50% buffer. The base case in this model clears that bar easily; your job is to show it holds under stress scenarios too.
|
||||
|
||||
**Signed lease agreement.** Without a lease in place, the credit assessment stays hypothetical. A long-term lease with indexed escalation is a positive signal to lenders — it translates future revenue into something closer to contracted income.
|
||||
**Signed lease agreement.** Without a lease in place, the credit assessment stays hypothetical. A long-term lease with indexed escalation is a positive signal — it converts uncertain future revenue into something closer to contracted income on the credit committee's worksheet.
|
||||
|
||||
**Monthly cashflow model for Year 1.** Lenders do not expect monthly forecasts to be accurate. They use them to assess whether you have thought through the ramp-up — the timing of fit-out completion, the month of first bookings, the staffing build-out. A monthly model signals operational seriousness.
|
||||
**Monthly cash flow model for Year 1.** Lenders do not expect monthly forecasts to be accurate. They use them to assess whether you have thought through the ramp-up — the timing of fit-out completion, the month of first bookings, the staffing build-out. A monthly model signals operational seriousness.
|
||||
|
||||
**Sensitivity analysis.** Show three scenarios: base case (45–60% utilization), downside (35%), and stress (25%). If your project only works at optimistic assumptions, that is important information — for you, not just for the bank.
|
||||
|
||||
@@ -183,8 +187,8 @@ A dedicated article on structuring a padel hall business plan and navigating Ger
|
||||
|
||||
## Bottom Line
|
||||
|
||||
Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on the low end, €1.9M at the top, with €1.2–1.5M as the honest planning figure for a solid six-court operation. The economics, modelled carefully, are genuinely attractive — payback in 3–5 years, 60%+ cash-on-cash return at maturity, and a market that continues to grow.
|
||||
Opening a padel hall in Germany in 2026 is a real capital commitment: €930k on the low end, €1.9M at the top, with €1.2–1.5M as the honest planning figure for a solid six-court operation. The economics, done right, are genuinely attractive — payback in 3–5 years, 60%+ cash-on-cash return at maturity, and a market that continues to grow.
|
||||
|
||||
The investors who succeed in this space 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 model above is the starting point. Your hall deserves a projection built around your actual numbers.
|
||||
**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.
|
||||
|
||||
@@ -121,6 +121,8 @@ Every state has a development bank: Investitionsbank Schleswig-Holstein, Thürin
|
||||
|
||||
## Personal Guarantee Reality: Don't Avoid This Conversation
|
||||
|
||||
Once the debt structure is in place, there is one more item that belongs in every financing conversation — and that is too often skipped until the term sheet arrives.
|
||||
|
||||
German banks financing a padel hall through a standalone project company will almost always require **persönliche Bürgschaft** (personal guarantee) from the founders. This means your personal assets — home, savings, existing investments — are at risk if the business fails.
|
||||
|
||||
Three ways to limit this exposure:
|
||||
|
||||
@@ -50,7 +50,7 @@ Squash followed a strikingly similar pattern in the 1980s: grassroots boom, infr
|
||||
|
||||
The counterargument has real merit: padel requires permanent, fixed courts. That infrastructure creates genuine stickiness that squash never had — players build habits, drive to a venue, become regulars. Padel is also demonstrably more accessible and social than squash, which supports long-term participation. German player numbers show no plateau effect yet.
|
||||
|
||||
Even so: if utilization falls from 65% to 35% in year five because hype fades, your model breaks. That scenario is largely unhedgeable — but it can be modeled. What does your P&L look like at 40% utilization sustained for two years? Can your financing structure survive it? If you haven't answered that question, you're not done with your business plan.
|
||||
Even so — if utilization falls from 65% to 35% in year five because hype fades, your model breaks. That scenario is largely unhedgeable — but it can be modeled. What does your P&L look like at 40% utilization sustained for two years? Can your financing structure survive it? If you haven't answered that question, you're not done with your business plan.
|
||||
|
||||
---
|
||||
|
||||
@@ -91,7 +91,7 @@ When a new competitor opens ten minutes away in year three, you feel it in utili
|
||||
|
||||
Padel has no real moat. No patents, no network effects, no meaningful switching costs. What you have is location, the community you've built, and service quality — genuine advantages, but ones that require continuous investment to maintain.
|
||||
|
||||
**The right move is to model this explicitly.** What does your P&L look like when a competitor opens in year three and takes 20% of your demand? What operational responses are available — pricing, loyalty programs, corporate contracts, additional programming? Having thought through the competitive response in advance means you won't be improvising when it happens.
|
||||
**Model this explicitly.** What does your P&L look like when a competitor opens in year three and takes 20% of your demand? What operational responses are available — pricing, loyalty programs, corporate contracts, additional programming? Thinking through the competitive response in advance means you won't be improvising when it happens.
|
||||
|
||||
---
|
||||
|
||||
@@ -111,7 +111,7 @@ Good facility managers, coaches who combine technical skill with genuine hospita
|
||||
|
||||
Courts need replacing. Artificial turf has a lifespan of five to eight years. Glass panels and framework require regular inspection and periodic replacement. If this isn't in your long-term financial model, you're looking at a significant unplanned capital call in year six or seven. Budget a per-court annual refurbishment reserve — and set it conservatively above zero.
|
||||
|
||||
**A note on F&B:** Running a café or bar inside your facility is an entirely different business — different skills, thin margins, and separate regulatory requirements. If food and beverage is part of your concept, outsourcing to a dedicated operator deserves serious consideration before you commit to running it in-house.
|
||||
**A note on F&B:** Running a café or bar inside your facility is an entirely different business — different skills, thin margins, and separate regulatory requirements. If food and beverage is part of your concept, outsourcing to a dedicated operator deserves serious consideration before committing to running it in-house.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ Key checks before committing to a site:
|
||||
|
||||
## The Site Scoring Framework: From 8 Criteria to a Decision
|
||||
|
||||
Anyone evaluating multiple sites in parallel needs a comparison tool. A weighted scoring matrix works well: each criterion is rated 1–5 and multiplied by a weighting factor.
|
||||
Any investor evaluating multiple sites in parallel needs a comparison tool. A weighted scoring matrix works well: each criterion is rated 1–5 and multiplied by a weighting factor.
|
||||
|
||||
A suggested weighting:
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ Mit dem detaillierten Businessplan gehen Sie zu Banken und ggf. Fördermittelgeb
|
||||
- 50–70 Prozent Fremdkapital (Bankdarlehen)
|
||||
- 30–50 Prozent Eigenkapital (eigene Mittel, stille Beteiligungen, Gesellschafterdarlehen)
|
||||
|
||||
Was Banken sehen wollen: belastbares Finanzmodell, Sicherheiten, Ihr persönliches Track Record, und — fast immer — eine persönliche Bürgschaft. (Mehr dazu im separaten Artikel zu Investitionsrisiken.)
|
||||
Was Banken sehen wollen: belastbares Finanzmodell, Sicherheiten, Ihr persönlicher Track Record, und — fast immer — eine persönliche Bürgschaft. Der separate Artikel zu Investitionsrisiken behandelt das Thema Bürgschaftsexposition ausführlich.
|
||||
|
||||
Klären Sie Förderprogramme: KfW-Mittel, Landesförderbanken und kommunale Sportförderprogramme können den Eigenkapitalbedarf oder die Zinsbelastung reduzieren. Diese Recherche lohnt sich.
|
||||
|
||||
@@ -251,6 +251,8 @@ Wer Dutzende Padelhallenprojekte in Europa beobachtet, sieht Muster auf beiden S
|
||||
|
||||
**Die Projekte, die langfristig erfolgreich sind**, haben alle drei Phasen — Planung, Bau, Eröffnung — mit derselben Sorgfalt behandelt und früh in Community und Stammkundschaft investiert.
|
||||
|
||||
Eine Padelhalle zu bauen ist komplex — aber kein ungelöstes Problem. Die Fehler, die Projekte scheitern lassen, sind fast immer dieselben. Genauso wie die Entscheidungen, die sie gelingen lassen.
|
||||
|
||||
---
|
||||
|
||||
## Planer und Lieferanten finden
|
||||
|
||||
@@ -159,6 +159,8 @@ Der Kapitaldienstdeckungsgrad (DSCR) auf den Bankkredit (€700k, 5 %, 10 Jahre
|
||||
|
||||
## Das persönliche Risiko: Bürgschaften offen ansprechen
|
||||
|
||||
Steht die Fremdkapitalstruktur, bleibt eine Frage, die in fast jedem Finanzierungsgespräch zu spät gestellt wird — und die zu oft erst auf dem Konditionenblatt der Bank auftaucht.
|
||||
|
||||
Banken werden für eine Padelhalle, die eine eigenständige Projektgesellschaft ist, fast immer eine **persönliche Bürgschaft** des Gründers fordern. Das bedeutet: Ihre privaten Vermögenswerte — Eigenheim, Ersparnisse, Beteiligungen — haften im Zweifelsfall.
|
||||
|
||||
Es gibt drei Wege, dieses Risiko zu begrenzen:
|
||||
|
||||
@@ -9,7 +9,7 @@ cornerstone: C2
|
||||
|
||||
# Padel Halle Kosten 2026: Die komplette CAPEX-Aufstellung
|
||||
|
||||
Wer ernsthaft über eine Padelhalle nachdenkt, bekommt auf die Frage nach den Kosten zunächst eine frustrierende Antwort: "Das kommt drauf an." Und ja — die Spanne ist tatsächlich enorm. Je nach Standort, Konzept und Bausubstanz liegen die Gesamtinvestitionskosten für eine sechsstellige Anlage zwischen **€930.000 und €1,9 Millionen**. Diese Streuung ist kein Zufall, sondern Ausdruck ganz konkreter Entscheidungen, die Sie als Investor treffen werden.
|
||||
Wer eine Padelhalle plant, bekommt auf die Kostenfrage zunächst eine frustrierende Antwort: „Das kommt drauf an." Und ja — die Spanne ist tatsächlich enorm. Je nach Standort, Konzept und Bausubstanz liegen die Gesamtinvestitionskosten für eine sechsstellige Anlage zwischen **€930.000 und €1,9 Millionen**. Diese Streuung ist kein Zufall, sondern Ausdruck ganz konkreter Entscheidungen, die Sie als Investor treffen werden.
|
||||
|
||||
Dieser Artikel schlüsselt die vollständige Investition auf — von der Bausubstanz über Platztechnik und Ausstattung bis hin zu Betriebskosten, Standortmieten und einer belastbaren 3-Jahres-Ergebnisprognose. Alle Zahlen basieren auf realen deutschen Marktdaten aus 2025/2026. Das Ziel: Sie sollen nach der Lektüre in der Lage sein, eine erste realistische Wirtschaftlichkeitsrechnung für Ihre konkrete Situation aufzustellen — und wissen, welche Fragen Sie Ihrer Bank stellen müssen.
|
||||
|
||||
@@ -19,7 +19,7 @@ Dieser Artikel schlüsselt die vollständige Investition auf — von der Bausubs
|
||||
|
||||
Warum liegen €930.000 und €1,9 Millionen so weit auseinander? Der größte Einzeltreiber ist der bauliche Aufwand. Wer eine bestehende Gewerbehalle — etwa einen ehemaligen Produktions- oder Logistikbau — kostengünstig anmieten und mit minimalem Umbau bespielen kann, landet am unteren Ende der Spanne. Wer dagegen auf grüner Wiese baut oder ein Gebäude von Grund auf saniert, zahlt entsprechend mehr.
|
||||
|
||||
Dazu kommt der Standortfaktor. In München oder Berlin kostet dasselbe Objekt in der Miete 40–60 % mehr als in Leipzig oder Kassel. Das drückt sich nicht nur in der laufenden OPEX aus, sondern auch in der Kaution und dem nötigen Working-Capital-Puffer — beides Teil der initialen CAPEX.
|
||||
Dazu kommt der Standortfaktor. In München oder Berlin kostet dasselbe Objekt in vergleichbaren Marktsegmenten 40–60 % mehr als in Leipzig oder Kassel — an den Extremen fällt der Abstand erheblich größer aus. Das schlägt sich nicht nur in der laufenden OPEX nieder, sondern auch in der Kaution und dem nötigen Working-Capital-Puffer — beides Teil der initialen CAPEX.
|
||||
|
||||
Realistischer Planungsansatz für eine **6-Court-Innenhalle** mit solider Ausstattung: **€1,2–1,5 Millionen Gesamtinvestition**. Wer mit deutlich weniger kalkuliert, unterschätzt in der Regel einen der drei teuersten Posten: Bau/Umbau, Lüftungstechnik oder den Kapitalpuffer für den Anlauf.
|
||||
|
||||
@@ -56,6 +56,8 @@ Die folgende Tabelle zeigt die typischen Bandbreiten für eine sechsstellige Inn
|
||||
|
||||
## Hallenmiete in Deutschland: Was Sie nach Standort zahlen
|
||||
|
||||
Bau und Courts binden den größten Teil des Startkapitals. Was über die langfristige Wirtschaftlichkeit entscheidet, zahlen Sie monatlich: die Miete.
|
||||
|
||||
Eine 6-Court-Halle benötigt je nach Konzept (Nebenräume, Lounge, Pro Shop) eine Fläche von **1.500 bis 2.500 qm**. Auf Basis aktueller Gewerberaummieten für Industrie- und Hallenflächen in deutschen Städten ergibt sich folgende Einschätzung:
|
||||
|
||||
| Stadt | Miete €/qm/Monat | Typische Monatsmiete (2.000 qm) |
|
||||
@@ -69,15 +71,15 @@ Eine 6-Court-Halle benötigt je nach Konzept (Nebenräume, Lounge, Pro Shop) ein
|
||||
| Stuttgart | €7–10 | €14.000–€20.000 |
|
||||
| Leipzig | €4–7 | €8.000–€14.000 |
|
||||
|
||||
In Hochpreislagen Berlins (Mitte, Prenzlauer Berg) oder Münchens (Schwabing, Maxvorstadt) liegen die Preise auch für Gewerbehallen teils noch darüber. Die in der OPEX-Tabelle verwendete Jahresmiete von €120.000 entspricht einer Monatsmiete von €10.000 — das ist ein realistischer Wert für eine mittelgroße deutsche Stadt mit einem Standort leicht außerhalb der Innenstadt.
|
||||
In Hochpreislagen Berlins (Mitte, Prenzlauer Berg) oder Münchens (Schwabing, Maxvorstadt) liegen die Preise auch für Gewerbehallen teils noch darüber. Die in der OPEX-Tabelle verwendete Jahresmiete von €120.000 entspricht einer Monatsmiete von €10.000 — das ist ein realistischer Wert für eine mittelgroße deutsche Stadt mit einem Standort leicht außerhalb der Innenstadt. Für München oder Berlin kalkulieren Sie mit den Werten aus der Stadtübersicht oben — und passen Sie die Erlösannahme entsprechend an.
|
||||
|
||||
Ein Hinweis zur Mietstruktur: Viele Vermieter verlangen bei Hallenflächen eine Laufzeit von mindestens 5–10 Jahren, oft mit Verlängerungsoptionen. Das bindet Sie, schafft aber auch Planungssicherheit für die Finanzierung. Banken bewerten einen langen Mietvertrag mit festen Konditionen positiv.
|
||||
Ein Hinweis zur Mietstruktur: Viele Vermieter verlangen bei Hallenflächen eine Laufzeit von mindestens 5–10 Jahren, oft mit Verlängerungsoptionen. Das bindet Sie, schafft aber auch Planungssicherheit für die Finanzierung. Ein langfristiger Mietvertrag mit indexierter Staffelung ist für die Bank ein echtes Positivsignal — er macht aus unsicheren künftigen Einnahmen etwas, das im Kreditbescheid wie planbarer Cashflow aussieht.
|
||||
|
||||
---
|
||||
|
||||
## Platzbuchungspreise: Was der Markt trägt
|
||||
|
||||
Die Mietpreise sind das Fundament Ihrer Ertragsrechnung. Hier die aktuellen Marktpreise nach Stadt, basierend auf Plattformdaten und direkten Hallenerhebungen:
|
||||
Das Ertragspotenzial folgt der Standortlogik ähnlich eng wie die Mietkosten. Hier die aktuellen Marktpreise nach Stadt, basierend auf Plattformdaten und direkten Hallenerhebungen:
|
||||
|
||||
| Stadt | Nebenzeiten (€/Std.) | Hauptzeiten (€/Std.) | Datenbasis |
|
||||
|---|---|---|---|
|
||||
@@ -167,7 +169,7 @@ Bei einem Darlehen von €800.000 (z. B. KfW oder Hausbank), 5 % Zinsen und 10 J
|
||||
|
||||
## Was Banken wirklich wollen
|
||||
|
||||
Eine Padelhalle ist für die meisten Bankberater ein ungewohntes Investitionsobjekt. Was zählt, ist nicht die Begeisterung für Padel — sondern die Qualität Ihrer Zahlengrundlage.
|
||||
Eine Padelhalle ist für die meisten Bankberater unbekanntes Terrain. Auslastungsquoten und Erlöse pro Court sind keine Größen, mit denen Kreditausschüsse täglich arbeiten — das ist Ihr Vorteil. Wer mit sauberen Zahlen und strukturierter Dokumentation ins Gespräch geht, fällt sofort positiv auf. Was den Kreditausschuss bewegt, ist nicht die Begeisterung für den Sport, sondern die Belastbarkeit der Unterlagen.
|
||||
|
||||
**Debt Service Coverage Ratio (DSCR) 1,2–1,5x:** Die Bank will sehen, dass Ihr operativer Cashflow den Schuldendienst mit einem Puffer von 20–50 % abdeckt. Mit einem EBITDA von €310.000 im ersten Jahr und einem Schuldendienst von €102.000 liegt der DSCR bei 3,0 — auf dem Papier sehr solide. Aber: Banken werden nachfragen, wie empfindlich dieses Ergebnis auf niedrigere Auslastung reagiert.
|
||||
|
||||
@@ -185,6 +187,6 @@ Wie Sie einen vollständigen Businessplan strukturieren und welche Unterlagen Ba
|
||||
|
||||
Die Kosten für eine Padelhalle sind real und erheblich — €930.000 bis €1,9 Millionen, realistischer Mittelpunkt €1,2–1,5 Millionen. Wer diese Zahlen kennt und versteht, wo die Hebel sitzen, kann daraus ein belastbares Investitionsmodell bauen. Wer mit Schätzungen aus zweiter Hand ins Bankgespräch geht, verliert Zeit und Glaubwürdigkeit.
|
||||
|
||||
Die Wirtschaftlichkeit stimmt: Bei konservativen Annahmen und solider Betriebsführung ist die Amortisation in 3–5 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 3–5 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. Das Modell oben ist der Einstieg. Ihre Halle verdient eine maßgeschneiderte Kalkulation.
|
||||
**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.
|
||||
|
||||
@@ -89,7 +89,7 @@ Wenn in Jahr drei ein neuer Wettbewerber 10 Fahrminuten entfernt aufmacht, ist I
|
||||
|
||||
Einen echten Burggraben gibt es im Padel-Geschäft kaum. Keine Patente, keine Netzwerkeffekte, keine Wechselkosten. Was bleibt, ist: Standort, Gemeinschaft, Servicequalität und die Beziehung zu Stammkunden. Das sind reale Vorteile — aber sie müssen aktiv aufgebaut und gepflegt werden.
|
||||
|
||||
**Was Sie jetzt schon tun können:** Modellieren Sie im Businessplan explizit das Szenario "neuer Wettbewerber in Jahr drei". Was ändert sich? Wie reagieren Sie? Welche Maßnahmen senken die Auslastungsschwelle für Profitabilität?
|
||||
**Rechnen Sie das durch.** Modellieren Sie im Businessplan explizit das Szenario „neuer Wettbewerber in Jahr drei". Was ändert sich? Wie reagieren Sie? Welche Maßnahmen senken die Auslastungsschwelle für Profitabilität?
|
||||
|
||||
---
|
||||
|
||||
|
||||
67
data/content/articles/padel-zubehoer-de.md
Normal file
67
data/content/articles/padel-zubehoer-de.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Padel-Zubehör: Das braucht jeder Spieler wirklich"
|
||||
slug: padel-zubehoer-de
|
||||
language: de
|
||||
url_path: /padel-zubehoer
|
||||
meta_description: "Welches Padel-Zubehör lohnt sich wirklich? Von Griffband und Vibrationsdämpfer bis zur Sporttasche — was ist nützlich, was ist Marketing?"
|
||||
---
|
||||
|
||||
# Padel-Zubehör: Das braucht jeder Spieler wirklich
|
||||
|
||||
<!-- TODO: Einleitung — Zubehör gibt es viel, sinnvoll ist wenig -->
|
||||
|
||||
Wer Padel ernsthafter betreibt, wird früh von Empfehlungen überhäuft: Griffband kaufen! Schutzhülle! Vibrationsdämpfer! Nicht alles davon ist sinnvoll — aber einiges tatsächlich unverzichtbar. Dieser Guide hilft dabei, nützliches Zubehör von überteuertem Marketing zu trennen.
|
||||
|
||||
---
|
||||
|
||||
## Das sinnvollste Zubehör im Überblick
|
||||
|
||||
[product-group:accessory]
|
||||
|
||||
---
|
||||
|
||||
## Griffband: Ja, unbedingt
|
||||
|
||||
<!-- TODO: Erklärung, welches Griffband sich lohnt -->
|
||||
|
||||
[product:platzhalter-griffband-amazon]
|
||||
|
||||
---
|
||||
|
||||
## Schläger-Schutzhülle: Ja, wenn man häufig transportiert
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Vibrationsdämpfer: Geschmackssache
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Sporttasche: Erst ab regelmäßigem Spiel
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie oft sollte man das Griffband wechseln?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Bei regelmäßigem Spielen empfehlen wir einen Wechsel alle 4–8 Wochen. Ein abgenutztes Griffband erhöht das Risiko, den Schläger wegzuschleudern, und mindert die Kontrolle.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Brauche ich eine spezielle Padeltasche?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Eine Padeltasche schützt den Schläger vor Beschädigungen beim Transport. Für gelegentliche Spieler reicht ein einfaches Cover. Wer mehrere Schläger trägt oder regelmäßig zum Club fährt, profitiert von einer Sporttasche mit gepolstertem Schlägerfach.
|
||||
|
||||
</details>
|
||||
70
data/content/articles/padelbaelle-vergleich-de.md
Normal file
70
data/content/articles/padelbaelle-vergleich-de.md
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Beste Padelbälle 2026: Test und Vergleich der populärsten Modelle"
|
||||
slug: padelbaelle-vergleich-de
|
||||
language: de
|
||||
url_path: /padelbaelle-vergleich
|
||||
meta_description: "Welche Padelbälle sind am besten? Wir vergleichen die beliebtesten Modelle nach Druckhaltigkeit, Spielgefühl und Preis-Leistungs-Verhältnis."
|
||||
---
|
||||
|
||||
# Beste Padelbälle 2026: Test und Vergleich der populärsten Modelle
|
||||
|
||||
<!-- TODO: Einleitung — warum Bälle oft unterschätzt werden -->
|
||||
|
||||
Der Ball ist das am häufigsten unterschätzte Equipment im Padel. Dabei entscheidet seine Druckhaltigkeit maßgeblich über das Spielgefühl. Ein Padelball verliert nach 4–6 Stunden intensivem Spiel merklich an Druck — und damit an Tempo, Kontrolle und Spaß.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Empfehlungen
|
||||
|
||||
[product-group:ball]
|
||||
|
||||
---
|
||||
|
||||
## Druckhaltigkeit: Was wirklich zählt
|
||||
|
||||
<!-- TODO: Erklärung des Druckverlusts + Testzeitraum -->
|
||||
|
||||
---
|
||||
|
||||
## Turnier- vs. Freizeitball
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Testsieger im Überblick
|
||||
|
||||
[product:platzhalter-ball-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Wie lange hält ein Padelball?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Ein hochwertiger Padelball ist nach etwa 4–8 Stunden Spielzeit merklich weicher. Im Freizeitbereich merkt man den Unterschied oft erst später. Profis und ambitionierte Spieler wechseln Bälle bereits nach einem Set.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Muss ich WCT- oder FIP-zertifizierte Bälle kaufen?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Für den Freizeiteinsatz nein. Für Turniere und Ligaspiele ja — die meisten Ligen schreiben zugelassene Ballmodelle vor. Im Training können beliebige Qualitätsbälle verwendet werden.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Wie lagere ich Padelbälle richtig?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Kühl und trocken lagern, nicht im Auto lassen. Manche Spieler verwenden Druckbehälter, um den Druckverlust zu verlangsamen — das funktioniert tatsächlich für bereits angebrochene Dosen.
|
||||
|
||||
</details>
|
||||
67
data/content/articles/padelschlaeger-anfaenger-de.md
Normal file
67
data/content/articles/padelschlaeger-anfaenger-de.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Padelschläger für Anfänger 2026: Die 5 besten Einstiegsmodelle"
|
||||
slug: padelschlaeger-anfaenger-de
|
||||
language: de
|
||||
url_path: /padelschlaeger-anfaenger
|
||||
meta_description: "Welcher Padelschläger eignet sich für Anfänger? Unsere Empfehlungen für Einsteiger: verzeihendes Spielgefühl, robuste Verarbeitung, fairer Preis."
|
||||
---
|
||||
|
||||
# Padelschläger für Anfänger 2026: Die 5 besten Einstiegsmodelle
|
||||
|
||||
<!-- TODO: Einleitung, warum Anfängerschläger sich von Profimodellen unterscheiden (150–200 Wörter) -->
|
||||
|
||||
Für den Einstieg ins Padel braucht man keinen teuren Profischaft. Im Gegenteil: Die meisten Hochleistungsschläger sind für Anfänger kontraproduktiv — ihr kleines Sweetspot-Fenster bestraft Fehlschläge, die in der Lernphase normal sind. Ein guter Anfängerschläger ist leicht, hat eine runde Form und verzeiht ungenaue Treffpunkte.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Top-5 für Einsteiger
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Was macht einen guten Anfängerschläger aus?
|
||||
|
||||
<!-- TODO: Erklärung der relevanten Schläger-Eigenschaften (Form, Gewicht, Material) -->
|
||||
|
||||
### Schlägerkopfform: Rund schlägt Diamant
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### Gewicht: Leichter ist nicht immer besser
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### Material: EVA vs. Foam
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Unsere Empfehlung im Detail
|
||||
|
||||
[product:platzhalter-anfaenger-schlaeger-amazon]
|
||||
|
||||
<!-- TODO: Ausführliche Besprechung mit Praxistest -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Ab welchem Preis lohnt sich ein eigener Schläger?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Wer mehr als einmal pro Woche spielt, sollte in einen eigenen Schläger investieren. Leihschläger im Club sind oft abgenutzt und vermitteln ein falsches Spielgefühl. Ab 60–80 Euro gibt es solide Einsteigerschläger.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Kann ich als Anfänger direkt mit einem 150-Euro-Schläger starten?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Ja, sofern es sich um ein anfängerfreundliches Modell aus diesem Preisbereich handelt. Preisschilder allein sagen wenig — ein 150-Euro-Diamantschläger kann für Einsteiger schlechter sein als ein 70-Euro-Rundschläger.
|
||||
|
||||
</details>
|
||||
55
data/content/articles/padelschlaeger-defensiv-de.md
Normal file
55
data/content/articles/padelschlaeger-defensiv-de.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "Padelschläger für defensive Spieler: Die besten Kontrollschläger 2026"
|
||||
slug: padelschlaeger-defensiv-de
|
||||
language: de
|
||||
url_path: /padelschlaeger-defensiv
|
||||
meta_description: "Die besten Padelschläger für defensive und kontrollbetonte Spieler. Runde und Tropfenform mit großem Sweetspot für sicheres Spiel vom Grundfeld."
|
||||
---
|
||||
|
||||
# Padelschläger für defensive Spieler: Die besten Kontrollschläger 2026
|
||||
|
||||
<!-- TODO: Einleitung zur defensiven Spielweise und warum der Schläger einen Unterschied macht -->
|
||||
|
||||
Im Padel entscheidet das Grundfeld. Wer vom hinteren Drittel sauber und kontrolliert spielen kann, zwingt den Gegner zu Fehlern. Für diesen Spielstil braucht man einen Schläger mit großem Sweetspot, weichem EVA-Kern und einer runden oder Tropfenform — nicht die auffälligsten Geräte, aber die effektivsten.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Empfehlungen für defensive Spieler
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Warum Kontrolle wichtiger ist als Power
|
||||
|
||||
<!-- TODO: Erklärung Spielstil + Schlägercharakteristik -->
|
||||
|
||||
---
|
||||
|
||||
## Testsieger im Detail
|
||||
|
||||
[product:platzhalter-defensiv-schlaeger-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Was ist der Unterschied zwischen einem Kontroll- und einem Powerschläger?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Kontrollschläger (runde Form, weicher Kern) vergrößern den Sweetspot und ermöglichen feingefühliges Spiel. Powerschläger (Diamantform, harter Kern) bieten mehr Hebelwirkung beim Smash, verzeihen aber weniger Fehlschläge.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Für welche Spielstufe sind Kontrollschläger geeignet?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Kontrollschläger sind für Anfänger, Freizeitspieler und taktisch orientierte Spieler aller Stufen geeignet. Auch viele erfahrene Spieler bevorzugen sie, weil Konsistenz auf Dauer mehr Punkte bringt als gelegentliche Powerschläge.
|
||||
|
||||
</details>
|
||||
67
data/content/articles/padelschlaeger-fortgeschrittene-de.md
Normal file
67
data/content/articles/padelschlaeger-fortgeschrittene-de.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Padelschläger für Fortgeschrittene: Die besten Modelle 2026"
|
||||
slug: padelschlaeger-fortgeschrittene-de
|
||||
language: de
|
||||
url_path: /padelschlaeger-fortgeschrittene
|
||||
meta_description: "Die besten Padelschläger für fortgeschrittene und ambitionierte Spieler. High-End-Modelle mit Carbon, Kevlar und ausgereifter Schlagbalance für Spieler ab 3.0."
|
||||
---
|
||||
|
||||
# Padelschläger für Fortgeschrittene: Die besten Modelle 2026
|
||||
|
||||
<!-- TODO: Einleitung — wann ist man bereit für einen Fortgeschrittenenschläger? -->
|
||||
|
||||
Ab einem gewissen Spielniveau lohnt sich der Griff zu einem anspruchsvolleren Schläger. Wer sauber trifft, kann von einer härteren Bespannung und einer präziseren Balance profitieren. Die Schläger in dieser Liste sind kein Selbstläufer — aber in den richtigen Händen ein echter Vorteil.
|
||||
|
||||
---
|
||||
|
||||
## Top-Schläger für Fortgeschrittene im Überblick
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Carbon, Kevlar, Glasfaser: Was steckt drin?
|
||||
|
||||
<!-- TODO: Materialüberblick mit Vor- und Nachteilen -->
|
||||
|
||||
### Carbon-Rahmen
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### 3K vs. 12K Carbon
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
### Kevlar-Einlagen
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Testbericht: Unser Empfehlungsschläger
|
||||
|
||||
[product:platzhalter-fortgeschrittene-schlaeger-amazon]
|
||||
|
||||
<!-- TODO: Praxistest -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Ab welcher Spielstufe lohnt sich ein Fortgeschrittenenschläger?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Wer regelmäßig spielt (2–3 Mal pro Woche), seit mindestens einem Jahr dabei ist und an Taktik und Technik arbeitet, kann von einem hochwertigeren Schläger profitieren. Für gelegentliche Spieler ist der Unterschied zu einem Mittelklassemodell kaum spürbar.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Müssen Fortgeschrittenenschläger teurer sein?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Nicht zwingend. Es gibt ausgezeichnete Modelle im 150–200-Euro-Segment, die professionell verarbeitete Carbon-Elemente enthalten. Alles über 300 Euro richtet sich meist an Spieler mit Wettkampfambitionen.
|
||||
|
||||
</details>
|
||||
55
data/content/articles/padelschlaeger-unter-100-de.md
Normal file
55
data/content/articles/padelschlaeger-unter-100-de.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
title: "Padelschläger unter 100 Euro: Die besten günstigen Modelle 2026"
|
||||
slug: padelschlaeger-unter-100-de
|
||||
language: de
|
||||
url_path: /padelschlaeger-unter-100
|
||||
meta_description: "Gute Padelschläger müssen nicht teuer sein. Die besten Modelle unter 100 Euro — mit echtem Spielgefühl, ohne Kompromisse bei der Verarbeitung."
|
||||
---
|
||||
|
||||
# Padelschläger unter 100 Euro: Die besten günstigen Modelle 2026
|
||||
|
||||
<!-- TODO: Einleitung — Gibt es wirklich gute Schläger für unter 100 Euro? -->
|
||||
|
||||
Wer sagt, dass Padel teuer sein muss? In der 50-100-Euro-Klasse gibt es Schläger, die sich von 200-Euro-Modellen im Freizeitspiel kaum unterscheiden. Der entscheidende Unterschied liegt oft im Material des Rahmens und im Kern — nicht im Spielgefühl.
|
||||
|
||||
---
|
||||
|
||||
## Die besten Schläger unter 100 Euro
|
||||
|
||||
[product-group:racket]
|
||||
|
||||
---
|
||||
|
||||
## Was bekommt man unter 100 Euro?
|
||||
|
||||
<!-- TODO: Realistische Erwartungen setzen -->
|
||||
|
||||
---
|
||||
|
||||
## Unser Preisklassen-Tipp
|
||||
|
||||
[product:platzhalter-budget-schlaeger-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Sind günstige Padelschläger schlechter verarbeitet?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Nicht zwangsläufig. Im Bereich 60–100 Euro findet man solide Fiberglas-Schläger bekannter Marken. Der Hauptunterschied zu teureren Modellen ist das Rahmenmaterial (kein Carbon) und ein schlichtes Design.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Lohnt es sich, für einen Einsteiger 100 Euro auszugeben?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Ja, wenn er weiß, dass er das Spiel ernsthafter betreiben will. Für einen ersten Test reicht auch ein 50-Euro-Schläger — aber wer nach der ersten Saison weiterspielen will, wird früh aufwerten wollen.
|
||||
|
||||
</details>
|
||||
61
data/content/articles/padelschuhe-test-de.md
Normal file
61
data/content/articles/padelschuhe-test-de.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "Padelschuhe Test 2026: Die besten Schuhe für Sand- und Kunstgras"
|
||||
slug: padelschuhe-test-de
|
||||
language: de
|
||||
url_path: /padelschuhe-test
|
||||
meta_description: "Welche Padelschuhe sind am besten? Unser Test der beliebtesten Modelle — für Sand, Kunstgras und Kunststoffbelag mit optimaler Dämpfung und Stabilität."
|
||||
---
|
||||
|
||||
# Padelschuhe Test 2026: Die besten Schuhe für Sand- und Kunstgras
|
||||
|
||||
<!-- TODO: Einleitung — warum normale Tennisschuhe nicht reichen -->
|
||||
|
||||
Padelschuhe werden häufig unterschätzt. Auf dem Sandbelag des Padel-Courts braucht man eine völlig andere Sohle als auf Tennishartplatz oder Hallenboden. Ein falscher Schuh erhöht nicht nur das Verletzungsrisiko — er kostet auch Punkte, weil man in Kurven wegrutscht.
|
||||
|
||||
---
|
||||
|
||||
## Unsere Top-Empfehlungen
|
||||
|
||||
[product-group:shoe]
|
||||
|
||||
---
|
||||
|
||||
## Welche Sohle für welchen Belag?
|
||||
|
||||
<!-- TODO: Sohlentypen und Untergrundtabelle -->
|
||||
|
||||
| Belag | Empfohlene Sohle |
|
||||
|---|---|
|
||||
| Sand (feiner Quarzsand) | Fishbone / Fischgrät |
|
||||
| Kunstgras | Multicourt / Omnidirectional |
|
||||
| Kunststoff/Beton | Glatte Multicourt-Sohle |
|
||||
|
||||
---
|
||||
|
||||
## Testbericht: Bester Allround-Schuh
|
||||
|
||||
[product:platzhalter-padelschuh-amazon]
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
---
|
||||
|
||||
## Häufige Fragen
|
||||
|
||||
<details>
|
||||
<summary>Kann ich Tennisschuhe für Padel verwenden?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Für den gelegentlichen Einstieg ja. Auf Dauer ist es nicht empfehlenswert: Tennisschuhe bieten auf Sand zu wenig Halt, und die Abnutzung ist höher. Nach 3–4 Monaten regelmäßigen Spielens zahlen sich dedizierte Padelschuhe aus.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Wie erkenne ich verschlissene Padelschuhe?</summary>
|
||||
|
||||
<!-- TODO -->
|
||||
|
||||
Wenn die Außenfläche der Sohle glatt wird oder das Profil auf unter 2 mm abgenutzt ist, verliert der Schuh seinen Halt. Bei Padel ist das gefährlicher als bei vielen anderen Sportarten, weil häufige Richtungswechsel auf losem Sand stattfinden.
|
||||
|
||||
</details>
|
||||
@@ -59,10 +59,10 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
healthcheck:
|
||||
@@ -81,10 +81,10 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
@@ -97,10 +97,10 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
@@ -114,10 +114,10 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
healthcheck:
|
||||
@@ -136,10 +136,10 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
@@ -152,10 +152,10 @@ services:
|
||||
env_file: ./.env
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/analytics.duckdb
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /data/padelnomics/analytics.duckdb:/app/data/analytics.duckdb:ro
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
|
||||
@@ -17,14 +17,12 @@ Usage:
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import UTC, datetime
|
||||
@@ -269,13 +267,46 @@ def run_export() -> None:
|
||||
send_alert(f"[export] {err}")
|
||||
|
||||
|
||||
_last_seen_head: str | None = None
|
||||
|
||||
|
||||
def web_code_changed() -> bool:
|
||||
"""Check if web app code changed since last deploy (after git pull)."""
|
||||
"""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", "diff", "--name-only", "HEAD~1", "HEAD", "--", "web/", "Dockerfile"],
|
||||
["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(result.stdout.strip())
|
||||
return bool(diff.stdout.strip())
|
||||
|
||||
|
||||
def current_deployed_tag() -> str | None:
|
||||
@@ -327,6 +358,10 @@ def git_pull_and_sync() -> None:
|
||||
run_shell(f"git checkout --detach {latest}")
|
||||
run_shell("sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env")
|
||||
run_shell("uv sync --all-packages")
|
||||
# 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.
|
||||
logger.info("Deploy complete — re-execing to load new code")
|
||||
os.execv(sys.executable, sys.argv)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2499,7 +2499,12 @@ async def article_results():
|
||||
@csrf_protect
|
||||
async def article_new():
|
||||
"""Create a manual article."""
|
||||
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path
|
||||
from ..content.routes import (
|
||||
BUILD_DIR,
|
||||
bake_product_cards,
|
||||
bake_scenario_cards,
|
||||
is_reserved_path,
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
@@ -2523,9 +2528,10 @@ async def article_new():
|
||||
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
|
||||
# Render markdown → HTML with scenario + product cards baked in
|
||||
body_html = mistune.html(body)
|
||||
body_html = await bake_scenario_cards(body_html)
|
||||
body_html = await bake_product_cards(body_html, lang=language)
|
||||
|
||||
build_dir = BUILD_DIR / language
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2561,7 +2567,12 @@ async def article_new():
|
||||
@csrf_protect
|
||||
async def article_edit(article_id: int):
|
||||
"""Edit a manual article."""
|
||||
from ..content.routes import BUILD_DIR, bake_scenario_cards, is_reserved_path
|
||||
from ..content.routes import (
|
||||
BUILD_DIR,
|
||||
bake_product_cards,
|
||||
bake_scenario_cards,
|
||||
is_reserved_path,
|
||||
)
|
||||
|
||||
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
||||
if not article:
|
||||
@@ -2591,6 +2602,7 @@ async def article_edit(article_id: int):
|
||||
if body:
|
||||
body_html = mistune.html(body)
|
||||
body_html = await bake_scenario_cards(body_html)
|
||||
body_html = await bake_product_cards(body_html, lang=language)
|
||||
build_dir = BUILD_DIR / language
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
(build_dir / f"{article['slug']}.html").write_text(body_html)
|
||||
@@ -2735,7 +2747,7 @@ async def rebuild_all():
|
||||
|
||||
async def _rebuild_article(article_id: int):
|
||||
"""Re-render a single article from its source."""
|
||||
from ..content.routes import BUILD_DIR, bake_scenario_cards
|
||||
from ..content.routes import BUILD_DIR, bake_product_cards, bake_scenario_cards
|
||||
|
||||
article = await fetch_one("SELECT * FROM articles WHERE id = ?", (article_id,))
|
||||
if not article:
|
||||
@@ -2760,6 +2772,7 @@ async def _rebuild_article(article_id: int):
|
||||
body_html = mistune.html(md_path.read_text())
|
||||
lang = article.get("language", "en") if hasattr(article, "get") else "en"
|
||||
body_html = await bake_scenario_cards(body_html, lang=lang)
|
||||
body_html = await bake_product_cards(body_html, lang=lang)
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(BUILD_DIR / f"{article['slug']}.html").write_text(body_html)
|
||||
|
||||
@@ -3233,3 +3246,388 @@ async def outreach_import():
|
||||
|
||||
await flash(f"Imported {imported} suppliers. Skipped {skipped} (duplicates or missing data).", "success")
|
||||
return redirect(url_for("admin.outreach"))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Affiliate Product Catalog
|
||||
# =============================================================================
|
||||
|
||||
AFFILIATE_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
|
||||
AFFILIATE_STATUSES = ("draft", "active", "archived")
|
||||
|
||||
|
||||
def _form_to_product(form) -> dict:
|
||||
"""Parse affiliate product form values into a data dict."""
|
||||
price_str = form.get("price_eur", "").strip()
|
||||
price_cents = None
|
||||
if price_str:
|
||||
try:
|
||||
price_cents = round(float(price_str.replace(",", ".")) * 100)
|
||||
except ValueError:
|
||||
price_cents = None
|
||||
|
||||
rating_str = form.get("rating", "").strip()
|
||||
rating = None
|
||||
if rating_str:
|
||||
try:
|
||||
rating = float(rating_str.replace(",", "."))
|
||||
except ValueError:
|
||||
rating = None
|
||||
|
||||
pros_raw = form.get("pros", "").strip()
|
||||
cons_raw = form.get("cons", "").strip()
|
||||
pros = json.dumps([line.strip() for line in pros_raw.splitlines() if line.strip()])
|
||||
cons = json.dumps([line.strip() for line in cons_raw.splitlines() if line.strip()])
|
||||
|
||||
return {
|
||||
"slug": form.get("slug", "").strip(),
|
||||
"name": form.get("name", "").strip(),
|
||||
"brand": form.get("brand", "").strip(),
|
||||
"category": form.get("category", "accessory").strip(),
|
||||
"retailer": form.get("retailer", "").strip(),
|
||||
"affiliate_url": form.get("affiliate_url", "").strip(),
|
||||
"image_url": form.get("image_url", "").strip(),
|
||||
"price_cents": price_cents,
|
||||
"currency": "EUR",
|
||||
"rating": rating,
|
||||
"pros": pros,
|
||||
"cons": cons,
|
||||
"description": form.get("description", "").strip(),
|
||||
"cta_label": form.get("cta_label", "").strip(),
|
||||
"status": form.get("status", "draft").strip(),
|
||||
"language": form.get("language", "de").strip() or "de",
|
||||
"sort_order": int(form.get("sort_order", "0") or "0"),
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/affiliate")
|
||||
@role_required("admin")
|
||||
async def affiliate_products():
|
||||
"""Affiliate product list — full page."""
|
||||
from ..affiliate import get_all_products, get_click_counts, get_distinct_retailers
|
||||
|
||||
q = request.args.get("q", "").strip()
|
||||
category = request.args.get("category", "").strip()
|
||||
retailer_filter = request.args.get("retailer", "").strip()
|
||||
status_filter = request.args.get("status", "").strip()
|
||||
|
||||
products = await get_all_products(
|
||||
status=status_filter or None,
|
||||
retailer=retailer_filter or None,
|
||||
)
|
||||
if q:
|
||||
q_lower = q.lower()
|
||||
products = [p for p in products if q_lower in p["name"].lower() or q_lower in p["brand"].lower()]
|
||||
if category:
|
||||
products = [p for p in products if p["category"] == category]
|
||||
|
||||
click_counts = await get_click_counts()
|
||||
for p in products:
|
||||
p["click_count"] = click_counts.get(p["id"], 0)
|
||||
|
||||
retailers = await get_distinct_retailers()
|
||||
|
||||
return await render_template(
|
||||
"admin/affiliate_products.html",
|
||||
admin_page="affiliate",
|
||||
products=products,
|
||||
click_counts=click_counts,
|
||||
retailers=retailers,
|
||||
categories=AFFILIATE_CATEGORIES,
|
||||
statuses=AFFILIATE_STATUSES,
|
||||
q=q,
|
||||
category=category,
|
||||
retailer_filter=retailer_filter,
|
||||
status_filter=status_filter,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/affiliate/results")
|
||||
@role_required("admin")
|
||||
async def affiliate_results():
|
||||
"""HTMX partial: filtered product rows."""
|
||||
from ..affiliate import get_all_products, get_click_counts
|
||||
|
||||
q = request.args.get("q", "").strip()
|
||||
category = request.args.get("category", "").strip()
|
||||
retailer_filter = request.args.get("retailer", "").strip()
|
||||
status_filter = request.args.get("status", "").strip()
|
||||
|
||||
products = await get_all_products(
|
||||
status=status_filter or None,
|
||||
retailer=retailer_filter or None,
|
||||
)
|
||||
if q:
|
||||
q_lower = q.lower()
|
||||
products = [p for p in products if q_lower in p["name"].lower() or q_lower in p["brand"].lower()]
|
||||
if category:
|
||||
products = [p for p in products if p["category"] == category]
|
||||
|
||||
click_counts = await get_click_counts()
|
||||
for p in products:
|
||||
p["click_count"] = click_counts.get(p["id"], 0)
|
||||
|
||||
return await render_template(
|
||||
"admin/partials/affiliate_results.html",
|
||||
products=products,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/affiliate/preview", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def affiliate_preview():
|
||||
"""Render a product card fragment from form data — used by live preview HTMX."""
|
||||
from ..content.routes import _bake_env
|
||||
from ..i18n import get_translations
|
||||
|
||||
form = await request.form
|
||||
data = _form_to_product(form)
|
||||
lang = data["language"] or "de"
|
||||
|
||||
# Convert JSON-string pros/cons to lists for the template
|
||||
product = dict(data)
|
||||
product["pros"] = json.loads(product["pros"]) if product["pros"] else []
|
||||
product["cons"] = json.loads(product["cons"]) if product["cons"] else []
|
||||
|
||||
if not product["name"]:
|
||||
return "<p style='color:#94A3B8;font-size:.875rem;padding:.5rem 0'>Fill in the form to see a preview.</p>"
|
||||
|
||||
tmpl = _bake_env.get_template("partials/product_card.html")
|
||||
html = tmpl.render(product=product, t=get_translations(lang), lang=lang)
|
||||
return html
|
||||
|
||||
|
||||
@bp.route("/affiliate/new", methods=["GET", "POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def affiliate_new():
|
||||
"""Create an affiliate product."""
|
||||
from ..affiliate import get_distinct_retailers
|
||||
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
data = _form_to_product(form)
|
||||
|
||||
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
|
||||
await flash("Slug, name, and affiliate URL are required.", "error")
|
||||
return await render_template(
|
||||
"admin/affiliate_form.html",
|
||||
admin_page="affiliate",
|
||||
data=data,
|
||||
editing=False,
|
||||
categories=AFFILIATE_CATEGORIES,
|
||||
statuses=AFFILIATE_STATUSES,
|
||||
retailers=await get_distinct_retailers(),
|
||||
)
|
||||
|
||||
existing = await fetch_one(
|
||||
"SELECT id FROM affiliate_products WHERE slug = ? AND language = ?",
|
||||
(data["slug"], data["language"]),
|
||||
)
|
||||
if existing:
|
||||
await flash(f"Slug '{data['slug']}' already exists for language '{data['language']}'.", "error")
|
||||
return await render_template(
|
||||
"admin/affiliate_form.html",
|
||||
admin_page="affiliate",
|
||||
data=data,
|
||||
editing=False,
|
||||
categories=AFFILIATE_CATEGORIES,
|
||||
statuses=AFFILIATE_STATUSES,
|
||||
retailers=await get_distinct_retailers(),
|
||||
)
|
||||
|
||||
await execute(
|
||||
"""INSERT INTO affiliate_products
|
||||
(slug, name, brand, category, retailer, affiliate_url, image_url,
|
||||
price_cents, currency, rating, pros, cons, description, cta_label,
|
||||
status, language, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
data["slug"], data["name"], data["brand"], data["category"],
|
||||
data["retailer"], data["affiliate_url"], data["image_url"],
|
||||
data["price_cents"], data["currency"], data["rating"],
|
||||
data["pros"], data["cons"], data["description"], data["cta_label"],
|
||||
data["status"], data["language"], data["sort_order"],
|
||||
),
|
||||
)
|
||||
await flash(f"Product '{data['name']}' created.", "success")
|
||||
return redirect(url_for("admin.affiliate_products"))
|
||||
|
||||
return await render_template(
|
||||
"admin/affiliate_form.html",
|
||||
admin_page="affiliate",
|
||||
data={},
|
||||
editing=False,
|
||||
categories=AFFILIATE_CATEGORIES,
|
||||
statuses=AFFILIATE_STATUSES,
|
||||
retailers=await get_distinct_retailers(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/affiliate/<int:product_id>/edit", methods=["GET", "POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def affiliate_edit(product_id: int):
|
||||
"""Edit an affiliate product."""
|
||||
from ..affiliate import get_distinct_retailers
|
||||
|
||||
product = await fetch_one("SELECT * FROM affiliate_products WHERE id = ?", (product_id,))
|
||||
if not product:
|
||||
await flash("Product not found.", "error")
|
||||
return redirect(url_for("admin.affiliate_products"))
|
||||
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
data = _form_to_product(form)
|
||||
|
||||
if not data["slug"] or not data["name"] or not data["affiliate_url"]:
|
||||
await flash("Slug, name, and affiliate URL are required.", "error")
|
||||
return await render_template(
|
||||
"admin/affiliate_form.html",
|
||||
admin_page="affiliate",
|
||||
data={**dict(product), **data},
|
||||
editing=True,
|
||||
product_id=product_id,
|
||||
categories=AFFILIATE_CATEGORIES,
|
||||
statuses=AFFILIATE_STATUSES,
|
||||
retailers=await get_distinct_retailers(),
|
||||
)
|
||||
|
||||
# Check slug collision only if slug or language changed
|
||||
if data["slug"] != product["slug"] or data["language"] != product["language"]:
|
||||
collision = await fetch_one(
|
||||
"SELECT id FROM affiliate_products WHERE slug = ? AND language = ? AND id != ?",
|
||||
(data["slug"], data["language"], product_id),
|
||||
)
|
||||
if collision:
|
||||
await flash(f"Slug '{data['slug']}' already exists for language '{data['language']}'.", "error")
|
||||
return await render_template(
|
||||
"admin/affiliate_form.html",
|
||||
admin_page="affiliate",
|
||||
data={**dict(product), **data},
|
||||
editing=True,
|
||||
product_id=product_id,
|
||||
categories=AFFILIATE_CATEGORIES,
|
||||
statuses=AFFILIATE_STATUSES,
|
||||
retailers=await get_distinct_retailers(),
|
||||
)
|
||||
|
||||
await execute(
|
||||
"""UPDATE affiliate_products
|
||||
SET slug=?, name=?, brand=?, category=?, retailer=?, affiliate_url=?,
|
||||
image_url=?, price_cents=?, currency=?, rating=?, pros=?, cons=?,
|
||||
description=?, cta_label=?, status=?, language=?, sort_order=?,
|
||||
updated_at=datetime('now')
|
||||
WHERE id=?""",
|
||||
(
|
||||
data["slug"], data["name"], data["brand"], data["category"],
|
||||
data["retailer"], data["affiliate_url"], data["image_url"],
|
||||
data["price_cents"], data["currency"], data["rating"],
|
||||
data["pros"], data["cons"], data["description"], data["cta_label"],
|
||||
data["status"], data["language"], data["sort_order"],
|
||||
product_id,
|
||||
),
|
||||
)
|
||||
await flash(f"Product '{data['name']}' updated.", "success")
|
||||
return redirect(url_for("admin.affiliate_products"))
|
||||
|
||||
# Render pros/cons JSON arrays as newline-separated text for the form
|
||||
product_dict = dict(product)
|
||||
try:
|
||||
product_dict["pros_text"] = "\n".join(json.loads(product["pros"] or "[]"))
|
||||
product_dict["cons_text"] = "\n".join(json.loads(product["cons"] or "[]"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
product_dict["pros_text"] = ""
|
||||
product_dict["cons_text"] = ""
|
||||
if product["price_cents"]:
|
||||
product_dict["price_eur"] = f"{product['price_cents'] / 100:.2f}"
|
||||
else:
|
||||
product_dict["price_eur"] = ""
|
||||
|
||||
return await render_template(
|
||||
"admin/affiliate_form.html",
|
||||
admin_page="affiliate",
|
||||
data=product_dict,
|
||||
editing=True,
|
||||
product_id=product_id,
|
||||
categories=AFFILIATE_CATEGORIES,
|
||||
statuses=AFFILIATE_STATUSES,
|
||||
retailers=await get_distinct_retailers(),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/affiliate/<int:product_id>/delete", methods=["POST"])
|
||||
@role_required("admin")
|
||||
@csrf_protect
|
||||
async def affiliate_delete(product_id: int):
|
||||
"""Delete an affiliate product."""
|
||||
product = await fetch_one("SELECT name FROM affiliate_products WHERE id = ?", (product_id,))
|
||||
if product:
|
||||
await execute("DELETE FROM affiliate_products WHERE id = ?", (product_id,))
|
||||
await flash(f"Product '{product['name']}' deleted.", "success")
|
||||
return redirect(url_for("admin.affiliate_products"))
|
||||
|
||||
|
||||
@bp.route("/affiliate/dashboard")
|
||||
@role_required("admin")
|
||||
async def affiliate_dashboard():
|
||||
"""Affiliate click statistics dashboard."""
|
||||
from ..affiliate import get_click_stats
|
||||
|
||||
days_count = int(request.args.get("days", "30") or "30")
|
||||
days_count = max(7, min(days_count, 365))
|
||||
stats = await get_click_stats(days_count)
|
||||
|
||||
# Build estimated revenue: clicks × assumed 3% CR × avg basket €80
|
||||
est_revenue = round(stats["total_clicks"] * 0.03 * 80)
|
||||
|
||||
# Article count (live articles that have been clicked)
|
||||
article_count = len(stats["top_articles"])
|
||||
|
||||
# Retailer bars: compute pct of max for width
|
||||
max_ret_clicks = max((r["click_count"] for r in stats["by_retailer"]), default=1)
|
||||
for r in stats["by_retailer"]:
|
||||
r["pct"] = round(r["click_count"] / max_ret_clicks * 100) if max_ret_clicks else 0
|
||||
total = stats["total_clicks"] or 1
|
||||
r["share_pct"] = round(r["click_count"] / total * 100)
|
||||
|
||||
return await render_template(
|
||||
"admin/affiliate_dashboard.html",
|
||||
admin_page="affiliate_dashboard",
|
||||
stats=stats,
|
||||
est_revenue=est_revenue,
|
||||
article_count=article_count,
|
||||
days_count=days_count,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/affiliate/<int:product_id>/toggle", methods=["POST"])
|
||||
@role_required("admin")
|
||||
async def affiliate_toggle(product_id: int):
|
||||
"""Toggle product status: draft → active → archived → draft."""
|
||||
product = await fetch_one(
|
||||
"SELECT id, name, status FROM affiliate_products WHERE id = ?", (product_id,)
|
||||
)
|
||||
if not product:
|
||||
return "", 404
|
||||
|
||||
cycle = {"draft": "active", "active": "archived", "archived": "draft"}
|
||||
new_status = cycle.get(product["status"], "draft")
|
||||
await execute(
|
||||
"UPDATE affiliate_products SET status=?, updated_at=datetime('now') WHERE id=?",
|
||||
(new_status, product_id),
|
||||
)
|
||||
|
||||
product_updated = await fetch_one(
|
||||
"SELECT * FROM affiliate_products WHERE id = ?", (product_id,)
|
||||
)
|
||||
from ..affiliate import get_click_counts
|
||||
click_counts = await get_click_counts()
|
||||
product_dict = dict(product_updated)
|
||||
product_dict["click_count"] = click_counts.get(product_id, 0)
|
||||
|
||||
return await render_template(
|
||||
"admin/partials/affiliate_row.html",
|
||||
product=product_dict,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "affiliate_dashboard" %}
|
||||
|
||||
{% block title %}Affiliate Dashboard - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl">Affiliate Dashboard</h1>
|
||||
<div class="flex gap-2">
|
||||
{% for d in [7, 30, 90] %}
|
||||
<a href="?days={{ d }}" class="btn-outline btn-sm {% if days_count == d %}active{% endif %}">{{ d }}d</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# ── Stats strip ── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:1rem;margin-bottom:1.5rem;">
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Clicks ({{ days_count }}d)</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.total_clicks | int }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Products</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ stats.active_products or 0 }}</div>
|
||||
<div class="text-xs text-slate">{{ stats.draft_products or 0 }} draft</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Articles (clicked)</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">{{ article_count }}</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:1.25rem;">
|
||||
<div class="text-xs font-semibold text-slate" style="text-transform:uppercase;letter-spacing:.06em;margin-bottom:.375rem;">Est. Revenue</div>
|
||||
<div class="mono" style="font-size:1.75rem;font-weight:700;color:#0F172A;">~€{{ est_revenue }}</div>
|
||||
<div class="text-xs text-slate">3% CR × €80 basket</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Daily bar chart ── #}
|
||||
{% if stats.daily_bars %}
|
||||
<div class="card mb-6" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks · Last {{ days_count }} Days</div>
|
||||
<div style="display:flex;align-items:flex-end;gap:2px;height:120px;overflow-x:auto;">
|
||||
{% for bar in stats.daily_bars %}
|
||||
<div title="{{ bar.day }}: {{ bar.click_count }} clicks"
|
||||
style="flex-shrink:0;width:8px;background:#1D4ED8;border-radius:3px 3px 0 0;min-height:2px;height:{{ bar.pct }}%;transition:opacity .15s;"
|
||||
onmouseover="this.style.opacity='.7'" onmouseout="this.style.opacity='1'">
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-top:.375rem;">
|
||||
<span class="text-xs text-slate">{{ stats.daily_bars[0].day if stats.daily_bars else '' }}</span>
|
||||
<span class="text-xs text-slate">{{ stats.daily_bars[-1].day if stats.daily_bars else '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;margin-bottom:1.5rem;">
|
||||
|
||||
{# ── Top products ── #}
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Products</div>
|
||||
{% if stats.top_products %}
|
||||
{% for p in stats.top_products %}
|
||||
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
|
||||
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
|
||||
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
<a href="{{ url_for('admin.affiliate_edit', product_id=p.id) }}" style="color:inherit;text-decoration:none;">{{ p.name }}</a>
|
||||
</span>
|
||||
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ p.click_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No clicks yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Top articles ── #}
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Top Articles</div>
|
||||
{% if stats.top_articles %}
|
||||
{% for a in stats.top_articles %}
|
||||
<div style="display:flex;align-items:center;gap:.75rem;padding:.5rem 0;{% if not loop.last %}border-bottom:1px solid #F1F5F9;{% endif %}">
|
||||
<span class="mono text-xs text-slate" style="width:1.5rem;text-align:right;">{{ loop.index }}</span>
|
||||
<span style="flex:1;font-size:.8125rem;color:#0F172A;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
|
||||
title="{{ a.article_slug }}">{{ a.article_slug }}</span>
|
||||
<span class="mono" style="font-weight:600;font-size:.875rem;color:#0F172A;">{{ a.click_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No clicks with article source yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Clicks by retailer ── #}
|
||||
{% if stats.by_retailer %}
|
||||
<div class="card" style="padding:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-4" style="text-transform:uppercase;letter-spacing:.06em;">Clicks by Retailer</div>
|
||||
{% for r in stats.by_retailer %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:.75rem;">
|
||||
<span style="width:140px;font-size:.8125rem;color:#0F172A;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
{{ r.retailer or 'Unknown' }}
|
||||
</span>
|
||||
<div style="flex:1;background:#F1F5F9;border-radius:4px;height:24px;overflow:hidden;">
|
||||
<div style="width:{{ r.pct }}%;background:#1D4ED8;height:100%;border-radius:4px;min-width:2px;"></div>
|
||||
</div>
|
||||
<span class="mono" style="font-size:.8125rem;font-weight:600;width:60px;text-align:right;flex-shrink:0;">
|
||||
{{ r.click_count }} <span class="text-slate" style="font-weight:400;">({{ r.share_pct }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
220
web/src/padelnomics/admin/templates/admin/affiliate_form.html
Normal file
220
web/src/padelnomics/admin/templates/admin/affiliate_form.html
Normal file
@@ -0,0 +1,220 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "affiliate" %}
|
||||
|
||||
{% block title %}{% if editing %}Edit Product{% else %}New Product{% endif %} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_head %}
|
||||
<script>
|
||||
function slugify(text) {
|
||||
return text.toLowerCase()
|
||||
.replace(/[äöü]/g, c => ({'ä':'ae','ö':'oe','ü':'ue'}[c]))
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var nameInput = document.getElementById('f-name');
|
||||
var slugInput = document.getElementById('f-slug');
|
||||
if (nameInput && slugInput && !slugInput.value) {
|
||||
nameInput.addEventListener('input', function() {
|
||||
if (!slugInput.dataset.manual) {
|
||||
slugInput.value = slugify(nameInput.value);
|
||||
}
|
||||
});
|
||||
slugInput.addEventListener('input', function() {
|
||||
slugInput.dataset.manual = '1';
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="text-slate text-sm" style="text-decoration:none">← Products</a>
|
||||
<h1 class="text-2xl mt-1">{% if editing %}Edit Product{% else %}New Product{% endif %}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{# HTMX preview trigger — outside the grid so it takes no layout space #}
|
||||
<div style="display:none"
|
||||
hx-post="{{ url_for('admin.affiliate_preview') }}"
|
||||
hx-target="#product-preview"
|
||||
hx-trigger="load, input from:#affiliate-form delay:600ms"
|
||||
hx-include="#affiliate-form"
|
||||
hx-push-url="false">
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 380px;gap:2rem;align-items:start" class="affiliate-form-grid">
|
||||
|
||||
{# ── Left: form ── #}
|
||||
<form method="post" id="affiliate-form"
|
||||
action="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="card" style="padding:1.5rem;display:flex;flex-direction:column;gap:1.25rem;">
|
||||
|
||||
{# Name #}
|
||||
<div>
|
||||
<label class="form-label" for="f-name">Name *</label>
|
||||
<input id="f-name" type="text" name="name" value="{{ data.get('name','') }}"
|
||||
class="form-input" placeholder="e.g. Bullpadel Vertex 04" required>
|
||||
</div>
|
||||
|
||||
{# Slug #}
|
||||
<div>
|
||||
<label class="form-label" for="f-slug">Slug *</label>
|
||||
<input id="f-slug" type="text" name="slug" value="{{ data.get('slug','') }}"
|
||||
class="form-input" placeholder="e.g. bullpadel-vertex-04-amazon" required
|
||||
pattern="[a-z0-9][a-z0-9\-]*">
|
||||
<p class="form-hint">Lowercase letters, numbers, hyphens only. Include retailer to disambiguate (e.g. <code>-amazon</code>, <code>-padelnuestro</code>).</p>
|
||||
</div>
|
||||
|
||||
{# Brand + Category row #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-brand">Brand</label>
|
||||
<input id="f-brand" type="text" name="brand" value="{{ data.get('brand','') }}"
|
||||
class="form-input" placeholder="e.g. Bullpadel">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-category">Category</label>
|
||||
<select id="f-category" name="category" class="form-input">
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if data.get('category','accessory') == cat %}selected{% endif %}>{{ cat | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Retailer #}
|
||||
<div>
|
||||
<label class="form-label" for="f-retailer">Retailer</label>
|
||||
<input id="f-retailer" type="text" name="retailer" value="{{ data.get('retailer','') }}"
|
||||
class="form-input" placeholder="e.g. Amazon, Padel Nuestro"
|
||||
list="retailers-list">
|
||||
<datalist id="retailers-list">
|
||||
{% for r in retailers %}
|
||||
<option value="{{ r }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{# Affiliate URL #}
|
||||
<div>
|
||||
<label class="form-label" for="f-url">Affiliate URL *</label>
|
||||
<input id="f-url" type="url" name="affiliate_url" value="{{ data.get('affiliate_url','') }}"
|
||||
class="form-input" placeholder="https://www.amazon.de/dp/B0XXXXX?tag=padelnomics-21" required>
|
||||
<p class="form-hint">Full URL with tracking params already baked in.</p>
|
||||
</div>
|
||||
|
||||
{# Image URL #}
|
||||
<div>
|
||||
<label class="form-label" for="f-image">Image URL</label>
|
||||
<input id="f-image" type="text" name="image_url" value="{{ data.get('image_url','') }}"
|
||||
class="form-input" placeholder="/static/images/affiliate/bullpadel-vertex-04.webp">
|
||||
<p class="form-hint">Local path (recommended) or external URL.</p>
|
||||
</div>
|
||||
|
||||
{# Price + Rating row #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-price">Price (EUR)</label>
|
||||
<input id="f-price" type="number" name="price_eur" value="{{ data.get('price_eur','') }}"
|
||||
class="form-input" placeholder="149.99" step="0.01" min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-rating">Rating (0–5)</label>
|
||||
<input id="f-rating" type="number" name="rating" value="{{ data.get('rating','') }}"
|
||||
class="form-input" placeholder="4.3" step="0.1" min="0" max="5">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Description #}
|
||||
<div>
|
||||
<label class="form-label" for="f-desc">Short Description</label>
|
||||
<textarea id="f-desc" name="description" rows="3"
|
||||
class="form-input" placeholder="One to two sentences describing the product...">{{ data.get('description','') }}</textarea>
|
||||
</div>
|
||||
|
||||
{# Pros #}
|
||||
<div>
|
||||
<label class="form-label" for="f-pros">Pros <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
|
||||
<textarea id="f-pros" name="pros" rows="4"
|
||||
class="form-input" placeholder="Carbon frame for maximum power Diamond shape for aggressive players">{{ data.get('pros_text', data.get('pros','')) }}</textarea>
|
||||
</div>
|
||||
|
||||
{# Cons #}
|
||||
<div>
|
||||
<label class="form-label" for="f-cons">Cons <span class="form-hint" style="font-weight:normal">(one per line)</span></label>
|
||||
<textarea id="f-cons" name="cons" rows="3"
|
||||
class="form-input" placeholder="Only for advanced players">{{ data.get('cons_text', data.get('cons','')) }}</textarea>
|
||||
</div>
|
||||
|
||||
{# CTA Label #}
|
||||
<div>
|
||||
<label class="form-label" for="f-cta">CTA Label</label>
|
||||
<input id="f-cta" type="text" name="cta_label" value="{{ data.get('cta_label','') }}"
|
||||
class="form-input" placeholder='Leave empty for default "Zum Angebot"'>
|
||||
</div>
|
||||
|
||||
{# Status + Language + Sort #}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="form-label" for="f-status">Status</label>
|
||||
<select id="f-status" name="status" class="form-input">
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if data.get('status','draft') == s %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-lang">Language</label>
|
||||
<select id="f-lang" name="language" class="form-input">
|
||||
<option value="de" {% if data.get('language','de') == 'de' %}selected{% endif %}>DE</option>
|
||||
<option value="en" {% if data.get('language','de') == 'en' %}selected{% endif %}>EN</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="f-sort">Sort Order</label>
|
||||
<input id="f-sort" type="number" name="sort_order" value="{{ data.get('sort_order', 0) }}"
|
||||
class="form-input" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Actions #}
|
||||
<div class="flex gap-3 justify-between" style="margin-top:.5rem">
|
||||
<div class="flex gap-2">
|
||||
<button type="submit" class="btn" formaction="{% if editing %}{{ url_for('admin.affiliate_edit', product_id=product_id) }}{% else %}{{ url_for('admin.affiliate_new') }}{% endif %}">
|
||||
{% if editing %}Save Changes{% else %}Create Product{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="btn-outline">Cancel</a>
|
||||
</div>
|
||||
{% if editing %}
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product_id) }}" style="margin:0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline"
|
||||
onclick="return confirm('Delete this product? This cannot be undone.')">Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ── Right: live preview ── #}
|
||||
<div style="position:sticky;top:1.5rem;">
|
||||
<div class="text-xs font-semibold text-slate mb-2" style="text-transform:uppercase;letter-spacing:.06em;">Preview</div>
|
||||
<div id="product-preview" style="border:1px solid #E2E8F0;border-radius:12px;padding:1rem;background:#F8FAFC;min-height:180px;">
|
||||
<p style="color:#94A3B8;font-size:.875rem;text-align:center;margin-top:2rem;">Loading preview…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (max-width: 900px) {
|
||||
.affiliate-form-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,83 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "affiliate" %}
|
||||
|
||||
{% block title %}Affiliate Products - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl">Affiliate Products</h1>
|
||||
<a href="{{ url_for('admin.affiliate_new') }}" class="btn btn-sm">+ New Product</a>
|
||||
</header>
|
||||
|
||||
{# Filters #}
|
||||
<div class="card mb-6" style="padding:1rem 1.25rem">
|
||||
<form class="flex flex-wrap gap-3 items-end"
|
||||
hx-get="{{ url_for('admin.affiliate_results') }}"
|
||||
hx-target="#aff-results"
|
||||
hx-trigger="change, input delay:300ms"
|
||||
hx-indicator="#aff-loading">
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
|
||||
<input type="text" name="q" value="{{ q }}" placeholder="Name or brand..."
|
||||
class="form-input" style="min-width:200px">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Category</label>
|
||||
<select name="category" class="form-input" style="min-width:120px">
|
||||
<option value="">All</option>
|
||||
{% for cat in categories %}
|
||||
<option value="{{ cat }}" {% if cat == category %}selected{% endif %}>{{ cat | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Retailer</label>
|
||||
<select name="retailer" class="form-input" style="min-width:140px">
|
||||
<option value="">All</option>
|
||||
{% for r in retailers %}
|
||||
<option value="{{ r }}" {% if r == retailer_filter %}selected{% endif %}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
||||
<select name="status" class="form-input" style="min-width:110px">
|
||||
<option value="">All</option>
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if s == status_filter %}selected{% endif %}>{{ s | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<svg id="aff-loading" class="htmx-indicator search-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="#CBD5E1" stroke-width="3"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="#0EA5E9" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Results #}
|
||||
<div id="aff-results">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Brand</th>
|
||||
<th>Retailer</th>
|
||||
<th>Category</th>
|
||||
<th>Price</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Clicks</th>
|
||||
<th class="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% include "admin/partials/affiliate_results.html" %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -99,6 +99,7 @@
|
||||
'suppliers': 'suppliers',
|
||||
'articles': 'content', 'scenarios': 'content', 'templates': 'content', 'pseo': 'content',
|
||||
'emails': 'email', 'inbox': 'email', 'compose': 'email', 'gallery': 'email', 'audiences': 'email', 'outreach': 'email',
|
||||
'affiliate': 'affiliate', 'affiliate_dashboard': 'affiliate',
|
||||
'billing': 'billing',
|
||||
'seo': 'analytics',
|
||||
'pipeline': 'pipeline',
|
||||
@@ -149,6 +150,11 @@
|
||||
Billing
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if active_section == 'affiliate' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 21v-7.5a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75V21m-4.5 0H2.36m11.14 0H18m0 0h3.64m-1.39 0V9.349M3.75 21V9.349m0 0a3.001 3.001 0 0 0 3.75-.615A2.993 2.993 0 0 0 9.75 9.75c.896 0 1.7-.393 2.25-1.016a2.993 2.993 0 0 0 2.25 1.016 2.993 2.993 0 0 0 2.25-1.016 3.001 3.001 0 0 0 3.75.614m-16.5 0a3.004 3.004 0 0 1-.621-4.72l1.189-1.19A1.5 1.5 0 0 1 5.378 3h13.243a1.5 1.5 0 0 1 1.06.44l1.19 1.189a3 3 0 0 1-.621 4.72M6.75 18h3.75a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75H6.75a.75.75 0 0 0-.75.75v3.75c0 .414.336.75.75.75Z"/></svg>
|
||||
Affiliate
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.seo') }}" class="{% if active_section == 'analytics' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"/></svg>
|
||||
Analytics
|
||||
@@ -196,6 +202,11 @@
|
||||
<a href="{{ url_for('admin.audiences') }}" class="{% if admin_page == 'audiences' %}active{% endif %}">Audiences</a>
|
||||
<a href="{{ url_for('admin.outreach') }}" class="{% if admin_page == 'outreach' %}active{% endif %}">Outreach</a>
|
||||
</nav>
|
||||
{% elif active_section == 'affiliate' %}
|
||||
<nav class="admin-subnav">
|
||||
<a href="{{ url_for('admin.affiliate_dashboard') }}" class="{% if admin_page == 'affiliate_dashboard' %}active{% endif %}">Dashboard</a>
|
||||
<a href="{{ url_for('admin.affiliate_products') }}" class="{% if admin_page == 'affiliate' %}active{% endif %}">Products</a>
|
||||
</nav>
|
||||
{% elif active_section == 'system' %}
|
||||
<nav class="admin-subnav">
|
||||
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">Users</a>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{% if products %}
|
||||
{% for product in products %}
|
||||
{% include "admin/partials/affiliate_row.html" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-slate" style="text-align:center;padding:2rem;">No products found.</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,29 @@
|
||||
<tr id="aff-{{ product.id }}">
|
||||
<td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="{{ product.name }}">
|
||||
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" style="color:#0F172A;text-decoration:none;font-weight:500;">{{ product.name }}</a>
|
||||
</td>
|
||||
<td class="text-slate">{{ product.brand or '—' }}</td>
|
||||
<td class="text-slate">{{ product.retailer or '—' }}</td>
|
||||
<td class="text-slate">{{ product.category }}</td>
|
||||
<td class="mono">
|
||||
{% if product.price_cents %}{{ "%.0f" | format(product.price_cents / 100) }}€{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button hx-post="{{ url_for('admin.affiliate_toggle', product_id=product.id) }}"
|
||||
hx-target="#aff-{{ product.id }}" hx-swap="outerHTML"
|
||||
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
|
||||
class="badge {% if product.status == 'active' %}badge-success{% elif product.status == 'draft' %}badge-warning{% else %}badge{% endif %}"
|
||||
style="cursor:pointer;border:none;">
|
||||
{{ product.status }}
|
||||
</button>
|
||||
</td>
|
||||
<td class="mono text-right">{{ product.click_count or 0 }}</td>
|
||||
<td class="text-right" style="white-space:nowrap">
|
||||
<a href="{{ url_for('admin.affiliate_edit', product_id=product.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
<form method="post" action="{{ url_for('admin.affiliate_delete', product_id=product.id) }}" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm"
|
||||
onclick="return confirm('Delete {{ product.name }}?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -6,15 +6,19 @@
|
||||
<td>
|
||||
{% for v in g.variants %}
|
||||
<div class="variant-row">
|
||||
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}"
|
||||
{% if v.display_status == 'live' %}
|
||||
<a href="/{{ v.language or 'en' }}{{ v.url_path }}" target="_blank"
|
||||
class="lang-chip lang-chip-{{ v.display_status }}"
|
||||
title="Edit {{ v.language|upper }} variant">
|
||||
title="View live {{ v.language|upper }} article">
|
||||
<span class="dot"></span>{{ v.language | upper }}
|
||||
{% if v.noindex %}<span class="noindex-tag">noindex</span>{% endif %}
|
||||
</a>
|
||||
{% if v.display_status == 'live' %}
|
||||
<a href="/{{ v.language or 'en' }}{{ v.url_path }}" target="_blank"
|
||||
class="btn-outline btn-sm view-lang-btn" title="View live article">View ↗</a>
|
||||
{% else %}
|
||||
<span class="lang-chip lang-chip-{{ v.display_status }}"
|
||||
title="{{ v.display_status | capitalize }}">
|
||||
<span class="dot"></span>{{ v.language | upper }}
|
||||
{% if v.noindex %}<span class="noindex-tag">noindex</span>{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.article_edit', article_id=v.id) }}"
|
||||
class="btn-outline btn-sm view-lang-btn">Edit</a>
|
||||
|
||||
224
web/src/padelnomics/affiliate.py
Normal file
224
web/src/padelnomics/affiliate.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Affiliate product catalog: product lookup, click logging, and stats queries.
|
||||
|
||||
All functions are plain async procedures — no classes, no state.
|
||||
|
||||
Design decisions:
|
||||
- IP hashing uses a daily salt (date + SECRET_KEY[:16]) for GDPR compliance.
|
||||
Rotating salt prevents re-identification across days without storing PII.
|
||||
- Products are fetched by (slug, language) with a graceful fallback to any
|
||||
language, so DE cards appear in EN articles rather than nothing.
|
||||
- Stats are computed entirely in SQL — no Python aggregation.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from .core import config, execute, fetch_all, fetch_one
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VALID_CATEGORIES = ("racket", "ball", "shoe", "bag", "grip", "eyewear", "accessory")
|
||||
VALID_STATUSES = ("draft", "active", "archived")
|
||||
|
||||
|
||||
def hash_ip(ip_address: str) -> str:
|
||||
"""SHA256(ip + YYYY-MM-DD + SECRET_KEY[:16]) with daily salt rotation."""
|
||||
assert ip_address, "ip_address must not be empty"
|
||||
today = date.today().isoformat()
|
||||
salt = config.SECRET_KEY[:16]
|
||||
raw = f"{ip_address}:{today}:{salt}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
async def get_product(slug: str, language: str = "de") -> dict | None:
|
||||
"""Return active product by slug+language, falling back to any language."""
|
||||
assert slug, "slug must not be empty"
|
||||
row = await fetch_one(
|
||||
"SELECT * FROM affiliate_products"
|
||||
" WHERE slug = ? AND language = ? AND status = 'active'",
|
||||
(slug, language),
|
||||
)
|
||||
if row:
|
||||
return _parse_product(row)
|
||||
# Graceful fallback: show any language rather than nothing
|
||||
row = await fetch_one(
|
||||
"SELECT * FROM affiliate_products"
|
||||
" WHERE slug = ? AND status = 'active' LIMIT 1",
|
||||
(slug,),
|
||||
)
|
||||
return _parse_product(row) if row else None
|
||||
|
||||
|
||||
async def get_products_by_category(category: str, language: str = "de") -> list[dict]:
|
||||
"""Return active products in category sorted by sort_order, with fallback."""
|
||||
assert category in VALID_CATEGORIES, f"unknown category: {category}"
|
||||
rows = await fetch_all(
|
||||
"SELECT * FROM affiliate_products"
|
||||
" WHERE category = ? AND language = ? AND status = 'active'"
|
||||
" ORDER BY sort_order ASC, id ASC",
|
||||
(category, language),
|
||||
)
|
||||
if rows:
|
||||
return [_parse_product(r) for r in rows]
|
||||
# Fallback: any language for this category
|
||||
rows = await fetch_all(
|
||||
"SELECT * FROM affiliate_products"
|
||||
" WHERE category = ? AND status = 'active'"
|
||||
" ORDER BY sort_order ASC, id ASC",
|
||||
(category,),
|
||||
)
|
||||
return [_parse_product(r) for r in rows]
|
||||
|
||||
|
||||
async def get_all_products(
|
||||
status: str | None = None,
|
||||
retailer: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Admin listing — all products, optionally filtered by status and/or retailer."""
|
||||
conditions = []
|
||||
params: list = []
|
||||
if status:
|
||||
assert status in VALID_STATUSES, f"unknown status: {status}"
|
||||
conditions.append("status = ?")
|
||||
params.append(status)
|
||||
if retailer:
|
||||
conditions.append("retailer = ?")
|
||||
params.append(retailer)
|
||||
|
||||
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
rows = await fetch_all(
|
||||
f"SELECT * FROM affiliate_products {where} ORDER BY sort_order ASC, id ASC",
|
||||
tuple(params),
|
||||
)
|
||||
return [_parse_product(r) for r in rows]
|
||||
|
||||
|
||||
async def get_click_counts() -> dict[int, int]:
|
||||
"""Return {product_id: click_count} for all products (used in admin list)."""
|
||||
rows = await fetch_all(
|
||||
"SELECT product_id, COUNT(*) AS cnt FROM affiliate_clicks GROUP BY product_id"
|
||||
)
|
||||
return {r["product_id"]: r["cnt"] for r in rows}
|
||||
|
||||
|
||||
async def log_click(
|
||||
product_id: int,
|
||||
ip_address: str,
|
||||
article_slug: str | None,
|
||||
referrer: str | None,
|
||||
) -> None:
|
||||
"""Insert a click event. Hashes IP for GDPR compliance."""
|
||||
assert product_id > 0, "product_id must be positive"
|
||||
assert ip_address, "ip_address must not be empty"
|
||||
ip = hash_ip(ip_address)
|
||||
await execute(
|
||||
"INSERT INTO affiliate_clicks (product_id, article_slug, referrer, ip_hash)"
|
||||
" VALUES (?, ?, ?, ?)",
|
||||
(product_id, article_slug, referrer, ip),
|
||||
)
|
||||
|
||||
|
||||
async def get_click_stats(days_count: int = 30) -> dict:
|
||||
"""Compute click statistics over the last N days, entirely in SQL."""
|
||||
assert 1 <= days_count <= 365, f"days must be 1-365, got {days_count}"
|
||||
|
||||
# Total clicks in window
|
||||
total_row = await fetch_one(
|
||||
"SELECT COUNT(*) AS cnt FROM affiliate_clicks"
|
||||
" WHERE clicked_at >= datetime('now', ?)",
|
||||
(f"-{days_count} days",),
|
||||
)
|
||||
total = total_row["cnt"] if total_row else 0
|
||||
|
||||
# Active product count
|
||||
product_counts = await fetch_one(
|
||||
"SELECT"
|
||||
" SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) AS active_count,"
|
||||
" SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) AS draft_count"
|
||||
" FROM affiliate_products"
|
||||
)
|
||||
|
||||
# Top products by clicks
|
||||
top_products = await fetch_all(
|
||||
"SELECT p.id, p.name, p.slug, p.retailer, COUNT(c.id) AS click_count"
|
||||
" FROM affiliate_products p"
|
||||
" LEFT JOIN affiliate_clicks c"
|
||||
" ON c.product_id = p.id"
|
||||
" AND c.clicked_at >= datetime('now', ?)"
|
||||
" GROUP BY p.id"
|
||||
" ORDER BY click_count DESC"
|
||||
" LIMIT 10",
|
||||
(f"-{days_count} days",),
|
||||
)
|
||||
|
||||
# Top articles by clicks
|
||||
top_articles = await fetch_all(
|
||||
"SELECT article_slug, COUNT(*) AS click_count"
|
||||
" FROM affiliate_clicks"
|
||||
" WHERE clicked_at >= datetime('now', ?)"
|
||||
" AND article_slug IS NOT NULL"
|
||||
" GROUP BY article_slug"
|
||||
" ORDER BY click_count DESC"
|
||||
" LIMIT 10",
|
||||
(f"-{days_count} days",),
|
||||
)
|
||||
|
||||
# Clicks by retailer
|
||||
by_retailer = await fetch_all(
|
||||
"SELECT p.retailer, COUNT(c.id) AS click_count"
|
||||
" FROM affiliate_products p"
|
||||
" LEFT JOIN affiliate_clicks c"
|
||||
" ON c.product_id = p.id"
|
||||
" AND c.clicked_at >= datetime('now', ?)"
|
||||
" GROUP BY p.retailer"
|
||||
" ORDER BY click_count DESC",
|
||||
(f"-{days_count} days",),
|
||||
)
|
||||
|
||||
# Daily click counts for bar chart
|
||||
daily = await fetch_all(
|
||||
"SELECT date(clicked_at) AS day, COUNT(*) AS click_count"
|
||||
" FROM affiliate_clicks"
|
||||
" WHERE clicked_at >= datetime('now', ?)"
|
||||
" GROUP BY day"
|
||||
" ORDER BY day ASC",
|
||||
(f"-{days_count} days",),
|
||||
)
|
||||
|
||||
# Normalize daily to percentage heights for CSS bar chart
|
||||
max_daily = max((r["click_count"] for r in daily), default=1)
|
||||
daily_bars = [
|
||||
{"day": r["day"], "click_count": r["click_count"],
|
||||
"pct": round(r["click_count"] / max_daily * 100)}
|
||||
for r in daily
|
||||
]
|
||||
|
||||
return {
|
||||
"total_clicks": total,
|
||||
"active_products": product_counts["active_count"] if product_counts else 0,
|
||||
"draft_products": product_counts["draft_count"] if product_counts else 0,
|
||||
"top_products": [dict(r) for r in top_products],
|
||||
"top_articles": [dict(r) for r in top_articles],
|
||||
"by_retailer": [dict(r) for r in by_retailer],
|
||||
"daily_bars": daily_bars,
|
||||
"days": days_count,
|
||||
}
|
||||
|
||||
|
||||
async def get_distinct_retailers() -> list[str]:
|
||||
"""Return sorted list of distinct retailer names for form datalist."""
|
||||
rows = await fetch_all(
|
||||
"SELECT DISTINCT retailer FROM affiliate_products"
|
||||
" WHERE retailer != '' ORDER BY retailer"
|
||||
)
|
||||
return [r["retailer"] for r in rows]
|
||||
|
||||
|
||||
def _parse_product(row) -> dict:
|
||||
"""Convert aiosqlite Row to plain dict, parsing JSON pros/cons arrays."""
|
||||
d = dict(row)
|
||||
d["pros"] = json.loads(d.get("pros") or "[]")
|
||||
d["cons"] = json.loads(d.get("cons") or "[]")
|
||||
return d
|
||||
@@ -4,6 +4,10 @@ DuckDB read-only analytics reader.
|
||||
Opens a single long-lived DuckDB connection at startup (read_only=True).
|
||||
All queries run via asyncio.to_thread() to avoid blocking the event loop.
|
||||
|
||||
When export_serving.py atomically renames a new analytics.duckdb into place,
|
||||
_check_and_reopen() detects the inode change and transparently reopens —
|
||||
no app restart required.
|
||||
|
||||
Usage:
|
||||
from .analytics import fetch_analytics, execute_user_query
|
||||
|
||||
@@ -14,6 +18,7 @@ Usage:
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -21,6 +26,8 @@ from typing import Any
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_conn = None # duckdb.DuckDBPyConnection | None — lazy import
|
||||
_conn_inode: int | None = None
|
||||
_reopen_lock = threading.Lock()
|
||||
_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
|
||||
|
||||
# DuckDB queries run in the asyncio thread pool. Cap them so a slow scan
|
||||
@@ -32,20 +39,67 @@ def open_analytics_db() -> None:
|
||||
"""Open the DuckDB connection. Call once at app startup."""
|
||||
import duckdb
|
||||
|
||||
global _conn
|
||||
global _conn, _conn_inode
|
||||
path = Path(_DUCKDB_PATH)
|
||||
if not path.exists():
|
||||
# Database doesn't exist yet — skip silently. Queries will return empty.
|
||||
return
|
||||
_conn = duckdb.connect(str(path), read_only=True)
|
||||
_conn_inode = path.stat().st_ino
|
||||
|
||||
|
||||
def close_analytics_db() -> None:
|
||||
"""Close the DuckDB connection. Call at app shutdown."""
|
||||
global _conn
|
||||
global _conn, _conn_inode
|
||||
if _conn is not None:
|
||||
_conn.close()
|
||||
_conn = None
|
||||
_conn_inode = None
|
||||
|
||||
|
||||
def _check_and_reopen() -> None:
|
||||
"""Reopen the connection if analytics.duckdb was atomically replaced (new inode).
|
||||
|
||||
Called at the start of each query. Requires a directory bind mount (not a file
|
||||
bind mount) so that os.stat() inside the container sees the new inode after rename.
|
||||
"""
|
||||
global _conn, _conn_inode
|
||||
import duckdb
|
||||
|
||||
path = Path(_DUCKDB_PATH)
|
||||
try:
|
||||
current_inode = path.stat().st_ino
|
||||
except OSError:
|
||||
return
|
||||
|
||||
if current_inode == _conn_inode:
|
||||
return # same file — nothing to do
|
||||
|
||||
with _reopen_lock:
|
||||
# Double-check under lock to avoid concurrent reopens.
|
||||
try:
|
||||
current_inode = path.stat().st_ino
|
||||
except OSError:
|
||||
return
|
||||
if current_inode == _conn_inode:
|
||||
return
|
||||
|
||||
old_conn = _conn
|
||||
try:
|
||||
new_conn = duckdb.connect(str(path), read_only=True)
|
||||
except Exception:
|
||||
logger.exception("Failed to reopen analytics DB after file change")
|
||||
return
|
||||
|
||||
_conn = new_conn
|
||||
_conn_inode = current_inode
|
||||
logger.info("Analytics DB reopened (inode changed to %d)", current_inode)
|
||||
|
||||
if old_conn is not None:
|
||||
try:
|
||||
old_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def fetch_analytics(sql: str, params: list | None = None) -> list[dict[str, Any]]:
|
||||
@@ -61,7 +115,11 @@ async def fetch_analytics(sql: str, params: list | None = None) -> list[dict[str
|
||||
return []
|
||||
|
||||
def _run() -> list[dict]:
|
||||
cur = _conn.cursor()
|
||||
_check_and_reopen()
|
||||
conn = _conn
|
||||
if conn is None:
|
||||
return []
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
rel = cur.execute(sql, params or [])
|
||||
cols = [d[0] for d in rel.description]
|
||||
@@ -104,8 +162,12 @@ async def execute_user_query(
|
||||
return [], [], "Analytics database is not available.", 0.0
|
||||
|
||||
def _run() -> tuple[list[str], list[tuple], str | None, float]:
|
||||
_check_and_reopen()
|
||||
conn = _conn
|
||||
if conn is None:
|
||||
return [], [], "Analytics database is not available.", 0.0
|
||||
t0 = time.monotonic()
|
||||
cur = _conn.cursor()
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
rel = cur.execute(sql)
|
||||
cols = [d[0] for d in rel.description]
|
||||
|
||||
@@ -280,6 +280,49 @@ def create_app() -> Quart:
|
||||
except Exception as e:
|
||||
return {"status": "unhealthy", "db": str(e)}, 500
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Affiliate click redirect — language-agnostic, no blueprint prefix
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@app.route("/go/<slug>")
|
||||
async def affiliate_redirect(slug: str):
|
||||
"""302 redirect to affiliate URL, logging the click.
|
||||
|
||||
Uses 302 (not 301) so every hit is tracked — browsers don't cache 302s.
|
||||
Extracts article_slug and lang from Referer header best-effort.
|
||||
"""
|
||||
from .affiliate import get_product, log_click
|
||||
from .core import check_rate_limit
|
||||
|
||||
# Extract lang from Referer path (e.g. /de/blog/... → "de"), default de
|
||||
referer = request.headers.get("Referer", "")
|
||||
lang = "de"
|
||||
article_slug = None
|
||||
if referer:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
ref_path = urlparse(referer).path
|
||||
parts = ref_path.strip("/").split("/")
|
||||
if parts and len(parts[0]) == 2:
|
||||
lang = parts[0]
|
||||
if len(parts) > 1:
|
||||
article_slug = parts[-1] or None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
product = await get_product(slug, lang)
|
||||
if not product:
|
||||
abort(404)
|
||||
|
||||
ip = request.remote_addr or "unknown"
|
||||
allowed, _info = await check_rate_limit(f"aff:{ip}", limit=60, window=60)
|
||||
if not allowed:
|
||||
# Still redirect even if rate-limited; just don't log the click
|
||||
return redirect(product["affiliate_url"], 302)
|
||||
|
||||
await log_click(product["id"], ip, article_slug, referer or None)
|
||||
return redirect(product["affiliate_url"], 302)
|
||||
|
||||
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
|
||||
@app.route("/terms")
|
||||
async def legacy_terms():
|
||||
|
||||
@@ -315,7 +315,7 @@ async def generate_articles(
|
||||
"""
|
||||
from ..core import execute as db_execute
|
||||
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||
from .routes import bake_scenario_cards, is_reserved_path
|
||||
from .routes import bake_product_cards, bake_scenario_cards, is_reserved_path
|
||||
|
||||
assert articles_per_day > 0, "articles_per_day must be positive"
|
||||
|
||||
@@ -443,6 +443,7 @@ async def generate_articles(
|
||||
body_html = await bake_scenario_cards(
|
||||
body_html, lang=lang, scenario_overrides=scenario_overrides
|
||||
)
|
||||
body_html = await bake_product_cards(body_html, lang=lang)
|
||||
t_bake += time.perf_counter() - t0
|
||||
|
||||
# Extract FAQ pairs for structured data
|
||||
@@ -584,7 +585,7 @@ async def preview_article(
|
||||
No disk write, no DB insert. Returns {title, url_path, html, meta_description}.
|
||||
"""
|
||||
from ..planner.calculator import DEFAULTS, calc, validate_state
|
||||
from .routes import bake_scenario_cards
|
||||
from .routes import bake_product_cards, bake_scenario_cards
|
||||
|
||||
config = load_template(slug)
|
||||
|
||||
@@ -641,6 +642,7 @@ async def preview_article(
|
||||
body_html = await bake_scenario_cards(
|
||||
body_html, lang=lang, scenario_overrides=scenario_overrides,
|
||||
)
|
||||
body_html = await bake_product_cards(body_html, lang=lang)
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
|
||||
@@ -27,6 +27,8 @@ RESERVED_PREFIXES = (
|
||||
)
|
||||
|
||||
SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]')
|
||||
PRODUCT_RE = re.compile(r'\[product:([a-z0-9_-]+)\]')
|
||||
PRODUCT_GROUP_RE = re.compile(r'\[product-group:([a-z0-9_-]+)\]')
|
||||
|
||||
SECTION_TEMPLATES = {
|
||||
None: "partials/scenario_summary.html",
|
||||
@@ -112,6 +114,53 @@ async def bake_scenario_cards(
|
||||
return html
|
||||
|
||||
|
||||
async def bake_product_cards(html: str, lang: str = "de") -> str:
|
||||
"""Replace [product:slug] and [product-group:category] markers with rendered HTML.
|
||||
|
||||
Processes markers in two passes (product first, then groups) to keep logic
|
||||
clear. Reverse iteration preserves string offsets when splicing.
|
||||
"""
|
||||
from ..affiliate import get_product, get_products_by_category
|
||||
|
||||
t = get_translations(lang)
|
||||
|
||||
# ── Pass 1: [product:slug] ────────────────────────────────────────────────
|
||||
product_matches = list(PRODUCT_RE.finditer(html))
|
||||
if product_matches:
|
||||
slugs = list({m.group(1) for m in product_matches})
|
||||
products: dict[str, dict | None] = {}
|
||||
for slug in slugs:
|
||||
products[slug] = await get_product(slug, lang)
|
||||
|
||||
tmpl = _bake_env.get_template("partials/product_card.html")
|
||||
for match in reversed(product_matches):
|
||||
slug = match.group(1)
|
||||
product = products.get(slug)
|
||||
if not product:
|
||||
continue
|
||||
card_html = tmpl.render(product=product, lang=lang, t=t)
|
||||
html = html[:match.start()] + card_html + html[match.end():]
|
||||
|
||||
# ── Pass 2: [product-group:category] ─────────────────────────────────────
|
||||
group_matches = list(PRODUCT_GROUP_RE.finditer(html))
|
||||
if group_matches:
|
||||
categories = list({m.group(1) for m in group_matches})
|
||||
groups: dict[str, list] = {}
|
||||
for cat in categories:
|
||||
groups[cat] = await get_products_by_category(cat, lang)
|
||||
|
||||
tmpl = _bake_env.get_template("partials/product_group.html")
|
||||
for match in reversed(group_matches):
|
||||
cat = match.group(1)
|
||||
group_products = groups.get(cat, [])
|
||||
if not group_products:
|
||||
continue
|
||||
grid_html = tmpl.render(products=group_products, category=cat, lang=lang, t=t)
|
||||
html = html[:match.start()] + grid_html + html[match.end():]
|
||||
|
||||
return html
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Markets Hub
|
||||
# =============================================================================
|
||||
|
||||
@@ -181,7 +181,7 @@ Der **Market Score ({{ market_score | round(1) }}/100)** misst die *Marktreife*:
|
||||
|
||||
{{ city_name }} has a **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 55 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 35 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.{% if opportunity_score %} The **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> of {{ opportunity_score | round(1) }}/100** scores investment potential — supply gaps, catchment reach, and sports culture as a demand proxy:{% if opportunity_score >= 65 and market_score < 40 %} limited competition meets strong location fundamentals{% elif opportunity_score >= 65 %} strong potential despite an already active market{% elif opportunity_score >= 40 %} solid potential as the market starts to fill in{% else %} the area is comparatively well-served; differentiation is the key lever{% endif %}.{% endif %}
|
||||
|
||||
The question investors actually need answered is: given current pricing, occupancy, and build costs, what does the return look like? The financial model below uses real {{ city_name }} market data to give you that answer.
|
||||
The question that matters: given current pricing, occupancy, and build costs, what does a padel investment in {{ city_name }} actually return? The financial model below works with real local market data.
|
||||
|
||||
## What Does a Padel Investment Cost in {{ city_name }}?
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ Der **Market Score (Ø {{ avg_market_score }}/100)** bewertet die Marktreife: Be
|
||||
|
||||
Padel is growing rapidly across {{ country_name_en }}. Our data tracks {{ total_venues }} venues — a figure that likely understates the true count given independent clubs not listed on booking platforms. The average <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score of {{ avg_market_score }}/100 across {{ city_count }} cities reflects both market maturity and data availability.
|
||||
|
||||
{% if avg_market_score >= 55 %}Markets scoring above 55 typically show an established player base, reliable pricing data, and predictable demand patterns — all critical for sound financial planning. Yet even in mature markets, venue density per 100,000 residents varies significantly between cities, pointing to pockets of underserved demand.{% elif avg_market_score >= 35 %}A mid-range score signals a growth phase: demand is proven, venue infrastructure is building, and pricing hasn't fully settled to competitive levels. This creates opportunities for well-positioned new entrants who can secure good locations before the market matures.{% else %}Emerging markets offer first-mover advantages — less direct competition, potentially better lease terms, and the opportunity to build a loyal player base before the market fills out. The trade-off is less pricing data and more uncertainty in demand projections.{% endif %}
|
||||
{% if avg_market_score >= 55 %}Markets scoring above 55 typically show an established player base, reliable pricing data, and predictable demand patterns — all critical for sound financial planning. Yet even in mature markets, venue density per 100,000 residents varies significantly between cities, leaving genuine supply gaps even in established markets.{% elif avg_market_score >= 35 %}A mid-range score signals a growth phase: demand is proven, venue infrastructure is building, and pricing hasn't fully settled to competitive levels. This creates opportunities for well-positioned new entrants who can secure good locations before the market matures.{% else %}Emerging markets offer first-mover advantages — less direct competition, potentially better lease terms, and the opportunity to build a loyal player base before the market fills out. The trade-off is less pricing data and more uncertainty in demand projections.{% endif %}
|
||||
|
||||
{% if avg_opportunity_score %}The average **<a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</a> of {{ avg_opportunity_score }}/100** shows how much investment potential remains untapped in {{ country_name_en }}. {% if avg_opportunity_score >= 60 and avg_market_score < 40 %}The combination of a high Opportunity Score and a moderate Market Score makes {{ country_name_en }} particularly attractive for new entrants: demand potential and sports culture are there, infrastructure is still building — first-mover conditions for well-chosen locations.{% elif avg_opportunity_score >= 60 %}Despite an already active market, locations with significant potential remain — particularly in mid-size cities and at the periphery of major metro areas.{% else %}Many locations in {{ country_name_en }} are already well-served. New projects need careful site selection and a clear differentiation strategy to compete.{% endif %}{% endif %}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{# Affiliate product card — editorial recommendation style.
|
||||
Variables: product (dict with parsed pros/cons lists), t (translations), lang.
|
||||
Rendered bake-time by bake_product_cards(); no request context available. #}
|
||||
{%- set price_eur = (product.price_cents / 100) if product.price_cents else none -%}
|
||||
{%- set cta = product.cta_label if product.cta_label else t.affiliate_cta_buy -%}
|
||||
<div class="aff-card" style="background:#fff;border:1px solid #E2E8F0;border-radius:16px;padding:1.5rem;margin:1.5rem 0;box-shadow:0 1px 3px rgba(0,0,0,.05);transition:transform .2s,box-shadow .2s;" onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 8px 24px rgba(0,0,0,.08)'" onmouseout="this.style.transform='';this.style.boxShadow='0 1px 3px rgba(0,0,0,.05)'">
|
||||
<div style="display:flex;gap:1.25rem;align-items:flex-start;flex-wrap:wrap;">
|
||||
|
||||
{# ── Image ── #}
|
||||
<div style="width:160px;flex-shrink:0;aspect-ratio:1;border-radius:12px;background:#F8FAFC;border:1px solid #E2E8F0;overflow:hidden;display:flex;align-items:center;justify-content:center;">
|
||||
{% if product.image_url %}
|
||||
<img src="{{ product.image_url }}" alt="{{ product.name }}" style="width:100%;height:100%;object-fit:contain;" loading="lazy">
|
||||
{% else %}
|
||||
<svg width="48" height="48" fill="none" stroke="#CBD5E1" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Content ── #}
|
||||
<div style="flex:1;min-width:0;">
|
||||
|
||||
{# Brand + retailer #}
|
||||
<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.375rem;">
|
||||
{% if product.brand %}
|
||||
<span style="text-transform:uppercase;font-size:.6875rem;font-weight:600;letter-spacing:.06em;color:#64748B;">{{ product.brand }}</span>
|
||||
{% endif %}
|
||||
{% if product.retailer %}
|
||||
<span style="background:#F1F5F9;border-radius:999px;padding:2px 8px;font-size:.625rem;font-weight:600;color:#64748B;letter-spacing:.04em;text-transform:uppercase;">{{ t.affiliate_at_retailer | tformat(retailer=product.retailer) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Name #}
|
||||
<h3 style="font-family:'Bricolage Grotesque',sans-serif;font-size:1.125rem;font-weight:700;color:#0F172A;letter-spacing:-.01em;margin:0 0 .375rem;">{{ product.name }}</h3>
|
||||
|
||||
{# Rating #}
|
||||
{% if product.rating %}
|
||||
{%- set stars_full = product.rating | int -%}
|
||||
{%- set has_half = (product.rating - stars_full) >= 0.5 -%}
|
||||
<div style="display:flex;align-items:center;gap:.25rem;margin-bottom:.375rem;">
|
||||
<span style="color:#D97706;font-size:.9375rem;">
|
||||
{%- for i in range(stars_full) %}★{% endfor -%}
|
||||
{%- if has_half %}★{% endif -%}
|
||||
{%- for i in range(5 - stars_full - (1 if has_half else 0)) %}<span style="color:#E2E8F0;">★</span>{% endfor -%}
|
||||
</span>
|
||||
<span style="font-size:.8125rem;color:#64748B;">{{ "%.1f" | format(product.rating) }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Price #}
|
||||
{% if price_eur %}
|
||||
<div style="font-family:'Commit Mono',monospace;font-size:1.25rem;font-weight:700;color:#0F172A;margin-bottom:.5rem;">{{ "%.2f" | format(price_eur) | replace('.', ',') }} €</div>
|
||||
{% endif %}
|
||||
|
||||
{# Description #}
|
||||
{% if product.description %}
|
||||
<p style="font-size:.875rem;color:#475569;line-height:1.55;margin:.625rem 0;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;">{{ product.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# Pros #}
|
||||
{% if product.pros %}
|
||||
<ul style="list-style:none;padding:0;margin:.625rem 0 .25rem;">
|
||||
{% for pro in product.pros %}
|
||||
<li style="font-size:.8125rem;color:#475569;line-height:1.7;"><span style="color:#16A34A;margin-right:.25rem;">✓</span>{{ pro }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# Cons #}
|
||||
{% if product.cons %}
|
||||
<ul style="list-style:none;padding:0;margin:.25rem 0 .75rem;">
|
||||
{% for con in product.cons %}
|
||||
<li style="font-size:.8125rem;color:#475569;line-height:1.7;"><span style="color:#EF4444;margin-right:.25rem;">✗</span>{{ con }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{# CTA #}
|
||||
<a href="/go/{{ product.slug }}" rel="sponsored nofollow noopener" target="_blank"
|
||||
style="display:block;width:100%;background:#1D4ED8;color:#fff;border-radius:12px;padding:.625rem 1.25rem;font-weight:600;font-size:.875rem;text-align:center;text-decoration:none;box-shadow:0 2px 10px rgba(29,78,216,.25);transition:background .2s,transform .2s;margin-top:.5rem;"
|
||||
onmouseover="this.style.background='#1E40AF';this.style.transform='translateY(-1px)'"
|
||||
onmouseout="this.style.background='#1D4ED8';this.style.transform=''">
|
||||
{{ cta }} →
|
||||
</a>
|
||||
|
||||
{# Disclosure #}
|
||||
<p style="font-size:.6875rem;color:#94A3B8;font-style:italic;margin:.5rem 0 0;text-align:center;">{{ t.affiliate_disclosure }}</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
{# Affiliate product comparison grid — editorial picks layout.
|
||||
Variables: products (list of dicts), category (str), t (translations), lang.
|
||||
Rendered bake-time by bake_product_cards(). #}
|
||||
{% if products %}
|
||||
<div style="margin:2rem 0;">
|
||||
|
||||
{# Section header #}
|
||||
<div style="text-transform:uppercase;font-size:.75rem;font-weight:600;color:#64748B;letter-spacing:.06em;margin-bottom:1rem;padding-bottom:.75rem;border-bottom:2px solid #E2E8F0;">
|
||||
{{ t.affiliate_our_picks }} · {{ category | capitalize }}
|
||||
</div>
|
||||
|
||||
{# Responsive grid of compact cards #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;">
|
||||
{% for product in products %}
|
||||
{%- set price_eur = (product.price_cents / 100) if product.price_cents else none -%}
|
||||
{%- set cta = product.cta_label if product.cta_label else t.affiliate_cta_buy -%}
|
||||
<div class="aff-card-compact" style="background:#fff;border:1px solid #E2E8F0;border-radius:16px;padding:1rem;display:flex;flex-direction:column;gap:.5rem;transition:transform .2s,box-shadow .2s;" onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 8px 24px rgba(0,0,0,.08)'" onmouseout="this.style.transform='';this.style.boxShadow=''">
|
||||
|
||||
{# Image #}
|
||||
<div style="aspect-ratio:1;border-radius:10px;background:#F8FAFC;border:1px solid #E2E8F0;overflow:hidden;display:flex;align-items:center;justify-content:center;">
|
||||
{% if product.image_url %}
|
||||
<img src="{{ product.image_url }}" alt="{{ product.name }}" style="width:100%;height:100%;object-fit:contain;" loading="lazy">
|
||||
{% else %}
|
||||
<svg width="36" height="36" fill="none" stroke="#CBD5E1" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Z"/></svg>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Brand #}
|
||||
{% if product.brand %}
|
||||
<span style="text-transform:uppercase;font-size:.625rem;font-weight:600;letter-spacing:.06em;color:#94A3B8;">{{ product.brand }}</span>
|
||||
{% endif %}
|
||||
|
||||
{# Name #}
|
||||
<h4 style="font-family:'Bricolage Grotesque',sans-serif;font-size:1rem;font-weight:700;color:#0F172A;letter-spacing:-.01em;margin:0;line-height:1.3;">{{ product.name }}</h4>
|
||||
|
||||
{# Rating + pros/cons counts #}
|
||||
<div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;">
|
||||
{% if product.rating %}
|
||||
<span style="color:#D97706;font-size:.8125rem;">★</span>
|
||||
<span style="font-size:.75rem;color:#64748B;">{{ "%.1f" | format(product.rating) }}</span>
|
||||
{% endif %}
|
||||
{% if product.pros %}
|
||||
<span style="font-size:.6875rem;color:#16A34A;background:#F0FDF4;border-radius:999px;padding:1px 6px;">{{ product.pros | length }} {{ t.affiliate_pros_label }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Price #}
|
||||
{% if price_eur %}
|
||||
<div style="font-family:'Commit Mono',monospace;font-size:1.0625rem;font-weight:700;color:#0F172A;">{{ "%.2f" | format(price_eur) | replace('.', ',') }} €</div>
|
||||
{% endif %}
|
||||
|
||||
{# CTA — pushed to bottom via margin-top:auto #}
|
||||
<a href="/go/{{ product.slug }}" rel="sponsored nofollow noopener" target="_blank"
|
||||
style="display:block;background:#1D4ED8;color:#fff;border-radius:10px;padding:.5rem 1rem;font-weight:600;font-size:.8125rem;text-align:center;text-decoration:none;margin-top:auto;transition:background .2s;"
|
||||
onmouseover="this.style.background='#1E40AF'"
|
||||
onmouseout="this.style.background='#1D4ED8'">
|
||||
{{ cta }} →
|
||||
</a>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Shared disclosure #}
|
||||
<p style="font-size:.6875rem;color:#94A3B8;font-style:italic;margin:.75rem 0 0;text-align:center;">{{ t.affiliate_disclosure }}</p>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1777,5 +1777,12 @@
|
||||
"report_q1_confirmed_title": "Download bereit",
|
||||
"report_q1_confirmed_body": "Unten auf den Button klicken, um das vollständige Bericht-PDF zu öffnen.",
|
||||
"report_q1_download_btn": "PDF herunterladen",
|
||||
"report_q1_download_note": "PDF öffnet im Browser. Rechtsklick zum Speichern."
|
||||
"report_q1_download_note": "PDF öffnet im Browser. Rechtsklick zum Speichern.",
|
||||
|
||||
"affiliate_cta_buy": "Zum Angebot",
|
||||
"affiliate_disclosure": "Affiliate-Link — wir erhalten eine Provision ohne Mehrkosten für dich.",
|
||||
"affiliate_pros_label": "Vorteile",
|
||||
"affiliate_cons_label": "Nachteile",
|
||||
"affiliate_at_retailer": "bei {retailer}",
|
||||
"affiliate_our_picks": "Unsere Empfehlungen"
|
||||
}
|
||||
@@ -1780,5 +1780,12 @@
|
||||
"report_q1_confirmed_title": "Your download is ready",
|
||||
"report_q1_confirmed_body": "Click below to open the full report PDF.",
|
||||
"report_q1_download_btn": "Download PDF",
|
||||
"report_q1_download_note": "PDF opens in your browser. Right-click to save."
|
||||
"report_q1_download_note": "PDF opens in your browser. Right-click to save.",
|
||||
|
||||
"affiliate_cta_buy": "View offer",
|
||||
"affiliate_disclosure": "Affiliate link — we may earn a commission at no extra cost to you.",
|
||||
"affiliate_pros_label": "Pros",
|
||||
"affiliate_cons_label": "Cons",
|
||||
"affiliate_at_retailer": "at {retailer}",
|
||||
"affiliate_our_picks": "Our picks"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Migration 0026: Affiliate product catalog + click tracking tables.
|
||||
|
||||
affiliate_products: admin-managed product catalog for editorial affiliate cards.
|
||||
- slug+language uniqueness mirrors articles (same slug can exist in DE + EN
|
||||
with different affiliate URLs, copy, and pros/cons).
|
||||
- retailer: display name (Amazon, Padel Nuestro, etc.) — stored in full URL
|
||||
with tracking params already baked into affiliate_url.
|
||||
- cta_label: per-product override; empty → use i18n default "Zum Angebot".
|
||||
- status: draft/active/archived — only active products are baked into articles.
|
||||
|
||||
affiliate_clicks: one row per /go/<slug> redirect hit.
|
||||
- ip_hash: SHA256(ip + YYYY-MM-DD + SECRET_KEY[:16]), daily rotation for GDPR.
|
||||
- article_slug: best-effort extraction from Referer header.
|
||||
"""
|
||||
|
||||
|
||||
def up(conn) -> None:
|
||||
conn.execute("""
|
||||
CREATE TABLE affiliate_products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
brand TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT 'accessory',
|
||||
retailer TEXT NOT NULL DEFAULT '',
|
||||
affiliate_url TEXT NOT NULL,
|
||||
image_url TEXT NOT NULL DEFAULT '',
|
||||
price_cents INTEGER,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
rating REAL,
|
||||
pros TEXT NOT NULL DEFAULT '[]',
|
||||
cons TEXT NOT NULL DEFAULT '[]',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
cta_label TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
language TEXT NOT NULL DEFAULT 'de',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT,
|
||||
UNIQUE(slug, language)
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE affiliate_clicks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL REFERENCES affiliate_products(id),
|
||||
article_slug TEXT,
|
||||
referrer TEXT,
|
||||
ip_hash TEXT NOT NULL,
|
||||
clicked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
# Queries: products by category+status, clicks by product and time
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_affiliate_products_category_status"
|
||||
" ON affiliate_products(category, status)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_affiliate_clicks_product_id"
|
||||
" ON affiliate_clicks(product_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX idx_affiliate_clicks_clicked_at"
|
||||
" ON affiliate_clicks(clicked_at)"
|
||||
)
|
||||
@@ -284,6 +284,184 @@ LEADS = [
|
||||
]
|
||||
|
||||
|
||||
AFFILIATE_PRODUCTS = [
|
||||
# Rackets
|
||||
{
|
||||
"slug": "bullpadel-vertex-04-amazon",
|
||||
"name": "Bullpadel Vertex 04",
|
||||
"brand": "Bullpadel",
|
||||
"category": "racket",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST01?tag=padelnomics-21",
|
||||
"price_cents": 17999,
|
||||
"rating": 4.7,
|
||||
"pros": '["Carbon-Rahmen für maximale Power", "Diamant-Form für aggressive Spieler", "Sehr gute Balance"]',
|
||||
"cons": '["Nur für fortgeschrittene Spieler", "Höherer Preis"]',
|
||||
"description": "Der Vertex 04 ist der Flaggschiff-Schläger von Bullpadel für Power-Spieler.",
|
||||
"status": "active",
|
||||
"language": "de",
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"slug": "head-delta-pro-amazon",
|
||||
"name": "HEAD Delta Pro",
|
||||
"brand": "HEAD",
|
||||
"category": "racket",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST02?tag=padelnomics-21",
|
||||
"price_cents": 14999,
|
||||
"rating": 4.5,
|
||||
"pros": '["Sehr kontrollorientiert", "Ideal für Defensivspieler", "Leicht"]',
|
||||
"cons": '["Weniger Power als Diamant-Formen"]',
|
||||
"description": "Runde Form mit perfekter Kontrolle — ideal für Einsteiger und Defensivspieler.",
|
||||
"status": "active",
|
||||
"language": "de",
|
||||
"sort_order": 2,
|
||||
},
|
||||
{
|
||||
"slug": "adidas-metalbone-30-amazon",
|
||||
"name": "Adidas Metalbone 3.0",
|
||||
"brand": "Adidas",
|
||||
"category": "racket",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST03?tag=padelnomics-21",
|
||||
"price_cents": 18999,
|
||||
"rating": 4.8,
|
||||
"pros": '["Brutale Power", "Hochwertige Verarbeitung", "Sehr beliebt auf Pro-Tour"]',
|
||||
"cons": '["Teuer", "Gewöhnungsbedürftig"]',
|
||||
"description": "Das Flaggschiff von Adidas Padel — getragen von den besten Profis der Welt.",
|
||||
"status": "active",
|
||||
"language": "de",
|
||||
"sort_order": 3,
|
||||
},
|
||||
{
|
||||
"slug": "wilson-bela-pro-v2-amazon",
|
||||
"name": "Wilson Bela Pro v2",
|
||||
"brand": "Wilson",
|
||||
"category": "racket",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST04?tag=padelnomics-21",
|
||||
"price_cents": 16999,
|
||||
"rating": 4.6,
|
||||
"pros": '["Bekannter Signature-Schläger", "Gute Mischung aus Power und Kontrolle"]',
|
||||
"cons": '["Fortgeschrittene bevorzugt"]',
|
||||
"description": "Der Schläger von Fernando Belasteguín — einer der meistgekauften Schläger weltweit.",
|
||||
"status": "active",
|
||||
"language": "de",
|
||||
"sort_order": 4,
|
||||
},
|
||||
# Beginner racket — draft (tests that draft products are excluded from public)
|
||||
{
|
||||
"slug": "dunlop-aero-star-amazon",
|
||||
"name": "Dunlop Aero Star",
|
||||
"brand": "Dunlop",
|
||||
"category": "racket",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST05?tag=padelnomics-21",
|
||||
"price_cents": 8999,
|
||||
"rating": 4.2,
|
||||
"pros": '["Günstig", "Für Einsteiger ideal"]',
|
||||
"cons": '["Wenig Power für Fortgeschrittene"]',
|
||||
"description": "Solider Einsteigerschläger für unter 90 Euro.",
|
||||
"status": "draft",
|
||||
"language": "de",
|
||||
"sort_order": 5,
|
||||
},
|
||||
# Shoes
|
||||
{
|
||||
"slug": "adidas-adipower-ctrl-amazon",
|
||||
"name": "Adidas Adipower Ctrl",
|
||||
"brand": "Adidas",
|
||||
"category": "shoe",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST10?tag=padelnomics-21",
|
||||
"price_cents": 9999,
|
||||
"rating": 4.4,
|
||||
"pros": '["Hervorragender Halt auf Sand", "Leicht und atmungsaktiv"]',
|
||||
"cons": '["Größenfehler möglich — eine Größe größer bestellen"]',
|
||||
"description": "Professioneller Padelschuh mit optimierter Sohle für Sand- und Kunstrasencourts.",
|
||||
"status": "active",
|
||||
"language": "de",
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"slug": "babolat-jet-premura-amazon",
|
||||
"name": "Babolat Jet Premura",
|
||||
"brand": "Babolat",
|
||||
"category": "shoe",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST11?tag=padelnomics-21",
|
||||
"price_cents": 11999,
|
||||
"rating": 4.6,
|
||||
"pros": '["Sehr leicht", "Gute Dämpfung", "Stylisches Design"]',
|
||||
"cons": '["Teurer als Mitbewerber"]',
|
||||
"description": "Ultraleichter Padelschuh von Babolat — ideal für schnelle Spieler.",
|
||||
"status": "active",
|
||||
"language": "de",
|
||||
"sort_order": 2,
|
||||
},
|
||||
# Balls
|
||||
{
|
||||
"slug": "head-padel-pro-balls-amazon",
|
||||
"name": "HEAD Padel Pro Bälle (3er-Dose)",
|
||||
"brand": "HEAD",
|
||||
"category": "ball",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST20?tag=padelnomics-21",
|
||||
"price_cents": 799,
|
||||
"rating": 4.5,
|
||||
"pros": '["Offizieller Turnierball", "Guter Druckerhalt", "Günstig"]',
|
||||
"cons": '["Bei intensivem Spiel nach 4–5 Sessions platter"]',
|
||||
"description": "Offizieller Turnierball von HEAD — der am häufigsten gespielte Padelball in Europa.",
|
||||
"status": "active",
|
||||
"language": "de",
|
||||
"sort_order": 1,
|
||||
},
|
||||
# Grips/Accessories
|
||||
{
|
||||
"slug": "bullpadel-overgrip-3er-amazon",
|
||||
"name": "Bullpadel Overgrip (3er-Pack)",
|
||||
"brand": "Bullpadel",
|
||||
"category": "grip",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST30?tag=padelnomics-21",
|
||||
"price_cents": 499,
|
||||
"rating": 4.3,
|
||||
"pros": '["Günstig", "Guter Halt auch bei Schweiß", "Einfach zu wechseln"]',
|
||||
"cons": '["Hält weniger lang als Originalgriff"]',
|
||||
"description": "Günstiges Overgrip-Set — jeder Padelspieler sollte regelmäßig wechseln.",
|
||||
"status": "active",
|
||||
"language": "de",
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"slug": "nox-padel-bag-amazon",
|
||||
"name": "NOX ML10 Schläger-Tasche",
|
||||
"brand": "NOX",
|
||||
"category": "accessory",
|
||||
"retailer": "Amazon",
|
||||
"affiliate_url": "https://www.amazon.de/dp/B0CXTEST40?tag=padelnomics-21",
|
||||
"price_cents": 5999,
|
||||
"rating": 4.4,
|
||||
"pros": '["Platz für 2 Schläger", "Gepolstertes Schlägerfach", "Robustes Material"]',
|
||||
"cons": '["Kein Schuhfach"]',
|
||||
"description": "Praktische Padelschläger-Tasche mit Platz für 2 Schläger und Zubehör.",
|
||||
"status": "active",
|
||||
"language": "de",
|
||||
"sort_order": 1,
|
||||
},
|
||||
]
|
||||
|
||||
# Article slugs for realistic click referrers
|
||||
_ARTICLE_SLUGS = [
|
||||
"beste-padelschlaeger-2026",
|
||||
"padelschlaeger-anfaenger",
|
||||
"padelschuhe-test",
|
||||
"padelbaelle-vergleich",
|
||||
"padel-zubehoer",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
db_path = DATABASE_PATH
|
||||
if not Path(db_path).exists():
|
||||
@@ -481,6 +659,72 @@ def main():
|
||||
)
|
||||
logger.info(" PadelTech unlocked lead #%s", lead_id)
|
||||
|
||||
# 7. Seed affiliate products
|
||||
logger.info("Seeding %s affiliate products...", len(AFFILIATE_PRODUCTS))
|
||||
product_ids: dict[str, int] = {}
|
||||
for p in AFFILIATE_PRODUCTS:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM affiliate_products WHERE slug = ? AND language = ?",
|
||||
(p["slug"], p["language"]),
|
||||
).fetchone()
|
||||
if existing:
|
||||
product_ids[p["slug"]] = existing["id"]
|
||||
logger.info(" %s already exists (id=%s)", p["name"], existing["id"])
|
||||
continue
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO affiliate_products
|
||||
(slug, name, brand, category, retailer, affiliate_url,
|
||||
price_cents, currency, rating, pros, cons, description,
|
||||
status, language, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
p["slug"], p["name"], p["brand"], p["category"], p["retailer"],
|
||||
p["affiliate_url"], p["price_cents"], p["rating"],
|
||||
p["pros"], p["cons"], p["description"],
|
||||
p["status"], p["language"], p["sort_order"],
|
||||
),
|
||||
)
|
||||
product_ids[p["slug"]] = cursor.lastrowid
|
||||
logger.info(" %s -> id=%s (%s)", p["name"], cursor.lastrowid, p["status"])
|
||||
|
||||
# 8. Seed affiliate clicks (realistic 30-day spread for dashboard charts)
|
||||
logger.info("Seeding affiliate clicks...")
|
||||
import random
|
||||
rng = random.Random(42)
|
||||
# click distribution: more on popular rackets, fewer on accessories
|
||||
click_weights = [
|
||||
("bullpadel-vertex-04-amazon", "beste-padelschlaeger-2026", 52),
|
||||
("adidas-metalbone-30-amazon", "beste-padelschlaeger-2026", 41),
|
||||
("head-delta-pro-amazon", "padelschlaeger-anfaenger", 38),
|
||||
("wilson-bela-pro-v2-amazon", "padelschlaeger-anfaenger", 29),
|
||||
("adidas-adipower-ctrl-amazon", "padelschuhe-test", 24),
|
||||
("babolat-jet-premura-amazon", "padelschuhe-test", 18),
|
||||
("head-padel-pro-balls-amazon", "padelbaelle-vergleich", 15),
|
||||
("bullpadel-overgrip-3er-amazon", "padel-zubehoer", 11),
|
||||
("nox-padel-bag-amazon", "padel-zubehoer", 8),
|
||||
]
|
||||
existing_click_count = conn.execute("SELECT COUNT(*) FROM affiliate_clicks").fetchone()[0]
|
||||
if existing_click_count == 0:
|
||||
for slug, article_slug, count in click_weights:
|
||||
pid = product_ids.get(slug)
|
||||
if not pid:
|
||||
continue
|
||||
for _ in range(count):
|
||||
days_ago = rng.randint(0, 29)
|
||||
hours_ago = rng.randint(0, 23)
|
||||
clicked_at = (now - timedelta(days=days_ago, hours=hours_ago)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
ip_hash = f"dev_{slug}_{_:04d}" # stable fake hash (not real SHA256)
|
||||
conn.execute(
|
||||
"""INSERT INTO affiliate_clicks
|
||||
(product_id, article_slug, referrer, ip_hash, clicked_at)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(pid, article_slug, f"https://padelnomics.io/de/blog/{article_slug}", ip_hash, clicked_at),
|
||||
)
|
||||
total_clicks = sum(c for _, _, c in click_weights)
|
||||
logger.info(" Inserted %s click events across 9 products", total_clicks)
|
||||
else:
|
||||
logger.info(" Clicks already seeded (%s rows), skipping", existing_click_count)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
332
web/tests/test_affiliate.py
Normal file
332
web/tests/test_affiliate.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
Tests for the affiliate product system.
|
||||
|
||||
Covers: hash_ip determinism, product CRUD, bake_product_cards marker replacement,
|
||||
click redirect (302 + logged), rate limiting, inactive product 404, multi-retailer.
|
||||
"""
|
||||
import json
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from padelnomics.affiliate import (
|
||||
get_all_products,
|
||||
get_click_counts,
|
||||
get_click_stats,
|
||||
get_product,
|
||||
get_products_by_category,
|
||||
hash_ip,
|
||||
log_click,
|
||||
)
|
||||
from padelnomics.content.routes import PRODUCT_GROUP_RE, PRODUCT_RE, bake_product_cards
|
||||
from padelnomics.core import execute, fetch_all
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _insert_product(
|
||||
slug="test-racket-amazon",
|
||||
name="Test Racket",
|
||||
brand="TestBrand",
|
||||
category="racket",
|
||||
retailer="Amazon",
|
||||
affiliate_url="https://amazon.de/dp/TEST?tag=test-21",
|
||||
status="active",
|
||||
language="de",
|
||||
price_cents=14999,
|
||||
pros=None,
|
||||
cons=None,
|
||||
sort_order=0,
|
||||
) -> int:
|
||||
"""Insert an affiliate product, return its id."""
|
||||
return await execute(
|
||||
"""INSERT INTO affiliate_products
|
||||
(slug, name, brand, category, retailer, affiliate_url,
|
||||
price_cents, currency, status, language, pros, cons, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
slug, name, brand, category, retailer, affiliate_url,
|
||||
price_cents, status, language,
|
||||
json.dumps(pros or ["Gut"]),
|
||||
json.dumps(cons or ["Teuer"]),
|
||||
sort_order,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── hash_ip ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_hash_ip_deterministic():
|
||||
"""Same IP + same day → same hash."""
|
||||
h1 = hash_ip("1.2.3.4")
|
||||
h2 = hash_ip("1.2.3.4")
|
||||
assert h1 == h2
|
||||
assert len(h1) == 64 # SHA256 hex digest
|
||||
|
||||
|
||||
def test_hash_ip_different_ips_differ():
|
||||
"""Different IPs → different hashes."""
|
||||
assert hash_ip("1.2.3.4") != hash_ip("5.6.7.8")
|
||||
|
||||
|
||||
def test_hash_ip_rotates_daily():
|
||||
"""Different days → different hashes for same IP (GDPR daily rotation)."""
|
||||
with patch("padelnomics.affiliate.date") as mock_date:
|
||||
mock_date.today.return_value = date(2026, 2, 1)
|
||||
h1 = hash_ip("1.2.3.4")
|
||||
mock_date.today.return_value = date(2026, 2, 2)
|
||||
h2 = hash_ip("1.2.3.4")
|
||||
assert h1 != h2
|
||||
|
||||
|
||||
# ── get_product ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_product_active_by_lang(db):
|
||||
"""get_product returns active product for correct language."""
|
||||
await _insert_product(slug="vertex-amazon", language="de", status="active")
|
||||
product = await get_product("vertex-amazon", "de")
|
||||
assert product is not None
|
||||
assert product["slug"] == "vertex-amazon"
|
||||
assert isinstance(product["pros"], list)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_product_draft_returns_none(db):
|
||||
"""Draft products are not returned."""
|
||||
await _insert_product(slug="vertex-draft", status="draft")
|
||||
product = await get_product("vertex-draft", "de")
|
||||
assert product is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_product_lang_fallback(db):
|
||||
"""Falls back to any language when no match for requested lang."""
|
||||
await _insert_product(slug="vertex-de-only", language="de", status="active")
|
||||
# Request EN but only DE exists — should fall back
|
||||
product = await get_product("vertex-de-only", "en")
|
||||
assert product is not None
|
||||
assert product["language"] == "de"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_product_not_found(db):
|
||||
"""Returns None for unknown slug."""
|
||||
product = await get_product("nonexistent-slug", "de")
|
||||
assert product is None
|
||||
|
||||
|
||||
# ── get_products_by_category ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_products_by_category_sorted(db):
|
||||
"""Returns products sorted by sort_order."""
|
||||
await _insert_product(slug="racket-b", name="Racket B", sort_order=2)
|
||||
await _insert_product(slug="racket-a", name="Racket A", sort_order=1)
|
||||
products = await get_products_by_category("racket", "de")
|
||||
assert len(products) == 2
|
||||
assert products[0]["sort_order"] == 1
|
||||
assert products[1]["sort_order"] == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_products_by_category_inactive_excluded(db):
|
||||
"""Draft and archived products are excluded."""
|
||||
await _insert_product(slug="racket-draft", status="draft")
|
||||
await _insert_product(slug="racket-archived", status="archived")
|
||||
products = await get_products_by_category("racket", "de")
|
||||
assert products == []
|
||||
|
||||
|
||||
# ── get_all_products ───────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_all_products_no_filter(db):
|
||||
"""Returns all products regardless of status."""
|
||||
await _insert_product(slug="p1", status="active")
|
||||
await _insert_product(slug="p2", status="draft")
|
||||
products = await get_all_products()
|
||||
assert len(products) == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_all_products_status_filter(db):
|
||||
"""Status filter returns only matching rows."""
|
||||
await _insert_product(slug="p-active", status="active")
|
||||
await _insert_product(slug="p-draft", status="draft")
|
||||
active = await get_all_products(status="active")
|
||||
assert len(active) == 1
|
||||
assert active[0]["slug"] == "p-active"
|
||||
|
||||
|
||||
# ── log_click + get_click_counts ──────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_log_click_inserts_row(db):
|
||||
"""log_click inserts a row into affiliate_clicks."""
|
||||
product_id = await _insert_product(slug="clickable")
|
||||
await log_click(product_id, "1.2.3.4", "beste-padelschlaeger", "https://example.com/de/blog/test")
|
||||
rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (product_id,))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["article_slug"] == "beste-padelschlaeger"
|
||||
# IP hash must not be the raw IP
|
||||
assert rows[0]["ip_hash"] != "1.2.3.4"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_click_counts(db):
|
||||
"""get_click_counts returns dict of product_id → count."""
|
||||
pid = await _insert_product(slug="tracked-product")
|
||||
await log_click(pid, "1.2.3.4", None, None)
|
||||
await log_click(pid, "5.6.7.8", None, None)
|
||||
counts = await get_click_counts()
|
||||
assert counts.get(pid) == 2
|
||||
|
||||
|
||||
# ── get_click_stats ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_get_click_stats_structure(db):
|
||||
"""get_click_stats returns expected keys."""
|
||||
stats = await get_click_stats(days_count=30)
|
||||
assert "total_clicks" in stats
|
||||
assert "top_products" in stats
|
||||
assert "daily_bars" in stats
|
||||
assert "by_retailer" in stats
|
||||
|
||||
|
||||
# ── bake_product_cards ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_replaces_marker(db):
|
||||
"""[product:slug] marker is replaced with rendered HTML."""
|
||||
await _insert_product(slug="vertex-04-amazon", name="Bullpadel Vertex 04", status="active")
|
||||
html = "<p>Intro</p>\n[product:vertex-04-amazon]\n<p>Outro</p>"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
assert "[product:vertex-04-amazon]" not in result
|
||||
assert "Bullpadel Vertex 04" in result
|
||||
assert "/go/vertex-04-amazon" in result
|
||||
assert "sponsored" in result
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_missing_slug_passthrough(db):
|
||||
"""Unknown slugs pass through unchanged — no product card rendered."""
|
||||
html = "<p>Text</p>\n[product:nonexistent-slug]\n<p>End</p>"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
# Surrounding content is intact; no product HTML injected
|
||||
assert "<p>Text</p>" in result
|
||||
assert "<p>End</p>" in result
|
||||
assert "<article" not in result # no product card rendered
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_group_marker(db):
|
||||
"""[product-group:category] renders a grid of products."""
|
||||
await _insert_product(slug="shoe-1-amazon", name="Test Shoe", category="shoe", status="active")
|
||||
html = "<h2>Shoes</h2>\n[product-group:shoe]\n<p>End</p>"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
assert "[product-group:shoe]" not in result
|
||||
assert "Test Shoe" in result
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_no_markers(db):
|
||||
"""HTML without markers is returned unchanged."""
|
||||
html = "<p>No markers here.</p>"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
assert result == html
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_bake_product_cards_draft_not_shown(db):
|
||||
"""Draft products are not baked into articles."""
|
||||
await _insert_product(slug="draft-product", name="Draft Product", status="draft")
|
||||
html = "[product:draft-product]"
|
||||
result = await bake_product_cards(html, lang="de")
|
||||
assert "Draft Product" not in result
|
||||
|
||||
|
||||
# ── regex patterns ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_product_re_matches():
|
||||
"""PRODUCT_RE matches valid [product:slug] markers."""
|
||||
assert PRODUCT_RE.match("[product:bullpadel-vertex-04-amazon]")
|
||||
assert PRODUCT_RE.match("[product:test-123]")
|
||||
|
||||
|
||||
def test_product_group_re_matches():
|
||||
"""PRODUCT_GROUP_RE matches valid [product-group:category] markers."""
|
||||
assert PRODUCT_GROUP_RE.match("[product-group:racket]")
|
||||
assert PRODUCT_GROUP_RE.match("[product-group:shoe]")
|
||||
|
||||
|
||||
# ── multi-retailer ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_multi_retailer_same_slug_different_lang(db):
|
||||
"""Same slug can exist in DE and EN with different affiliate URLs."""
|
||||
await _insert_product(
|
||||
slug="vertex-04", language="de",
|
||||
affiliate_url="https://amazon.de/dp/TEST?tag=de-21",
|
||||
)
|
||||
await execute(
|
||||
"""INSERT INTO affiliate_products
|
||||
(slug, name, brand, category, retailer, affiliate_url,
|
||||
price_cents, currency, status, language, pros, cons, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'EUR', ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
"vertex-04", "Test Racket EN", "TestBrand", "racket", "Amazon UK",
|
||||
"https://amazon.co.uk/dp/TEST?tag=en-21",
|
||||
14999, "active", "en", "[]", "[]", 0,
|
||||
),
|
||||
)
|
||||
de_product = await get_product("vertex-04", "de")
|
||||
en_product = await get_product("vertex-04", "en")
|
||||
assert de_product is not None
|
||||
assert en_product is not None
|
||||
assert de_product["affiliate_url"] != en_product["affiliate_url"]
|
||||
assert "amazon.de" in de_product["affiliate_url"]
|
||||
assert "amazon.co.uk" in en_product["affiliate_url"]
|
||||
|
||||
|
||||
# ── click redirect (e2e via Quart test client) ────────────────────────────────
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_affiliate_redirect_302(app, db):
|
||||
"""GET /go/<slug> redirects to affiliate_url with 302."""
|
||||
await _insert_product(slug="redirect-test", affiliate_url="https://amazon.de/dp/XYZ?tag=test-21")
|
||||
async with app.test_client() as client:
|
||||
response = await client.get("/go/redirect-test")
|
||||
assert response.status_code == 302
|
||||
assert "amazon.de" in response.headers.get("Location", "")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_affiliate_redirect_logs_click(app, db):
|
||||
"""Successful redirect logs a click in affiliate_clicks."""
|
||||
pid = await _insert_product(slug="logged-test", affiliate_url="https://amazon.de/dp/LOG?tag=test-21")
|
||||
async with app.test_client() as client:
|
||||
await client.get(
|
||||
"/go/logged-test",
|
||||
headers={"Referer": "https://padelnomics.io/de/beste-padelschlaeger-2026"},
|
||||
)
|
||||
rows = await fetch_all("SELECT * FROM affiliate_clicks WHERE product_id = ?", (pid,))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["article_slug"] == "beste-padelschlaeger-2026"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_affiliate_redirect_inactive_404(app, db):
|
||||
"""Draft products return 404 on /go/<slug>."""
|
||||
await _insert_product(slug="inactive-test", status="draft")
|
||||
async with app.test_client() as client:
|
||||
response = await client.get("/go/inactive-test")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("db")
|
||||
async def test_affiliate_redirect_unknown_404(app, db):
|
||||
"""Unknown slug returns 404."""
|
||||
async with app.test_client() as client:
|
||||
response = await client.get("/go/totally-unknown-xyz")
|
||||
assert response.status_code == 404
|
||||
Reference in New Issue
Block a user