diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a4d1d6..5d538f0 100644 --- a/CHANGELOG.md +++ b/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/` 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/` 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()`. diff --git a/PROJECT.md b/PROJECT.md index 5eeaa01..9b1a226 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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/` 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, `` 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) diff --git a/data/content/articles/beste-padelschlaeger-de.md b/data/content/articles/beste-padelschlaeger-de.md new file mode 100644 index 0000000..228829c --- /dev/null +++ b/data/content/articles/beste-padelschlaeger-de.md @@ -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 + + + +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 + + + +### Platz 1: [Produktname einfügen] + +[product:platzhalter-schlaeger-1-amazon] + + + +### Platz 2: [Produktname einfügen] + +[product:platzhalter-schlaeger-2-amazon] + +### Platz 3: [Produktname einfügen] + +[product:platzhalter-schlaeger-3-amazon] + +--- + +## So haben wir getestet + + + +--- + +## Kaufberatung: Welcher Schläger passt zu mir? + + + +| Spielertyp | Empfohlene Form | Empfohlenes Gewicht | +|---|---|---| +| Anfänger | Rund | 355–365 g | +| Allspieler | Tropfen | 360–370 g | +| Fortgeschrittener | Diamant | 365–380 g | + +--- + +## Häufige Fragen + +
+Wie oft sollte man einen Padelschläger wechseln? + + + +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. + +
+ +
+Was kostet ein guter Padelschläger? + + + +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. + +
+ +
+Runder oder Diamant-Schläger — was ist besser? + + + +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. + +
diff --git a/data/content/articles/padel-ausruestung-anfaenger-de.md b/data/content/articles/padel-ausruestung-anfaenger-de.md new file mode 100644 index 0000000..8620048 --- /dev/null +++ b/data/content/articles/padel-ausruestung-anfaenger-de.md @@ -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? + + + +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] + + + +### 2. Schuhe + +[product:platzhalter-padelschuh-amazon] + + + +### 3. Bälle + +[product:platzhalter-ball-amazon] + + + +--- + +## Was kann ich mir zunächst sparen? + + + +--- + +## Das komplette Anfänger-Set: Unsere Empfehlung + +[product-group:accessory] + +--- + +## Häufige Fragen + +
+Wie viel kostet ein komplettes Padel-Starterpaket? + + + +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. + +
+ +
+Kann ich mit geliehener Ausrüstung starten? + + + +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. + +
diff --git a/data/content/articles/padel-geschenke-de.md b/data/content/articles/padel-geschenke-de.md new file mode 100644 index 0000000..6233d67 --- /dev/null +++ b/data/content/articles/padel-geschenke-de.md @@ -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 + + + +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] + + + +--- + +## Geschenke unter 50 Euro + +[product-group:accessory] + + + +--- + +## Geschenke unter 100 Euro + + + +[product:platzhalter-schuh-amazon] + +--- + +## Das perfekte Geschenk: Ein neuer Schläger + +[product-group:racket] + + + +--- + +## Häufige Fragen + +
+Wie finde ich heraus, welcher Schläger passt? + + + +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. + +
+ +
+Gibt es Padel-Geschenksets? + + + +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. + +
diff --git a/data/content/articles/padel-zubehoer-de.md b/data/content/articles/padel-zubehoer-de.md new file mode 100644 index 0000000..985c97a --- /dev/null +++ b/data/content/articles/padel-zubehoer-de.md @@ -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 + + + +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 + + + +[product:platzhalter-griffband-amazon] + +--- + +## Schläger-Schutzhülle: Ja, wenn man häufig transportiert + + + +--- + +## Vibrationsdämpfer: Geschmackssache + + + +--- + +## Sporttasche: Erst ab regelmäßigem Spiel + + + +--- + +## Häufige Fragen + +
+Wie oft sollte man das Griffband wechseln? + + + +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. + +
+ +
+Brauche ich eine spezielle Padeltasche? + + + +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. + +
diff --git a/data/content/articles/padelbaelle-vergleich-de.md b/data/content/articles/padelbaelle-vergleich-de.md new file mode 100644 index 0000000..55ae324 --- /dev/null +++ b/data/content/articles/padelbaelle-vergleich-de.md @@ -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 + + + +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 + + + +--- + +## Turnier- vs. Freizeitball + + + +--- + +## Testsieger im Überblick + +[product:platzhalter-ball-amazon] + + + +--- + +## Häufige Fragen + +
+Wie lange hält ein Padelball? + + + +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. + +
+ +
+Muss ich WCT- oder FIP-zertifizierte Bälle kaufen? + + + +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. + +
+ +
+Wie lagere ich Padelbälle richtig? + + + +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. + +
diff --git a/data/content/articles/padelschlaeger-anfaenger-de.md b/data/content/articles/padelschlaeger-anfaenger-de.md new file mode 100644 index 0000000..55675c0 --- /dev/null +++ b/data/content/articles/padelschlaeger-anfaenger-de.md @@ -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 + + + +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? + + + +### Schlägerkopfform: Rund schlägt Diamant + + + +### Gewicht: Leichter ist nicht immer besser + + + +### Material: EVA vs. Foam + + + +--- + +## Unsere Empfehlung im Detail + +[product:platzhalter-anfaenger-schlaeger-amazon] + + + +--- + +## Häufige Fragen + +
+Ab welchem Preis lohnt sich ein eigener Schläger? + + + +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. + +
+ +
+Kann ich als Anfänger direkt mit einem 150-Euro-Schläger starten? + + + +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. + +
diff --git a/data/content/articles/padelschlaeger-defensiv-de.md b/data/content/articles/padelschlaeger-defensiv-de.md new file mode 100644 index 0000000..3523680 --- /dev/null +++ b/data/content/articles/padelschlaeger-defensiv-de.md @@ -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 + + + +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 + + + +--- + +## Testsieger im Detail + +[product:platzhalter-defensiv-schlaeger-amazon] + + + +--- + +## Häufige Fragen + +
+Was ist der Unterschied zwischen einem Kontroll- und einem Powerschläger? + + + +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. + +
+ +
+Für welche Spielstufe sind Kontrollschläger geeignet? + + + +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. + +
diff --git a/data/content/articles/padelschlaeger-fortgeschrittene-de.md b/data/content/articles/padelschlaeger-fortgeschrittene-de.md new file mode 100644 index 0000000..fa186d5 --- /dev/null +++ b/data/content/articles/padelschlaeger-fortgeschrittene-de.md @@ -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 + + + +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? + + + +### Carbon-Rahmen + + + +### 3K vs. 12K Carbon + + + +### Kevlar-Einlagen + + + +--- + +## Testbericht: Unser Empfehlungsschläger + +[product:platzhalter-fortgeschrittene-schlaeger-amazon] + + + +--- + +## Häufige Fragen + +
+Ab welcher Spielstufe lohnt sich ein Fortgeschrittenenschläger? + + + +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. + +
+ +
+Müssen Fortgeschrittenenschläger teurer sein? + + + +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. + +
diff --git a/data/content/articles/padelschlaeger-unter-100-de.md b/data/content/articles/padelschlaeger-unter-100-de.md new file mode 100644 index 0000000..679f312 --- /dev/null +++ b/data/content/articles/padelschlaeger-unter-100-de.md @@ -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 + + + +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? + + + +--- + +## Unser Preisklassen-Tipp + +[product:platzhalter-budget-schlaeger-amazon] + + + +--- + +## Häufige Fragen + +
+Sind günstige Padelschläger schlechter verarbeitet? + + + +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. + +
+ +
+Lohnt es sich, für einen Einsteiger 100 Euro auszugeben? + + + +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. + +
diff --git a/data/content/articles/padelschuhe-test-de.md b/data/content/articles/padelschuhe-test-de.md new file mode 100644 index 0000000..65e5193 --- /dev/null +++ b/data/content/articles/padelschuhe-test-de.md @@ -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 + + + +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? + + + +| 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] + + + +--- + +## Häufige Fragen + +
+Kann ich Tennisschuhe für Padel verwenden? + + + +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. + +
+ +
+Wie erkenne ich verschlissene Padelschuhe? + + + +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. + +
diff --git a/web/src/padelnomics/admin/routes.py b/web/src/padelnomics/admin/routes.py index e29eb8b..2afb389 100644 --- a/web/src/padelnomics/admin/routes.py +++ b/web/src/padelnomics/admin/routes.py @@ -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,363 @@ 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/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//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//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//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, + ) diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_dashboard.html b/web/src/padelnomics/admin/templates/admin/affiliate_dashboard.html new file mode 100644 index 0000000..b9091df --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_dashboard.html @@ -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 %} +
+

