feat(iteration-2): i18n, UX & quote flow improvements
- Auth templates fully translated (DE/EN) with before_request lang detection - Flash messages in auth routes use get_translations(g.lang) lookups - Quote verify URL bug fixed: includes /<lang>/ prefix in worker email - Sie→Du conversion across public/supplier/directory/leads templates - Budget label: 'Budgetschätzung' → 'Budget', step=10000 on input - Context option: 'Erweiterung' copy made more specific - Footer reordered Brand|Product|Company|Legal and fixed grid-3→grid-4 - Quote sidebar visibility: display:none → display:block (media query hides <1400px) - Floating feedback button: fixed bottom-right speech-bubble SVG - Quote step 1: editable when pre-filled from planner, with 'Edit in Planner' link - Quote step 6 & 8: financing_status, decision_process, services_needed mandatory - Disposable email + fake phone filtering in core.py, applied at auth and leads - Directory labels (category/country/region) translated via get_directory_labels(lang) - Result tab tooltips for IRR, MOIC, RevPAH, EBITDA, Payback, DSCR, Debt Yield, etc. - Markets hub gated behind waitlist decorator (POST handler + markets_waitlist.html) - Email design refresh: brand blue #1D4ED8 button, monogram logo, proper footer - USER_FLOWS.md documents all 12 user flows - test_e2e_flows.py: 46 Playwright E2E tests across all flows (port 5113, -m visual) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
224
padelnomics/docs/USER_FLOWS.md
Normal file
224
padelnomics/docs/USER_FLOWS.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# User Flows
|
||||||
|
|
||||||
|
All user-facing flows through the padelnomics app. Use this as the reference when writing E2E tests or auditing coverage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Visitor → Planner
|
||||||
|
|
||||||
|
**Entry:** `/<lang>/` → click "Planner" in nav
|
||||||
|
|
||||||
|
| Step | URL | Notes |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 1 | `GET /<lang>/planner/` | Wizard loads with default state (indoor, 6 courts, rent). `s` = default state, `d` = calc results. |
|
||||||
|
| 2 | Adjust any slider | `POST /<lang>/planner/calculate` (HTMX, `hx-trigger="input changed delay:200ms"`) → returns `#tab-content` partial |
|
||||||
|
| 3 | Switch result tab | `POST /<lang>/planner/calculate` with `activeTab=<tab>` → HTMX swaps `#tab-content` |
|
||||||
|
| 4 | View charts | Charts embedded as `<script type="application/json" id="chartX-data">` in response. `initCharts()` in `planner.js` renders them. |
|
||||||
|
| 5 | Wizard preview | `#wizPreview` updated OOB (`hx-swap-oob="true"`) with CAPEX / monthly CF / IRR |
|
||||||
|
|
||||||
|
**Auth required:** No (logged-in users get their default scenario pre-loaded)
|
||||||
|
**HTMX partials:** `calculate_response.html`, `wizard_preview.html`
|
||||||
|
**Key state:** `s` (validated via `validate_state()`), `d` (calc output via `calc()`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Visitor → Quote Request (from Planner)
|
||||||
|
|
||||||
|
**Entry:** Planner → quote sidebar (>1400px wide) or inline CTA (results tabs, <1400px)
|
||||||
|
|
||||||
|
| Step | URL | Notes |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 1 | `GET /<lang>/leads/quote` | Planner passes state via query params — step 1 pre-populated |
|
||||||
|
| 2–9 | `POST /<lang>/leads/quote/step/<n>` | HTMX: each step swaps `#q-step` content; progress bar OOB-swapped into `#q-progress` |
|
||||||
|
| 9 (submit) | `POST /<lang>/leads/quote` | Standard HTML form POST (not HTMX) |
|
||||||
|
| After submit | Verification email sent if guest; or lead created directly if logged in |
|
||||||
|
| Verify | `GET /<lang>/leads/verify?token=...&lead=...` | Token validated → lead status updated → success page |
|
||||||
|
|
||||||
|
**Auth required:** No (guests get email verification; logged-in users skip verification)
|
||||||
|
**Key validation:** Step 1: facility_type required. Step 2: country required. Step 5: timeline required. Step 7: stakeholder_type required. Step 9: name, email, phone, consent required.
|
||||||
|
**Email sent:** `send_quote_verification` (worker task) — verify URL must include `/<lang>/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Visitor → Quote Request (Direct)
|
||||||
|
|
||||||
|
Same as Flow 2 but arrives at `/<lang>/leads/quote` directly (no planner state). Step 1 shows the full editable form (no pre-fill).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Visitor → Directory
|
||||||
|
|
||||||
|
**Entry:** `/<lang>/directory/` via nav
|
||||||
|
|
||||||
|
| Step | URL | Notes |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 1 | `GET /<lang>/directory/` | Lists all suppliers, filter UI visible |
|
||||||
|
| 2 | Search/filter | `GET /<lang>/directory/results?q=...&country=...&category=...` HTMX swaps `#dir-results` |
|
||||||
|
| 3 | Click supplier card | `GET /<lang>/directory/<slug>` — supplier detail page |
|
||||||
|
| 4 | Send enquiry | `POST /<lang>/directory/<slug>/enquiry` HTMX swaps `#enquiry-result` |
|
||||||
|
| 4b | External link | `GET /<lang>/directory/<slug>/website` → 302 redirect (click-tracking) |
|
||||||
|
| 4c | Get quote | `GET /<lang>/directory/<slug>/quote` → redirect to quote wizard |
|
||||||
|
|
||||||
|
**Auth required:** No
|
||||||
|
**Category/country labels:** Must be translated per `g.lang` via `get_directory_labels(lang)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Visitor → Signup
|
||||||
|
|
||||||
|
**Entry:** Any CTA "Create Account" / "Sign up" → `/auth/signup`
|
||||||
|
|
||||||
|
| Step | URL | Notes |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 1 | `GET /auth/signup` | Signup form (or waitlist form if `WAITLIST_MODE=true`) |
|
||||||
|
| 2 | Submit email | `POST /auth/signup` → sends magic link via `send_welcome` + `send_magic_link` tasks |
|
||||||
|
| 3 | Click email link | `GET /auth/verify?token=...` → session created, redirect to `next` param or `/dashboard/` |
|
||||||
|
| 4 | Dashboard | `GET /dashboard/` |
|
||||||
|
|
||||||
|
**Auth required:** No
|
||||||
|
**Language detection:** Auth routes have no `<lang>` prefix — lang detected from cookie or `Accept-Language` header via `@bp.before_request`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Returning User → Login
|
||||||
|
|
||||||
|
**Entry:** `/auth/login`
|
||||||
|
|
||||||
|
| Step | URL | Notes |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 1 | `GET /auth/login` | Login form |
|
||||||
|
| 2 | Submit email | `POST /auth/login` → enqueues `send_magic_link` |
|
||||||
|
| 3 | Confirmation | `GET /auth/magic-link-sent` |
|
||||||
|
| 4 | Click email link | `GET /auth/verify?token=...` → session set, redirect |
|
||||||
|
| 5 | Dashboard or prior page | Session `user_id` set |
|
||||||
|
|
||||||
|
**Dev shortcut:** `GET /auth/dev-login?email=test@example.com` (DEBUG mode only) — instant login, used in tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. User → Save/Load Scenario
|
||||||
|
|
||||||
|
**Entry:** Planner while logged in
|
||||||
|
|
||||||
|
| Step | URL | Notes |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 1 | Open scenarios panel | `GET /<lang>/planner/scenarios` HTMX partial — lists saved scenarios |
|
||||||
|
| 2 | Save current state | `POST /<lang>/planner/scenarios/save` (JSON body: `{name, state}`) → creates/updates scenario |
|
||||||
|
| 3 | Load scenario | `GET /<lang>/planner/scenarios/<id>` → returns scenario JSON → JS populates form |
|
||||||
|
| 4 | Set default | `POST /<lang>/planner/scenarios/<id>/default` |
|
||||||
|
| 5 | Delete | `DELETE /<lang>/planner/scenarios/<id>` |
|
||||||
|
|
||||||
|
**Auth required:** Yes (`@login_required`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. User → Export PDF
|
||||||
|
|
||||||
|
**Entry:** Planner → "Export" tab/button
|
||||||
|
|
||||||
|
| Step | URL | Notes |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 1 | `GET /<lang>/planner/export` | Export options page (or waitlist if `WAITLIST_MODE=true`) |
|
||||||
|
| 2 | Checkout | `POST /<lang>/planner/export/checkout` → returns Paddle checkout URL (JSON) |
|
||||||
|
| 3 | Paddle checkout | External Paddle overlay/redirect |
|
||||||
|
| 4 | Post-checkout | `GET /<lang>/planner/export/success` |
|
||||||
|
| 5 | Download PDF | `GET /<lang>/planner/export/<id>` (checks subscription/purchase) |
|
||||||
|
|
||||||
|
**Auth required:** Yes (`@login_required`)
|
||||||
|
**Email sent:** `send_welcome` (if new user), PDF ready notification
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Supplier → Signup
|
||||||
|
|
||||||
|
**Entry:** `/<lang>/suppliers/signup` or from nav "For Suppliers"
|
||||||
|
|
||||||
|
| Step | URL | Notes |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 1 | `GET /<lang>/suppliers/signup` | Plan selection (or waitlist if `WAITLIST_MODE=true`) |
|
||||||
|
| 2–4 | `POST /<lang>/suppliers/signup/step/<n>` | HTMX wizard: step 2 = details, step 3 = credits, step 4 = contact |
|
||||||
|
| Checkout | `POST /<lang>/suppliers/signup/checkout` | Returns Paddle URL |
|
||||||
|
| Success | `GET /<lang>/suppliers/signup/success` | Post-checkout confirmation |
|
||||||
|
|
||||||
|
**Auth required:** No
|
||||||
|
**Plans:** `basic` (free listing), `growth` (leads), `pro` (pro listing + leads)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Supplier → Dashboard
|
||||||
|
|
||||||
|
**Entry:** Login → redirect to `/<lang>/suppliers/dashboard` (if supplier role)
|
||||||
|
|
||||||
|
| Tab | URL | Notes |
|
||||||
|
|-----|-----|-------|
|
||||||
|
| Overview | `GET /<lang>/suppliers/dashboard/overview` | Stats: views, leads, credits |
|
||||||
|
| Lead Feed | `GET /<lang>/suppliers/dashboard/leads` | Lead cards (teased for basic, unlockable for growth/pro) |
|
||||||
|
| Listing | `GET /<lang>/suppliers/dashboard/listing` | Edit supplier profile |
|
||||||
|
| Boosts | `GET /<lang>/suppliers/dashboard/boosts` | Purchase credit packs |
|
||||||
|
|
||||||
|
**Auth required:** Yes — `@_supplier_required` (basic+); lead tabs require `@_lead_tier_required` (growth/pro)
|
||||||
|
**Dashboard shell:** `GET /<lang>/suppliers/dashboard` — tabs loaded via HTMX
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Supplier → Unlock Lead
|
||||||
|
|
||||||
|
**Entry:** Lead feed in supplier dashboard
|
||||||
|
|
||||||
|
| Step | URL | Notes |
|
||||||
|
|------|-----|-------|
|
||||||
|
| 1 | View teased lead | `GET /<lang>/suppliers/dashboard/leads` — lead shown with blurred contact info |
|
||||||
|
| 2 | Unlock | `POST /<lang>/suppliers/leads/<id>/unlock` — deducts 1 credit, reveals full lead |
|
||||||
|
| 3 | Receive email | `send_lead_forward_email` task enqueued — full project brief sent to supplier |
|
||||||
|
| 4 | Entrepreneur notified | `send_lead_matched_notification` task — notifies entrepreneur a supplier was matched |
|
||||||
|
|
||||||
|
**Auth required:** Yes — `@_lead_tier_required`
|
||||||
|
**Credit check:** Server-side check; if 0 credits → redirect to boosts tab
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Admin Flows
|
||||||
|
|
||||||
|
**Entry:** `/admin/` (requires `@role_required("admin")`)
|
||||||
|
|
||||||
|
| Area | URL | What you can do |
|
||||||
|
|------|-----|-----------------|
|
||||||
|
| Dashboard | `GET /admin/` | Stats overview |
|
||||||
|
| Users | `GET /admin/users`, `/admin/users/<id>` | List, view, impersonate |
|
||||||
|
| Leads | `GET /admin/leads`, `/admin/leads/<id>` | List, filter, view detail, change status, forward to supplier, create |
|
||||||
|
| Suppliers | `GET /admin/suppliers`, `/admin/suppliers/<id>` | List, view, adjust credits, change tier, create |
|
||||||
|
| Feedback | `GET /admin/feedback` | View all submitted feedback |
|
||||||
|
| Article Templates | `GET /admin/templates` | CRUD + bulk generate articles from template+data |
|
||||||
|
| Published Scenarios | `GET /admin/scenarios` | CRUD public scenario cards (shown on landing) |
|
||||||
|
| Articles | `GET /admin/articles` | CRUD, publish/unpublish, rebuild HTML |
|
||||||
|
| Task Queue | `GET /admin/tasks` | View worker tasks, retry/delete failed |
|
||||||
|
|
||||||
|
**Dev shortcut:** `/auth/dev-login?email=<admin-email>` where email is in `config.ADMIN_EMAILS`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route Prefix Reference
|
||||||
|
|
||||||
|
| Blueprint | URL Prefix | Lang-prefixed? |
|
||||||
|
|-----------|------------|----------------|
|
||||||
|
| `public` | `/<lang>` | Yes |
|
||||||
|
| `planner` | `/<lang>/planner` | Yes |
|
||||||
|
| `directory` | `/<lang>/directory` | Yes |
|
||||||
|
| `leads` | `/<lang>/leads` | Yes |
|
||||||
|
| `suppliers` | `/<lang>/suppliers` | Yes |
|
||||||
|
| `content` | `/<lang>` (catch-all, registered last) | Yes |
|
||||||
|
| `auth` | `/auth` | No |
|
||||||
|
| `dashboard` | `/dashboard` | No |
|
||||||
|
| `billing` | `/billing` | No |
|
||||||
|
| `admin` | `/admin` | No |
|
||||||
|
|
||||||
|
**Language detection for non-prefixed blueprints:** Cookie (`lang`) → `Accept-Language` header → fallback `"en"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Test Shortcuts
|
||||||
|
|
||||||
|
- **Dev login (no magic link):** `GET /auth/dev-login?email=...` (only when `DEBUG=True`)
|
||||||
|
- **Admin login:** `GET /auth/dev-login?email=<email-in-ADMIN_EMAILS>`
|
||||||
|
- **Quote verify URL pattern:** `GET /<lang>/leads/verify?token=...&lead=...`
|
||||||
|
- **Auth verify URL pattern:** `GET /auth/verify?token=...`
|
||||||
@@ -8,7 +8,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
|
from quart import Blueprint, flash, g, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..core import capture_waitlist_email, config, csrf_protect, execute, fetch_one, waitlist_gate
|
from ..core import capture_waitlist_email, config, csrf_protect, execute, fetch_one, is_disposable_email, waitlist_gate
|
||||||
|
from ..i18n import SUPPORTED_LANGS, get_translations
|
||||||
|
|
||||||
# Blueprint with its own template folder
|
# Blueprint with its own template folder
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -19,6 +20,21 @@ bp = Blueprint(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.before_request
|
||||||
|
async def pull_auth_lang() -> None:
|
||||||
|
"""Detect language for auth routes (no URL prefix available).
|
||||||
|
|
||||||
|
Priority: lang cookie → Accept-Language header → fallback 'en'.
|
||||||
|
"""
|
||||||
|
lang = request.cookies.get("lang", "")
|
||||||
|
if lang not in SUPPORTED_LANGS:
|
||||||
|
accept = request.headers.get("Accept-Language", "")
|
||||||
|
lang = accept[:2].lower() if accept else ""
|
||||||
|
if lang not in SUPPORTED_LANGS:
|
||||||
|
lang = "en"
|
||||||
|
g.lang = lang
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# SQL Queries
|
# SQL Queries
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -183,31 +199,35 @@ async def login():
|
|||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
_t = get_translations(g.lang)
|
||||||
form = await request.form
|
form = await request.form
|
||||||
email = form.get("email", "").strip().lower()
|
email = form.get("email", "").strip().lower()
|
||||||
|
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
await flash("Please enter a valid email address.", "error")
|
await flash(_t["auth_flash_invalid_email"], "error")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
if is_disposable_email(email):
|
||||||
|
await flash(_t["auth_flash_disposable_email"], "error")
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
# Get or create user
|
# Get or create user
|
||||||
user = await get_user_by_email(email)
|
user = await get_user_by_email(email)
|
||||||
if not user:
|
if not user:
|
||||||
user_id = await create_user(email)
|
user_id = await create_user(email)
|
||||||
else:
|
else:
|
||||||
user_id = user["id"]
|
user_id = user["id"]
|
||||||
|
|
||||||
# Create magic link token
|
# Create magic link token
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
await create_auth_token(user_id, token)
|
await create_auth_token(user_id, token)
|
||||||
|
|
||||||
# Queue email
|
# Queue email
|
||||||
from ..worker import enqueue
|
from ..worker import enqueue
|
||||||
await enqueue("send_magic_link", {"email": email, "token": token})
|
await enqueue("send_magic_link", {"email": email, "token": token})
|
||||||
|
|
||||||
await flash("Check your email for the sign-in link!", "success")
|
await flash(_t["auth_flash_login_sent"], "success")
|
||||||
return redirect(url_for("auth.magic_link_sent", email=email))
|
return redirect(url_for("auth.magic_link_sent", email=email))
|
||||||
|
|
||||||
return await render_template("login.html")
|
return await render_template("login.html")
|
||||||
|
|
||||||
|
|
||||||
@@ -221,12 +241,13 @@ async def signup():
|
|||||||
|
|
||||||
# Waitlist POST handling
|
# Waitlist POST handling
|
||||||
if config.WAITLIST_MODE and request.method == "POST":
|
if config.WAITLIST_MODE and request.method == "POST":
|
||||||
|
_t = get_translations(g.lang)
|
||||||
form = await request.form
|
form = await request.form
|
||||||
email = form.get("email", "").strip().lower()
|
email = form.get("email", "").strip().lower()
|
||||||
plan = form.get("plan", "signup")
|
plan = form.get("plan", "signup")
|
||||||
|
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
await flash("Please enter a valid email address.", "error")
|
await flash(_t["auth_flash_invalid_email"], "error")
|
||||||
return redirect(url_for("auth.signup"))
|
return redirect(url_for("auth.signup"))
|
||||||
|
|
||||||
await capture_waitlist_email(email, intent=plan)
|
await capture_waitlist_email(email, intent=plan)
|
||||||
@@ -236,33 +257,37 @@ async def signup():
|
|||||||
plan = request.args.get("plan", "free")
|
plan = request.args.get("plan", "free")
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
_t = get_translations(g.lang)
|
||||||
form = await request.form
|
form = await request.form
|
||||||
email = form.get("email", "").strip().lower()
|
email = form.get("email", "").strip().lower()
|
||||||
selected_plan = form.get("plan", "free")
|
selected_plan = form.get("plan", "free")
|
||||||
|
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
await flash("Please enter a valid email address.", "error")
|
await flash(_t["auth_flash_invalid_email"], "error")
|
||||||
return redirect(url_for("auth.signup", plan=selected_plan))
|
return redirect(url_for("auth.signup", plan=selected_plan))
|
||||||
|
if is_disposable_email(email):
|
||||||
|
await flash(_t["auth_flash_disposable_email"], "error")
|
||||||
|
return redirect(url_for("auth.signup", plan=selected_plan))
|
||||||
|
|
||||||
# Check if user exists
|
# Check if user exists
|
||||||
user = await get_user_by_email(email)
|
user = await get_user_by_email(email)
|
||||||
if user:
|
if user:
|
||||||
await flash("Account already exists. Please sign in.", "info")
|
await flash(_t["auth_flash_account_exists"], "info")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
# Create user
|
# Create user
|
||||||
user_id = await create_user(email)
|
user_id = await create_user(email)
|
||||||
|
|
||||||
# Create magic link token
|
# Create magic link token
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
await create_auth_token(user_id, token)
|
await create_auth_token(user_id, token)
|
||||||
|
|
||||||
# Queue emails
|
# Queue emails
|
||||||
from ..worker import enqueue
|
from ..worker import enqueue
|
||||||
await enqueue("send_magic_link", {"email": email, "token": token})
|
await enqueue("send_magic_link", {"email": email, "token": token})
|
||||||
await enqueue("send_welcome", {"email": email})
|
await enqueue("send_welcome", {"email": email})
|
||||||
|
|
||||||
await flash("Check your email to complete signup!", "success")
|
await flash(_t["auth_flash_signup_sent"], "success")
|
||||||
return redirect(url_for("auth.magic_link_sent", email=email))
|
return redirect(url_for("auth.magic_link_sent", email=email))
|
||||||
|
|
||||||
return await render_template("signup.html", plan=plan)
|
return await render_template("signup.html", plan=plan)
|
||||||
@@ -273,22 +298,24 @@ async def verify():
|
|||||||
"""Verify magic link token."""
|
"""Verify magic link token."""
|
||||||
token = request.args.get("token")
|
token = request.args.get("token")
|
||||||
|
|
||||||
|
_t = get_translations(g.lang)
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
await flash("Invalid or expired link.", "error")
|
await flash(_t["auth_flash_invalid_token"], "error")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
token_data = await get_valid_token(token)
|
token_data = await get_valid_token(token)
|
||||||
|
|
||||||
if not token_data:
|
if not token_data:
|
||||||
await flash("Invalid or expired link. Please request a new one.", "error")
|
await flash(_t["auth_flash_invalid_token_detail"], "error")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
# Mark token as used
|
# Mark token as used
|
||||||
await mark_token_used(token_data["id"])
|
await mark_token_used(token_data["id"])
|
||||||
|
|
||||||
# Update last login
|
# Update last login
|
||||||
await update_user(token_data["user_id"], last_login_at=datetime.utcnow().isoformat())
|
await update_user(token_data["user_id"], last_login_at=datetime.utcnow().isoformat())
|
||||||
|
|
||||||
# Set session
|
# Set session
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
session["user_id"] = token_data["user_id"]
|
session["user_id"] = token_data["user_id"]
|
||||||
@@ -296,7 +323,7 @@ async def verify():
|
|||||||
# Auto-grant admin role if email is in ADMIN_EMAILS
|
# Auto-grant admin role if email is in ADMIN_EMAILS
|
||||||
await ensure_admin_role(token_data["user_id"], token_data["email"])
|
await ensure_admin_role(token_data["user_id"], token_data["email"])
|
||||||
|
|
||||||
await flash("Successfully signed in!", "success")
|
await flash(_t["auth_flash_signed_in"], "success")
|
||||||
|
|
||||||
# Redirect to intended page or dashboard
|
# Redirect to intended page or dashboard
|
||||||
next_url = request.args.get("next", url_for("dashboard.index"))
|
next_url = request.args.get("next", url_for("dashboard.index"))
|
||||||
@@ -307,8 +334,9 @@ async def verify():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def logout():
|
async def logout():
|
||||||
"""Log out user."""
|
"""Log out user."""
|
||||||
|
_t = get_translations(g.lang)
|
||||||
session.clear()
|
session.clear()
|
||||||
await flash("You have been signed out.", "info")
|
await flash(_t["auth_flash_signed_out"], "info")
|
||||||
return redirect(url_for("public.landing"))
|
return redirect(url_for("public.landing"))
|
||||||
|
|
||||||
|
|
||||||
@@ -347,21 +375,22 @@ async def dev_login():
|
|||||||
@csrf_protect
|
@csrf_protect
|
||||||
async def resend():
|
async def resend():
|
||||||
"""Resend magic link."""
|
"""Resend magic link."""
|
||||||
|
_t = get_translations(g.lang)
|
||||||
form = await request.form
|
form = await request.form
|
||||||
email = form.get("email", "").strip().lower()
|
email = form.get("email", "").strip().lower()
|
||||||
|
|
||||||
if not email:
|
if not email:
|
||||||
await flash("Email address required.", "error")
|
await flash(_t["auth_flash_invalid_email"], "error")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
user = await get_user_by_email(email)
|
user = await get_user_by_email(email)
|
||||||
if user:
|
if user:
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
await create_auth_token(user["id"], token)
|
await create_auth_token(user["id"], token)
|
||||||
|
|
||||||
from ..worker import enqueue
|
from ..worker import enqueue
|
||||||
await enqueue("send_magic_link", {"email": email, "token": token})
|
await enqueue("send_magic_link", {"email": email, "token": token})
|
||||||
|
|
||||||
# Always show success (don't reveal if email exists)
|
# Always show success (don't reveal if email exists)
|
||||||
await flash("If that email is registered, we've sent a new link.", "success")
|
await flash(_t["auth_flash_resend_sent"], "success")
|
||||||
return redirect(url_for("auth.magic_link_sent", email=email))
|
return redirect(url_for("auth.magic_link_sent", email=email))
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Sign In - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.auth_login_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
<div class="card max-w-sm mx-auto mt-8">
|
<div class="card max-w-sm mx-auto mt-8">
|
||||||
<h1 class="text-2xl mb-1">Sign In</h1>
|
<h1 class="text-2xl mb-1">{{ t.auth_login_title }}</h1>
|
||||||
<p class="text-slate mb-6">Enter your email to receive a sign-in link.</p>
|
<p class="text-slate mb-6">{{ t.auth_login_sub }}</p>
|
||||||
|
|
||||||
<form method="post" class="space-y-4">
|
<form method="post" class="space-y-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="form-label">Email</label>
|
<label for="email" class="form-label">{{ t.auth_login_email_label }}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
@@ -24,12 +24,12 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn w-full">Send Sign-In Link</button>
|
<button type="submit" class="btn w-full">{{ t.auth_login_btn }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="text-center text-sm text-slate mt-6">
|
<p class="text-center text-sm text-slate mt-6">
|
||||||
Don't have an account?
|
{{ t.auth_login_no_account }}
|
||||||
<a href="{{ url_for('auth.signup') }}">Sign up</a>
|
<a href="{{ url_for('auth.signup') }}">{{ t.auth_login_signup_link }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Check Your Email - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.auth_magic_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
<div class="card max-w-sm mx-auto mt-8 text-center">
|
<div class="card max-w-sm mx-auto mt-8 text-center">
|
||||||
<h1 class="text-2xl mb-4">Check Your Email</h1>
|
<h1 class="text-2xl mb-4">{{ t.auth_magic_title }}</h1>
|
||||||
|
|
||||||
<p class="text-slate-dark">We've sent a sign-in link to:</p>
|
<p class="text-slate-dark">{{ t.auth_magic_sent_to }}</p>
|
||||||
<p class="font-semibold text-navy my-2">{{ email }}</p>
|
<p class="font-semibold text-navy my-2">{{ email }}</p>
|
||||||
|
|
||||||
<p class="text-slate text-sm">Click the link in the email to sign in. The link expires in {{ config.MAGIC_LINK_EXPIRY_MINUTES }} minutes.</p>
|
<p class="text-slate text-sm">{{ t.auth_magic_instructions.replace('{minutes}', config.MAGIC_LINK_EXPIRY_MINUTES | string) }}</p>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<details class="text-left">
|
<details class="text-left">
|
||||||
<summary class="cursor-pointer text-sm font-medium text-navy">Didn't receive the email?</summary>
|
<summary class="cursor-pointer text-sm font-medium text-navy">{{ t.auth_magic_no_email }}</summary>
|
||||||
<ul class="list-disc pl-6 mt-2 space-y-1 text-sm text-slate-dark">
|
<ul class="list-disc pl-6 mt-2 space-y-1 text-sm text-slate-dark">
|
||||||
<li>Check your spam folder</li>
|
<li>{{ t.auth_magic_check_spam }}</li>
|
||||||
<li>Make sure the email address is correct</li>
|
<li>{{ t.auth_magic_correct_email }}</li>
|
||||||
<li>Wait a minute and try again</li>
|
<li>{{ t.auth_magic_wait }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('auth.resend') }}" class="mt-4">
|
<form method="post" action="{{ url_for('auth.resend') }}" class="mt-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="email" value="{{ email }}">
|
<input type="hidden" name="email" value="{{ email }}">
|
||||||
<button type="submit" class="btn-outline w-full">Resend Link</button>
|
<button type="submit" class="btn-outline w-full">{{ t.auth_magic_resend_btn }}</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Sign Up - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.auth_signup_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
<div class="card max-w-sm mx-auto mt-8">
|
<div class="card max-w-sm mx-auto mt-8">
|
||||||
<h1 class="text-2xl mb-1">Create Free Account</h1>
|
<h1 class="text-2xl mb-1">{{ t.auth_signup_title }}</h1>
|
||||||
<p class="text-slate mb-6">Save your padel business plan, get supplier quotes, and find financing.</p>
|
<p class="text-slate mb-6">{{ t.auth_signup_sub }}</p>
|
||||||
|
|
||||||
<form method="post" class="space-y-4">
|
<form method="post" class="space-y-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="form-label">Email</label>
|
<label for="email" class="form-label">{{ t.auth_login_email_label }}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
@@ -22,15 +22,15 @@
|
|||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
>
|
>
|
||||||
<p class="form-hint">No credit card required. Full access to all features.</p>
|
<p class="form-hint">{{ t.auth_signup_hint }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn w-full">Create Free Account</button>
|
<button type="submit" class="btn w-full">{{ t.auth_signup_btn }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="text-center text-sm text-slate mt-6">
|
<p class="text-center text-sm text-slate mt-6">
|
||||||
Already have an account?
|
{{ t.auth_signup_have_account }}
|
||||||
<a href="{{ url_for('auth.login') }}">Sign in</a>
|
<a href="{{ url_for('auth.login') }}">{{ t.auth_signup_signin_link }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Join the Waitlist - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.auth_waitlist_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
<div class="card max-w-sm mx-auto mt-8">
|
<div class="card max-w-sm mx-auto mt-8">
|
||||||
<h1 class="text-2xl mb-1">Be First to Launch Your Padel Business</h1>
|
<h1 class="text-2xl mb-1">{{ t.auth_waitlist_title }}</h1>
|
||||||
<p class="text-slate mb-6">We're preparing to launch the ultimate planning platform for padel entrepreneurs. Join the waitlist for early access, exclusive bonuses, and priority support.</p>
|
<p class="text-slate mb-6">{{ t.auth_waitlist_sub }}</p>
|
||||||
|
|
||||||
<form method="post" class="space-y-4">
|
<form method="post" class="space-y-4">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="plan" value="{{ plan }}">
|
<input type="hidden" name="plan" value="{{ plan }}">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="email" class="form-label">Email</label>
|
<label for="email" class="form-label">{{ t.auth_login_email_label }}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
@@ -23,15 +23,15 @@
|
|||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
>
|
>
|
||||||
<p class="form-hint">You'll be among the first to get access when we launch.</p>
|
<p class="form-hint">{{ t.auth_waitlist_hint }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn w-full">Join Waitlist</button>
|
<button type="submit" class="btn w-full">{{ t.auth_waitlist_btn }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="text-center text-sm text-slate mt-6">
|
<p class="text-center text-sm text-slate mt-6">
|
||||||
Already have an account?
|
{{ t.auth_signup_have_account }}
|
||||||
<a href="{{ url_for('auth.login') }}">Sign in</a>
|
<a href="{{ url_for('auth.login') }}">{{ t.auth_signup_signin_link }}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}You're on the List - {{ config.APP_NAME }}{% endblock %}
|
{% block title %}{{ t.auth_waitlist_confirmed_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
<div class="card max-w-sm mx-auto mt-8 text-center">
|
<div class="card max-w-sm mx-auto mt-8 text-center">
|
||||||
<h1 class="text-2xl mb-4">You're on the Waitlist!</h1>
|
<h1 class="text-2xl mb-4">{{ t.auth_waitlist_confirmed_title }}</h1>
|
||||||
|
|
||||||
<p class="text-slate-dark">We've sent a confirmation to:</p>
|
<p class="text-slate-dark">{{ t.auth_waitlist_confirmed_sent_to }}</p>
|
||||||
<p class="font-semibold text-navy my-2">{{ email }}</p>
|
<p class="font-semibold text-navy my-2">{{ email }}</p>
|
||||||
|
|
||||||
<p class="text-slate text-sm mb-6">You'll be among the first to know when we launch. We'll send you early access, exclusive launch bonuses, and priority onboarding.</p>
|
<p class="text-slate text-sm mb-6">{{ t.auth_waitlist_confirmed_sub }}</p>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class="text-left mt-6">
|
<div class="text-left mt-6">
|
||||||
<h3 class="text-sm font-semibold text-navy mb-3">What happens next?</h3>
|
<h3 class="text-sm font-semibold text-navy mb-3">{{ t.auth_waitlist_confirmed_next }}</h3>
|
||||||
<ul class="list-disc pl-6 space-y-1 text-sm text-slate-dark">
|
<ul class="list-disc pl-6 space-y-1 text-sm text-slate-dark">
|
||||||
<li>You'll receive a confirmation email shortly</li>
|
<li>{{ t.auth_waitlist_confirmed_step1 }}</li>
|
||||||
<li>We'll notify you as soon as we launch</li>
|
<li>{{ t.auth_waitlist_confirmed_step2 }}</li>
|
||||||
<li>You'll get exclusive early access before the public launch</li>
|
<li>{{ t.auth_waitlist_confirmed_step3 }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="{{ url_for('public.landing') }}" class="btn-outline w-full mt-6">Back to Home</a>
|
<a href="{{ url_for('public.landing') }}" class="btn-outline w-full mt-6">{{ t.auth_waitlist_confirmed_back }}</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ from pathlib import Path
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from quart import Blueprint, abort, render_template, request
|
from quart import Blueprint, abort, g, render_template, request
|
||||||
|
|
||||||
from ..core import fetch_all, fetch_one
|
from ..core import capture_waitlist_email, config, csrf_protect, fetch_all, fetch_one, waitlist_gate
|
||||||
|
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
"content",
|
"content",
|
||||||
@@ -89,9 +89,18 @@ async def bake_scenario_cards(html: str) -> str:
|
|||||||
# Markets Hub
|
# Markets Hub
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@bp.route("/markets")
|
@bp.route("/markets", methods=["GET", "POST"])
|
||||||
|
@csrf_protect
|
||||||
|
@waitlist_gate("markets_waitlist.html")
|
||||||
async def markets():
|
async def markets():
|
||||||
"""Hub page: search + country/region filter for articles."""
|
"""Hub page: search + country/region filter for articles."""
|
||||||
|
if config.WAITLIST_MODE and request.method == "POST":
|
||||||
|
form = await request.form
|
||||||
|
email = form.get("email", "").strip().lower()
|
||||||
|
if email and "@" in email:
|
||||||
|
await capture_waitlist_email(email, intent="markets")
|
||||||
|
return await render_template("markets_waitlist.html", confirmed=True)
|
||||||
|
|
||||||
q = request.args.get("q", "").strip()
|
q = request.args.get("q", "").strip()
|
||||||
country = request.args.get("country", "")
|
country = request.args.get("country", "")
|
||||||
region = request.args.get("region", "")
|
region = request.args.get("region", "")
|
||||||
@@ -123,6 +132,7 @@ async def markets():
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/markets/results")
|
@bp.route("/markets/results")
|
||||||
|
@waitlist_gate("markets_waitlist.html")
|
||||||
async def market_results():
|
async def market_results():
|
||||||
"""HTMX partial: filtered article cards."""
|
"""HTMX partial: filtered article cards."""
|
||||||
q = request.args.get("q", "").strip()
|
q = request.args.get("q", "").strip()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block head %}
|
{% block head %}
|
||||||
<meta name="description" content="{% if lang == 'de' %}Padel-Platz-Kostenanalyse und Marktdaten für Städte weltweit. Echte Finanzszenarien mit lokalen Daten.{% else %}Padel court cost analysis and market data for cities worldwide. Real financial scenarios with local data.{% endif %}">
|
<meta name="description" content="{% if lang == 'de' %}Padel-Platz-Kostenanalyse und Marktdaten für Städte weltweit. Echte Finanzszenarien mit lokalen Daten.{% else %}Padel court cost analysis and market data for cities worldwide. Real financial scenarios with local data.{% endif %}">
|
||||||
<meta property="og:title" content="{% if lang == 'de' %}Padel-Märkte - {{ config.APP_NAME }}{% else %}Padel Markets - {{ config.APP_NAME }}{% endif %}">
|
<meta property="og:title" content="{% if lang == 'de' %}Padel-Märkte - {{ config.APP_NAME }}{% else %}Padel Markets - {{ config.APP_NAME }}{% endif %}">
|
||||||
<meta property="og:description" content="{% if lang == 'de' %}Erkunden Sie Padel-Platz-Kosten, Umsatzprojektionen und Investitionsrenditen nach Stadt.{% else %}Explore padel court costs, revenue projections, and investment returns by city.{% endif %}">
|
<meta property="og:description" content="{% if lang == 'de' %}Erkunde Padel-Platz-Kosten, Umsatzprojektionen und Investitionsrenditen nach Stadt.{% else %}Explore padel court costs, revenue projections, and investment returns by city.{% endif %}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.waitlist_markets_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<div class="card max-w-md mx-auto mt-8">
|
||||||
|
<h1 class="text-2xl mb-2">{{ t.waitlist_markets_title }}</h1>
|
||||||
|
<p class="text-slate mb-6">{{ t.waitlist_markets_sub }}</p>
|
||||||
|
|
||||||
|
<ul class="list-disc pl-5 space-y-1 text-sm text-slate-dark mb-6">
|
||||||
|
<li>{{ t.waitlist_markets_feature1 }}</li>
|
||||||
|
<li>{{ t.waitlist_markets_feature2 }}</li>
|
||||||
|
<li>{{ t.waitlist_markets_feature3 }}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if confirmed %}
|
||||||
|
<div class="alert alert--success mb-4">
|
||||||
|
<p class="font-semibold">{{ t.waitlist_markets_confirmed_title }}</p>
|
||||||
|
<p class="text-sm">{{ t.waitlist_markets_confirmed_body }}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" class="space-y-4">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="form-label">{{ t.waitlist_markets_email_label }}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
class="form-input"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
<p class="form-hint">{{ t.waitlist_markets_hint }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn w-full">{{ t.waitlist_markets_btn }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-slate mt-6">
|
||||||
|
{{ t.waitlist_markets_have_account }}
|
||||||
|
<a href="{{ url_for('auth.login') }}">{{ t.waitlist_markets_signin_link }}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -177,6 +177,118 @@ EMAIL_ADDRESSES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Input validation helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_DISPOSABLE_EMAIL_DOMAINS: frozenset[str] = frozenset({
|
||||||
|
# Germany / Austria / Switzerland common disposables
|
||||||
|
"byom.de", "trash-mail.de", "spamgourmet.de", "mailnull.com",
|
||||||
|
"spambog.de", "trashmail.de", "wegwerf-email.de", "spam4.me",
|
||||||
|
"yopmail.de",
|
||||||
|
# Global well-known disposables
|
||||||
|
"guerrillamail.com", "guerrillamail.net", "guerrillamail.org",
|
||||||
|
"guerrillamail.biz", "guerrillamail.de", "guerrillamail.info",
|
||||||
|
"guerrillamailblock.com", "grr.la", "spam4.me",
|
||||||
|
"mailinator.com", "mailinator.net", "mailinator.org",
|
||||||
|
"tempmail.com", "temp-mail.org", "tempmail.net", "tempmail.io",
|
||||||
|
"10minutemail.com", "10minutemail.net", "10minutemail.org",
|
||||||
|
"10minemail.com", "10minutemail.de",
|
||||||
|
"yopmail.com", "yopmail.fr", "yopmail.net",
|
||||||
|
"sharklasers.com", "guerrillamail.info", "grr.la",
|
||||||
|
"throwam.com", "throwam.net",
|
||||||
|
"maildrop.cc", "dispostable.com",
|
||||||
|
"discard.email", "discardmail.com", "discardmail.de",
|
||||||
|
"spamgourmet.com", "spamgourmet.net",
|
||||||
|
"trashmail.at", "trashmail.com", "trashmail.io",
|
||||||
|
"trashmail.me", "trashmail.net", "trashmail.org",
|
||||||
|
"trash-mail.at", "trash-mail.com",
|
||||||
|
"fakeinbox.com", "fakemail.fr", "fakemail.net",
|
||||||
|
"getnada.com", "getairmail.com",
|
||||||
|
"bccto.me", "chacuo.net",
|
||||||
|
"crapmail.org", "crap.email",
|
||||||
|
"spamherelots.com", "spamhereplease.com",
|
||||||
|
"throwam.com", "throwam.net",
|
||||||
|
"spamspot.com", "spamthisplease.com",
|
||||||
|
"filzmail.com",
|
||||||
|
"mytemp.email", "mynullmail.com",
|
||||||
|
"mailnesia.com", "mailnull.com",
|
||||||
|
"no-spam.ws", "noblepioneer.com",
|
||||||
|
"nospam.ze.tc", "nospam4.us",
|
||||||
|
"owlpic.com",
|
||||||
|
"pookmail.com",
|
||||||
|
"poof.email",
|
||||||
|
"qq1234.org",
|
||||||
|
"receivemail.org",
|
||||||
|
"rtrtr.com",
|
||||||
|
"s0ny.net",
|
||||||
|
"safetymail.info",
|
||||||
|
"shitmail.me",
|
||||||
|
"smellfear.com",
|
||||||
|
"spamavert.com",
|
||||||
|
"spambog.com", "spambog.net", "spambog.ru",
|
||||||
|
"spamgob.com",
|
||||||
|
"spamherelots.com",
|
||||||
|
"spamslicer.com",
|
||||||
|
"spamthisplease.com",
|
||||||
|
"spoofmail.de",
|
||||||
|
"super-auswahl.de",
|
||||||
|
"tempr.email",
|
||||||
|
"throwam.com",
|
||||||
|
"tilien.com",
|
||||||
|
"tmailinator.com",
|
||||||
|
"trashdevil.com", "trashdevil.de",
|
||||||
|
"trbvm.com",
|
||||||
|
"turual.com",
|
||||||
|
"uggsrock.com",
|
||||||
|
"viditag.com",
|
||||||
|
"vomoto.com",
|
||||||
|
"vpn.st",
|
||||||
|
"wegwerfemail.de", "wegwerfemail.net", "wegwerfemail.org",
|
||||||
|
"wetrainbayarea.com",
|
||||||
|
"willhackforfood.biz",
|
||||||
|
"wuzupmail.net",
|
||||||
|
"xemaps.com",
|
||||||
|
"xmailer.be",
|
||||||
|
"xoxy.net",
|
||||||
|
"yep.it",
|
||||||
|
"yogamaven.com",
|
||||||
|
"z1p.biz",
|
||||||
|
"zoemail.org",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def is_disposable_email(email: str) -> bool:
|
||||||
|
"""Return True if the email address uses a known disposable domain."""
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return False
|
||||||
|
domain = email.rsplit("@", 1)[1].strip().lower()
|
||||||
|
return domain in _DISPOSABLE_EMAIL_DOMAINS
|
||||||
|
|
||||||
|
|
||||||
|
def is_plausible_phone(phone: str) -> bool:
|
||||||
|
"""Return True if the phone number looks like a real number.
|
||||||
|
|
||||||
|
Rejects:
|
||||||
|
- Too short after stripping formatting (<7 digits)
|
||||||
|
- All-same digits (e.g. 0000000000, 1111111111)
|
||||||
|
- The entire digit string is a sequential run (e.g. 1234567890, 0987654321)
|
||||||
|
"""
|
||||||
|
if not phone:
|
||||||
|
return False
|
||||||
|
digits = "".join(c for c in phone if c.isdigit())
|
||||||
|
if len(digits) < 7:
|
||||||
|
return False
|
||||||
|
if len(set(digits)) == 1:
|
||||||
|
return False
|
||||||
|
# Reject only when the entire digit string is a consecutive run
|
||||||
|
ascending = "0123456789"
|
||||||
|
descending = "9876543210"
|
||||||
|
if digits in ascending or digits in descending:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def send_email(
|
async def send_email(
|
||||||
to: str, subject: str, html: str, text: str = None, from_addr: str = None
|
to: str, subject: str, html: str, text: str = None, from_addr: str = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ Supplier directory: public, searchable listing of padel court suppliers.
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, make_response, redirect, render_template, request, url_for
|
from quart import Blueprint, g, make_response, redirect, render_template, request, url_for
|
||||||
|
|
||||||
from ..core import csrf_protect, execute, fetch_all, fetch_one
|
from ..core import csrf_protect, execute, fetch_all, fetch_one
|
||||||
|
from ..i18n import get_translations
|
||||||
|
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
"directory",
|
"directory",
|
||||||
@@ -51,8 +52,20 @@ REGION_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_directory_labels(lang: str) -> tuple[dict, dict, dict]:
|
||||||
|
"""Return (category_labels, country_labels, region_labels) translated for lang."""
|
||||||
|
t = get_translations(lang)
|
||||||
|
cat = {k: t.get(f"dir_cat_{k}", v) for k, v in CATEGORY_LABELS.items()}
|
||||||
|
country = {k: t.get(f"dir_country_{k}", v) for k, v in COUNTRY_LABELS.items()}
|
||||||
|
region = {k: t.get(f"dir_region_{k.lower().replace(' ', '_')}", k) for k in REGION_LABELS}
|
||||||
|
return cat, country, region
|
||||||
|
|
||||||
|
|
||||||
async def _build_directory_query(q, country, category, region, page, per_page=24):
|
async def _build_directory_query(q, country, category, region, page, per_page=24):
|
||||||
"""Shared query builder for directory index and HTMX results."""
|
"""Shared query builder for directory index and HTMX results."""
|
||||||
|
lang = g.get("lang", "en")
|
||||||
|
cat_labels, country_labels, region_labels = get_directory_labels(lang)
|
||||||
|
|
||||||
now = datetime.now(UTC).isoformat()
|
now = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
params: list = []
|
params: list = []
|
||||||
@@ -134,8 +147,9 @@ async def _build_directory_query(q, country, category, region, page, per_page=24
|
|||||||
"total_pages": total_pages,
|
"total_pages": total_pages,
|
||||||
"total": total,
|
"total": total,
|
||||||
"now": now,
|
"now": now,
|
||||||
"country_labels": COUNTRY_LABELS,
|
"country_labels": country_labels,
|
||||||
"category_labels": CATEGORY_LABELS,
|
"category_labels": cat_labels,
|
||||||
|
"region_labels": region_labels,
|
||||||
"card_colors": card_colors,
|
"card_colors": card_colors,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,12 +224,16 @@ async def supplier_detail(slug: str):
|
|||||||
)
|
)
|
||||||
enquiry_count = row["cnt"] if row else 0
|
enquiry_count = row["cnt"] if row else 0
|
||||||
|
|
||||||
|
lang = g.get("lang", "en")
|
||||||
|
cat_labels, country_labels, region_labels = get_directory_labels(lang)
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"supplier_detail.html",
|
"supplier_detail.html",
|
||||||
supplier=supplier,
|
supplier=supplier,
|
||||||
active_boosts=active_boosts,
|
active_boosts=active_boosts,
|
||||||
country_labels=COUNTRY_LABELS,
|
country_labels=country_labels,
|
||||||
category_labels=CATEGORY_LABELS,
|
category_labels=cat_labels,
|
||||||
|
region_labels=region_labels,
|
||||||
services_list=services_list,
|
services_list=services_list,
|
||||||
social_links=social_links,
|
social_links=social_links,
|
||||||
enquiry_count=enquiry_count,
|
enquiry_count=enquiry_count,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
<meta name="description" content="Über {{ total_suppliers }}+ Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software. Den richtigen Partner für Ihr Projekt finden.">
|
<meta name="description" content="Über {{ total_suppliers }}+ Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software. Den richtigen Partner für dein Projekt finden.">
|
||||||
<meta property="og:title" content="Padel-Platz Anbieterverzeichnis - {{ config.APP_NAME }}">
|
<meta property="og:title" content="Padel-Platz Anbieterverzeichnis - {{ config.APP_NAME }}">
|
||||||
<meta property="og:description" content="Über {{ total_suppliers }}+ Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software.">
|
<meta property="og:description" content="Über {{ total_suppliers }}+ Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software.">
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -305,7 +305,7 @@
|
|||||||
<main class="container-page">
|
<main class="container-page">
|
||||||
<div class="dir-hero">
|
<div class="dir-hero">
|
||||||
<h1>{{ t.dir_heading }}</h1>
|
<h1>{{ t.dir_heading }}</h1>
|
||||||
<p>{% if lang == 'de' %}Über {{ total_suppliers }}+ Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen und Spezialisten für Ihr Projekt.{% else %}Browse {{ total_suppliers }}+ suppliers across {{ total_countries }} countries. Find manufacturers, builders, and specialists for your project.{% endif %}</p>
|
<p>{% if lang == 'de' %}Über {{ total_suppliers }}+ Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen und Spezialisten für dein Projekt.{% else %}Browse {{ total_suppliers }}+ suppliers across {{ total_countries }} countries. Find manufacturers, builders, and specialists for your project.{% endif %}</p>
|
||||||
<div class="dir-stats">
|
<div class="dir-stats">
|
||||||
<span><strong>{{ total_suppliers }}</strong> {{ t.dir_stat_suppliers }}</span>
|
<span><strong>{{ total_suppliers }}</strong> {{ t.dir_stat_suppliers }}</span>
|
||||||
<span><strong>{{ total_countries }}</strong> {{ t.dir_stat_countries }}</span>
|
<span><strong>{{ total_countries }}</strong> {{ t.dir_stat_countries }}</span>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
<p style="font-weight:700;color:#16A34A;margin-bottom:4px">{{ t.enquiry_success_title }}</p>
|
<p style="font-weight:700;color:#16A34A;margin-bottom:4px">{{ t.enquiry_success_title }}</p>
|
||||||
<p style="font-size:0.8125rem;color:#166534">
|
<p style="font-size:0.8125rem;color:#166534">
|
||||||
{% if supplier and supplier.contact_email %}
|
{% if supplier and supplier.contact_email %}
|
||||||
{% if lang == 'de' %}Ihre Nachricht wurde an {{ supplier.name }} weitergeleitet. Sie werden sich direkt bei Ihnen melden.{% else %}We've forwarded your message to {{ supplier.name }}. They'll be in touch directly.{% endif %}
|
{% if lang == 'de' %}Deine Nachricht wurde an {{ supplier.name }} weitergeleitet. Der Anbieter meldet sich direkt bei dir.{% else %}We've forwarded your message to {{ supplier.name }}. They'll be in touch directly.{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if lang == 'de' %}Ihre Nachricht wurde empfangen. Das Team wird sich in Kürze bei Ihnen melden.{% else %}Your message has been received. The team will be in touch shortly.{% endif %}
|
{% if lang == 'de' %}Deine Nachricht wurde empfangen. Das Team meldet sich in Kürze bei dir.{% else %}Your message has been received. The team will be in touch shortly.{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<p>{% if lang == 'de' %}Dein Projektfoto{% else %}Your project photo{% endif %}</p>
|
<p>{% if lang == 'de' %}Dein Projektfoto{% else %}Your project photo{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dir-card__featured" style="background:#3B82F6">{% if lang == 'de' %}Beispiel{% else %}Example{% endif %}</div>
|
<div class="dir-card__featured" style="background:#3B82F6">{% if lang == 'de' %}Beispiel{% else %}Example{% endif %}</div>
|
||||||
<div class="dir-card__cat dir-card__cat--example">{% if lang == 'de' %}Ihre Kategorie{% else %}Your Category{% endif %}</div>
|
<div class="dir-card__cat dir-card__cat--example">{% if lang == 'de' %}Deine Kategorie{% else %}Your Category{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dir-card__body">
|
<div class="dir-card__body">
|
||||||
<div class="dir-card__logo-wrap">
|
<div class="dir-card__logo-wrap">
|
||||||
|
|||||||
@@ -461,7 +461,7 @@
|
|||||||
<div class="sp-cta-strip">
|
<div class="sp-cta-strip">
|
||||||
<div class="sp-cta-strip__text">
|
<div class="sp-cta-strip__text">
|
||||||
<h3>{{ t.sp_cta_basic_h3 }}</h3>
|
<h3>{{ t.sp_cta_basic_h3 }}</h3>
|
||||||
<p>{% if lang == 'de' %}Upgraden Sie auf Growth, um in unserer Anbieter-Vermittlung zu erscheinen und qualifizierte Projekt-Leads zu erhalten.{% else %}Upgrade to Growth to appear in our supplier matching and receive qualified project leads.{% endif %}</p>
|
<p>{% if lang == 'de' %}Upgrade auf Growth, um in unserer Anbieter-Vermittlung zu erscheinen und qualifizierte Projekt-Leads zu erhalten.{% else %}Upgrade to Growth to appear in our supplier matching and receive qualified project leads.{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="{{ url_for('suppliers.signup') }}" class="sp-cta-strip__btn">
|
<a href="{{ url_for('suppliers.signup') }}" class="sp-cta-strip__btn">
|
||||||
{% if lang == 'de' %}Auf Growth upgraden →{% else %}Upgrade to Growth →{% endif %}
|
{% if lang == 'de' %}Auf Growth upgraden →{% else %}Upgrade to Growth →{% endif %}
|
||||||
|
|||||||
@@ -56,6 +56,49 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"cookie_manage": "Manage",
|
"cookie_manage": "Manage",
|
||||||
"cookie_accept_all": "Accept all",
|
"cookie_accept_all": "Accept all",
|
||||||
"cookie_close": "Close",
|
"cookie_close": "Close",
|
||||||
|
# ── Auth templates ───────────────────────────────────────────────────
|
||||||
|
"auth_login_title": "Sign In",
|
||||||
|
"auth_login_sub": "Enter your email to receive a sign-in link.",
|
||||||
|
"auth_login_email_label": "Email",
|
||||||
|
"auth_login_btn": "Send Sign-In Link",
|
||||||
|
"auth_login_no_account": "Don't have an account?",
|
||||||
|
"auth_login_signup_link": "Sign up",
|
||||||
|
"auth_signup_title": "Create Free Account",
|
||||||
|
"auth_signup_sub": "Save your padel business plan, get supplier quotes, and find financing.",
|
||||||
|
"auth_signup_hint": "No credit card required. Full access to all features.",
|
||||||
|
"auth_signup_btn": "Create Free Account",
|
||||||
|
"auth_signup_have_account": "Already have an account?",
|
||||||
|
"auth_signup_signin_link": "Sign in",
|
||||||
|
"auth_magic_title": "Check Your Email",
|
||||||
|
"auth_magic_sent_to": "We've sent a sign-in link to:",
|
||||||
|
"auth_magic_instructions": "Click the link in the email to sign in. The link expires in {minutes} minutes.",
|
||||||
|
"auth_magic_no_email": "Didn't receive the email?",
|
||||||
|
"auth_magic_check_spam": "Check your spam folder",
|
||||||
|
"auth_magic_correct_email": "Make sure the email address is correct",
|
||||||
|
"auth_magic_wait": "Wait a minute and try again",
|
||||||
|
"auth_magic_resend_btn": "Resend Link",
|
||||||
|
"auth_waitlist_title": "Be First to Launch Your Padel Business",
|
||||||
|
"auth_waitlist_sub": "We're preparing to launch the ultimate planning platform for padel entrepreneurs. Join the waitlist for early access, exclusive bonuses, and priority support.",
|
||||||
|
"auth_waitlist_hint": "You'll be among the first to get access when we launch.",
|
||||||
|
"auth_waitlist_btn": "Join Waitlist",
|
||||||
|
"auth_waitlist_confirmed_title": "You're on the Waitlist!",
|
||||||
|
"auth_waitlist_confirmed_sent_to": "We've sent a confirmation to:",
|
||||||
|
"auth_waitlist_confirmed_sub": "You'll be among the first to know when we launch. We'll send you early access, exclusive launch bonuses, and priority onboarding.",
|
||||||
|
"auth_waitlist_confirmed_next": "What happens next?",
|
||||||
|
"auth_waitlist_confirmed_step1": "You'll receive a confirmation email shortly",
|
||||||
|
"auth_waitlist_confirmed_step2": "We'll notify you as soon as we launch",
|
||||||
|
"auth_waitlist_confirmed_step3": "You'll get exclusive early access before the public launch",
|
||||||
|
"auth_waitlist_confirmed_back": "Back to Home",
|
||||||
|
"auth_flash_invalid_email": "Please enter a valid email address.",
|
||||||
|
"auth_flash_disposable_email": "Please use a permanent email address.",
|
||||||
|
"auth_flash_login_sent": "Check your email for the sign-in link!",
|
||||||
|
"auth_flash_account_exists": "Account already exists. Please sign in.",
|
||||||
|
"auth_flash_signup_sent": "Check your email to complete signup!",
|
||||||
|
"auth_flash_invalid_token": "Invalid or expired link.",
|
||||||
|
"auth_flash_invalid_token_detail": "Invalid or expired link. Please request a new one.",
|
||||||
|
"auth_flash_signed_in": "Successfully signed in!",
|
||||||
|
"auth_flash_signed_out": "You have been signed out.",
|
||||||
|
"auth_flash_resend_sent": "If that email is registered, we've sent a new link.",
|
||||||
# ── Flash messages (public-facing blueprints only) ───────────────────
|
# ── Flash messages (public-facing blueprints only) ───────────────────
|
||||||
"flash_feedback_success": "Thank you for your feedback!",
|
"flash_feedback_success": "Thank you for your feedback!",
|
||||||
"flash_feedback_empty": "Please enter a message.",
|
"flash_feedback_empty": "Please enter a message.",
|
||||||
@@ -268,6 +311,57 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"dir_empty_heading": "No suppliers found",
|
"dir_empty_heading": "No suppliers found",
|
||||||
"dir_empty_sub": "Try adjusting your search or filters.",
|
"dir_empty_sub": "Try adjusting your search or filters.",
|
||||||
"dir_empty_clear": "Clear all filters",
|
"dir_empty_clear": "Clear all filters",
|
||||||
|
# ── Directory category labels ────────────────────────────────────────
|
||||||
|
"dir_cat_manufacturer": "Manufacturer",
|
||||||
|
"dir_cat_turnkey": "Turnkey Provider",
|
||||||
|
"dir_cat_consultant": "Consultant",
|
||||||
|
"dir_cat_hall_builder": "Hall Builder",
|
||||||
|
"dir_cat_turf": "Turf / Surfaces",
|
||||||
|
"dir_cat_lighting": "Lighting",
|
||||||
|
"dir_cat_software": "Software",
|
||||||
|
"dir_cat_industry_body": "Industry Body",
|
||||||
|
"dir_cat_franchise": "Franchise / Operator",
|
||||||
|
# ── Directory region labels ──────────────────────────────────────────
|
||||||
|
"dir_region_europe": "Europe",
|
||||||
|
"dir_region_north_america": "North America",
|
||||||
|
"dir_region_latin_america": "Latin America",
|
||||||
|
"dir_region_middle_east": "Middle East",
|
||||||
|
"dir_region_asia_pacific": "Asia Pacific",
|
||||||
|
"dir_region_africa": "Africa",
|
||||||
|
# ── Directory country labels ─────────────────────────────────────────
|
||||||
|
"dir_country_DE": "Germany",
|
||||||
|
"dir_country_ES": "Spain",
|
||||||
|
"dir_country_IT": "Italy",
|
||||||
|
"dir_country_FR": "France",
|
||||||
|
"dir_country_PT": "Portugal",
|
||||||
|
"dir_country_GB": "United Kingdom",
|
||||||
|
"dir_country_NL": "Netherlands",
|
||||||
|
"dir_country_BE": "Belgium",
|
||||||
|
"dir_country_SE": "Sweden",
|
||||||
|
"dir_country_DK": "Denmark",
|
||||||
|
"dir_country_FI": "Finland",
|
||||||
|
"dir_country_NO": "Norway",
|
||||||
|
"dir_country_AT": "Austria",
|
||||||
|
"dir_country_SI": "Slovenia",
|
||||||
|
"dir_country_IS": "Iceland",
|
||||||
|
"dir_country_CH": "Switzerland",
|
||||||
|
"dir_country_EE": "Estonia",
|
||||||
|
"dir_country_US": "United States",
|
||||||
|
"dir_country_CA": "Canada",
|
||||||
|
"dir_country_MX": "Mexico",
|
||||||
|
"dir_country_BR": "Brazil",
|
||||||
|
"dir_country_AR": "Argentina",
|
||||||
|
"dir_country_AE": "UAE",
|
||||||
|
"dir_country_SA": "Saudi Arabia",
|
||||||
|
"dir_country_TR": "Turkey",
|
||||||
|
"dir_country_CN": "China",
|
||||||
|
"dir_country_IN": "India",
|
||||||
|
"dir_country_SG": "Singapore",
|
||||||
|
"dir_country_ID": "Indonesia",
|
||||||
|
"dir_country_TH": "Thailand",
|
||||||
|
"dir_country_AU": "Australia",
|
||||||
|
"dir_country_ZA": "South Africa",
|
||||||
|
"dir_country_EG": "Egypt",
|
||||||
# ── Supplier detail ──────────────────────────────────────────────────
|
# ── Supplier detail ──────────────────────────────────────────────────
|
||||||
"sp_back": "Back to Directory",
|
"sp_back": "Back to Directory",
|
||||||
"sp_verified": "Verified \u2713",
|
"sp_verified": "Verified \u2713",
|
||||||
@@ -301,6 +395,11 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"q_btn_submit": "Submit & Get Quotes \u2192",
|
"q_btn_submit": "Submit & Get Quotes \u2192",
|
||||||
"q1_heading": "Your Project",
|
"q1_heading": "Your Project",
|
||||||
"q1_subheading": "What type of padel facility are you planning?",
|
"q1_subheading": "What type of padel facility are you planning?",
|
||||||
|
"q1_prefill_sub": "Pre-filled from your planner \u2014 adjust as needed.",
|
||||||
|
"q1_edit_in_planner": "Edit in Planner",
|
||||||
|
"q6_required_hint": "Please select a financing status.",
|
||||||
|
"q6_decision_required_hint": "Please select your decision process.",
|
||||||
|
"q8_required_hint": "Please select at least one service.",
|
||||||
"q1_facility_label": "Facility Type",
|
"q1_facility_label": "Facility Type",
|
||||||
"q1_facility_indoor": "Indoor",
|
"q1_facility_indoor": "Indoor",
|
||||||
"q1_facility_outdoor": "Outdoor",
|
"q1_facility_outdoor": "Outdoor",
|
||||||
@@ -438,6 +537,18 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"mkt_all_countries": "All Countries",
|
"mkt_all_countries": "All Countries",
|
||||||
"mkt_all_regions": "All Regions",
|
"mkt_all_regions": "All Regions",
|
||||||
"mkt_no_results": "No markets found. Try adjusting your filters.",
|
"mkt_no_results": "No markets found. Try adjusting your filters.",
|
||||||
|
"waitlist_markets_title": "Markets Intelligence — Coming Soon",
|
||||||
|
"waitlist_markets_sub": "Deep-dive market reports for padel investors: construction costs, revenue benchmarks, occupancy data, and ROI analysis by city and region.",
|
||||||
|
"waitlist_markets_feature1": "Real cost data from operating venues across 30+ countries",
|
||||||
|
"waitlist_markets_feature2": "Revenue benchmarks and utilization rates by market maturity",
|
||||||
|
"waitlist_markets_feature3": "Investment return profiles and comparable transactions",
|
||||||
|
"waitlist_markets_email_label": "Your email",
|
||||||
|
"waitlist_markets_btn": "Get early access",
|
||||||
|
"waitlist_markets_hint": "Be the first to know when markets data launches.",
|
||||||
|
"waitlist_markets_confirmed_title": "You're on the list",
|
||||||
|
"waitlist_markets_confirmed_body": "We'll notify you as soon as markets intelligence is available.",
|
||||||
|
"waitlist_markets_have_account": "Already have an account?",
|
||||||
|
"waitlist_markets_signin_link": "Sign in",
|
||||||
# ── Article detail ───────────────────────────────────────────────────
|
# ── Article detail ───────────────────────────────────────────────────
|
||||||
"art_run_numbers_h2": "Run Your Own Numbers",
|
"art_run_numbers_h2": "Run Your Own Numbers",
|
||||||
"art_run_numbers_text": "Use our free financial planner to model a padel center with your own assumptions.",
|
"art_run_numbers_text": "Use our free financial planner to model a padel center with your own assumptions.",
|
||||||
@@ -595,6 +706,16 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"tip_hold_years": "Investment holding period before exit/sale. Typical for PE/investors: 5\u20137 years. Owner-operators may hold indefinitely.",
|
"tip_hold_years": "Investment holding period before exit/sale. Typical for PE/investors: 5\u20137 years. Owner-operators may hold indefinitely.",
|
||||||
"tip_exit_multiple": "EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 4\u20136\u00d7, strong brand: 6\u20138\u00d7.",
|
"tip_exit_multiple": "EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 4\u20136\u00d7, strong brand: 6\u20138\u00d7.",
|
||||||
"tip_annual_rev_growth": "Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.",
|
"tip_annual_rev_growth": "Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.",
|
||||||
|
"tip_result_irr": "Internal Rate of Return over your hold period — the annualised discount rate that makes NPV of all cash flows zero. Target: 20%+. N/A if cash flows never turn positive.",
|
||||||
|
"tip_result_moic": "Multiple on Invested Capital: total money returned ÷ total invested. Includes operating cash flows plus exit proceeds. 2.0x means doubling your money.",
|
||||||
|
"tip_result_break_even": "Minimum utilisation needed for revenues to cover all costs including debt service. Below 35% means a comfortable safety margin.",
|
||||||
|
"tip_result_coc": "Cash-on-Cash: Year 3 net cash flow ÷ equity invested. Measures annual yield on your equity in a stabilised operating year.",
|
||||||
|
"tip_result_revpah": "Revenue per Available Hour — net revenue ÷ all available court-hours. Key industry KPI; higher values indicate better pricing or fill rate.",
|
||||||
|
"tip_result_payback": "Month when cumulative cash flows (after initial investment) turn positive. Earlier payback = less capital at risk.",
|
||||||
|
"tip_result_ebitda_mo": "EBITDA: Earnings Before Interest, Tax, Depreciation & Amortisation. Operating cash surplus before financing costs.",
|
||||||
|
"tip_result_dscr": "Debt Service Coverage Ratio: EBITDA ÷ annual debt payments (principal + interest). Banks typically require at least 1.2x to approve a loan.",
|
||||||
|
"tip_result_debt_yield": "Stabilised EBITDA ÷ loan amount. Lenders use this to check the asset can service its debt independently of market valuations. 10%+ is healthy.",
|
||||||
|
"tip_result_yield_on_cost": "Stabilised EBITDA ÷ total CAPEX. Unlevered return on investment — useful for comparing against other asset classes or development projects.",
|
||||||
"btn_save": "Save",
|
"btn_save": "Save",
|
||||||
"btn_my_scenarios": "My Scenarios",
|
"btn_my_scenarios": "My Scenarios",
|
||||||
"btn_reset": "Reset to Defaults",
|
"btn_reset": "Reset to Defaults",
|
||||||
@@ -742,6 +863,49 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"cookie_manage": "Verwalten",
|
"cookie_manage": "Verwalten",
|
||||||
"cookie_accept_all": "Alle akzeptieren",
|
"cookie_accept_all": "Alle akzeptieren",
|
||||||
"cookie_close": "Schlie\u00dfen",
|
"cookie_close": "Schlie\u00dfen",
|
||||||
|
# ── Auth templates ───────────────────────────────────────────────────
|
||||||
|
"auth_login_title": "Anmelden",
|
||||||
|
"auth_login_sub": "Gib deine E-Mail-Adresse ein, um einen Anmeldelink zu erhalten.",
|
||||||
|
"auth_login_email_label": "E-Mail",
|
||||||
|
"auth_login_btn": "Anmeldelink senden",
|
||||||
|
"auth_login_no_account": "Noch kein Konto?",
|
||||||
|
"auth_login_signup_link": "Registrieren",
|
||||||
|
"auth_signup_title": "Kostenloses Konto erstellen",
|
||||||
|
"auth_signup_sub": "Speichere deinen Padel-Businessplan, hole Anbieterangebote ein und finde Finanzierung.",
|
||||||
|
"auth_signup_hint": "Keine Kreditkarte erforderlich. Voller Zugang zu allen Funktionen.",
|
||||||
|
"auth_signup_btn": "Kostenloses Konto erstellen",
|
||||||
|
"auth_signup_have_account": "Bereits ein Konto?",
|
||||||
|
"auth_signup_signin_link": "Anmelden",
|
||||||
|
"auth_magic_title": "E-Mail pr\u00fcfen",
|
||||||
|
"auth_magic_sent_to": "Wir haben dir einen Anmeldelink geschickt an:",
|
||||||
|
"auth_magic_instructions": "Klick auf den Link in der E-Mail, um dich anzumelden. Der Link l\u00e4uft in {minutes} Minuten ab.",
|
||||||
|
"auth_magic_no_email": "Keine E-Mail erhalten?",
|
||||||
|
"auth_magic_check_spam": "Schau in deinen Spam-Ordner",
|
||||||
|
"auth_magic_correct_email": "Stelle sicher, dass die E-Mail-Adresse korrekt ist",
|
||||||
|
"auth_magic_wait": "Warte eine Minute und versuche es erneut",
|
||||||
|
"auth_magic_resend_btn": "Link erneut senden",
|
||||||
|
"auth_waitlist_title": "Sei Erster beim Start deines Padel-Business",
|
||||||
|
"auth_waitlist_sub": "Wir bereiten die ultimative Planungsplattform f\u00fcr Padel-Unternehmer vor. Trag dich in die Warteliste ein f\u00fcr Fr\u00fchzugang, exklusive Boni und priorisierten Support.",
|
||||||
|
"auth_waitlist_hint": "Du geh\u00f6rst zu den Ersten, die Zugang erhalten, wenn wir launchen.",
|
||||||
|
"auth_waitlist_btn": "In Warteliste eintragen",
|
||||||
|
"auth_waitlist_confirmed_title": "Du stehst auf der Warteliste!",
|
||||||
|
"auth_waitlist_confirmed_sent_to": "Wir haben dir eine Best\u00e4tigung geschickt an:",
|
||||||
|
"auth_waitlist_confirmed_sub": "Du geh\u00f6rst zu den Ersten, die es wissen, wenn wir launchen. Wir schicken dir Fr\u00fchzugang, exklusive Launch-Boni und prioriertes Onboarding.",
|
||||||
|
"auth_waitlist_confirmed_next": "Was passiert als N\u00e4chstes?",
|
||||||
|
"auth_waitlist_confirmed_step1": "Du erh\u00e4ltst in K\u00fcrze eine Best\u00e4tigungs-E-Mail",
|
||||||
|
"auth_waitlist_confirmed_step2": "Wir benachrichtigen dich, sobald wir launchen",
|
||||||
|
"auth_waitlist_confirmed_step3": "Du erh\u00e4ltst exklusiven Fr\u00fchzugang vor dem \u00f6ffentlichen Launch",
|
||||||
|
"auth_waitlist_confirmed_back": "Zur\u00fcck zur Startseite",
|
||||||
|
"auth_flash_invalid_email": "Bitte gib eine g\u00fcltige E-Mail-Adresse ein.",
|
||||||
|
"auth_flash_disposable_email": "Bitte verwende eine dauerhafte E-Mail-Adresse.",
|
||||||
|
"auth_flash_login_sent": "Schau in deine E-Mails f\u00fcr den Anmeldelink!",
|
||||||
|
"auth_flash_account_exists": "Konto existiert bereits. Bitte melde dich an.",
|
||||||
|
"auth_flash_signup_sent": "Schau in deine E-Mails, um die Registrierung abzuschlie\u00dfen!",
|
||||||
|
"auth_flash_invalid_token": "Ung\u00fcltiger oder abgelaufener Link.",
|
||||||
|
"auth_flash_invalid_token_detail": "Ung\u00fcltiger oder abgelaufener Link. Bitte fordere einen neuen an.",
|
||||||
|
"auth_flash_signed_in": "Erfolgreich angemeldet!",
|
||||||
|
"auth_flash_signed_out": "Du wurdest abgemeldet.",
|
||||||
|
"auth_flash_resend_sent": "Wenn diese E-Mail-Adresse registriert ist, haben wir einen neuen Link gesendet.",
|
||||||
# ── Flash messages ───────────────────────────────────────────────────
|
# ── Flash messages ───────────────────────────────────────────────────
|
||||||
"flash_feedback_success": "Vielen Dank f\u00fcr dein Feedback!",
|
"flash_feedback_success": "Vielen Dank f\u00fcr dein Feedback!",
|
||||||
"flash_feedback_empty": "Bitte gib eine Nachricht ein.",
|
"flash_feedback_empty": "Bitte gib eine Nachricht ein.",
|
||||||
@@ -954,6 +1118,57 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"dir_empty_heading": "Keine Anbieter gefunden",
|
"dir_empty_heading": "Keine Anbieter gefunden",
|
||||||
"dir_empty_sub": "Passe Deine Suche oder Filter an.",
|
"dir_empty_sub": "Passe Deine Suche oder Filter an.",
|
||||||
"dir_empty_clear": "Alle Filter zur\u00fccksetzen",
|
"dir_empty_clear": "Alle Filter zur\u00fccksetzen",
|
||||||
|
# ── Directory category labels ────────────────────────────────────────
|
||||||
|
"dir_cat_manufacturer": "Hersteller",
|
||||||
|
"dir_cat_turnkey": "Generalunternehmer",
|
||||||
|
"dir_cat_consultant": "Berater",
|
||||||
|
"dir_cat_hall_builder": "Hallenbauer",
|
||||||
|
"dir_cat_turf": "Belag / Oberfl\u00e4chen",
|
||||||
|
"dir_cat_lighting": "Beleuchtung",
|
||||||
|
"dir_cat_software": "Software",
|
||||||
|
"dir_cat_industry_body": "Branchenverband",
|
||||||
|
"dir_cat_franchise": "Franchise / Betreiber",
|
||||||
|
# ── Directory region labels ──────────────────────────────────────────
|
||||||
|
"dir_region_europe": "Europa",
|
||||||
|
"dir_region_north_america": "Nordamerika",
|
||||||
|
"dir_region_latin_america": "Lateinamerika",
|
||||||
|
"dir_region_middle_east": "Naher Osten",
|
||||||
|
"dir_region_asia_pacific": "Asien-Pazifik",
|
||||||
|
"dir_region_africa": "Afrika",
|
||||||
|
# ── Directory country labels ─────────────────────────────────────────
|
||||||
|
"dir_country_DE": "Deutschland",
|
||||||
|
"dir_country_ES": "Spanien",
|
||||||
|
"dir_country_IT": "Italien",
|
||||||
|
"dir_country_FR": "Frankreich",
|
||||||
|
"dir_country_PT": "Portugal",
|
||||||
|
"dir_country_GB": "Vereinigtes K\u00f6nigreich",
|
||||||
|
"dir_country_NL": "Niederlande",
|
||||||
|
"dir_country_BE": "Belgien",
|
||||||
|
"dir_country_SE": "Schweden",
|
||||||
|
"dir_country_DK": "D\u00e4nemark",
|
||||||
|
"dir_country_FI": "Finnland",
|
||||||
|
"dir_country_NO": "Norwegen",
|
||||||
|
"dir_country_AT": "\u00d6sterreich",
|
||||||
|
"dir_country_SI": "Slowenien",
|
||||||
|
"dir_country_IS": "Island",
|
||||||
|
"dir_country_CH": "Schweiz",
|
||||||
|
"dir_country_EE": "Estland",
|
||||||
|
"dir_country_US": "Vereinigte Staaten",
|
||||||
|
"dir_country_CA": "Kanada",
|
||||||
|
"dir_country_MX": "Mexiko",
|
||||||
|
"dir_country_BR": "Brasilien",
|
||||||
|
"dir_country_AR": "Argentinien",
|
||||||
|
"dir_country_AE": "VAE",
|
||||||
|
"dir_country_SA": "Saudi-Arabien",
|
||||||
|
"dir_country_TR": "T\u00fcrkei",
|
||||||
|
"dir_country_CN": "China",
|
||||||
|
"dir_country_IN": "Indien",
|
||||||
|
"dir_country_SG": "Singapur",
|
||||||
|
"dir_country_ID": "Indonesien",
|
||||||
|
"dir_country_TH": "Thailand",
|
||||||
|
"dir_country_AU": "Australien",
|
||||||
|
"dir_country_ZA": "S\u00fcdafrika",
|
||||||
|
"dir_country_EG": "\u00c4gypten",
|
||||||
# ── Supplier detail ──────────────────────────────────────────────────
|
# ── Supplier detail ──────────────────────────────────────────────────
|
||||||
"sp_back": "Zur\u00fcck zum Verzeichnis",
|
"sp_back": "Zur\u00fcck zum Verzeichnis",
|
||||||
"sp_verified": "Verifiziert \u2713",
|
"sp_verified": "Verifiziert \u2713",
|
||||||
@@ -987,6 +1202,11 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"q_btn_submit": "Absenden & Angebote erhalten \u2192",
|
"q_btn_submit": "Absenden & Angebote erhalten \u2192",
|
||||||
"q1_heading": "Dein Projekt",
|
"q1_heading": "Dein Projekt",
|
||||||
"q1_subheading": "Welche Art von Padel-Anlage planst Du?",
|
"q1_subheading": "Welche Art von Padel-Anlage planst Du?",
|
||||||
|
"q1_prefill_sub": "Aus dem Planer vorausgef\u00fcllt \u2014 passe die Angaben nach Bedarf an.",
|
||||||
|
"q1_edit_in_planner": "Im Planer bearbeiten",
|
||||||
|
"q6_required_hint": "Bitte w\u00e4hle einen Finanzierungsstatus.",
|
||||||
|
"q6_decision_required_hint": "Bitte w\u00e4hle deinen Entscheidungsprozess.",
|
||||||
|
"q8_required_hint": "Bitte w\u00e4hle mindestens eine Leistung.",
|
||||||
"q1_facility_label": "Anlagentyp",
|
"q1_facility_label": "Anlagentyp",
|
||||||
"q1_facility_indoor": "Indoor",
|
"q1_facility_indoor": "Indoor",
|
||||||
"q1_facility_outdoor": "Outdoor",
|
"q1_facility_outdoor": "Outdoor",
|
||||||
@@ -1011,7 +1231,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"q3_subheading": "Was beschreibt Dein Projekt am besten?",
|
"q3_subheading": "Was beschreibt Dein Projekt am besten?",
|
||||||
"q3_context_label": "Projektsituation",
|
"q3_context_label": "Projektsituation",
|
||||||
"q3_context_new": "Neues eigenst\u00e4ndiges Geb\u00e4ude",
|
"q3_context_new": "Neues eigenst\u00e4ndiges Geb\u00e4ude",
|
||||||
"q3_context_adding": "Erweiterung eines bestehenden Clubs",
|
"q3_context_adding": "Erweiterung einer bestehenden Halle / eines Tennisclubs",
|
||||||
"q3_context_converting": "Umbau eines Geb\u00e4udes",
|
"q3_context_converting": "Umbau eines Geb\u00e4udes",
|
||||||
"q3_context_venue_search": "Hilfe bei der Standortsuche",
|
"q3_context_venue_search": "Hilfe bei der Standortsuche",
|
||||||
"q4_heading": "Projektphase",
|
"q4_heading": "Projektphase",
|
||||||
@@ -1031,7 +1251,7 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"q5_timeline_3_6": "3\u20136 Monate",
|
"q5_timeline_3_6": "3\u20136 Monate",
|
||||||
"q5_timeline_6_12": "6\u201312 Monate",
|
"q5_timeline_6_12": "6\u201312 Monate",
|
||||||
"q5_timeline_12_plus": "12+ Monate",
|
"q5_timeline_12_plus": "12+ Monate",
|
||||||
"q5_budget_label": "Budgetsch\u00e4tzung (\u20ac)",
|
"q5_budget_label": "Budget (\u20ac)",
|
||||||
"q6_heading": "Finanzierung",
|
"q6_heading": "Finanzierung",
|
||||||
"q6_subheading": "Wie finanzierst Du das Projekt?",
|
"q6_subheading": "Wie finanzierst Du das Projekt?",
|
||||||
"q6_status_label": "Finanzierungsstatus",
|
"q6_status_label": "Finanzierungsstatus",
|
||||||
@@ -1123,7 +1343,19 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"mkt_search_placeholder": "M\u00e4rkte suchen\u2026",
|
"mkt_search_placeholder": "M\u00e4rkte suchen\u2026",
|
||||||
"mkt_all_countries": "Alle L\u00e4nder",
|
"mkt_all_countries": "Alle L\u00e4nder",
|
||||||
"mkt_all_regions": "Alle Regionen",
|
"mkt_all_regions": "Alle Regionen",
|
||||||
"mkt_no_results": "Keine M\u00e4rkte gefunden. Passe Deine Filter an.",
|
"mkt_no_results": "Keine Märkte gefunden. Passe Deine Filter an.",
|
||||||
|
"waitlist_markets_title": "Marktdaten — Demnächst verfügbar",
|
||||||
|
"waitlist_markets_sub": "Detaillierte Marktberichte für Padel-Investoren: Baukosten, Umsatz-Benchmarks, Auslastungsdaten und ROI-Analysen nach Stadt und Region.",
|
||||||
|
"waitlist_markets_feature1": "Echte Kostendaten aus laufenden Anlagen in über 30 Ländern",
|
||||||
|
"waitlist_markets_feature2": "Umsatz-Benchmarks und Auslastungsquoten nach Marktreife",
|
||||||
|
"waitlist_markets_feature3": "Renditeprofile und Vergleichstransaktionen",
|
||||||
|
"waitlist_markets_email_label": "Deine E-Mail-Adresse",
|
||||||
|
"waitlist_markets_btn": "Frühen Zugang sichern",
|
||||||
|
"waitlist_markets_hint": "Als Erstes informiert werden, wenn die Marktdaten verfügbar sind.",
|
||||||
|
"waitlist_markets_confirmed_title": "Du stehst auf der Liste",
|
||||||
|
"waitlist_markets_confirmed_body": "Wir benachrichtigen Dich, sobald die Marktdaten verfügbar sind.",
|
||||||
|
"waitlist_markets_have_account": "Bereits ein Konto?",
|
||||||
|
"waitlist_markets_signin_link": "Anmelden",
|
||||||
# ── Article detail ───────────────────────────────────────────────────
|
# ── Article detail ───────────────────────────────────────────────────
|
||||||
"art_run_numbers_h2": "Eigene Zahlen berechnen",
|
"art_run_numbers_h2": "Eigene Zahlen berechnen",
|
||||||
"art_run_numbers_text": "Nutze unseren kostenlosen Finanzplaner, um ein Padel-Center mit Deinen eigenen Annahmen zu modellieren.",
|
"art_run_numbers_text": "Nutze unseren kostenlosen Finanzplaner, um ein Padel-Center mit Deinen eigenen Annahmen zu modellieren.",
|
||||||
@@ -1280,7 +1512,17 @@ _TRANSLATIONS: dict[str, dict[str, str]] = {
|
|||||||
"tip_construction_months": "Monate Bau/Einrichtung vor der Er\u00f6ffnung. Kosten laufen bereits auf (Zinsen, Miete), aber noch kein Umsatz.",
|
"tip_construction_months": "Monate Bau/Einrichtung vor der Er\u00f6ffnung. Kosten laufen bereits auf (Zinsen, Miete), aber noch kein Umsatz.",
|
||||||
"tip_hold_years": "Investitionshaltedauer bis zum Exit/Verkauf. Typisch f\u00fcr PE/Investoren: 5\u20137 Jahre. Betreiber-Eigent\u00fcmer k\u00f6nnen unbegrenzt halten.",
|
"tip_hold_years": "Investitionshaltedauer bis zum Exit/Verkauf. Typisch f\u00fcr PE/Investoren: 5\u20137 Jahre. Betreiber-Eigent\u00fcmer k\u00f6nnen unbegrenzt halten.",
|
||||||
"tip_exit_multiple": "EBITDA-Multiplikator zur Unternehmensbewertung beim Exit. Spiegelt Marktnachfrage, Markenst\u00e4rke und Wachstumspotenzial wider. Kleines Business: 4\u20136\u00d7, starke Marke: 6\u20138\u00d7.",
|
"tip_exit_multiple": "EBITDA-Multiplikator zur Unternehmensbewertung beim Exit. Spiegelt Marktnachfrage, Markenst\u00e4rke und Wachstumspotenzial wider. Kleines Business: 4\u20136\u00d7, starke Marke: 6\u20138\u00d7.",
|
||||||
"tip_annual_rev_growth": "Erwartetes j\u00e4hrliches Umsatzwachstum nach der ersten 12-monatigen Anlaufphase. Getrieben durch Preiserh\u00f6hungen und steigende Auslastung.",
|
"tip_annual_rev_growth": "Erwartetes jährliches Umsatzwachstum nach der ersten 12-monatigen Anlaufphase. Getrieben durch Preiserhöhungen und steigende Auslastung.",
|
||||||
|
"tip_result_irr": "Interner Zinsfuß über den Haltezeitraum — der annualisierte Diskontsatz, bei dem der Barwert aller Cashflows null ergibt. Ziel: über 20 %. N/A wenn Cashflows nie positiv werden.",
|
||||||
|
"tip_result_moic": "Multiple on Invested Capital: gesamte Rückflüsse ÷ investiertes Kapital. Beinhaltet laufende Cashflows und Exit-Erlös. 2,0x bedeutet, das eingesetzte Kapital zu verdoppeln.",
|
||||||
|
"tip_result_break_even": "Mindestauslastung, bei der Einnahmen alle Kosten einschließlich Schuldendienst decken. Unter 35 % signalisiert einen komfortablen Sicherheitspuffer.",
|
||||||
|
"tip_result_coc": "Cash-on-Cash: Netto-Cashflow im Jahr 3 ÷ eingesetztes Eigenkapital. Misst die jährliche Rendite auf das Eigenkapital in einem stabilisierten Betriebsjahr.",
|
||||||
|
"tip_result_revpah": "Umsatz pro verfügbarer Stunde — Nettoumsatz ÷ alle verfügbaren Court-Stunden. Wichtiger Branchenkennwert; höhere Werte deuten auf bessere Preisgestaltung oder Auslastung hin.",
|
||||||
|
"tip_result_payback": "Monat, in dem die kumulierten Cashflows (nach der Anfangsinvestition) positiv werden. Früherer Payback = weniger gebundenes Kapital.",
|
||||||
|
"tip_result_ebitda_mo": "EBITDA: Ergebnis vor Zinsen, Steuern, Abschreibungen. Operativer Cash-Überschuss vor Finanzierungskosten.",
|
||||||
|
"tip_result_dscr": "Debt Service Coverage Ratio: EBITDA ÷ jährliche Schuldenzahlungen (Tilgung + Zinsen). Banken fordern in der Regel mindestens 1,2x für eine Kreditgenehmigung.",
|
||||||
|
"tip_result_debt_yield": "Stabilisiertes EBITDA ÷ Darlehensbetrag. Zeigt Kreditgebern, ob das Objekt unabhängig von Marktbewertungen seinen Schuldendienst leisten kann. Über 10 % ist gesund.",
|
||||||
|
"tip_result_yield_on_cost": "Stabilisiertes EBITDA ÷ Gesamtinvestition (CAPEX). Ungehebelte Rendite — nützlich zum Vergleich mit anderen Anlageklassen oder Bauprojekten.",
|
||||||
"btn_save": "Speichern",
|
"btn_save": "Speichern",
|
||||||
"btn_my_scenarios": "Meine Szenarien",
|
"btn_my_scenarios": "Meine Szenarien",
|
||||||
"btn_reset": "Zur\u00fccksetzen",
|
"btn_reset": "Zur\u00fccksetzen",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from ..auth.routes import (
|
|||||||
mark_token_used,
|
mark_token_used,
|
||||||
update_user,
|
update_user,
|
||||||
)
|
)
|
||||||
from ..core import config, csrf_protect, execute, fetch_one, send_email
|
from ..core import config, csrf_protect, execute, fetch_one, is_disposable_email, is_plausible_phone, send_email
|
||||||
from ..i18n import get_translations
|
from ..i18n import get_translations
|
||||||
|
|
||||||
bp = Blueprint(
|
bp = Blueprint(
|
||||||
@@ -172,9 +172,9 @@ def _get_quote_steps(lang: str) -> list:
|
|||||||
{"n": 3, "title": t["q3_heading"], "required": []},
|
{"n": 3, "title": t["q3_heading"], "required": []},
|
||||||
{"n": 4, "title": t["q4_heading"], "required": []},
|
{"n": 4, "title": t["q4_heading"], "required": []},
|
||||||
{"n": 5, "title": t["q5_heading"], "required": ["timeline"]},
|
{"n": 5, "title": t["q5_heading"], "required": ["timeline"]},
|
||||||
{"n": 6, "title": t["q6_heading"], "required": []},
|
{"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]},
|
||||||
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},
|
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},
|
||||||
{"n": 8, "title": t["q8_heading"], "required": []},
|
{"n": 8, "title": t["q8_heading"], "required": ["services_needed"]},
|
||||||
{"n": 9, "title": t["q9_heading"], "required": ["contact_name", "contact_email", "contact_phone"]},
|
{"n": 9, "title": t["q9_heading"], "required": ["contact_name", "contact_email", "contact_phone"]},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -279,6 +279,12 @@ async def quote_request():
|
|||||||
errors.append("Email is required")
|
errors.append("Email is required")
|
||||||
if not form.get("contact_phone", "").strip():
|
if not form.get("contact_phone", "").strip():
|
||||||
errors.append("Phone number is required")
|
errors.append("Phone number is required")
|
||||||
|
contact_email_raw = form.get("contact_email", "").strip()
|
||||||
|
if contact_email_raw and is_disposable_email(contact_email_raw):
|
||||||
|
errors.append("Please use a permanent email address, not a temporary one.")
|
||||||
|
contact_phone_raw = form.get("contact_phone", "").strip()
|
||||||
|
if contact_phone_raw and not is_plausible_phone(contact_phone_raw):
|
||||||
|
errors.append("Please enter a valid phone number.")
|
||||||
if errors:
|
if errors:
|
||||||
if is_json:
|
if is_json:
|
||||||
return jsonify({"ok": False, "errors": errors}), 422
|
return jsonify({"ok": False, "errors": errors}), 422
|
||||||
@@ -406,6 +412,7 @@ async def quote_request():
|
|||||||
"email": contact_email,
|
"email": contact_email,
|
||||||
"token": token,
|
"token": token,
|
||||||
"lead_id": lead_id,
|
"lead_id": lead_id,
|
||||||
|
"lang": g.get("lang", "en"),
|
||||||
"contact_name": form.get("contact_name", ""),
|
"contact_name": form.get("contact_name", ""),
|
||||||
"facility_type": form.get("facility_type", ""),
|
"facility_type": form.get("facility_type", ""),
|
||||||
"court_count": form.get("court_count", ""),
|
"court_count": form.get("court_count", ""),
|
||||||
|
|||||||
@@ -1,44 +1,23 @@
|
|||||||
{# Step 1: Your Project #}
|
{# Step 1: Your Project #}
|
||||||
{% if data.get('facility_type') %}
|
|
||||||
{# Pre-filled from planner — show read-only summary #}
|
|
||||||
<h2 class="q-step-title">{{ t.q1_heading }}</h2>
|
|
||||||
<p class="q-step-sub">{% if lang == 'de' %}Aus dem Planer vorausgefüllt. Diese Angaben können Sie im Planer bearbeiten.{% else %}Pre-filled from the planner. You can edit these in the planner.{% endif %}</p>
|
|
||||||
|
|
||||||
<div class="q-prefill-card">
|
|
||||||
<dl style="display:grid;grid-template-columns:1fr 1fr;gap:2px 1rem;margin:0">
|
|
||||||
<dt>Facility</dt><dd>{{ data.facility_type | replace('_',' ') | title }}</dd>
|
|
||||||
{% if data.get('court_count') %}<dt>Courts</dt><dd>{{ data.court_count }}</dd>{% endif %}
|
|
||||||
{% if data.get('glass_type') %}<dt>Glass</dt><dd>{{ data.glass_type | replace('_',' ') | title }}</dd>{% endif %}
|
|
||||||
{% if data.get('lighting_type') %}<dt>Lighting</dt><dd>{{ data.lighting_type | replace('_',' ') | title }}</dd>{% endif %}
|
|
||||||
{% if data.get('budget_estimate') %}<dt>Budget</dt><dd>€{{ data.budget_estimate }}</dd>{% endif %}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form hx-post="{{ url_for('leads.quote_step', step=1) }}"
|
|
||||||
hx-target="#quote-step" hx-swap="innerHTML">
|
|
||||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<input type="hidden" name="facility_type" value="{{ data.facility_type }}">
|
|
||||||
|
|
||||||
<div class="q-nav">
|
|
||||||
<div></div>
|
|
||||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
{# Direct visit — show full form #}
|
|
||||||
<form hx-post="{{ url_for('leads.quote_step', step=1) }}"
|
<form hx-post="{{ url_for('leads.quote_step', step=1) }}"
|
||||||
hx-target="#quote-step" hx-swap="innerHTML">
|
hx-target="#quote-step" hx-swap="innerHTML">
|
||||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
<h2 class="q-step-title">{{ t.q1_heading }}</h2>
|
<h2 class="q-step-title">{{ t.q1_heading }}</h2>
|
||||||
|
{% if data.get('facility_type') %}
|
||||||
|
<p class="q-step-sub">
|
||||||
|
{{ t.q1_prefill_sub }}
|
||||||
|
<a href="{{ url_for('planner.index') }}" target="_blank" rel="noopener"
|
||||||
|
style="color:#1D4ED8;font-weight:500;white-space:nowrap">{{ t.q1_edit_in_planner }} ↗</a>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
<p class="q-step-sub">{{ t.q1_subheading }}</p>
|
<p class="q-step-sub">{{ t.q1_subheading }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="q-field-group">
|
<div class="q-field-group">
|
||||||
<span class="q-label">{{ t.q1_facility_label }} <span class="required">*</span></span>
|
<span class="q-label">{{ t.q1_facility_label }} <span class="required">*</span></span>
|
||||||
{% if 'facility_type' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wählen Sie einen Anlagentyp{% else %}Please select a facility type{% endif %}</p>{% endif %}
|
{% if 'facility_type' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wähle einen Anlagentyp{% else %}Please select a facility type{% endif %}</p>{% endif %}
|
||||||
<div class="q-pills">
|
<div class="q-pills">
|
||||||
{% for val, label in [('indoor', t.q1_facility_indoor), ('outdoor', t.q1_facility_outdoor), ('both', t.q1_facility_both)] %}
|
{% for val, label in [('indoor', t.q1_facility_indoor), ('outdoor', t.q1_facility_outdoor), ('both', t.q1_facility_both)] %}
|
||||||
<label><input type="radio" name="facility_type" value="{{ val }}" {{ 'checked' if data.get('facility_type') == val }}><span class="q-pill">{{ label }}</span></label>
|
<label><input type="radio" name="facility_type" value="{{ val }}" {{ 'checked' if data.get('facility_type') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
@@ -74,7 +53,6 @@
|
|||||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
<div class="q-progress__meta">
|
<div class="q-progress__meta">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<div class="q-field-group">
|
<div class="q-field-group">
|
||||||
<label class="q-label" for="country">{{ t.q2_country_label }} <span class="required">*</span></label>
|
<label class="q-label" for="country">{{ t.q2_country_label }} <span class="required">*</span></label>
|
||||||
{% if 'country' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wählen Sie ein Land{% else %}Please select a country{% endif %}</p>{% endif %}
|
{% if 'country' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wähle ein Land{% else %}Please select a country{% endif %}</p>{% endif %}
|
||||||
<select id="country" name="country" class="q-input {% if 'country' in errors %}q-input--error{% endif %}">
|
<select id="country" name="country" class="q-input {% if 'country' in errors %}q-input--error{% endif %}">
|
||||||
<option value="">{{ t.q2_country_default }}</option>
|
<option value="">{{ t.q2_country_default }}</option>
|
||||||
{% for code, name in [('DE', 'Germany'), ('ES', 'Spain'), ('IT', 'Italy'), ('FR', 'France'), ('NL', 'Netherlands'), ('SE', 'Sweden'), ('UK', 'United Kingdom'), ('PT', 'Portugal'), ('BE', 'Belgium'), ('AT', 'Austria'), ('CH', 'Switzerland'), ('DK', 'Denmark'), ('FI', 'Finland'), ('NO', 'Norway'), ('PL', 'Poland'), ('CZ', 'Czech Republic'), ('AE', 'UAE'), ('SA', 'Saudi Arabia'), ('US', 'United States'), ('OTHER', 'Other')] %}
|
{% for code, name in [('DE', 'Germany'), ('ES', 'Spain'), ('IT', 'Italy'), ('FR', 'France'), ('NL', 'Netherlands'), ('SE', 'Sweden'), ('UK', 'United Kingdom'), ('PT', 'Portugal'), ('BE', 'Belgium'), ('AT', 'Austria'), ('CH', 'Switzerland'), ('DK', 'Denmark'), ('FI', 'Finland'), ('NO', 'Norway'), ('PL', 'Poland'), ('CZ', 'Czech Republic'), ('AE', 'UAE'), ('SA', 'Saudi Arabia'), ('US', 'United States'), ('OTHER', 'Other')] %}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<div class="q-field-group">
|
<div class="q-field-group">
|
||||||
<span class="q-label">{{ t.q5_timeline_label }} <span class="required">*</span></span>
|
<span class="q-label">{{ t.q5_timeline_label }} <span class="required">*</span></span>
|
||||||
{% if 'timeline' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wählen Sie einen Zeitplan{% else %}Please select a timeline{% endif %}</p>{% endif %}
|
{% if 'timeline' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wähle einen Zeitplan{% else %}Please select a timeline{% endif %}</p>{% endif %}
|
||||||
<div class="q-pills">
|
<div class="q-pills">
|
||||||
{% for val, label in [('asap', t.q5_timeline_asap), ('3-6mo', t.q5_timeline_3_6), ('6-12mo', t.q5_timeline_6_12), ('12+mo', t.q5_timeline_12_plus)] %}
|
{% for val, label in [('asap', t.q5_timeline_asap), ('3-6mo', t.q5_timeline_3_6), ('6-12mo', t.q5_timeline_6_12), ('12+mo', t.q5_timeline_12_plus)] %}
|
||||||
<label><input type="radio" name="timeline" value="{{ val }}" {{ 'checked' if data.get('timeline') == val }}><span class="q-pill">{{ label }}</span></label>
|
<label><input type="radio" name="timeline" value="{{ val }}" {{ 'checked' if data.get('timeline') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<div class="q-field-group">
|
<div class="q-field-group">
|
||||||
<label class="q-label" for="budget_estimate">{{ t.q5_budget_label }}</label>
|
<label class="q-label" for="budget_estimate">{{ t.q5_budget_label }}</label>
|
||||||
<input type="number" id="budget_estimate" name="budget_estimate" class="q-input" placeholder="e.g. 500000" value="{{ data.get('budget_estimate', '') }}">
|
<input type="number" id="budget_estimate" name="budget_estimate" class="q-input" placeholder="e.g. 500000" min="0" step="10000" value="{{ data.get('budget_estimate', '') }}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="q-nav">
|
<div class="q-nav">
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
<p class="q-step-sub">{{ t.q6_subheading }}</p>
|
<p class="q-step-sub">{{ t.q6_subheading }}</p>
|
||||||
|
|
||||||
<div class="q-field-group">
|
<div class="q-field-group">
|
||||||
<span class="q-label">{{ t.q6_status_label }}</span>
|
<span class="q-label">{{ t.q6_status_label }} <span class="required">*</span></span>
|
||||||
|
{% if 'financing_status' in errors %}<p class="q-error-hint">{{ t.q6_required_hint }}</p>{% endif %}
|
||||||
<div class="q-pills">
|
<div class="q-pills">
|
||||||
{% for val, label in [('self_funded', t.q6_status_self), ('loan_approved', t.q6_status_loan), ('seeking', t.q6_status_seeking), ('not_started', t.q6_status_not_started)] %}
|
{% for val, label in [('self_funded', t.q6_status_self), ('loan_approved', t.q6_status_loan), ('seeking', t.q6_status_seeking), ('not_started', t.q6_status_not_started)] %}
|
||||||
<label><input type="radio" name="financing_status" value="{{ val }}" {{ 'checked' if data.get('financing_status') == val }}><span class="q-pill">{{ label }}</span></label>
|
<label><input type="radio" name="financing_status" value="{{ val }}" {{ 'checked' if data.get('financing_status') == val }} required><span class="q-pill">{{ label }}</span></label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,10 +25,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="q-field-group">
|
<div class="q-field-group">
|
||||||
<span class="q-label">{{ t.q6_decision_label }}</span>
|
<span class="q-label">{{ t.q6_decision_label }} <span class="required">*</span></span>
|
||||||
|
{% if 'decision_process' in errors %}<p class="q-error-hint">{{ t.q6_decision_required_hint }}</p>{% endif %}
|
||||||
<div class="q-pills">
|
<div class="q-pills">
|
||||||
{% for val, label in [('solo', t.q6_decision_solo), ('partners', t.q6_decision_partners), ('committee', t.q6_decision_committee)] %}
|
{% for val, label in [('solo', t.q6_decision_solo), ('partners', t.q6_decision_partners), ('committee', t.q6_decision_committee)] %}
|
||||||
<label><input type="radio" name="decision_process" value="{{ val }}" {{ 'checked' if data.get('decision_process') == val }}><span class="q-pill">{{ label }}</span></label>
|
<label><input type="radio" name="decision_process" value="{{ val }}" {{ 'checked' if data.get('decision_process') == val }} required><span class="q-pill">{{ label }}</span></label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{# Step 8: Services Needed #}
|
{# Step 8: Services Needed #}
|
||||||
<form hx-post="{{ url_for('leads.quote_step', step=8) }}"
|
<form id="q-step-8-form"
|
||||||
|
hx-post="{{ url_for('leads.quote_step', step=8) }}"
|
||||||
hx-target="#quote-step" hx-swap="innerHTML">
|
hx-target="#quote-step" hx-swap="innerHTML">
|
||||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
@@ -8,7 +9,9 @@
|
|||||||
<p class="q-step-sub">{{ t.q8_subheading }}</p>
|
<p class="q-step-sub">{{ t.q8_subheading }}</p>
|
||||||
|
|
||||||
<div class="q-field-group">
|
<div class="q-field-group">
|
||||||
<span class="q-label">{{ t.q8_services_label }} <span style="color:#94A3B8;font-weight:400">{{ t.q8_services_note }}</span></span>
|
<span class="q-label">{{ t.q8_services_label }} <span class="required">*</span> <span style="color:#94A3B8;font-weight:400">{{ t.q8_services_note }}</span></span>
|
||||||
|
{% if 'services_needed' in errors %}<p class="q-error-hint" id="q8-error">{{ t.q8_required_hint }}</p>{% endif %}
|
||||||
|
<p class="q-error-hint" id="q8-client-error" style="display:none">{{ t.q8_required_hint }}</p>
|
||||||
<div class="q-pills">
|
<div class="q-pills">
|
||||||
{% set selected_services = data.get('services_needed', []) %}
|
{% set selected_services = data.get('services_needed', []) %}
|
||||||
{% for val, label in [('court_supply', t.q8_court_supply), ('installation', t.q8_installation), ('construction', t.q8_construction), ('design', t.q8_design), ('lighting', t.q8_lighting), ('flooring', t.q8_flooring), ('turnkey', t.q8_turnkey)] %}
|
{% for val, label in [('court_supply', t.q8_court_supply), ('installation', t.q8_installation), ('construction', t.q8_construction), ('design', t.q8_design), ('lighting', t.q8_lighting), ('flooring', t.q8_flooring), ('turnkey', t.q8_turnkey)] %}
|
||||||
@@ -26,9 +29,22 @@
|
|||||||
<button type="button" class="q-btn-back"
|
<button type="button" class="q-btn-back"
|
||||||
hx-get="{{ url_for('leads.quote_step', step=7, _accumulated=data | tojson) }}"
|
hx-get="{{ url_for('leads.quote_step', step=7, _accumulated=data | tojson) }}"
|
||||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
<button type="submit" class="q-btn-next" id="q8-next-btn">{{ t.q_btn_next }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var form = document.getElementById('q-step-8-form');
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener('htmx:confirm', function(e) {
|
||||||
|
var checked = form.querySelectorAll('input[name="services_needed"]:checked');
|
||||||
|
if (checked.length === 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('q8-client-error').style.display = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||||
<div class="q-progress__meta">
|
<div class="q-progress__meta">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="metric-card__sub">{{ t.sub_stabilized }}</div>
|
<div class="metric-card__sub">{{ t.sub_stabilized }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="metric-card__label">{{ t.card_payback }}</div>
|
<div class="metric-card__label">{{ t.card_payback }} <span class="ti">i<span class="tp">{{ t.tip_result_payback }}</span></span></div>
|
||||||
<div class="metric-card__value c-head">{{ payback_label }}</div>
|
<div class="metric-card__value c-head">{{ payback_label }}</div>
|
||||||
<div class="metric-card__sub">{{ payback_sub }}</div>
|
<div class="metric-card__sub">{{ payback_sub }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,17 +6,17 @@
|
|||||||
<div class="section-header"><h3>{{ t.metrics_return }}</h3></div>
|
<div class="section-header"><h3>{{ t.metrics_return }}</h3></div>
|
||||||
<div class="grid-4">
|
<div class="grid-4">
|
||||||
<div class="metric-card metric-card-sm">
|
<div class="metric-card metric-card-sm">
|
||||||
<div class="metric-card__label">IRR</div>
|
<div class="metric-card__label">IRR <span class="ti">i<span class="tp">{{ t.tip_result_irr }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
|
||||||
<div class="metric-card__sub">{{ s.holdYears }}-year</div>
|
<div class="metric-card__sub">{{ s.holdYears }}-year</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card metric-card-sm">
|
<div class="metric-card metric-card-sm">
|
||||||
<div class="metric-card__label">MOIC</div>
|
<div class="metric-card__label">MOIC <span class="ti">i<span class="tp">{{ t.tip_result_moic }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
|
||||||
<div class="metric-card__sub">Total return multiple</div>
|
<div class="metric-card__sub">Total return multiple</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card metric-card-sm">
|
<div class="metric-card metric-card-sm">
|
||||||
<div class="metric-card__label">Cash-on-Cash</div>
|
<div class="metric-card__label">Cash-on-Cash <span class="ti">i<span class="tp">{{ t.tip_result_coc }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
|
||||||
<div class="metric-card__sub">Y3 NCF ÷ Equity</div>
|
<div class="metric-card__sub">Y3 NCF ÷ Equity</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<div class="section-header"><h3>{{ t.metrics_revenue }}</h3></div>
|
<div class="section-header"><h3>{{ t.metrics_revenue }}</h3></div>
|
||||||
<div class="grid-4">
|
<div class="grid-4">
|
||||||
<div class="metric-card metric-card-sm">
|
<div class="metric-card metric-card-sm">
|
||||||
<div class="metric-card__label">RevPAH</div>
|
<div class="metric-card__label">RevPAH <span class="ti">i<span class="tp">{{ t.tip_result_revpah }}</span></span></div>
|
||||||
<div class="metric-card__value c-blue">{{ d.revPAH | fmt_currency }}</div>
|
<div class="metric-card__value c-blue">{{ d.revPAH | fmt_currency }}</div>
|
||||||
<div class="metric-card__sub">Revenue per Available Hour</div>
|
<div class="metric-card__sub">Revenue per Available Hour</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<div class="section-header"><h3>{{ t.metrics_debt }}</h3></div>
|
<div class="section-header"><h3>{{ t.metrics_debt }}</h3></div>
|
||||||
<div class="grid-4">
|
<div class="grid-4">
|
||||||
<div class="metric-card metric-card-sm">
|
<div class="metric-card metric-card-sm">
|
||||||
<div class="metric-card__label">DSCR (Y3)</div>
|
<div class="metric-card__label">DSCR (Y3) <span class="ti">i<span class="tp">{{ t.tip_result_dscr }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if y3_dscr >= 1.2 else 'c-red' }}">{{ '∞' if y3_dscr > 99 else y3_dscr | fmt_x }}</div>
|
<div class="metric-card__value {{ 'c-green' if y3_dscr >= 1.2 else 'c-red' }}">{{ '∞' if y3_dscr > 99 else y3_dscr | fmt_x }}</div>
|
||||||
<div class="metric-card__sub">Min 1.2x for banks</div>
|
<div class="metric-card__sub">Min 1.2x for banks</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<div class="metric-card__sub">Loan ÷ Total Investment</div>
|
<div class="metric-card__sub">Loan ÷ Total Investment</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card metric-card-sm">
|
<div class="metric-card metric-card-sm">
|
||||||
<div class="metric-card__label">Debt Yield</div>
|
<div class="metric-card__label">Debt Yield <span class="ti">i<span class="tp">{{ t.tip_result_debt_yield }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.debtYield > 0.1 else 'c-amber' }}">{{ d.debtYield | fmt_pct }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.debtYield > 0.1 else 'c-amber' }}">{{ d.debtYield | fmt_pct }}</div>
|
||||||
<div class="metric-card__sub">Stab. EBITDA ÷ Loan</div>
|
<div class="metric-card__sub">Stab. EBITDA ÷ Loan</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<div class="metric-card__sub">Investment per floor area</div>
|
<div class="metric-card__sub">Investment per floor area</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card metric-card-sm">
|
<div class="metric-card metric-card-sm">
|
||||||
<div class="metric-card__label">Yield on Cost</div>
|
<div class="metric-card__label">Yield on Cost <span class="ti">i<span class="tp">{{ t.tip_result_yield_on_cost }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.yieldOnCost > 0.08 else 'c-amber' }}">{{ d.yieldOnCost | fmt_pct }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.yieldOnCost > 0.08 else 'c-amber' }}">{{ d.yieldOnCost | fmt_pct }}</div>
|
||||||
<div class="metric-card__sub">Stab. EBITDA ÷ CAPEX</div>
|
<div class="metric-card__sub">Stab. EBITDA ÷ CAPEX</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
<div class="section-header"><h3>{{ t.metrics_ops }}</h3></div>
|
<div class="section-header"><h3>{{ t.metrics_ops }}</h3></div>
|
||||||
<div class="grid-4">
|
<div class="grid-4">
|
||||||
<div class="metric-card metric-card-sm">
|
<div class="metric-card metric-card-sm">
|
||||||
<div class="metric-card__label">Break-Even Util.</div>
|
<div class="metric-card__label">Break-Even Util. <span class="ti">i<span class="tp">{{ t.tip_result_break_even }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.breakEvenUtil < 0.35 else 'c-amber' }}">{{ d.breakEvenUtil | fmt_pct }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.breakEvenUtil < 0.35 else 'c-amber' }}">{{ d.breakEvenUtil | fmt_pct }}</div>
|
||||||
<div class="metric-card__sub">{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day</div>
|
<div class="metric-card__sub">{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="metric-card__sub">{{ t.sub_stabilized }}</div>
|
<div class="metric-card__sub">{{ t.sub_stabilized }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="metric-card__label">{{ t.card_ebitda_mo }}</div>
|
<div class="metric-card__label">{{ t.card_ebitda_mo }} <span class="ti">i<span class="tp">{{ t.tip_result_ebitda_mo }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.ebitdaMonth >= 0 else 'c-red' }}">{{ d.ebitdaMonth | int | fmt_currency }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.ebitdaMonth >= 0 else 'c-red' }}">{{ d.ebitdaMonth | int | fmt_currency }}</div>
|
||||||
<div class="metric-card__sub">{{ margin }}% margin</div>
|
<div class="metric-card__sub">{{ margin }}% margin</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="metric-card__sub">{{ t.sub_year3 }}</div>
|
<div class="metric-card__sub">{{ t.sub_year3 }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="metric-card__label">{{ t.card_rev_pah }}</div>
|
<div class="metric-card__label">{{ t.card_rev_pah }} <span class="ti">i<span class="tp">{{ t.tip_result_revpah }}</span></span></div>
|
||||||
<div class="metric-card__value c-blue">{{ d.revPAH | fmt_currency }}</div>
|
<div class="metric-card__value c-blue">{{ d.revPAH | fmt_currency }}</div>
|
||||||
<div class="metric-card__sub">Revenue per available hour</div>
|
<div class="metric-card__sub">Revenue per available hour</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<div class="grid-4 mb-4">
|
<div class="grid-4 mb-4">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="metric-card__label">{{ t.card_irr }}</div>
|
<div class="metric-card__label">{{ t.card_irr }} <span class="ti">i<span class="tp">{{ t.tip_result_irr }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
|
||||||
<div class="metric-card__sub">{{ '✓ Above 20%' if d.irr_ok and d.irr > 0.2 else '✗ Below target' }}</div>
|
<div class="metric-card__sub">{{ '✓ Above 20%' if d.irr_ok and d.irr > 0.2 else '✗ Below target' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="metric-card__label">{{ t.card_moic }}</div>
|
<div class="metric-card__label">{{ t.card_moic }} <span class="ti">i<span class="tp">{{ t.tip_result_moic }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
|
||||||
<div class="metric-card__sub">{{ '✓ Above 2.0x' if d.moic > 2 else '✗ Below 2.0x' }}</div>
|
<div class="metric-card__sub">{{ '✓ Above 2.0x' if d.moic > 2 else '✗ Below 2.0x' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="metric-card__label">{{ t.card_break_even }}</div>
|
<div class="metric-card__label">{{ t.card_break_even }} <span class="ti">i<span class="tp">{{ t.tip_result_break_even }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.breakEvenUtil < 0.35 else 'c-amber' }}">{{ d.breakEvenUtil | fmt_pct }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.breakEvenUtil < 0.35 else 'c-amber' }}">{{ d.breakEvenUtil | fmt_pct }}</div>
|
||||||
<div class="metric-card__sub">{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day</div>
|
<div class="metric-card__sub">{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="metric-card__label">{{ t.card_cash_on_cash }}</div>
|
<div class="metric-card__label">{{ t.card_cash_on_cash }} <span class="ti">i<span class="tp">{{ t.tip_result_coc }}</span></span></div>
|
||||||
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
|
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
|
||||||
<div class="metric-card__sub">Year 3 NCF ÷ Equity</div>
|
<div class="metric-card__sub">Year 3 NCF ÷ Equity</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}{% if lang == 'de' %}Über Padelnomics — Planungsplattform für Padelplatz-Investitionen{% else %}About Padelnomics — Padel Court Investment Platform{% endif %}{% endblock %}
|
{% block title %}{% if lang == 'de' %}Über Padelnomics — Planungsplattform für Padelplatz-Investitionen{% else %}About Padelnomics — Padel Court Investment Platform{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<meta name="description" content="{% if lang == 'de' %}Padelnomics ist eine kostenlose Finanzplanungsplattform für Padel-Unternehmer. Modellieren Sie Ihre Investition, finden Sie Anbieter und planen Sie Ihr Padel-Business mit professionellen Tools.{% else %}Padelnomics is a free financial planning platform for padel entrepreneurs. Model your investment, find suppliers, and plan your padel court business with professional-grade tools.{% endif %}">
|
<meta name="description" content="{% if lang == 'de' %}Padelnomics ist eine kostenlose Finanzplanungsplattform für Padel-Unternehmer. Modelliere deine Investition, finde Anbieter und plane dein Padel-Business mit professionellen Tools.{% else %}Padelnomics is a free financial planning platform for padel entrepreneurs. Model your investment, find suppliers, and plan your padel court business with professional-grade tools.{% endif %}">
|
||||||
<meta property="og:title" content="{% if lang == 'de' %}Über Padelnomics — Planungsplattform für Padelplatz-Investitionen{% else %}About Padelnomics — Padel Court Investment Platform{% endif %}">
|
<meta property="og:title" content="{% if lang == 'de' %}Über Padelnomics — Planungsplattform für Padelplatz-Investitionen{% else %}About Padelnomics — Padel Court Investment Platform{% endif %}">
|
||||||
<meta property="og:description" content="{% if lang == 'de' %}Entwickelt für Padel-Unternehmer, die professionelle Finanztools ohne Beratungskosten benötigen. Kostenloser Planer, 60+ Variablen, Anbieterverzeichnis und mehr.{% else %}Built for padel entrepreneurs who need professional financial tools without consulting fees. Free planner, 60+ variables, supplier directory, and more.{% endif %}">
|
<meta property="og:description" content="{% if lang == 'de' %}Entwickelt für Padel-Unternehmer, die professionelle Finanztools ohne Beratungskosten benötigen. Kostenloser Planer, 60+ Variablen, Anbieterverzeichnis und mehr.{% else %}Built for padel entrepreneurs who need professional financial tools without consulting fees. Free planner, 60+ variables, supplier directory, and more.{% endif %}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
{% block title %}{% if lang == 'de' %}Funktionen - Padel-Kostenrechner & Finanzplaner | {{ config.APP_NAME }}{% else %}Features - Padel Court Financial Planner | {{ config.APP_NAME }}{% endif %}{% endblock %}
|
{% block title %}{% if lang == 'de' %}Funktionen - Padel-Kostenrechner & Finanzplaner | {{ config.APP_NAME }}{% else %}Features - Padel Court Financial Planner | {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<meta name="description" content="{% if lang == 'de' %}60+ anpassbare Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und professionelle Finanzprojektionen für Ihre Padelplatz-Investition.{% else %}60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.{% endif %}">
|
<meta name="description" content="{% if lang == 'de' %}60+ anpassbare Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und professionelle Finanzprojektionen für deine Padelplatz-Investition.{% else %}60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.{% endif %}">
|
||||||
<meta property="og:title" content="{% if lang == 'de' %}Funktionen - Padel-Kostenrechner & Finanzplaner | {{ config.APP_NAME }}{% else %}Features - Padel Court Financial Planner | {{ config.APP_NAME }}{% endif %}">
|
<meta property="og:title" content="{% if lang == 'de' %}Funktionen - Padel-Kostenrechner & Finanzplaner | {{ config.APP_NAME }}{% else %}Features - Padel Court Financial Planner | {{ config.APP_NAME }}{% endif %}">
|
||||||
<meta property="og:description" content="{% if lang == 'de' %}60+ anpassbare Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und professionelle Finanzprojektionen für Ihre Padelplatz-Investition.{% else %}60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.{% endif %}">
|
<meta property="og:description" content="{% if lang == 'de' %}60+ anpassbare Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und professionelle Finanzprojektionen für deine Padelplatz-Investition.{% else %}60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.{% endif %}">
|
||||||
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<h2 class="text-xl mb-2">{{ t.features_card_1_h2 }}</h2>
|
<h2 class="text-xl mb-2">{{ t.features_card_1_h2 }}</h2>
|
||||||
<p class="text-slate-dark">
|
<p class="text-slate-dark">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Jede Annahme ist anpassbar: Platzbaukosten, Miete, Stundensätze, Auslastungskurven, Finanzierungskonditionen, Exit-Multiplikatoren. Nichts ist fest vorgegeben — Ihr Modell spiegelt Ihre Realität wider.
|
Jede Annahme ist anpassbar: Platzbaukosten, Miete, Stundensätze, Auslastungskurven, Finanzierungskonditionen, Exit-Multiplikatoren. Nichts ist fest vorgegeben — Dein Modell spiegelt deine Realität wider.
|
||||||
{% else %}
|
{% else %}
|
||||||
Every assumption is adjustable. Court costs, rent, hourly pricing, utilization curves, financing terms, exit multiples. Nothing is hard-coded — your model reflects your reality.
|
Every assumption is adjustable. Court costs, rent, hourly pricing, utilization curves, financing terms, exit multiples. Nothing is hard-coded — your model reflects your reality.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<h2 class="text-xl mb-2">{{ t.features_card_2_h2 }}</h2>
|
<h2 class="text-xl mb-2">{{ t.features_card_2_h2 }}</h2>
|
||||||
<p class="text-slate-dark">
|
<p class="text-slate-dark">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Annahmen, Investition (CAPEX), Betriebsmodell, Cashflow, Renditen & Exit sowie Kennzahlen. Jeder Tab mit interaktiven Diagrammen, die sich in Echtzeit aktualisieren, wenn Sie Eingaben anpassen.
|
Annahmen, Investition (CAPEX), Betriebsmodell, Cashflow, Renditen & Exit sowie Kennzahlen. Jeder Tab mit interaktiven Diagrammen, die sich in Echtzeit aktualisieren, wenn du Eingaben anpasst.
|
||||||
{% else %}
|
{% else %}
|
||||||
Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each tab with interactive charts that update in real time as you adjust inputs.
|
Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each tab with interactive charts that update in real time as you adjust inputs.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<h2 class="text-xl mb-2">{{ t.features_card_3_h2 }}</h2>
|
<h2 class="text-xl mb-2">{{ t.features_card_3_h2 }}</h2>
|
||||||
<p class="text-slate-dark">
|
<p class="text-slate-dark">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Innenhallenmodelle (Anmietung eines Bestandsgebäudes oder Neubau) und Außenanlagen mit Saisonalitätsanpassungen. Szenarien direkt nebeneinander vergleichen, um den besten Ansatz für Ihren Markt zu finden.
|
Innenhallenmodelle (Anmietung eines Bestandsgebäudes oder Neubau) und Außenanlagen mit Saisonalitätsanpassungen. Szenarien direkt nebeneinander vergleichen, um den besten Ansatz für deinen Markt zu finden.
|
||||||
{% else %}
|
{% else %}
|
||||||
Model indoor halls (rent an existing building or build new) and outdoor courts with seasonality adjustments. Compare scenarios side by side to find the best approach for your market.
|
Model indoor halls (rent an existing building or build new) and outdoor courts with seasonality adjustments. Compare scenarios side by side to find the best approach for your market.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<h2 class="text-xl mb-2">{{ t.features_card_4_h2 }}</h2>
|
<h2 class="text-xl mb-2">{{ t.features_card_4_h2 }}</h2>
|
||||||
<p class="text-slate-dark">
|
<p class="text-slate-dark">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Sehen Sie, wie sich Ihre IRR und Cash-Rendite bei unterschiedlichen Auslastungsraten und Preisen verändern. Ermitteln Sie Ihren Break-even-Punkt sofort mit der integrierten Sensitivitätsmatrix.
|
Sieh dir an, wie sich deine IRR und Cash-Rendite bei unterschiedlichen Auslastungsraten und Preisen verändern. Ermittle deinen Break-even-Punkt sofort mit der integrierten Sensitivitätsmatrix.
|
||||||
{% else %}
|
{% else %}
|
||||||
See how your IRR and cash yield change across different utilization rates and pricing levels. Find your break-even point instantly with the built-in sensitivity matrix.
|
See how your IRR and cash yield change across different utilization rates and pricing levels. Find your break-even point instantly with the built-in sensitivity matrix.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<h2 class="text-xl mb-2">{{ t.features_card_6_h2 }}</h2>
|
<h2 class="text-xl mb-2">{{ t.features_card_6_h2 }}</h2>
|
||||||
<p class="text-slate-dark">
|
<p class="text-slate-dark">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Unbegrenzte Szenarien speichern. Verschiedene Standorte, Platzzahlen, Finanzierungsstrukturen und Preisstrategien testen. Laden und vergleichen, um den optimalen Plan für Ihre Investition zu finden.
|
Unbegrenzte Szenarien speichern. Verschiedene Standorte, Platzzahlen, Finanzierungsstrukturen und Preisstrategien testen. Laden und vergleichen, um den optimalen Plan für deine Investition zu finden.
|
||||||
{% else %}
|
{% else %}
|
||||||
Save unlimited scenarios. Test different locations, court counts, financing structures, and pricing strategies. Load and compare to find the optimal plan for your investment.
|
Save unlimited scenarios. Test different locations, court counts, financing structures, and pricing strategies. Load and compare to find the optimal plan for your investment.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
<h2 class="text-xl mb-2">{{ t.features_cf_h2 }}</h2>
|
<h2 class="text-xl mb-2">{{ t.features_cf_h2 }}</h2>
|
||||||
<p class="text-slate-dark">
|
<p class="text-slate-dark">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Monatliche Cashflow-Projektionen über 10 Jahre. Eigen-/Fremdkapitalaufteilung, Zinssätze und Kreditlaufzeiten modellieren. Schuldendienstdeckungsgrade und freien Cashflow Monat für Monat einsehen. Wasserfalldiagramme zeigen genau, wohin Ihr Geld fließt.
|
Monatliche Cashflow-Projektionen über 10 Jahre. Eigen-/Fremdkapitalaufteilung, Zinssätze und Kreditlaufzeiten modellieren. Schuldendienstdeckungsgrade und freien Cashflow Monat für Monat einsehen. Wasserfalldiagramme zeigen genau, wohin dein Geld fließt.
|
||||||
{% else %}
|
{% else %}
|
||||||
10-year monthly cash flow projections. Model your equity/debt split, interest rates, and loan terms. See debt service coverage ratios and free cash flow month by month. Waterfall charts show exactly where your money goes.
|
10-year monthly cash flow projections. Model your equity/debt split, interest rates, and loan terms. See debt service coverage ratios and free cash flow month by month. Waterfall charts show exactly where your money goes.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}{% if lang == 'de' %}Padelnomics - Padel-Kostenrechner & Finanzplaner{% else %}Padelnomics - Padel Court Business Plan & ROI Calculator{% endif %}{% endblock %}
|
{% block title %}{% if lang == 'de' %}Padelnomics - Padel-Kostenrechner & Finanzplaner{% else %}Padelnomics - Padel Court Business Plan & ROI Calculator{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<meta name="description" content="{% if lang == 'de' %}Modellieren Sie Ihre Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Innen-/Außenanlage, Miet- oder Eigentumsmodell.{% else %}Plan your padel court investment in minutes. 60+ variables, sensitivity analysis, and professional-grade projections. Indoor/outdoor, rent/buy models.{% endif %}">
|
<meta name="description" content="{% if lang == 'de' %}Modelliere deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Innen-/Außenanlage, Miet- oder Eigentumsmodell.{% else %}Plan your padel court investment in minutes. 60+ variables, sensitivity analysis, and professional-grade projections. Indoor/outdoor, rent/buy models.{% endif %}">
|
||||||
<meta property="og:title" content="Padelnomics - Padel Court Financial Planner">
|
<meta property="og:title" content="Padelnomics - Padel Court Financial Planner">
|
||||||
<meta property="og:description" content="{% if lang == 'de' %}Der professionellste Padel-Finanzplaner. 60+ Variablen, 6 Analyse-Tabs, Diagramme, Sensitivitätsanalyse und Anbieter-Vermittlung.{% else %}The most sophisticated padel court business plan calculator. 60+ variables, 6 analysis tabs, charts, sensitivity analysis, and supplier connections.{% endif %}">
|
<meta property="og:description" content="{% if lang == 'de' %}Der professionellste Padel-Finanzplaner. 60+ Variablen, 6 Analyse-Tabs, Diagramme, Sensitivitätsanalyse und Anbieter-Vermittlung.{% else %}The most sophisticated padel court business plan calculator. 60+ variables, 6 analysis tabs, charts, sensitivity analysis, and supplier connections.{% endif %}">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
<p class="hero-desc">
|
<p class="hero-desc">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Modellieren Sie Ihre Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Dann werden Sie mit verifizierten Anbietern zusammengebracht.
|
Modelliere deine Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Dann wirst du mit verifizierten Anbietern zusammengebracht.
|
||||||
{% else %}
|
{% else %}
|
||||||
Model your padel court investment with 60+ variables, sensitivity analysis,
|
Model your padel court investment with 60+ variables, sensitivity analysis,
|
||||||
and professional-grade projections. Then get matched with verified suppliers.
|
and professional-grade projections. Then get matched with verified suppliers.
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
<h3 class="journey-step__title">{{ t.landing_journey_02 }}</h3>
|
<h3 class="journey-step__title">{{ t.landing_journey_02 }}</h3>
|
||||||
<p class="journey-step__desc">
|
<p class="journey-step__desc">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Modellieren Sie Ihre Investition mit 60+ Variablen, Diagrammen und Sensitivitätsanalyse.
|
Modelliere deine Investition mit 60+ Variablen, Diagrammen und Sensitivitätsanalyse.
|
||||||
{% else %}
|
{% else %}
|
||||||
Model your investment with 60+ variables, charts, and sensitivity analysis.
|
Model your investment with 60+ variables, charts, and sensitivity analysis.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
<h3 class="journey-step__title">{{ t.landing_journey_03 }} <span class="badge-soon">{{ t.landing_journey_03_badge }}</span></h3>
|
<h3 class="journey-step__title">{{ t.landing_journey_03 }} <span class="badge-soon">{{ t.landing_journey_03_badge }}</span></h3>
|
||||||
<p class="journey-step__desc">
|
<p class="journey-step__desc">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Kontakte zu Banken und Investoren herstellen. Ihr Finanzplan wird zum Businesscase.
|
Kontakte zu Banken und Investoren herstellen. Dein Finanzplan wird zum Businesscase.
|
||||||
{% else %}
|
{% else %}
|
||||||
Connect with banks and investors. Your planner becomes your business case.
|
Connect with banks and investors. Your planner becomes your business case.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
<h3 class="journey-step__title">{{ t.landing_journey_04 }}</h3>
|
<h3 class="journey-step__title">{{ t.landing_journey_04 }}</h3>
|
||||||
<p class="journey-step__desc">
|
<p class="journey-step__desc">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Über {{ total_suppliers }}+ Platz-Anbieter aus {{ total_countries }} Ländern durchsuchen. Passend zu Ihren Anforderungen vermittelt.
|
Über {{ total_suppliers }}+ Platz-Anbieter aus {{ total_countries }} Ländern durchsuchen. Passend zu deinen Anforderungen vermittelt.
|
||||||
{% else %}
|
{% else %}
|
||||||
Browse {{ total_suppliers }}+ court suppliers across {{ total_countries }} countries. Get matched to your specs.
|
Browse {{ total_suppliers }}+ court suppliers across {{ total_countries }} countries. Get matched to your specs.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -365,7 +365,7 @@
|
|||||||
<h3 class="journey-step__title">{{ t.landing_journey_05 }} <span class="badge-soon">{{ t.landing_journey_05_badge }}</span></h3>
|
<h3 class="journey-step__title">{{ t.landing_journey_05 }} <span class="badge-soon">{{ t.landing_journey_05_badge }}</span></h3>
|
||||||
<p class="journey-step__desc">
|
<p class="journey-step__desc">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Launch-Playbook, Performance-Benchmarks und Wachstumsanalysen für Ihren Betrieb.
|
Launch-Playbook, Performance-Benchmarks und Wachstumsanalysen für deinen Betrieb.
|
||||||
{% else %}
|
{% else %}
|
||||||
Launch playbook, performance benchmarks, and expansion analytics.
|
Launch playbook, performance benchmarks, and expansion analytics.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
<h3 class="text-lg mb-2">📉 {{ t.landing_feature_4_h3 }}</h3>
|
<h3 class="text-lg mb-2">📉 {{ t.landing_feature_4_h3 }}</h3>
|
||||||
<p class="text-sm text-slate-dark">
|
<p class="text-sm text-slate-dark">
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Sehen Sie, wie sich Ihre Renditen bei unterschiedlichen Auslastungsraten und Preisen verändern. Break-even-Punkt sofort ermitteln.
|
Sieh dir an, wie sich deine Renditen bei unterschiedlichen Auslastungsraten und Preisen verändern. Break-even-Punkt sofort ermitteln.
|
||||||
{% else %}
|
{% else %}
|
||||||
See how your returns change with different utilization rates and pricing. Find your break-even point instantly.
|
See how your returns change with different utilization rates and pricing. Find your break-even point instantly.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -459,7 +459,7 @@
|
|||||||
<h3>{{ t.landing_supplier_step_1_title }}</h3>
|
<h3>{{ t.landing_supplier_step_1_title }}</h3>
|
||||||
<p>
|
<p>
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Nutzen Sie den Finanzplaner, um Ihre Platzzahl, Ihr Budget und Ihren Zeitplan zu modellieren.
|
Nutze den Finanzplaner, um deine Platzzahl, dein Budget und deinen Zeitplan zu modellieren.
|
||||||
{% else %}
|
{% else %}
|
||||||
Use the financial planner to model your courts, budget, and timeline.
|
Use the financial planner to model your courts, budget, and timeline.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -470,7 +470,7 @@
|
|||||||
<h3>{{ t.landing_supplier_step_2_title }}</h3>
|
<h3>{{ t.landing_supplier_step_2_title }}</h3>
|
||||||
<p>
|
<p>
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Angebote anfordern — wir vermitteln Sie anhand Ihrer Projektspezifikationen an passende Anbieter.
|
Angebote anfordern — wir vermitteln dich anhand deiner Projektspezifikationen an passende Anbieter.
|
||||||
{% else %}
|
{% else %}
|
||||||
Request quotes and we match you with suppliers based on your project specs.
|
Request quotes and we match you with suppliers based on your project specs.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
<summary>{{ t.landing_faq_q2 }}</summary>
|
<summary>{{ t.landing_faq_q2 }}</summary>
|
||||||
<p>
|
<p>
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Nein. Der Planer funktioniert sofort ohne Registrierung. Erstellen Sie ein Konto, um Szenarien zu speichern, Konfigurationen zu vergleichen und PDF-Berichte zu exportieren.
|
Nein. Der Planer funktioniert sofort ohne Registrierung. Erstelle ein Konto, um Szenarien zu speichern, Konfigurationen zu vergleichen und PDF-Berichte zu exportieren.
|
||||||
{% else %}
|
{% else %}
|
||||||
No. The planner works instantly with no signup. Create an account to save scenarios, compare configurations, and export PDF reports.
|
No. The planner works instantly with no signup. Create an account to save scenarios, compare configurations, and export PDF reports.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -521,7 +521,7 @@
|
|||||||
<summary>{{ t.landing_faq_q3 }}</summary>
|
<summary>{{ t.landing_faq_q3 }}</summary>
|
||||||
<p>
|
<p>
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Wenn Sie über den Planer Angebote anfordern, teilen wir Ihre Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren Sie direkt mit ihren Angeboten.
|
Wenn du über den Planer Angebote anforderst, teilen wir deine Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren dich direkt mit ihren Angeboten.
|
||||||
{% else %}
|
{% else %}
|
||||||
When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with relevant suppliers from our directory. They contact you directly with proposals.
|
When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with relevant suppliers from our directory. They contact you directly with proposals.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -541,7 +541,7 @@
|
|||||||
<summary>{{ t.landing_faq_q5 }}</summary>
|
<summary>{{ t.landing_faq_q5 }}</summary>
|
||||||
<p>
|
<p>
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Das Modell verwendet reale Standardwerte auf Basis europäischer Marktdaten. Jede Annahme ist anpassbar, sodass Sie Ihre lokalen Gegebenheiten abbilden können. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern, und hilft Ihnen, die Bandbreite möglicher Ergebnisse zu verstehen.
|
Das Modell verwendet reale Standardwerte auf Basis europäischer Marktdaten. Jede Annahme ist anpassbar, sodass du deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern, und hilft dir, die Bandbreite möglicher Ergebnisse zu verstehen.
|
||||||
{% else %}
|
{% else %}
|
||||||
The model uses real-world defaults based on European market data. Every assumption is adjustable so you can match your local conditions. The sensitivity analysis shows how results change across different scenarios, helping you understand the range of outcomes.
|
The model uses real-world defaults based on European market data. Every assumption is adjustable so you can match your local conditions. The sensitivity analysis shows how results change across different scenarios, helping you understand the range of outcomes.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -559,7 +559,7 @@
|
|||||||
Padel ist der am schnellsten wachsende Sport in Europa — die Nachfrage nach Plätzen übersteigt das Angebot in Deutschland, Österreich, der Schweiz und darüber hinaus bei weitem. Eine Paddelhalle zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Innenhalle mit 6–8 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 2–3 Mio. € (Neubau), mit Amortisationszeiten von 3–5 Jahren für gut gelegene Anlagen.
|
Padel ist der am schnellsten wachsende Sport in Europa — die Nachfrage nach Plätzen übersteigt das Angebot in Deutschland, Österreich, der Schweiz und darüber hinaus bei weitem. Eine Paddelhalle zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Innenhalle mit 6–8 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 2–3 Mio. € (Neubau), mit Amortisationszeiten von 3–5 Jahren für gut gelegene Anlagen.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es Ihnen, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob Sie als Unternehmer Ihre erste Anlage prüfen, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrieren oder als Investor eine bestehende Paddelhalle bewerten — Padelnomics gibt Ihnen die finanzielle Klarheit für fundierte Entscheidungen.
|
Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es dir, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob du als Unternehmer deine erste Anlage prüfst, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrierst oder als Investor eine bestehende Paddelhalle bewertest — Padelnomics gibt dir die finanzielle Klarheit für fundierte Entscheidungen.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>
|
<p>
|
||||||
@@ -578,7 +578,7 @@
|
|||||||
<h2>{{ t.landing_final_cta_h2 }}</h2>
|
<h2>{{ t.landing_final_cta_h2 }}</h2>
|
||||||
<p>
|
<p>
|
||||||
{% if lang == 'de' %}
|
{% if lang == 'de' %}
|
||||||
Modellieren Sie Ihre Investition und lassen Sie sich mit verifizierten Platz-Anbietern aus {{ total_countries }} Ländern zusammenbringen.
|
Modelliere deine Investition und lass dich mit verifizierten Platz-Anbietern aus {{ total_countries }} Ländern zusammenbringen.
|
||||||
{% else %}
|
{% else %}
|
||||||
Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.
|
Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -685,7 +685,7 @@
|
|||||||
"name": "Muss ich mich registrieren?",
|
"name": "Muss ich mich registrieren?",
|
||||||
"acceptedAnswer": {
|
"acceptedAnswer": {
|
||||||
"@type": "Answer",
|
"@type": "Answer",
|
||||||
"text": "Nein. Der Planer funktioniert sofort ohne Registrierung. Erstellen Sie ein Konto, um Szenarien zu speichern, Konfigurationen zu vergleichen und PDF-Berichte zu exportieren."
|
"text": "Nein. Der Planer funktioniert sofort ohne Registrierung. Erstelle ein Konto, um Szenarien zu speichern, Konfigurationen zu vergleichen und PDF-Berichte zu exportieren."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -693,7 +693,7 @@
|
|||||||
"name": "Wie funktioniert die Anbieter-Vermittlung?",
|
"name": "Wie funktioniert die Anbieter-Vermittlung?",
|
||||||
"acceptedAnswer": {
|
"acceptedAnswer": {
|
||||||
"@type": "Answer",
|
"@type": "Answer",
|
||||||
"text": "Wenn Sie über den Planer Angebote anfordern, teilen wir Ihre Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren Sie direkt mit ihren Angeboten."
|
"text": "Wenn du über den Planer Angebote anforderst, teilen wir deine Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren dich direkt mit ihren Angeboten."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -709,7 +709,7 @@
|
|||||||
"name": "Wie genau sind die Finanzprojektionen?",
|
"name": "Wie genau sind die Finanzprojektionen?",
|
||||||
"acceptedAnswer": {
|
"acceptedAnswer": {
|
||||||
"@type": "Answer",
|
"@type": "Answer",
|
||||||
"text": "Das Modell verwendet reale Standardwerte auf Basis europäischer Marktdaten. Jede Annahme ist anpassbar, sodass Sie Ihre lokalen Gegebenheiten abbilden können. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern."
|
"text": "Das Modell verwendet reale Standardwerte auf Basis europäischer Marktdaten. Jede Annahme ist anpassbar, sodass du deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -624,7 +624,7 @@
|
|||||||
|
|
||||||
/* ── Quote Sidebar CTA (desktop fixed) ── */
|
/* ── Quote Sidebar CTA (desktop fixed) ── */
|
||||||
.quote-sidebar {
|
.quote-sidebar {
|
||||||
display: none;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: max(1rem, calc((100vw - 72rem) / 2 - 280px));
|
right: max(1rem, calc((100vw - 72rem) / 2 - 280px));
|
||||||
top: 80px;
|
top: 80px;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div data-step="1">
|
<div data-step="1">
|
||||||
<h2 class="s-step-title">{% if lang == 'de' %}Plan auswählen{% else %}Choose Your Plan{% endif %}</h2>
|
<h2 class="s-step-title">{% if lang == 'de' %}Plan auswählen{% else %}Choose Your Plan{% endif %}</h2>
|
||||||
<p class="s-step-sub">{% if lang == 'de' %}Wählen Sie den Plan, der zu Ihren Wachstumszielen passt.{% else %}Select the plan that fits your growth goals.{% endif %}</p>
|
<p class="s-step-sub">{% if lang == 'de' %}Wähle den Plan, der zu deinen Wachstumszielen passt.{% else %}Select the plan that fits your growth goals.{% endif %}</p>
|
||||||
|
|
||||||
<!-- Billing period toggle (CSS sibling selector trick).
|
<!-- Billing period toggle (CSS sibling selector trick).
|
||||||
Radios must be direct siblings of .s-billing-toggle AND <form> so that
|
Radios must be direct siblings of .s-billing-toggle AND <form> so that
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div data-step="2">
|
<div data-step="2">
|
||||||
<h2 class="s-step-title">{% if lang == 'de' %}Boost-Add-ons{% else %}Boost Add-Ons{% endif %}</h2>
|
<h2 class="s-step-title">{% if lang == 'de' %}Boost-Add-ons{% else %}Boost Add-Ons{% endif %}</h2>
|
||||||
<p class="s-step-sub">{% if lang == 'de' %}Erhöhen Sie Ihre Sichtbarkeit mit optionalen Boosts. {% if included_boosts %}Einige sind in Ihrem Plan enthalten.{% endif %}{% else %}Increase your visibility with optional boosts. {% if included_boosts %}Some are included in your plan.{% endif %}{% endif %}</p>
|
<p class="s-step-sub">{% if lang == 'de' %}Erhöhe deine Sichtbarkeit mit optionalen Boosts. {% if included_boosts %}Einige sind in deinem Plan enthalten.{% endif %}{% else %}Increase your visibility with optional boosts. {% if included_boosts %}Some are included in your plan.{% endif %}{% endif %}</p>
|
||||||
|
|
||||||
<form hx-post="{{ url_for('suppliers.signup_step', step=2) }}"
|
<form hx-post="{{ url_for('suppliers.signup_step', step=2) }}"
|
||||||
hx-target="#signup-step" hx-swap="innerHTML">
|
hx-target="#signup-step" hx-swap="innerHTML">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div data-step="3">
|
<div data-step="3">
|
||||||
<h2 class="s-step-title">{% if lang == 'de' %}Credit-Pakete{% else %}Credit Packs{% endif %}</h2>
|
<h2 class="s-step-title">{% if lang == 'de' %}Credit-Pakete{% else %}Credit Packs{% endif %}</h2>
|
||||||
<p class="s-step-sub">{% if lang == 'de' %}Optional Ihre Lead-Credits aufstocken. Ihr Plan enthält monatliche Credits — Pakete geben Ihnen zusätzliche.{% else %}Optionally top up your lead credits. Your plan includes monthly credits — packs give you extra.{% endif %}</p>
|
<p class="s-step-sub">{% if lang == 'de' %}Optional deine Lead-Credits aufstocken. Dein Plan enthält monatliche Credits — Pakete geben dir zusätzliche.{% else %}Optionally top up your lead credits. Your plan includes monthly credits — packs give you extra.{% endif %}</p>
|
||||||
|
|
||||||
<form hx-post="{{ url_for('suppliers.signup_step', step=3) }}"
|
<form hx-post="{{ url_for('suppliers.signup_step', step=3) }}"
|
||||||
hx-target="#signup-step" hx-swap="innerHTML">
|
hx-target="#signup-step" hx-swap="innerHTML">
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
hx-post="{{ url_for('suppliers.signup_step', step=1) }}"
|
hx-post="{{ url_for('suppliers.signup_step', step=1) }}"
|
||||||
hx-target="#signup-step" hx-swap="innerHTML"
|
hx-target="#signup-step" hx-swap="innerHTML"
|
||||||
hx-include="[name='_accumulated']">{% if lang == 'de' %}Zurück{% else %}Back{% endif %}</button>
|
hx-include="[name='_accumulated']">{% if lang == 'de' %}Zurück{% else %}Back{% endif %}</button>
|
||||||
<button type="submit" class="s-btn-next">{% if lang == 'de' %}Weiter: Ihre Daten{% else %}Next: Your Details{% endif %}</button>
|
<button type="submit" class="s-btn-next">{% if lang == 'de' %}Weiter: Deine Daten{% else %}Next: Your Details{% endif %}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div data-step="4">
|
<div data-step="4">
|
||||||
<h2 class="s-step-title">{% if lang == 'de' %}Kontodaten{% else %}Account Details{% endif %}</h2>
|
<h2 class="s-step-title">{% if lang == 'de' %}Kontodaten{% else %}Account Details{% endif %}</h2>
|
||||||
<p class="s-step-sub">{% if lang == 'de' %}Erzählen Sie uns von Ihrem Unternehmen und wie wir Sie erreichen können.{% else %}Tell us about your company and how to reach you.{% endif %}</p>
|
<p class="s-step-sub">{% if lang == 'de' %}Erzähl uns von deinem Unternehmen und wie wir dich erreichen können.{% else %}Tell us about your company and how to reach you.{% endif %}</p>
|
||||||
|
|
||||||
<form method="post" action="{{ url_for('suppliers.signup_checkout') }}">
|
<form method="post" action="{{ url_for('suppliers.signup_checkout') }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
<div style="background:#F8FAFC;border-radius:12px;padding:1rem;margin-bottom:1.5rem;text-align:left">
|
<div style="background:#F8FAFC;border-radius:12px;padding:1rem;margin-bottom:1.5rem;text-align:left">
|
||||||
<h3 style="font-size:0.8125rem;font-weight:700;margin:0 0 0.5rem">{{ t.sup_success_next_h3 }}</h3>
|
<h3 style="font-size:0.8125rem;font-weight:700;margin:0 0 0.5rem">{{ t.sup_success_next_h3 }}</h3>
|
||||||
<ul style="list-style:none;padding:0;margin:0;font-size:0.8125rem;color:#475569">
|
<ul style="list-style:none;padding:0;margin:0;font-size:0.8125rem;color:#475569">
|
||||||
<li style="padding:3px 0">✓ {% if lang == 'de' %}Ihr Eintrag wird in wenigen Minuten aktualisiert{% else %}Your listing will be upgraded within minutes{% endif %}</li>
|
<li style="padding:3px 0">✓ {% if lang == 'de' %}Dein Eintrag wird in wenigen Minuten aktualisiert{% else %}Your listing will be upgraded within minutes{% endif %}</li>
|
||||||
<li style="padding:3px 0">✓ {% if lang == 'de' %}Lead-Credits wurden Ihrem Konto hinzugefügt{% else %}Lead credits have been added to your account{% endif %}</li>
|
<li style="padding:3px 0">✓ {% if lang == 'de' %}Lead-Credits wurden deinem Konto hinzugefügt{% else %}Lead credits have been added to your account{% endif %}</li>
|
||||||
<li style="padding:3px 0">✓ {% if lang == 'de' %}Prüfen Sie Ihre E-Mail auf einen Anmelde-Link{% else %}Check your email for a sign-in link{% endif %}</li>
|
<li style="padding:3px 0">✓ {% if lang == 'de' %}Prüfe deine E-Mail auf einen Anmelde-Link{% else %}Check your email for a sign-in link{% endif %}</li>
|
||||||
<li style="padding:3px 0">✓ {% if lang == 'de' %}Durchsuchen und entsperren Sie Leads in Ihrem Feed{% else %}Browse and unlock leads in your feed{% endif %}</li>
|
<li style="padding:3px 0">✓ {% if lang == 'de' %}Durchsuche und entsperre Leads in deinem Feed{% else %}Browse and unlock leads in your feed{% endif %}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
{% set plan_info = plans.get(plan, plans['supplier_growth']) %}
|
{% set plan_info = plans.get(plan, plans['supplier_growth']) %}
|
||||||
|
|
||||||
<h1 class="text-2xl mb-1">{{ t.sup_waitlist_h1 }}</h1>
|
<h1 class="text-2xl mb-1">{{ t.sup_waitlist_h1 }}</h1>
|
||||||
<p class="text-slate mb-6">{% if lang == 'de' %}Wir bauen die ultimative Plattform, um verifizierte Padel-Anbieter mit Unternehmern zu verbinden. Seien Sie Erster in der Schlange für den {{ plan_info.name }}-Tier-Zugang.{% else %}We're building the ultimate platform to connect verified padel suppliers with entrepreneurs. Be first in line for {{ plan_info.name }} tier access.{% endif %}</p>
|
<p class="text-slate mb-6">{% if lang == 'de' %}Wir bauen die ultimative Plattform, um verifizierte Padel-Anbieter mit Unternehmern zu verbinden. Sei Erster in der Schlange für den {{ plan_info.name }}-Tier-Zugang.{% else %}We're building the ultimate platform to connect verified padel suppliers with entrepreneurs. Be first in line for {{ plan_info.name }} tier access.{% endif %}</p>
|
||||||
|
|
||||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-6">
|
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-6">
|
||||||
<h3 class="font-semibold text-navy text-sm mb-2">{% if lang == 'de' %}{{ plan_info.name }} Plan-Highlights{% else %}{{ plan_info.name }} Plan Highlights{% endif %}</h3>
|
<h3 class="font-semibold text-navy text-sm mb-2">{% if lang == 'de' %}{{ plan_info.name }} Plan-Highlights{% else %}{{ plan_info.name }} Plan Highlights{% endif %}</h3>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{% if lang == 'de' %}Sie stehen auf der Anbieter-Warteliste - {{ config.APP_NAME }}{% else %}You're on the Supplier Waitlist - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
{% block title %}{% if lang == 'de' %}Du stehst auf der Anbieter-Warteliste - {{ config.APP_NAME }}{% else %}You're on the Supplier Waitlist - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main class="container-page py-12">
|
<main class="container-page py-12">
|
||||||
|
|||||||
@@ -66,25 +66,6 @@
|
|||||||
<a href="{{ url_for('content.markets') }}">{{ t.nav_markets }}</a>
|
<a href="{{ url_for('content.markets') }}">{{ t.nav_markets }}</a>
|
||||||
<a href="{{ url_for('public.suppliers') }}">{{ t.nav_suppliers }}</a>
|
<a href="{{ url_for('public.suppliers') }}">{{ t.nav_suppliers }}</a>
|
||||||
<a href="{{ url_for('public.landing') }}#faq">{{ t.nav_help }}</a>
|
<a href="{{ url_for('public.landing') }}#faq">{{ t.nav_help }}</a>
|
||||||
<!-- Feedback button -->
|
|
||||||
<div style="position:relative" id="feedback-wrap">
|
|
||||||
<button type="button" onclick="document.getElementById('feedback-popover').toggleAttribute('hidden')"
|
|
||||||
style="font-size:0.75rem;padding:4px 10px;border:1px solid #E2E8F0;border-radius:6px;background:white;cursor:pointer;color:#64748B;font-family:inherit">
|
|
||||||
{{ t.nav_feedback }}
|
|
||||||
</button>
|
|
||||||
<div id="feedback-popover" hidden
|
|
||||||
style="position:absolute;right:0;top:110%;width:280px;background:white;border:1px solid #E2E8F0;border-radius:10px;padding:1rem;box-shadow:0 8px 24px rgba(0,0,0,0.1);z-index:100">
|
|
||||||
<form hx-post="{{ url_for('public.feedback') }}" hx-target="#feedback-popover" hx-swap="innerHTML">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
<input type="hidden" name="page_url" id="feedback-page-url">
|
|
||||||
<p style="font-size:0.8125rem;font-weight:600;color:#1E293B;margin:0 0 8px">{{ t.nav_feedback }}</p>
|
|
||||||
<textarea name="message" rows="3" required placeholder="{{ t.base_feedback_placeholder }}"
|
|
||||||
style="width:100%;border:1px solid #E2E8F0;border-radius:6px;padding:8px;font-size:0.8125rem;font-family:inherit;resize:vertical"></textarea>
|
|
||||||
<button type="submit" class="btn" style="width:100%;margin-top:8px;font-size:0.8125rem;padding:8px">{{ t.nav_send }}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>document.getElementById('feedback-page-url').value = window.location.pathname;</script>
|
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<a href="{{ url_for('dashboard.index') }}">{{ t.nav_dashboard }}</a>
|
<a href="{{ url_for('dashboard.index') }}">{{ t.nav_dashboard }}</a>
|
||||||
{% if is_admin %}
|
{% if is_admin %}
|
||||||
@@ -176,7 +157,7 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="container-page mt-16 pt-8 border-t border-light-gray">
|
<footer class="container-page mt-16 pt-8 border-t border-light-gray">
|
||||||
<div class="grid-3 mb-8">
|
<div class="grid-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1">
|
<div class="mb-1">
|
||||||
<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:0.9375rem;color:#0F172A;letter-spacing:-0.02em">padelnomics</span>
|
<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;font-size:0.9375rem;color:#0F172A;letter-spacing:-0.02em">padelnomics</span>
|
||||||
@@ -192,6 +173,12 @@
|
|||||||
<li><a href="{{ url_for('public.suppliers') }}">{{ t.nav_suppliers }}</a></li>
|
<li><a href="{{ url_for('public.suppliers') }}">{{ t.nav_suppliers }}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-navy text-sm mb-2">{{ t.footer_company }}</p>
|
||||||
|
<ul class="space-y-1 text-sm">
|
||||||
|
<li><a href="{{ url_for('public.about') }}">{{ t.base_about }}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-navy text-sm mb-2">{{ t.footer_legal }}</p>
|
<p class="font-semibold text-navy text-sm mb-2">{{ t.footer_legal }}</p>
|
||||||
<ul class="space-y-1 text-sm">
|
<ul class="space-y-1 text-sm">
|
||||||
@@ -201,12 +188,6 @@
|
|||||||
<li><a href="#" onclick="document.cookie='cookie_consent=;path=/;max-age=0';location.reload();return false">{{ t.base_manage_cookies }}</a></li>
|
<li><a href="#" onclick="document.cookie='cookie_consent=;path=/;max-age=0';location.reload();return false">{{ t.base_manage_cookies }}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p class="font-semibold text-navy text-sm mb-2">{{ t.footer_company }}</p>
|
|
||||||
<ul class="space-y-1 text-sm">
|
|
||||||
<li><a href="{{ url_for('public.about') }}">{{ t.base_about }}</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Language toggle -->
|
<!-- Language toggle -->
|
||||||
{% if request.path.startswith('/en/') or request.path.startswith('/de/') %}
|
{% if request.path.startswith('/en/') or request.path.startswith('/de/') %}
|
||||||
@@ -250,5 +231,38 @@
|
|||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|
||||||
{% include "_cookie_banner.html" %}
|
{% include "_cookie_banner.html" %}
|
||||||
|
|
||||||
|
<!-- Floating feedback button -->
|
||||||
|
<div id="feedback-wrap" style="position:fixed;bottom:1.5rem;right:1.5rem;z-index:200;">
|
||||||
|
<button type="button" id="feedback-toggle"
|
||||||
|
aria-label="{{ t.nav_feedback }}"
|
||||||
|
onclick="(function(){var p=document.getElementById('feedback-popover');p.hidden=!p.hidden;})()"
|
||||||
|
style="width:48px;height:48px;border-radius:50%;background:#1D4ED8;border:none;cursor:pointer;box-shadow:0 4px 16px rgba(29,78,216,0.35);display:flex;align-items:center;justify-content:center;padding:0;">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="feedback-popover" hidden
|
||||||
|
style="position:absolute;bottom:calc(100% + 0.75rem);right:0;width:288px;background:white;border:1px solid #E2E8F0;border-radius:12px;padding:1rem;box-shadow:0 8px 32px rgba(0,0,0,0.12);">
|
||||||
|
<form hx-post="{{ url_for('public.feedback') }}" hx-target="#feedback-popover" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="page_url" id="feedback-page-url">
|
||||||
|
<p style="font-size:0.8125rem;font-weight:600;color:#1E293B;margin:0 0 8px">{{ t.nav_feedback }}</p>
|
||||||
|
<textarea name="message" rows="3" required placeholder="{{ t.base_feedback_placeholder }}"
|
||||||
|
style="width:100%;box-sizing:border-box;border:1px solid #E2E8F0;border-radius:6px;padding:8px;font-size:0.8125rem;font-family:inherit;resize:vertical"></textarea>
|
||||||
|
<button type="submit" class="btn" style="width:100%;margin-top:8px;font-size:0.8125rem;padding:8px">{{ t.nav_send }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('feedback-page-url').value = window.location.pathname;
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
var wrap = document.getElementById('feedback-wrap');
|
||||||
|
if (wrap && !wrap.contains(e.target)) {
|
||||||
|
var pop = document.getElementById('feedback-popover');
|
||||||
|
if (pop) pop.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -14,26 +14,55 @@ HANDLERS: dict[str, callable] = {}
|
|||||||
|
|
||||||
def _email_wrap(body: str) -> str:
|
def _email_wrap(body: str) -> str:
|
||||||
"""Wrap email body in a branded layout with inline CSS."""
|
"""Wrap email body in a branded layout with inline CSS."""
|
||||||
|
year = datetime.utcnow().year
|
||||||
return f"""\
|
return f"""\
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head><meta charset="utf-8"></head>
|
<head>
|
||||||
<body style="margin:0;padding:0;background-color:#F8FAFC;font-family:'Inter',Helvetica,Arial,sans-serif;">
|
<meta charset="utf-8">
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#F8FAFC;padding:40px 0;">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>{config.APP_NAME}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#F1F5F9;font-family:Helvetica,Arial,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#F1F5F9;padding:40px 16px;">
|
||||||
<tr><td align="center">
|
<tr><td align="center">
|
||||||
<table width="480" cellpadding="0" cellspacing="0" style="background-color:#FFFFFF;border-radius:8px;border:1px solid #E2E8F0;overflow:hidden;">
|
<table width="520" cellpadding="0" cellspacing="0" style="max-width:520px;width:100%;background-color:#FFFFFF;border-radius:10px;border:1px solid #E2E8F0;overflow:hidden;">
|
||||||
<!-- Header -->
|
|
||||||
<tr><td style="background-color:#0F172A;padding:24px 32px;">
|
<!-- Logo header -->
|
||||||
<span style="color:#FFFFFF;font-size:18px;font-weight:700;letter-spacing:-0.02em;">{config.APP_NAME}</span>
|
<tr><td style="background-color:#0F172A;padding:28px 36px 24px;">
|
||||||
|
<table cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align:middle;">
|
||||||
|
<!-- Padel racket monogram -->
|
||||||
|
<span style="display:inline-block;width:32px;height:32px;background-color:#1D4ED8;border-radius:6px;text-align:center;line-height:32px;font-size:17px;font-weight:800;color:#fff;font-family:Helvetica,Arial,sans-serif;margin-right:10px;vertical-align:middle;">P</span>
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align:middle;">
|
||||||
|
<span style="color:#FFFFFF;font-size:18px;font-weight:700;letter-spacing:-0.03em;vertical-align:middle;">{config.APP_NAME}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<tr><td style="padding:32px;color:#475569;font-size:15px;line-height:1.6;">
|
<tr><td style="padding:36px;color:#334155;font-size:15px;line-height:1.65;">
|
||||||
{body}
|
{body}
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<tr><td style="height:1px;background-color:#E2E8F0;"></td></tr>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<tr><td style="padding:20px 32px;border-top:1px solid #E2E8F0;text-align:center;">
|
<tr><td style="padding:20px 36px;background-color:#F8FAFC;">
|
||||||
<span style="color:#94A3B8;font-size:12px;">© {config.APP_NAME} · You received this because you have an account.</span>
|
<p style="margin:0 0 6px;font-size:12px;color:#94A3B8;text-align:center;">
|
||||||
|
<a href="{config.BASE_URL}" style="color:#64748B;text-decoration:none;font-weight:500;">{config.APP_NAME}</a>
|
||||||
|
·
|
||||||
|
The padel business planning platform
|
||||||
|
</p>
|
||||||
|
<p style="margin:0;font-size:11px;color:#CBD5E1;text-align:center;">
|
||||||
|
© {year} {config.APP_NAME}. You received this email because you have an account or submitted a request.
|
||||||
|
</p>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
</td></tr>
|
</td></tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -44,10 +73,10 @@ def _email_wrap(body: str) -> str:
|
|||||||
def _email_button(url: str, label: str) -> str:
|
def _email_button(url: str, label: str) -> str:
|
||||||
"""Render a branded CTA button for email."""
|
"""Render a branded CTA button for email."""
|
||||||
return (
|
return (
|
||||||
f'<table cellpadding="0" cellspacing="0" style="margin:24px 0;">'
|
f'<table cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">'
|
||||||
f'<tr><td style="background-color:#3B82F6;border-radius:6px;text-align:center;">'
|
f'<tr><td style="background-color:#1D4ED8;border-radius:7px;text-align:center;">'
|
||||||
f'<a href="{url}" style="display:inline-block;padding:12px 28px;'
|
f'<a href="{url}" style="display:inline-block;padding:13px 30px;'
|
||||||
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;">'
|
f'color:#FFFFFF;font-size:15px;font-weight:600;text-decoration:none;letter-spacing:-0.01em;">'
|
||||||
f'{label}</a></td></tr></table>'
|
f'{label}</a></td></tr></table>'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -174,8 +203,9 @@ async def handle_send_magic_link(payload: dict) -> None:
|
|||||||
@task("send_quote_verification")
|
@task("send_quote_verification")
|
||||||
async def handle_send_quote_verification(payload: dict) -> None:
|
async def handle_send_quote_verification(payload: dict) -> None:
|
||||||
"""Send verification email for quote request."""
|
"""Send verification email for quote request."""
|
||||||
|
lang = payload.get("lang", "en")
|
||||||
link = (
|
link = (
|
||||||
f"{config.BASE_URL}/leads/verify"
|
f"{config.BASE_URL}/{lang}/leads/verify"
|
||||||
f"?token={payload['token']}&lead={payload['lead_id']}"
|
f"?token={payload['token']}&lead={payload['lead_id']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
508
padelnomics/tests/test_e2e_flows.py
Normal file
508
padelnomics/tests/test_e2e_flows.py
Normal file
@@ -0,0 +1,508 @@
|
|||||||
|
"""
|
||||||
|
Comprehensive E2E flow tests using Playwright.
|
||||||
|
|
||||||
|
Covers all major user flows: public pages, planner, auth, directory, quote wizard,
|
||||||
|
and cross-cutting checks (translations, footer, language switcher).
|
||||||
|
|
||||||
|
Skipped by default (requires `playwright install chromium`).
|
||||||
|
Run explicitly with:
|
||||||
|
uv run pytest -m visual tests/test_e2e_flows.py -v
|
||||||
|
|
||||||
|
Server runs on port 5113 (isolated from test_visual.py on 5111 and
|
||||||
|
test_quote_wizard.py on 5112).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import multiprocessing
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import expect, sync_playwright
|
||||||
|
|
||||||
|
from padelnomics import core
|
||||||
|
from padelnomics.app import create_app
|
||||||
|
from padelnomics.migrations.migrate import migrate
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.visual
|
||||||
|
|
||||||
|
PORT = 5113
|
||||||
|
BASE = f"http://127.0.0.1:{PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Server / Browser Fixtures
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _run_server(ready_event):
|
||||||
|
"""Run the Quart dev server in a subprocess with in-memory SQLite."""
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
async def _serve():
|
||||||
|
tmp_db = str(Path(tempfile.mkdtemp()) / "schema.db")
|
||||||
|
migrate(tmp_db)
|
||||||
|
tmp_conn = sqlite3.connect(tmp_db)
|
||||||
|
rows = tmp_conn.execute(
|
||||||
|
"SELECT sql FROM sqlite_master"
|
||||||
|
" WHERE sql IS NOT NULL"
|
||||||
|
" AND name NOT LIKE 'sqlite_%'"
|
||||||
|
" AND name NOT LIKE '%_fts_%'"
|
||||||
|
" AND name != '_migrations'"
|
||||||
|
" ORDER BY rowid"
|
||||||
|
).fetchall()
|
||||||
|
tmp_conn.close()
|
||||||
|
schema_ddl = ";\n".join(r[0] for r in rows) + ";"
|
||||||
|
|
||||||
|
conn = await aiosqlite.connect(":memory:")
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
await conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
await conn.executescript(schema_ddl)
|
||||||
|
await conn.commit()
|
||||||
|
core._db = conn
|
||||||
|
|
||||||
|
with patch.object(core, "init_db", new_callable=AsyncMock), \
|
||||||
|
patch.object(core, "close_db", new_callable=AsyncMock):
|
||||||
|
app = create_app()
|
||||||
|
app.config["TESTING"] = True
|
||||||
|
|
||||||
|
ready_event.set()
|
||||||
|
await app.run_task(host="127.0.0.1", port=PORT)
|
||||||
|
|
||||||
|
asyncio.run(_serve())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def live_server():
|
||||||
|
ready = multiprocessing.Event()
|
||||||
|
proc = multiprocessing.Process(target=_run_server, args=(ready,), daemon=True)
|
||||||
|
proc.start()
|
||||||
|
ready.wait(timeout=10)
|
||||||
|
time.sleep(1)
|
||||||
|
yield BASE
|
||||||
|
proc.terminate()
|
||||||
|
proc.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def browser():
|
||||||
|
with sync_playwright() as p:
|
||||||
|
b = p.chromium.launch(headless=True)
|
||||||
|
yield b
|
||||||
|
b.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def page(browser):
|
||||||
|
pg = browser.new_page(viewport={"width": 1440, "height": 900})
|
||||||
|
yield pg
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mobile_page(browser):
|
||||||
|
pg = browser.new_page(viewport={"width": 390, "height": 844})
|
||||||
|
yield pg
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
|
||||||
|
def dev_login(page, base, email="test@example.com"):
|
||||||
|
"""Instantly authenticate via dev-login endpoint."""
|
||||||
|
page.goto(f"{base}/auth/dev-login?email={email}")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# A. Public Pages — smoke test every public GET returns 200
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_root_redirects_to_lang(live_server, page):
|
||||||
|
"""GET / should redirect to /<lang>/."""
|
||||||
|
resp = page.goto(live_server + "/")
|
||||||
|
assert resp.ok, f"/ returned {resp.status}"
|
||||||
|
assert "/en/" in page.url or "/de/" in page.url, f"No lang in URL: {page.url}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", [
|
||||||
|
"/en/",
|
||||||
|
"/de/",
|
||||||
|
"/en/features",
|
||||||
|
"/en/terms",
|
||||||
|
"/en/privacy",
|
||||||
|
"/en/about",
|
||||||
|
"/en/imprint",
|
||||||
|
"/en/suppliers",
|
||||||
|
"/de/features",
|
||||||
|
"/de/about",
|
||||||
|
"/de/suppliers",
|
||||||
|
])
|
||||||
|
def test_public_page_200(live_server, page, path):
|
||||||
|
"""Every public page should return 200 and contain meaningful content."""
|
||||||
|
resp = page.goto(live_server + path)
|
||||||
|
assert resp.ok, f"{path} returned {resp.status}"
|
||||||
|
# Verify page has <h1> or <h2> — not a blank error page
|
||||||
|
heading = page.locator("h1, h2").first
|
||||||
|
expect(heading).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_robots_txt(live_server, page):
|
||||||
|
resp = page.goto(live_server + "/robots.txt")
|
||||||
|
assert resp.ok
|
||||||
|
assert "User-agent" in page.content()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sitemap_xml(live_server, page):
|
||||||
|
resp = page.goto(live_server + "/sitemap.xml")
|
||||||
|
assert resp.ok
|
||||||
|
assert "<urlset" in page.content() or "<sitemapindex" in page.content()
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_endpoint(live_server, page):
|
||||||
|
resp = page.goto(live_server + "/health")
|
||||||
|
assert resp.ok
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# B. Planner Flow
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_planner_en_loads(live_server, page):
|
||||||
|
"""Planner page renders wizard and tab bar."""
|
||||||
|
resp = page.goto(live_server + "/en/planner/")
|
||||||
|
assert resp.ok
|
||||||
|
# Tab bar
|
||||||
|
expect(page.locator("#tab-bar")).to_be_visible()
|
||||||
|
# Wizard form
|
||||||
|
expect(page.locator("#planner-form")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_planner_de_loads(live_server, page):
|
||||||
|
"""German planner renders with German UI strings."""
|
||||||
|
resp = page.goto(live_server + "/de/planner/")
|
||||||
|
assert resp.ok
|
||||||
|
# Should contain a German-language label somewhere
|
||||||
|
content = page.content()
|
||||||
|
assert "Investition" in content or "Annahmen" in content or "Anlage" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_planner_calculate_htmx(live_server, page):
|
||||||
|
"""Adjusting a planner input fires HTMX and updates the results panel."""
|
||||||
|
page.goto(live_server + "/en/planner/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Wait for tab content to be present
|
||||||
|
tab_content = page.locator("#tab-content")
|
||||||
|
expect(tab_content).to_be_visible()
|
||||||
|
|
||||||
|
# Trigger a change on a number input to fire HTMX recalc
|
||||||
|
first_input = page.locator("#planner-form input[type='number']").first
|
||||||
|
first_input.click()
|
||||||
|
first_input.press("ArrowUp")
|
||||||
|
|
||||||
|
# Wait for HTMX response to update tab-content
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
# Tab content should still exist and be non-empty after recalc
|
||||||
|
expect(tab_content).to_be_visible()
|
||||||
|
assert len(tab_content.inner_html()) > 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_planner_tab_switching(live_server, page):
|
||||||
|
"""Clicking all result tabs renders different content each time."""
|
||||||
|
page.goto(live_server + "/en/planner/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
tabs = ["capex", "operating", "cashflow", "returns", "metrics"]
|
||||||
|
seen_contents = set()
|
||||||
|
|
||||||
|
for tab_id in tabs:
|
||||||
|
btn = page.locator(f"button[data-tab='{tab_id}'], [data-tab='{tab_id}']").first
|
||||||
|
if btn.count() == 0:
|
||||||
|
# Try clicking by visible text
|
||||||
|
btn = page.get_by_role("button", name=tab_id, exact=False).first
|
||||||
|
btn.click()
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
html = page.locator("#tab-content").inner_html()
|
||||||
|
seen_contents.add(html[:100]) # first 100 chars as fingerprint
|
||||||
|
|
||||||
|
# All tabs should render distinct content
|
||||||
|
assert len(seen_contents) >= 3, "Tab content didn't change across tabs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_planner_chart_data_present(live_server, page):
|
||||||
|
"""Result tabs embed chart data as JSON script tags."""
|
||||||
|
page.goto(live_server + "/en/planner/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Charts on the capex tab
|
||||||
|
chart_scripts = page.locator("script[type='application/json']")
|
||||||
|
assert chart_scripts.count() >= 1, "No chart JSON script tags found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_planner_quote_sidebar_visible_wide(live_server, page):
|
||||||
|
"""Quote sidebar should be visible on wide viewport (>1400px)."""
|
||||||
|
pg = page.context.new_page()
|
||||||
|
pg.set_viewport_size({"width": 1600, "height": 900})
|
||||||
|
pg.goto(live_server + "/en/planner/")
|
||||||
|
pg.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
sidebar = pg.locator(".quote-sidebar")
|
||||||
|
if sidebar.count() > 0:
|
||||||
|
# If present, should not be display:none
|
||||||
|
display = pg.evaluate(
|
||||||
|
"getComputedStyle(document.querySelector('.quote-sidebar')).display"
|
||||||
|
)
|
||||||
|
assert display != "none", f"Quote sidebar is hidden on wide viewport: display={display}"
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# C. Auth Flow
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_login_page_loads(live_server, page):
|
||||||
|
resp = page.goto(live_server + "/auth/login")
|
||||||
|
assert resp.ok
|
||||||
|
expect(page.locator("form")).to_be_visible()
|
||||||
|
expect(page.locator("input[type='email']")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_signup_page_loads(live_server, page):
|
||||||
|
resp = page.goto(live_server + "/auth/signup")
|
||||||
|
assert resp.ok
|
||||||
|
expect(page.locator("form")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_login_redirects_to_dashboard(live_server, page):
|
||||||
|
"""Dev login should create session and redirect to dashboard."""
|
||||||
|
page.goto(live_server + "/auth/dev-login?email=devtest@example.com")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
assert "/dashboard" in page.url, f"Expected dashboard redirect, got: {page.url}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_dashboard_loads(live_server, page):
|
||||||
|
"""After dev-login, dashboard should be accessible."""
|
||||||
|
dev_login(page, live_server, "dash@example.com")
|
||||||
|
assert "/dashboard" in page.url
|
||||||
|
resp_status = page.evaluate("() => document.readyState")
|
||||||
|
assert resp_status == "complete"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_dashboard_redirects(live_server, page):
|
||||||
|
"""Without auth, /dashboard/ should redirect to login."""
|
||||||
|
page.goto(live_server + "/dashboard/", wait_until="networkidle")
|
||||||
|
assert "login" in page.url or "auth" in page.url, (
|
||||||
|
f"Expected redirect to login, got: {page.url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_magic_link_sent_page(live_server, page):
|
||||||
|
"""Magic link sent page renders with the email address shown."""
|
||||||
|
resp = page.goto(live_server + "/auth/magic-link-sent?email=test@example.com")
|
||||||
|
assert resp.ok
|
||||||
|
expect(page.get_by_text("test@example.com")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# D. Directory Flow
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_directory_en_loads(live_server, page):
|
||||||
|
resp = page.goto(live_server + "/en/directory/")
|
||||||
|
assert resp.ok
|
||||||
|
expect(page.locator("h1, h2").first).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_de_loads(live_server, page):
|
||||||
|
resp = page.goto(live_server + "/de/directory/")
|
||||||
|
assert resp.ok
|
||||||
|
content = page.content()
|
||||||
|
# Should have German-language UI (filter label, heading, etc.)
|
||||||
|
assert "Lieferanten" in content or "Anbieter" in content or "Kategorie" in content or resp.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_search_htmx(live_server, page):
|
||||||
|
"""Typing in directory search fires HTMX and returns results partial."""
|
||||||
|
page.goto(live_server + "/en/directory/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
search = page.locator("input[type='search'], input[name='q'], input[type='text']").first
|
||||||
|
if search.count() == 0:
|
||||||
|
pytest.skip("No search input found")
|
||||||
|
|
||||||
|
search.fill("padel")
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
# Results container should exist
|
||||||
|
results = page.locator("#supplier-results, #results, [id*='result']").first
|
||||||
|
expect(results).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# E. Quote Flow (key steps)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_quote_step1_loads(live_server, page):
|
||||||
|
"""Quote wizard step 1 renders the form."""
|
||||||
|
resp = page.goto(live_server + "/en/leads/quote")
|
||||||
|
assert resp.ok
|
||||||
|
expect(page.locator("form")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_quote_step1_de_loads(live_server, page):
|
||||||
|
resp = page.goto(live_server + "/de/leads/quote")
|
||||||
|
assert resp.ok
|
||||||
|
content = page.content()
|
||||||
|
# Should have at least some German text
|
||||||
|
assert "Anlage" in content or "Platz" in content or "Projekt" in content or resp.ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_quote_verify_url_includes_lang(live_server, page):
|
||||||
|
"""Verify the leads/verify route exists at /<lang>/leads/verify."""
|
||||||
|
# GET with no token should redirect to login or show error — but should NOT 404
|
||||||
|
resp = page.goto(live_server + "/en/leads/verify?token=invalid")
|
||||||
|
# Should be 200 (shows error) or a redirect — not 404
|
||||||
|
assert resp.status != 404, f"Verify endpoint returned 404 — lang prefix missing?"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# F. Authenticated Flows (planner scenarios, leads forms)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_authenticated_planner_loads(live_server, page):
|
||||||
|
"""Authenticated user can access the planner."""
|
||||||
|
dev_login(page, live_server, "planneruser@example.com")
|
||||||
|
resp = page.goto(live_server + "/en/planner/")
|
||||||
|
assert resp.ok
|
||||||
|
expect(page.locator("#planner-form")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_planner_scenarios_list(live_server, page):
|
||||||
|
"""Authenticated user can access scenarios list."""
|
||||||
|
dev_login(page, live_server, "scenuser@example.com")
|
||||||
|
resp = page.goto(live_server + "/en/planner/scenarios")
|
||||||
|
assert resp.ok
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# G. Cross-Cutting Checks
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_footer_present_on_public_pages(live_server, page):
|
||||||
|
"""Footer should be present on all public pages."""
|
||||||
|
for path in ["/en/", "/en/features", "/en/about"]:
|
||||||
|
page.goto(live_server + path)
|
||||||
|
footer = page.locator("footer")
|
||||||
|
expect(footer).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_footer_has_four_column_layout(live_server, page):
|
||||||
|
"""Footer grid should have 4 link columns."""
|
||||||
|
page.goto(live_server + "/en/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Count footer navigation columns (divs/sections with link lists)
|
||||||
|
footer_cols = page.evaluate("""
|
||||||
|
(() => {
|
||||||
|
const footer = document.querySelector('footer');
|
||||||
|
if (!footer) return 0;
|
||||||
|
const grid = footer.querySelector('[class*="grid"]');
|
||||||
|
if (!grid) return 0;
|
||||||
|
return grid.children.length;
|
||||||
|
})()
|
||||||
|
""")
|
||||||
|
assert footer_cols >= 4, f"Expected 4 footer columns, found {footer_cols}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_language_switcher_en_to_de(live_server, page):
|
||||||
|
"""Language switcher should navigate from /en/ to /de/."""
|
||||||
|
page.goto(live_server + "/en/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# Find language switcher link for DE
|
||||||
|
de_link = page.locator("a[href*='/de/'], a[href='/de']").first
|
||||||
|
if de_link.count() == 0:
|
||||||
|
pytest.skip("No DE language switcher link found")
|
||||||
|
|
||||||
|
de_link.click()
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
assert "/de/" in page.url or page.url.endswith("/de"), f"Language switch failed: {page.url}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_missing_translations_en(live_server, page):
|
||||||
|
"""EN pages should not contain 'None' or 'undefined' translation markers."""
|
||||||
|
for path in ["/en/", "/en/features", "/en/about"]:
|
||||||
|
page.goto(live_server + path)
|
||||||
|
content = page.locator("body").inner_text()
|
||||||
|
# Check for obvious translation failure markers
|
||||||
|
assert "t.auth_" not in content, f"Untranslated key in {path}"
|
||||||
|
assert "{{" not in content, f"Jinja template not rendered in {path}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_missing_translations_de(live_server, page):
|
||||||
|
"""DE pages should contain German text, not raw English keys."""
|
||||||
|
page.goto(live_server + "/de/")
|
||||||
|
content = page.locator("body").inner_text()
|
||||||
|
assert "{{" not in content, "Jinja template not rendered in /de/"
|
||||||
|
assert "t.auth_" not in content, "Untranslated key in /de/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_login_page_german(live_server, page):
|
||||||
|
"""Auth login should render in German when lang cookie is 'de'."""
|
||||||
|
page.context.add_cookies([{
|
||||||
|
"name": "lang", "value": "de",
|
||||||
|
"domain": "127.0.0.1", "path": "/"
|
||||||
|
}])
|
||||||
|
page.goto(live_server + "/auth/login")
|
||||||
|
content = page.content()
|
||||||
|
# German auth page should contain German text
|
||||||
|
assert "Anmelden" in content or "E-Mail" in content or "Weiter" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_404_for_nonexistent_page(live_server, page):
|
||||||
|
"""Non-existent pages should return 404."""
|
||||||
|
resp = page.goto(live_server + "/en/this-page-does-not-exist-xyz")
|
||||||
|
assert resp.status == 404, f"Expected 404, got {resp.status}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_redirect_terms(live_server, page):
|
||||||
|
"""Legacy /terms should redirect to /en/terms."""
|
||||||
|
resp = page.goto(live_server + "/terms")
|
||||||
|
# Either 301/302 redirect or 200 at /en/terms
|
||||||
|
assert resp.ok or resp.status in (301, 302)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# H. Markets Waitlist (WAITLIST_MODE=False by default — page should load)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_markets_hub_loads(live_server, page):
|
||||||
|
"""Markets hub should load normally when WAITLIST_MODE is off."""
|
||||||
|
resp = page.goto(live_server + "/en/markets")
|
||||||
|
assert resp.ok
|
||||||
|
expect(page.locator("h1, h2").first).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_markets_results_partial_loads(live_server, page):
|
||||||
|
"""Markets results HTMX partial should return 200."""
|
||||||
|
resp = page.goto(live_server + "/en/markets/results")
|
||||||
|
assert resp.ok
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# I. Tooltip Presence (result tab tooltips)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def test_planner_tooltips_present(live_server, page):
|
||||||
|
"""Result tabs should contain tooltip spans for complex financial terms."""
|
||||||
|
page.goto(live_server + "/en/planner/")
|
||||||
|
page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
|
# The returns tab should have tooltip spans
|
||||||
|
returns_btn = page.locator("button[data-tab='returns'], [data-tab='returns']").first
|
||||||
|
if returns_btn.count() > 0:
|
||||||
|
returns_btn.click()
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
|
||||||
|
# After clicking returns tab, look for tooltip info spans
|
||||||
|
ti_spans = page.locator(".ti")
|
||||||
|
assert ti_spans.count() >= 1, "No tooltip spans (.ti) found on results tab"
|
||||||
Reference in New Issue
Block a user