Compare commits
4 Commits
v202603010
...
v202603011
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f2ffd432b | ||
|
|
c9dec066f7 | ||
|
|
fea4f85da3 | ||
|
|
2590020014 |
@@ -32,10 +32,6 @@ LITESTREAM_R2_BUCKET=ENC[AES256_GCM,data:pAqSkoJzsw==,iv:5J1Js7JPH/j1oTmEBdNXjwd
|
||||
LITESTREAM_R2_ACCESS_KEY_ID=ENC[AES256_GCM,data:e89yGzousImmdO7WVqmRWLJNejDFH5eTaw7G74CyZSw=,iv:bR1jgqSzJlxPA8LMMg2Mc1Lnp01iZgaqa9dgAoV0RpY=,tag:m92xzCP0qaP2onK7ChwA1Q==,type:str]
|
||||
LITESTREAM_R2_SECRET_ACCESS_KEY=ENC[AES256_GCM,data:yzXeb8c/Y0d+EluY7g6buo4BnFvBDEVblOi7doNgOp3siLvfMmPkjdRLqZzA14ET6CW5vef9i51yijPYwuhnbw==,iv:IYQRZ8SsquUQpsHH3X/iovz2wFskR4iHyvr0arY7Ag4=,tag:9G5lpHloacjQbEhSk9T2pw==,type:str]
|
||||
LITESTREAM_R2_ENDPOINT=ENC[AES256_GCM,data:qqDLfsPeiWOfwtgpZeItypnYNmIOD07fV0IPlZfphhUFeY0Z/BRpkVXA7nfqQ2M6PmcYKVIlBiBY,iv:hsEBxxv1+fvUY4v3nhBP8puKlu216eAGZDUNBAjibas=,tag:MvnsJ8W3oSrv4ZrWW/p+dg==,type:str]
|
||||
#ENC[AES256_GCM,data:YGV2exKdGOUkblNZZos=,iv:NuabFM/gNHIzYmDMRZ2tglFYdMPVFuHFGd+AAWvvu6Q=,tag:gZRoNNEmjL9v3nC8j9YkHw==,type:comment]
|
||||
DUCKDB_PATH=ENC[AES256_GCM,data:GgOEQ5B1KeQrVavhoMU/JGXcVu3H,iv:XY8JiaosxaUDv5PwizrZFWuNKMSOeuE3cfVyp51r++8=,tag:RnoDE5+7WQolFLejfRZ//w==,type:str]
|
||||
SERVING_DUCKDB_PATH=ENC[AES256_GCM,data:U2X9KmlgnWXM9uCfhHCJ03HMGCLm,iv:KHHdBTq+ct4AG7Jt4zLog/5jbDC7LvHA6KzWNTDS/Yw=,tag:m5uIG/bS4vaBooSYoYa6SA==,type:str]
|
||||
LANDING_DIR=ENC[AES256_GCM,data:NkEmV8LOwEiN9Sal,iv:mQHBVT6lNoEEEVbl7a5bNN5qoF/LvTyWXQvvkv/z/B0=,tag:IgA5A1nfF91fOBdYxEN71g==,type:str]
|
||||
#ENC[AES256_GCM,data:jvZYm7ceM4jtNRg=,iv:nuv65SDTZiaVukVZ40seBZevpqP8uiKCgJyQcIrY524=,tag:cq6gB3vmJzJWIXCLHaIc9g==,type:comment]
|
||||
REPO_DIR=ENC[AES256_GCM,data:ae8i6PpGFaiYFA/gGIhczg==,iv:nmsIRMPJYocIO6Z2Gz4OIzAOvSpdgDYmUaIr2hInFo0=,tag:EmAYG5NujnHg8lPaO/uAnQ==,type:str]
|
||||
WORKFLOWS_PATH=ENC[AES256_GCM,data:sGU4l68Pbb1thsPyG104mWXWD+zJGTIcR/TqVbPmew==,iv:+xhGkX+ep4kFEAU65ELdDrfjrl/WyuaOi35JI3OB/zM=,tag:brauZhFq8twPXmvhZKjhDQ==,type:str]
|
||||
@@ -62,7 +58,7 @@ sops_age__list_1__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb2
|
||||
sops_age__list_1__map_recipient=age1wjepykv3glvsrtegu25tevg7vyn3ngpl607u3yjc9ucay04s045s796msw
|
||||
sops_age__list_2__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFeHhaOURNZnRVMEwxNThu\nUjF4Q0kwUXhTUE1QSzZJbmpubnh3RnpQTmdvCjRmWWxpNkxFUmVGb3NRbnlydW5O\nWEg3ZXJQTU4vcndzS2pUQXY3Q0ttYjAKLS0tIE9IRFJ1c2ZxbGVHa2xTL0swbGN1\nTzgwMThPUDRFTWhuZHJjZUYxOTZrU00KY62qrNBCUQYxwcLMXFEnLkwncxq3BPJB\nKm4NzeHBU87XmPWVrgrKuf+PH1mxJlBsl7Hev8xBTy7l6feiZjLIvQ==\n-----END AGE ENCRYPTED FILE-----\n
|
||||
sops_age__list_2__map_recipient=age1c783ym2q5x9tv7py5d28uc4k44aguudjn03g97l9nzs00dd9tsrqum8h4d
|
||||
sops_lastmodified=2026-02-28T17:03:44Z
|
||||
sops_mac=ENC[AES256_GCM,data:IQ9jpRxVUssaMK+qFcM3nPdzXHkiqp6E+DhEey1TfqUu5GCBNsWeVy9m9A6p9RWhu2NtJV7aKdUeqneuMtD1q5Tnm6L96zuyot2ESnx2N2ssD9ilrDauQxoBJcrJVnGV61CgaCz9458w8BuVUZydn3MoHeRaU7bOBBzQlTI6vZk=,iv:qHqdt3av/KZRQHr/OS/9KdAJUgKlKEDgan7qI3Zzkck=,tag:fOvdO9iRTTF1Siobu2mLqg==,type:str]
|
||||
sops_lastmodified=2026-03-01T00:26:54Z
|
||||
sops_mac=ENC[AES256_GCM,data:DdcABGVm9KbAcFrF0iuZlAaugsouNs7Hon2mZISaHs15/2H/Pd9FniXW3KeQ0+/NdZFQkz/h3i3bVFampcpFS1AxuOE5+1/IgWn8sKtaqPc7E9y8g6lxMnwTkUX2z+n/Q2nR8KAcO9IyE0GNjIluMWkxPWQuLzlRYDOjRN4/1e0=,iv:rm+6lXhYu6VUmrdCIrU0BRN2/ooa21Fw1ESWxr7vATg=,tag:GZmLLZf/LQaNeNNAAEg5bA==,type:str]
|
||||
sops_unencrypted_suffix=_unencrypted
|
||||
sops_version=3.12.1
|
||||
|
||||
@@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- 15 new tests in `web/tests/test_affiliate.py` (41 total)
|
||||
|
||||
### Fixed
|
||||
- **Data Platform admin view showing stale/zero row counts** — Docker web containers were mounting `/opt/padelnomics/data` (stale copy) instead of `/data/padelnomics` (live supervisor output). Fixed volume mount in all 6 containers (blue/green × app/worker/scheduler) and added `LANDING_DIR=/app/data/pipeline/landing` so extraction stats and landing zone file stats are visible to the web app.
|
||||
- **`workflows.toml` never found in dev** — `_REPO_ROOT` in `pipeline_routes.py` used `parents[5]` (one level too far up) instead of `parents[4]`. Workflow schedules now display correctly on the pipeline overview tab in dev.
|
||||
- **Article preview frontmatter bug** — `_rebuild_article()` in `admin/routes.py` now strips YAML frontmatter before passing markdown to `mistune.html()`, preventing raw `title:`, `slug:` etc. from appearing as visible text in article previews.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,67 +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>
|
||||
---
|
||||
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>
|
||||
@@ -60,9 +60,10 @@ services:
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
healthcheck:
|
||||
@@ -82,9 +83,10 @@ services:
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
@@ -98,9 +100,10 @@ services:
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
@@ -115,9 +118,10 @@ services:
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
healthcheck:
|
||||
@@ -137,9 +141,10 @@ services:
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
@@ -153,9 +158,10 @@ services:
|
||||
environment:
|
||||
- DATABASE_PATH=/app/data/app.db
|
||||
- SERVING_DUCKDB_PATH=/app/data/pipeline/analytics.duckdb
|
||||
- LANDING_DIR=/app/data/pipeline/landing
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- /opt/padelnomics/data:/app/data/pipeline:ro
|
||||
- /data/padelnomics:/app/data/pipeline:ro
|
||||
networks:
|
||||
- net
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
-- 4. Country-level income (global fallback from stg_income / ilc_di03)
|
||||
--
|
||||
-- Distance calculations use ST_Distance_Sphere (DuckDB spatial extension).
|
||||
-- A bounding-box pre-filter (~0.5°, ≈55km) reduces the cross-join before the
|
||||
-- exact sphere distance is computed.
|
||||
-- Spatial joins use BETWEEN predicates (not ABS()) to enable DuckDB's IEJoin
|
||||
-- (interval join) optimization: O((N+M) log M) vs O(N×M) nested-loop.
|
||||
-- Country pre-filters restrict the left side to ~20K rows for padel/tennis CTEs
|
||||
-- (~8 countries each), down from ~140K global locations.
|
||||
|
||||
MODEL (
|
||||
name foundation.dim_locations,
|
||||
@@ -147,6 +149,8 @@ padel_courts AS (
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
),
|
||||
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
|
||||
-- BETWEEN enables DuckDB IEJoin (O((N+M) log M)) vs ABS() nested-loop (O(N×M)).
|
||||
-- Country pre-filter reduces left side from ~140K to ~20K rows (padel is ~8 countries).
|
||||
nearest_padel AS (
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
@@ -158,9 +162,12 @@ nearest_padel AS (
|
||||
) AS nearest_padel_court_km
|
||||
FROM locations l
|
||||
JOIN padel_courts p
|
||||
-- ~55km bounding box pre-filter to limit cross-join before sphere calc
|
||||
ON ABS(l.lat - p.lat) < 0.5
|
||||
AND ABS(l.lon - p.lon) < 0.5
|
||||
-- ~55km bounding box pre-filter; BETWEEN triggers IEJoin optimization
|
||||
ON l.lat BETWEEN p.lat - 0.5 AND p.lat + 0.5
|
||||
AND l.lon BETWEEN p.lon - 0.5 AND p.lon + 0.5
|
||||
WHERE l.country_code IN (
|
||||
SELECT DISTINCT country_code FROM padel_courts WHERE country_code IS NOT NULL
|
||||
)
|
||||
GROUP BY l.geoname_id
|
||||
),
|
||||
-- Padel venues within 5km of each location (counts as "local padel supply")
|
||||
@@ -170,24 +177,35 @@ padel_local AS (
|
||||
COUNT(*) AS padel_venue_count
|
||||
FROM locations l
|
||||
JOIN padel_courts p
|
||||
ON ABS(l.lat - p.lat) < 0.05 -- ~5km bbox pre-filter
|
||||
AND ABS(l.lon - p.lon) < 0.05
|
||||
WHERE ST_Distance_Sphere(
|
||||
-- ~5km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||
ON l.lat BETWEEN p.lat - 0.05 AND p.lat + 0.05
|
||||
AND l.lon BETWEEN p.lon - 0.05 AND p.lon + 0.05
|
||||
WHERE l.country_code IN (
|
||||
SELECT DISTINCT country_code FROM padel_courts WHERE country_code IS NOT NULL
|
||||
)
|
||||
AND ST_Distance_Sphere(
|
||||
ST_Point(l.lon, l.lat),
|
||||
ST_Point(p.lon, p.lat)
|
||||
) / 1000.0 <= 5.0
|
||||
GROUP BY l.geoname_id
|
||||
),
|
||||
-- Tennis courts within 25km of each location (sports culture proxy)
|
||||
-- Country pre-filter reduces left side from ~140K to ~20K rows (tennis courts are European only).
|
||||
tennis_nearby AS (
|
||||
SELECT
|
||||
l.geoname_id,
|
||||
COUNT(*) AS tennis_courts_within_25km
|
||||
FROM locations l
|
||||
JOIN staging.stg_tennis_courts t
|
||||
ON ABS(l.lat - t.lat) < 0.23 -- ~25km bbox pre-filter
|
||||
AND ABS(l.lon - t.lon) < 0.23
|
||||
WHERE ST_Distance_Sphere(
|
||||
-- ~25km bbox pre-filter; BETWEEN triggers IEJoin optimization
|
||||
ON l.lat BETWEEN t.lat - 0.23 AND t.lat + 0.23
|
||||
AND l.lon BETWEEN t.lon - 0.23 AND t.lon + 0.23
|
||||
WHERE l.country_code IN (
|
||||
SELECT DISTINCT country_code
|
||||
FROM staging.stg_tennis_courts
|
||||
WHERE country_code IS NOT NULL
|
||||
)
|
||||
AND ST_Distance_Sphere(
|
||||
ST_Point(l.lon, l.lat),
|
||||
ST_Point(t.lon, t.lat)
|
||||
) / 1000.0 <= 25.0
|
||||
|
||||
@@ -49,7 +49,7 @@ _LANDING_DIR = os.environ.get("LANDING_DIR", "data/landing")
|
||||
_SERVING_DUCKDB_PATH = os.environ.get("SERVING_DUCKDB_PATH", "data/analytics.duckdb")
|
||||
|
||||
# Repo root: web/src/padelnomics/admin/ → up 4 levels
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[5]
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
_WORKFLOWS_TOML = _REPO_ROOT / "infra" / "supervisor" / "workflows.toml"
|
||||
|
||||
# A "running" row older than this is considered stale/crashed.
|
||||
|
||||
@@ -3037,6 +3037,7 @@ async def outreach():
|
||||
current_search=search,
|
||||
current_follow_up=follow_up,
|
||||
page=page,
|
||||
outreach_email=EMAIL_ADDRESSES["outreach"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -40,8 +40,10 @@
|
||||
.admin-subnav {
|
||||
display: flex; align-items: stretch; padding: 0 2rem;
|
||||
background: #fff; border-bottom: 1px solid #E2E8F0;
|
||||
flex-shrink: 0; overflow-x: auto; gap: 0;
|
||||
flex-shrink: 0; overflow-x: auto; overflow-y: hidden; gap: 0;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.admin-subnav::-webkit-scrollbar { display: none; }
|
||||
.admin-subnav a {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
padding: 0 1px; margin: 0 13px 0 0; height: 42px;
|
||||
|
||||
@@ -3,6 +3,19 @@
|
||||
|
||||
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_head %}
|
||||
<style>
|
||||
.funnel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.funnel-grid { grid-template-columns: repeat(5, 1fr); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
@@ -47,7 +60,7 @@
|
||||
|
||||
<!-- Lead Funnel -->
|
||||
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Lead Funnel</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
||||
<div class="funnel-grid mb-8">
|
||||
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Planner Users</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p>
|
||||
@@ -72,7 +85,7 @@
|
||||
|
||||
<!-- Supplier Stats -->
|
||||
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Supplier Funnel</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
||||
<div class="funnel-grid mb-8">
|
||||
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Claimed Suppliers</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p>
|
||||
|
||||
@@ -2,13 +2,30 @@
|
||||
{% set admin_page = "outreach" %}
|
||||
{% block title %}Outreach Pipeline - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_head %}
|
||||
<style>
|
||||
.pipeline-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.pipeline-status-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.pipeline-status-grid { grid-template-columns: repeat(6, 1fr); }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl">Outreach</h1>
|
||||
<p class="text-sm text-slate mt-1">
|
||||
{{ pipeline.total }} supplier{{ 's' if pipeline.total != 1 else '' }} in pipeline
|
||||
· Sending domain: <span class="mono text-xs">hello.padelnomics.io</span>
|
||||
· Sending from: <span class="mono text-xs">{{ outreach_email }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -18,7 +35,7 @@
|
||||
</header>
|
||||
|
||||
<!-- Pipeline cards -->
|
||||
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:0.75rem;margin-bottom:1.5rem">
|
||||
<div class="pipeline-status-grid">
|
||||
{% set status_colors = {
|
||||
'prospect': '#E2E8F0',
|
||||
'contacted': '#DBEAFE',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% if emails %}
|
||||
<div class="card">
|
||||
<div style="overflow-x:auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -38,6 +39,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center" style="padding:2rem">
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
{% if leads %}
|
||||
<div class="card">
|
||||
<div style="overflow-x:auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -58,6 +59,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% if suppliers %}
|
||||
<div class="card">
|
||||
<div style="overflow-x:auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -19,6 +20,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center" style="padding:2rem">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% if suppliers %}
|
||||
<div class="card">
|
||||
<div style="overflow-x:auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -47,6 +48,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center" style="padding:2rem">
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
|
||||
{% block admin_head %}
|
||||
<style>
|
||||
.pipeline-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.pipeline-stat-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
|
||||
.pipeline-tabs {
|
||||
display: flex; gap: 0; border-bottom: 2px solid #E2E8F0; margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -46,7 +55,7 @@
|
||||
</header>
|
||||
|
||||
<!-- Health stat cards -->
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.75rem" class="mb-6">
|
||||
<div class="pipeline-stat-grid mb-6">
|
||||
<div class="card text-center" style="padding:0.875rem">
|
||||
<p class="text-xs text-slate">Total Runs</p>
|
||||
<p class="text-2xl font-bold text-navy metric">{{ summary.total | default(0) }}</p>
|
||||
|
||||
@@ -218,9 +218,7 @@
|
||||
.nav-bar[data-navopen="true"] .nav-mobile {
|
||||
display: flex;
|
||||
}
|
||||
.nav-mobile a,
|
||||
.nav-mobile button.nav-auth-btn,
|
||||
.nav-mobile a.nav-auth-btn {
|
||||
.nav-mobile a:not(.nav-auth-btn) {
|
||||
display: block;
|
||||
padding: 0.625rem 0;
|
||||
border-bottom: 1px solid #F1F5F9;
|
||||
@@ -230,15 +228,18 @@
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.nav-mobile a:last-child { border-bottom: none; }
|
||||
.nav-mobile a:hover { color: #1D4ED8; }
|
||||
.nav-mobile a:not(.nav-auth-btn):last-child { border-bottom: none; }
|
||||
.nav-mobile a:not(.nav-auth-btn):hover { color: #1D4ED8; }
|
||||
/* nav-auth-btn in mobile menu: override block style, keep button colours */
|
||||
.nav-mobile a.nav-auth-btn,
|
||||
.nav-mobile button.nav-auth-btn {
|
||||
display: inline-flex;
|
||||
margin-top: 0.5rem;
|
||||
padding: 6px 16px;
|
||||
border-bottom: none;
|
||||
width: auto;
|
||||
align-self: flex-start;
|
||||
color: #fff;
|
||||
}
|
||||
.nav-mobile .nav-mobile-section {
|
||||
font-size: 0.6875rem;
|
||||
|
||||
Reference in New Issue
Block a user