Affiliate Dashboard

+
+ {% for d in [7, 30, 90] %} + {{ d }}d + {% endfor %} +
+
+ + {# ── Stats strip ── #} +
+ +
+
Clicks ({{ days_count }}d)
+
{{ stats.total_clicks | int }}
+
+ +
+
Products
+
{{ stats.active_products or 0 }}
+
{{ stats.draft_products or 0 }} draft
+
+ +
+
Articles (clicked)
+
{{ article_count }}
+
+ +
+
Est. Revenue
+
~€{{ est_revenue }}
+
3% CR × €80 basket
+
+ +
+ + {# ── Daily bar chart ── #} + {% if stats.daily_bars %} +
+
Clicks · Last {{ days_count }} Days
+
+ {% for bar in stats.daily_bars %} +
+
+ {% endfor %} +
+
+ {{ stats.daily_bars[0].day if stats.daily_bars else '' }} + {{ stats.daily_bars[-1].day if stats.daily_bars else '' }} +
+
+ {% endif %} + +
+ + {# ── Top products ── #} +
+
Top Products
+ {% if stats.top_products %} + {% for p in stats.top_products %} +
+ {{ loop.index }} + + {{ p.name }} + + {{ p.click_count }} +
+ {% endfor %} + {% else %} +

No clicks yet.

+ {% endif %} +
+ + {# ── Top articles ── #} +
+
Top Articles
+ {% if stats.top_articles %} + {% for a in stats.top_articles %} +
+ {{ loop.index }} + {{ a.article_slug }} + {{ a.click_count }} +
+ {% endfor %} + {% else %} +

No clicks with article source yet.

+ {% endif %} +
+ +
+ + {# ── Clicks by retailer ── #} + {% if stats.by_retailer %} +
+
Clicks by Retailer
+ {% for r in stats.by_retailer %} +
+ + {{ r.retailer or 'Unknown' }} + +
+
+
+ + {{ r.click_count }} ({{ r.share_pct }}%) + +
+ {% endfor %} +
+ {% endif %} + +{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_form.html b/web/src/padelnomics/admin/templates/admin/affiliate_form.html new file mode 100644 index 0000000..35302c6 --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_form.html @@ -0,0 +1,216 @@ +{% 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 %} + +{% endblock %} + +{% block admin_content %} +
+
+ ← Products +

{% if editing %}Edit Product{% else %}New Product{% endif %}

+
+
+ +
+ + {# ── Left: form ── #} +
+ + +
+ + {# Name #} +
+ + +
+ + {# Slug #} +
+ + +

Lowercase letters, numbers, hyphens only. Include retailer to disambiguate (e.g. -amazon, -padelnuestro).

+
+ + {# Brand + Category row #} +
+
+ + +
+
+ + +
+
+ + {# Retailer #} +
+ + + + {% for r in retailers %} + +
+ + {# Affiliate URL #} +
+ + +

Full URL with tracking params already baked in.

+
+ + {# Image URL #} +
+ + +

Local path (recommended) or external URL.

+
+ + {# Price + Rating row #} +
+
+ + +
+
+ + +
+
+ + {# Description #} +
+ + +
+ + {# Pros #} +
+ + +
+ + {# Cons #} +
+ + +
+ + {# CTA Label #} +
+ + +
+ + {# Status + Language + Sort #} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {# Actions #} +
+
+ + Cancel +
+ {% if editing %} + + + + + {% endif %} +
+ +
+ + + {# ── Right: live preview ── #} +
+
Preview
+
+

+ Fill in the form to see a live preview. +

+
+
+ +
+ + +{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/affiliate_products.html b/web/src/padelnomics/admin/templates/admin/affiliate_products.html new file mode 100644 index 0000000..925390f --- /dev/null +++ b/web/src/padelnomics/admin/templates/admin/affiliate_products.html @@ -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 %} +
+

Affiliate Products

+ + New Product +
+ + {# Filters #} +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + {# Results #} +
+ + + + + + + + + + + + + + + {% include "admin/partials/affiliate_results.html" %} + +
NameBrandRetailerCategoryPriceStatusClicksActions
+
+{% endblock %} diff --git a/web/src/padelnomics/admin/templates/admin/base_admin.html b/web/src/padelnomics/admin/templates/admin/base_admin.html index def2822..fde5947 100644 --- a/web/src/padelnomics/admin/templates/admin/base_admin.html +++ b/web/src/padelnomics/admin/templates/admin/base_admin.html @@ -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', 'billing': 'billing', 'seo': 'analytics', 'pipeline': 'pipeline', @@ -149,6 +150,11 @@ Billing + + + Affiliate + + Analytics @@ -196,6 +202,11 @@ Audiences Outreach + {% elif active_section == 'affiliate' %} + {% elif active_section == 'system' %}