migrate from Pico CSS to Tailwind CSS v4

Replace Pico CSS CDN with Tailwind v4 standalone CLI (no Node.js).
Brand theme with navy/electric/accent palette, component classes,
self-hosted Commit Mono font. Docker multi-stage CSS build.
Logo links to dashboard when logged in.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-16 14:45:32 +01:00
parent 2763bcd943
commit 72077fdd46
35 changed files with 1219 additions and 1031 deletions

View File

@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Changed
- **Pico CSS → Tailwind CSS v4** — full design system migration across all templates (except planner, which keeps its own CSS)
- Standalone Tailwind CLI binary (no Node.js) with `make css-build` / `make css-watch`
- Court Tech brand theme: navy/charcoal/electric/accent color palette
- Component classes (`.btn`, `.card`, `.form-input`, `.table`, `.badge`, `.flash`, etc.) in `input.css` for consistent styling
- Self-hosted Commit Mono font (replaces JetBrains Mono) for monospace data display
- Docker multi-stage build: CSS compiled in dedicated stage before Python build
- Landing page teaser calculator restyled with Tailwind utilities and brand colors
### Removed
- Pico CSS CDN dependency
- `custom.css` (replaced by Tailwind `input.css` with `@layer components`)
- JetBrains Mono font (replaced by self-hosted Commit Mono)
### Fixed
- Empty env vars (e.g. `SECRET_KEY=`) now fall back to defaults instead of silently using `""` — fixes 500 on every request when `.env` has blank values

View File

@@ -36,3 +36,7 @@ htmlcov/
dist/
build/
*.egg-info/
# Tailwind CSS
bin/tailwindcss
src/padelnomics/static/css/output.css

View File

@@ -1,3 +1,12 @@
# CSS build stage (Tailwind standalone CLI, no Node.js)
FROM debian:bookworm-slim AS css-build
ADD https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
RUN chmod +x /usr/local/bin/tailwindcss
WORKDIR /app
COPY src/ ./src/
RUN tailwindcss -i ./src/padelnomics/static/css/input.css \
-o ./src/padelnomics/static/css/output.css --minify
# Build stage
FROM python:3.12-slim AS build
COPY --from=ghcr.io/astral-sh/uv:0.8 /uv /uvx /bin/
@@ -15,6 +24,7 @@ RUN useradd -m -u 1000 appuser
WORKDIR /app
RUN mkdir -p /app/data && chown -R appuser:appuser /app
COPY --from=build --chown=appuser:appuser /app .
COPY --from=css-build /app/src/padelnomics/static/css/output.css ./src/padelnomics/static/css/output.css
USER appuser
ENV PYTHONUNBUFFERED=1
ENV DATABASE_PATH=/app/data/app.db

12
padelnomics/Makefile Normal file
View File

@@ -0,0 +1,12 @@
TAILWIND := ./bin/tailwindcss
bin/tailwindcss:
@mkdir -p bin
curl -sLo bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64
chmod +x bin/tailwindcss
css-build: bin/tailwindcss
$(TAILWIND) -i src/padelnomics/static/css/input.css -o src/padelnomics/static/css/output.css --minify
css-watch: bin/tailwindcss
$(TAILWIND) -i src/padelnomics/static/css/input.css -o src/padelnomics/static/css/output.css --watch

View File

@@ -2,9 +2,22 @@
Plan, finance, and build your padel business.
## Development
### CSS (Tailwind)
## CI/CD:
Uses the [Tailwind CSS standalone CLI](https://tailwindcss.com/blog/standalone-cli) — no Node.js required.
```bash
make css-watch # rebuild on file changes (dev)
make css-build # one-off minified build (CI/Docker)
```
The first run downloads the Tailwind binary to `bin/tailwindcss` automatically.
Edit `src/padelnomics/static/css/input.css` for theme tokens, base styles, and component classes. Output is gitignored — Docker builds it in a dedicated stage.
## CI/CD:
Go to GitLab → padelnomics → Settings → CI/CD → Variables and add:
┌─────────────────┬────────────────────────────┬───────────────────────────────────────────┐

View File

@@ -3,62 +3,62 @@
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8">
<div>
<h1>Admin Dashboard</h1>
<h1 class="text-2xl">Admin Dashboard</h1>
{% if session.get('admin_impersonating') %}
<mark>Currently impersonating a user</mark>
<form method="post" action="{{ url_for('admin.stop_impersonating') }}" style="display: inline;">
<div class="flex items-center gap-2 mt-2">
<span class="badge-warning">Currently impersonating a user</span>
<form method="post" action="{{ url_for('admin.stop_impersonating') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary outline" style="padding: 0.25rem 0.5rem;">Stop</button>
<button type="submit" class="btn-outline btn-sm">Stop</button>
</form>
</div>
{% endif %}
</div>
<form method="post" action="{{ url_for('admin.logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary outline">Logout</button>
<button type="submit" class="btn-outline">Logout</button>
</form>
</header>
<!-- Stats Grid -->
<div class="grid">
<article>
<header><small>Total Users</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.users_total }}</strong></p>
<small>+{{ stats.users_today }} today, +{{ stats.users_week }} this week</small>
</article>
<article>
<header><small>Active Subscriptions</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.active_subscriptions }}</strong></p>
</article>
<article>
<header><small>Task Queue</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.tasks_pending }}</strong> pending</p>
<div class="grid-3 mb-8">
<div class="card text-center">
<p class="card-header">Total Users</p>
<p class="text-3xl font-bold text-navy metric">{{ stats.users_total }}</p>
<p class="text-xs text-slate mt-1">+{{ stats.users_today }} today, +{{ stats.users_week }} this week</p>
</div>
<div class="card text-center">
<p class="card-header">Active Subscriptions</p>
<p class="text-3xl font-bold text-navy metric">{{ stats.active_subscriptions }}</p>
</div>
<div class="card text-center">
<p class="card-header">Task Queue</p>
<p class="text-3xl font-bold text-navy metric">{{ stats.tasks_pending }} <span class="text-base font-normal text-slate">pending</span></p>
{% if stats.tasks_failed > 0 %}
<small style="color: var(--del-color);">{{ stats.tasks_failed }} failed</small>
<p class="text-xs text-danger mt-1">{{ stats.tasks_failed }} failed</p>
{% else %}
<small style="color: var(--ins-color);">0 failed</small>
<p class="text-xs text-accent mt-1">0 failed</p>
{% endif %}
</article>
</div>
</div>
<!-- Quick Links -->
<div class="grid" style="margin-bottom: 2rem;">
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline">All Users</a>
<a href="{{ url_for('admin.tasks') }}" role="button" class="secondary outline">Task Queue</a>
<a href="{{ url_for('dashboard.index') }}" role="button" class="secondary outline">View as User</a>
<div class="grid-3 mb-10">
<a href="{{ url_for('admin.users') }}" class="btn-outline text-center">All Users</a>
<a href="{{ url_for('admin.tasks') }}" class="btn-outline text-center">Task Queue</a>
<a href="{{ url_for('dashboard.index') }}" class="btn-outline text-center">View as User</a>
</div>
<div class="grid">
<div class="grid-2">
<!-- Recent Users -->
<section>
<h2>Recent Users</h2>
<article>
<h2 class="text-xl mb-4">Recent Users</h2>
<div class="card">
{% if recent_users %}
<table>
<table class="table">
<thead>
<tr>
<th>Email</th>
@@ -69,28 +69,26 @@
<tbody>
{% for u in recent_users %}
<tr>
<td>
<a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a>
</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.plan or 'free' }}</td>
<td>{{ u.created_at[:10] }}</td>
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ url_for('admin.users') }}">View all </a>
<a href="{{ url_for('admin.users') }}" class="text-sm mt-3 inline-block">View all &rarr;</a>
{% else %}
<p>No users yet.</p>
<p class="text-slate text-sm">No users yet.</p>
{% endif %}
</article>
</div>
</section>
<!-- Failed Tasks -->
<section>
<h2>Failed Tasks</h2>
<article>
<h2 class="text-xl mb-4">Failed Tasks</h2>
<div class="card">
{% if failed_tasks %}
<table>
<table class="table">
<thead>
<tr>
<th>Task</th>
@@ -102,22 +100,22 @@
{% for task in failed_tasks[:5] %}
<tr>
<td>{{ task.task_name }}</td>
<td><small>{{ task.error[:50] }}...</small></td>
<td><span class="text-xs text-slate">{{ task.error[:50] }}...</span></td>
<td>
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;">
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button>
<button type="submit" class="btn-outline btn-sm">Retry</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ url_for('admin.tasks') }}">View all </a>
<a href="{{ url_for('admin.tasks') }}" class="text-sm mt-3 inline-block">View all &rarr;</a>
{% else %}
<p style="color: var(--ins-color);">No failed tasks</p>
<p class="text-accent text-sm">No failed tasks</p>
{% endif %}
</article>
</div>
</section>
</div>
</main>

View File

@@ -3,28 +3,27 @@
{% block title %}Admin Login - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto;">
<header>
<h1>Admin Login</h1>
</header>
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8">
<h1 class="text-2xl mb-6">Admin Login</h1>
<form method="post">
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="password">
Password
<div>
<label for="password" class="form-label">Password</label>
<input
type="password"
id="password"
name="password"
class="form-input"
required
autofocus
>
</label>
</div>
<button type="submit">Login</button>
<button type="submit" class="btn w-full">Login</button>
</form>
</article>
</div>
</main>
{% endblock %}

View File

@@ -3,18 +3,19 @@
{% block title %}Tasks - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>Task Queue</h1>
<a href="{{ url_for('admin.index') }}" role="button" class="secondary outline"> Dashboard</a>
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8">
<h1 class="text-2xl">Task Queue</h1>
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">&larr; Dashboard</a>
</header>
<!-- Failed Tasks -->
{% if failed_tasks %}
<section>
<h2 style="color: var(--del-color);">Failed Tasks ({{ failed_tasks | length }})</h2>
<article style="border-color: var(--del-color);">
<table>
<section class="mb-10">
<h2 class="text-xl text-danger mb-4">Failed Tasks ({{ failed_tasks | length }})</h2>
<div class="card border-danger/30">
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
@@ -28,25 +29,25 @@
<tbody>
{% for task in failed_tasks %}
<tr>
<td>{{ task.id }}</td>
<td><code>{{ task.task_name }}</code></td>
<td class="mono text-sm">{{ task.id }}</td>
<td><code class="text-sm bg-soft-white px-1 rounded">{{ task.task_name }}</code></td>
<td>
<details>
<summary>{{ task.error[:40] if task.error else 'No error' }}...</summary>
<pre style="font-size: 0.75rem; white-space: pre-wrap;">{{ task.error }}</pre>
<summary class="cursor-pointer text-xs text-slate">{{ task.error[:40] if task.error else 'No error' }}...</summary>
<pre class="text-xs mt-2 whitespace-pre-wrap text-slate-dark">{{ task.error }}</pre>
</details>
</td>
<td>{{ task.retries }}</td>
<td>{{ task.created_at[:16] }}</td>
<td class="mono text-sm">{{ task.retries }}</td>
<td class="mono text-sm">{{ task.created_at[:16] }}</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" style="margin: 0;">
<div class="flex gap-2">
<form method="post" action="{{ url_for('admin.task_retry', task_id=task.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline" style="padding: 0.25rem 0.5rem; margin: 0;">Retry</button>
<button type="submit" class="btn-outline btn-sm">Retry</button>
</form>
<form method="post" action="{{ url_for('admin.task_delete', task_id=task.id) }}" style="margin: 0;">
<form method="post" action="{{ url_for('admin.task_delete', task_id=task.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">Delete</button>
<button type="submit" class="btn-outline btn-sm text-danger">Delete</button>
</form>
</div>
</td>
@@ -54,16 +55,18 @@
{% endfor %}
</tbody>
</table>
</article>
</div>
</div>
</section>
{% endif %}
<!-- All Tasks -->
<section>
<h2>Recent Tasks</h2>
<article>
<h2 class="text-xl mb-4">Recent Tasks</h2>
<div class="card">
{% if tasks %}
<table>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
@@ -77,30 +80,31 @@
<tbody>
{% for task in tasks %}
<tr>
<td>{{ task.id }}</td>
<td><code>{{ task.task_name }}</code></td>
<td class="mono text-sm">{{ task.id }}</td>
<td><code class="text-sm bg-soft-white px-1 rounded">{{ task.task_name }}</code></td>
<td>
{% if task.status == 'complete' %}
<span style="color: var(--ins-color);">complete</span>
<span class="badge-success">complete</span>
{% elif task.status == 'failed' %}
<span style="color: var(--del-color);">failed</span>
<span class="badge-danger">failed</span>
{% elif task.status == 'pending' %}
<span style="color: var(--mark-background-color);">pending</span>
<span class="badge-warning">pending</span>
{% else %}
{{ task.status }}
<span class="badge">{{ task.status }}</span>
{% endif %}
</td>
<td>{{ task.run_at[:16] if task.run_at else '-' }}</td>
<td>{{ task.created_at[:16] }}</td>
<td>{{ task.completed_at[:16] if task.completed_at else '-' }}</td>
<td class="mono text-sm">{{ task.run_at[:16] if task.run_at else '-' }}</td>
<td class="mono text-sm">{{ task.created_at[:16] }}</td>
<td class="mono text-sm">{{ task.completed_at[:16] if task.completed_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>No tasks in queue.</p>
<p class="text-slate text-sm">No tasks in queue.</p>
{% endif %}
</article>
</div>
</section>
</main>
{% endblock %}

View File

@@ -3,67 +3,75 @@
{% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>{{ user.email }}</h1>
<a href="{{ url_for('admin.users') }}" role="button" class="secondary outline"> Users</a>
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8">
<h1 class="text-2xl">{{ user.email }}</h1>
<a href="{{ url_for('admin.users') }}" class="btn-outline btn-sm">&larr; Users</a>
</header>
<div class="grid">
<div class="grid-2 mb-8">
<!-- User Info -->
<article>
<header><h3>User Info</h3></header>
<dl>
<dt>ID</dt>
<dd>{{ user.id }}</dd>
<dt>Email</dt>
<div class="card">
<h3 class="text-lg mb-4">User Info</h3>
<dl class="space-y-3 text-sm">
<div class="flex justify-between">
<dt class="text-slate">ID</dt>
<dd class="mono">{{ user.id }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-slate">Email</dt>
<dd>{{ user.email }}</dd>
<dt>Name</dt>
</div>
<div class="flex justify-between">
<dt class="text-slate">Name</dt>
<dd>{{ user.name or '-' }}</dd>
<dt>Created</dt>
<dd>{{ user.created_at }}</dd>
<dt>Last Login</dt>
<dd>{{ user.last_login_at or 'Never' }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-slate">Created</dt>
<dd class="mono">{{ user.created_at }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-slate">Last Login</dt>
<dd class="mono">{{ user.last_login_at or 'Never' }}</dd>
</div>
</dl>
</article>
</div>
<!-- Subscription -->
<article>
<header><h3>Subscription</h3></header>
<dl>
<dt>Plan</dt>
<div class="card">
<h3 class="text-lg mb-4">Subscription</h3>
<dl class="space-y-3 text-sm">
<div class="flex justify-between">
<dt class="text-slate">Plan</dt>
<dd>
{% if user.plan %}
<mark>{{ user.plan }}</mark>
<span class="badge">{{ user.plan }}</span>
{% else %}
free
<span class="text-slate">free</span>
{% endif %}
</dd>
<dt>Status</dt>
</div>
<div class="flex justify-between">
<dt class="text-slate">Status</dt>
<dd>{{ user.sub_status or 'N/A' }}</dd>
</div>
{% if user.paddle_customer_id %}
<dt>Paddle Customer</dt>
<dd>{{ user.paddle_customer_id }}</dd>
<div class="flex justify-between">
<dt class="text-slate">Paddle Customer</dt>
<dd class="mono">{{ user.paddle_customer_id }}</dd>
</div>
{% endif %}
</dl>
</article>
</div>
</div>
<!-- Actions -->
<article>
<header><h3>Actions</h3></header>
<div style="display: flex; gap: 1rem;">
<div class="card">
<h3 class="text-lg mb-4">Actions</h3>
<form method="post" action="{{ url_for('admin.impersonate', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary">Impersonate User</button>
<button type="submit" class="btn-secondary">Impersonate User</button>
</form>
</div>
</article>
</main>
{% endblock %}

View File

@@ -3,29 +3,25 @@
{% block title %}Users - Admin - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header style="display: flex; justify-content: space-between; align-items: center;">
<h1>Users</h1>
<a href="{{ url_for('admin.index') }}" role="button" class="secondary outline"> Dashboard</a>
<main class="container-page py-12">
<header class="flex justify-between items-center mb-8">
<h1 class="text-2xl">Users</h1>
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">&larr; Dashboard</a>
</header>
<!-- Search -->
<form method="get" style="margin-bottom: 2rem;">
<div class="grid">
<input
type="search"
name="search"
placeholder="Search by email..."
value="{{ search }}"
>
<button type="submit">Search</button>
<form method="get" class="mb-8">
<div class="flex gap-3 max-w-md">
<input type="search" name="search" class="form-input" placeholder="Search by email..." value="{{ search }}">
<button type="submit" class="btn">Search</button>
</div>
</form>
<!-- User Table -->
<article>
<div class="card">
{% if users %}
<table>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>ID</th>
@@ -40,44 +36,43 @@
<tbody>
{% for u in users %}
<tr>
<td>{{ u.id }}</td>
<td class="mono text-sm">{{ u.id }}</td>
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
<td>{{ u.name or '-' }}</td>
<td>
{% if u.plan %}
<mark>{{ u.plan }}</mark>
<span class="badge">{{ u.plan }}</span>
{% else %}
free
<span class="text-sm text-slate">free</span>
{% endif %}
</td>
<td>{{ u.created_at[:10] }}</td>
<td>{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
<td class="mono text-sm">{{ u.last_login_at[:10] if u.last_login_at else 'Never' }}</td>
<td>
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" style="margin: 0;">
<form method="post" action="{{ url_for('admin.impersonate', user_id=u.id) }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.25rem 0.5rem; margin: 0;">
Impersonate
</button>
<button type="submit" class="btn-outline btn-sm">Impersonate</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
<div style="display: flex; gap: 1rem; justify-content: center; margin-top: 1rem;">
<div class="flex gap-4 justify-center mt-6 text-sm">
{% if page > 1 %}
<a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}"> Previous</a>
<a href="?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}">&larr; Previous</a>
{% endif %}
<span>Page {{ page }}</span>
<span class="text-slate">Page {{ page }}</span>
{% if users | length == 50 %}
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">Next </a>
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">Next &rarr;</a>
{% endif %}
</div>
{% else %}
<p>No users found.</p>
<p class="text-slate text-sm">No users found.</p>
{% endif %}
</article>
</div>
</main>
{% endblock %}

View File

@@ -3,37 +3,34 @@
{% block title %}Sign In - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto;">
<header>
<h1>Sign In</h1>
<p>Enter your email to receive a sign-in link.</p>
</header>
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8">
<h1 class="text-2xl mb-1">Sign In</h1>
<p class="text-slate mb-6">Enter your email to receive a sign-in link.</p>
<form method="post">
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="email">
Email
<div>
<label for="email" class="form-label">Email</label>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="you@example.com"
required
autofocus
>
</label>
</div>
<button type="submit">Send Sign-In Link</button>
<button type="submit" class="btn w-full">Send Sign-In Link</button>
</form>
<footer style="text-align: center; margin-top: 1rem;">
<small>
<p class="text-center text-sm text-slate mt-6">
Don't have an account?
<a href="{{ url_for('auth.signup') }}">Sign up</a>
</small>
</footer>
</article>
</p>
</div>
</main>
{% endblock %}

View File

@@ -3,33 +3,31 @@
{% block title %}Check Your Email - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto; text-align: center;">
<header>
<h1>Check Your Email</h1>
</header>
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8 text-center">
<h1 class="text-2xl mb-4">Check Your Email</h1>
<p>We've sent a sign-in link to:</p>
<p><strong>{{ email }}</strong></p>
<p class="text-slate-dark">We've sent a sign-in link to:</p>
<p class="font-semibold text-navy my-2">{{ email }}</p>
<p>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">Click the link in the email to sign in. The link expires in {{ config.MAGIC_LINK_EXPIRY_MINUTES }} minutes.</p>
<hr>
<details>
<summary>Didn't receive the email?</summary>
<ul style="text-align: left;">
<details class="text-left">
<summary class="cursor-pointer text-sm font-medium text-navy">Didn't receive the email?</summary>
<ul class="list-disc pl-6 mt-2 space-y-1 text-sm text-slate-dark">
<li>Check your spam folder</li>
<li>Make sure the email address is correct</li>
<li>Wait a minute and try again</li>
</ul>
<form method="post" action="{{ url_for('auth.resend') }}">
<form method="post" action="{{ url_for('auth.resend') }}" class="mt-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="email" value="{{ email }}">
<button type="submit" class="secondary outline">Resend Link</button>
<button type="submit" class="btn-outline w-full">Resend Link</button>
</form>
</details>
</article>
</div>
</main>
{% endblock %}

View File

@@ -3,39 +3,35 @@
{% block title %}Sign Up - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 400px; margin: 4rem auto;">
<header>
<h1>Create Free Account</h1>
<p>Save your padel business plan, get supplier quotes, and find financing.</p>
</header>
<main class="container-page py-12">
<div class="card max-w-sm mx-auto mt-8">
<h1 class="text-2xl mb-1">Create Free Account</h1>
<p class="text-slate mb-6">Save your padel business plan, get supplier quotes, and find financing.</p>
<form method="post">
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="email">
Email
<div>
<label for="email" class="form-label">Email</label>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="you@example.com"
required
autofocus
>
</label>
<p class="form-hint">No credit card required. Full access to all features.</p>
</div>
<small>No credit card required. Full access to all features.</small>
<button type="submit">Create Free Account</button>
<button type="submit" class="btn w-full">Create Free Account</button>
</form>
<footer style="text-align: center; margin-top: 1rem;">
<small>
<p class="text-center text-sm text-slate mt-6">
Already have an account?
<a href="{{ url_for('auth.login') }}">Sign in</a>
</small>
</footer>
</article>
</p>
</div>
</main>
{% endblock %}

View File

@@ -2,17 +2,17 @@
{% block title %}Free Financial Planner - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<hgroup>
<h1>100% Free. No Catch.</h1>
<main class="container-page py-12">
<div class="heading-group text-center">
<h1 class="text-3xl">100% Free. No Catch.</h1>
<p>The most sophisticated padel court financial planner available — completely free. Plan your investment with 60+ variables, sensitivity analysis, and professional-grade projections.</p>
</hgroup>
</div>
<div class="grid">
<article>
<header><strong>Financial Planner</strong></header>
<p><strong>Free</strong> — forever</p>
<ul>
<div class="grid-2 mt-8">
<div class="card">
<p class="card-header">Financial Planner</p>
<p class="text-lg font-bold text-navy mb-4">Free <span class="text-sm font-normal text-slate">— forever</span></p>
<ul class="space-y-2 text-sm text-slate-dark mb-6">
<li>60+ adjustable variables</li>
<li>6 analysis tabs (CAPEX, Operating, Cash Flow, Returns, Metrics)</li>
<li>Sensitivity analysis (utilization + pricing)</li>
@@ -21,27 +21,27 @@
<li>Indoor/outdoor &amp; rent/buy models</li>
</ul>
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
<a href="{{ url_for('planner.index') }}" class="btn w-full text-center">Open Planner</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button">Create Free Account</a>
<a href="{{ url_for('auth.signup') }}" class="btn w-full text-center">Create Free Account</a>
{% endif %}
</article>
</div>
<article>
<header><strong>Need Help Building?</strong></header>
<p>We connect you with verified partners</p>
<ul>
<div class="card">
<p class="card-header">Need Help Building?</p>
<p class="text-slate-dark mb-4">We connect you with verified partners</p>
<ul class="space-y-2 text-sm text-slate-dark mb-6">
<li>Court supplier quotes</li>
<li>Financing &amp; bank connections</li>
<li>Construction planning</li>
<li>Equipment sourcing</li>
</ul>
{% if user %}
<a href="{{ url_for('leads.suppliers') }}" role="button" class="outline">Get Supplier Quotes</a>
<a href="{{ url_for('leads.suppliers') }}" class="btn-outline w-full text-center">Get Supplier Quotes</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button" class="outline">Sign Up to Get Started</a>
<a href="{{ url_for('auth.signup') }}" class="btn-outline w-full text-center">Sign Up to Get Started</a>
{% endif %}
</article>
</div>
</div>
</main>
{% endblock %}

View File

@@ -3,17 +3,13 @@
{% block title %}Welcome - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 500px; margin: 4rem auto; text-align: center;">
<header>
<h1>Welcome to Padelnomics!</h1>
</header>
<main class="container-page py-12">
<div class="card max-w-md mx-auto mt-8 text-center">
<h1 class="text-2xl mb-4">Welcome to Padelnomics!</h1>
<p>Your account is ready. Start planning your padel court investment with our financial planner.</p>
<p class="text-slate-dark mb-6">Your account is ready. Start planning your padel court investment with our financial planner.</p>
<p>
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
</p>
</article>
<a href="{{ url_for('planner.index') }}" class="btn">Open Planner</a>
</div>
</main>
{% endblock %}

View File

@@ -2,39 +2,33 @@
{% block title %}Dashboard - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header>
<h1>Dashboard</h1>
<p>Welcome back{% if user.name %}, {{ user.name }}{% endif %}!</p>
</header>
<main class="container-page py-12">
<h1 class="text-2xl mb-1">Dashboard</h1>
<p class="text-slate mb-8">Welcome back{% if user.name %}, {{ user.name }}{% endif %}!</p>
<div class="grid">
<article>
<header><small>Saved Scenarios</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.scenarios }}</strong></p>
<small>No limits</small>
</article>
<article>
<header><small>Lead Requests</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>{{ stats.leads }}</strong></p>
<small>Supplier &amp; financing inquiries</small>
</article>
<article>
<header><small>Plan</small></header>
<p style="font-size: 2rem; margin: 0;"><strong>Free</strong></p>
<small>Full access to all features</small>
</article>
<div class="grid-3 mb-10">
<div class="card text-center">
<p class="card-header">Saved Scenarios</p>
<p class="text-3xl font-bold text-navy metric">{{ stats.scenarios }}</p>
<p class="text-xs text-slate mt-1">No limits</p>
</div>
<div class="card text-center">
<p class="card-header">Lead Requests</p>
<p class="text-3xl font-bold text-navy metric">{{ stats.leads }}</p>
<p class="text-xs text-slate mt-1">Supplier &amp; financing inquiries</p>
</div>
<div class="card text-center">
<p class="card-header">Plan</p>
<p class="text-3xl font-bold text-navy">Free</p>
<p class="text-xs text-slate mt-1">Full access to all features</p>
</div>
</div>
<section>
<h2>Quick Actions</h2>
<div class="grid">
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
<a href="{{ url_for('leads.suppliers') }}" role="button" class="secondary outline">Get Supplier Quotes</a>
<a href="{{ url_for('dashboard.settings') }}" role="button" class="secondary outline">Settings</a>
<h2 class="text-xl mb-4">Quick Actions</h2>
<div class="grid-3">
<a href="{{ url_for('planner.index') }}" class="btn text-center">Open Planner</a>
<a href="{{ url_for('leads.suppliers') }}" class="btn-outline text-center">Get Supplier Quotes</a>
<a href="{{ url_for('dashboard.settings') }}" class="btn-outline text-center">Settings</a>
</div>
</section>
</main>
{% endblock %}

View File

@@ -2,47 +2,45 @@
{% block title %}Settings - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<header>
<h1>Settings</h1>
</header>
<main class="container-page py-12">
<h1 class="text-2xl mb-8">Settings</h1>
<section>
<h2>Profile</h2>
<article>
<form method="post">
<section class="mb-10">
<h2 class="text-xl mb-4">Profile</h2>
<div class="card">
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="email">
Email
<input type="email" id="email" value="{{ user.email }}" disabled>
<small>Email cannot be changed</small>
</label>
<div>
<label for="email" class="form-label">Email</label>
<input type="email" id="email" value="{{ user.email }}" class="form-input bg-soft-white" disabled>
<p class="form-hint">Email cannot be changed</p>
</div>
<label for="name">
Name
<input type="text" id="name" name="name" value="{{ user.name or '' }}" placeholder="Your name">
</label>
<div>
<label for="name" class="form-label">Name</label>
<input type="text" id="name" name="name" value="{{ user.name or '' }}" placeholder="Your name" class="form-input">
</div>
<button type="submit">Save Changes</button>
<button type="submit" class="btn">Save Changes</button>
</form>
</article>
</div>
</section>
<section>
<h2>Danger Zone</h2>
<article>
<p>Once you delete your account, there is no going back.</p>
<h2 class="text-xl mb-4">Danger Zone</h2>
<div class="card border-danger/30">
<p class="text-slate-dark mb-4">Once you delete your account, there is no going back.</p>
<details>
<summary role="button" class="secondary outline">Delete Account</summary>
<p>This will delete all your scenarios and data permanently.</p>
<summary class="cursor-pointer text-sm font-semibold text-danger">Delete Account</summary>
<p class="text-sm text-slate-dark mt-3 mb-3">This will delete all your scenarios and data permanently.</p>
<form method="post" action="{{ url_for('dashboard.delete_account') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="secondary">Yes, Delete My Account</button>
<button type="submit" class="btn-danger btn-sm">Yes, Delete My Account</button>
</form>
</details>
</article>
</div>
</section>
</main>
{% endblock %}

View File

@@ -2,35 +2,39 @@
{% block title %}Find Financing - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<hgroup>
<h1>Find Financing for Your Padel Project</h1>
<main class="container-page py-12">
<div class="heading-group">
<h1 class="text-2xl">Find Financing for Your Padel Project</h1>
<p>We work with banks and investors experienced in sports facility financing. Tell us about your project and we'll make introductions.</p>
</hgroup>
</div>
<article>
<form method="post">
<div class="card max-w-2xl">
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="location">Project location
<input type="text" id="location" name="location" placeholder="City, region, or country" required>
</label>
<div>
<label for="location" class="form-label">Project location</label>
<input type="text" id="location" name="location" class="form-input" placeholder="City, region, or country" required>
</div>
<label for="court_count">Number of courts
<input type="number" id="court_count" name="court_count" min="1" max="50" value="{{ prefill.get('court_count', 4) }}">
</label>
<div>
<label for="court_count" class="form-label">Number of courts</label>
<input type="number" id="court_count" name="court_count" class="form-input" min="1" max="50" value="{{ prefill.get('court_count', 4) }}">
</div>
<label for="budget">Estimated total investment
<input type="text" id="budget" name="budget" placeholder="e.g. 500000">
<small>The total CAPEX from your financial plan.</small>
</label>
<div>
<label for="budget" class="form-label">Estimated total investment</label>
<input type="text" id="budget" name="budget" class="form-input" placeholder="e.g. 500000">
<p class="form-hint">The total CAPEX from your financial plan.</p>
</div>
<label for="message">Additional details
<textarea id="message" name="message" rows="4" placeholder="How much equity can you contribute? Do you have existing real estate? Any existing banking relationships?"></textarea>
</label>
<div>
<label for="message" class="form-label">Additional details</label>
<textarea id="message" name="message" rows="4" class="form-input" placeholder="How much equity can you contribute? Do you have existing real estate? Any existing banking relationships?"></textarea>
</div>
<button type="submit">Find Financing Partners</button>
<button type="submit" class="btn">Find Financing Partners</button>
</form>
</article>
</div>
</main>
{% endblock %}

View File

@@ -2,35 +2,39 @@
{% block title %}Get Court Supplier Quotes - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<hgroup>
<h1>Get Court Supplier Quotes</h1>
<main class="container-page py-12">
<div class="heading-group">
<h1 class="text-2xl">Get Court Supplier Quotes</h1>
<p>Tell us about your project and we'll connect you with verified padel court suppliers who can provide detailed quotes.</p>
</hgroup>
</div>
<article>
<form method="post">
<div class="card max-w-2xl">
<form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="location">Where do you want to build?
<input type="text" id="location" name="location" placeholder="City, region, or country" required>
</label>
<div>
<label for="location" class="form-label">Where do you want to build?</label>
<input type="text" id="location" name="location" class="form-input" placeholder="City, region, or country" required>
</div>
<label for="court_count">How many courts?
<input type="number" id="court_count" name="court_count" min="1" max="50" value="{{ prefill.get('court_count', 4) }}">
</label>
<div>
<label for="court_count" class="form-label">How many courts?</label>
<input type="number" id="court_count" name="court_count" class="form-input" min="1" max="50" value="{{ prefill.get('court_count', 4) }}">
</div>
<label for="budget">Estimated total budget
<input type="text" id="budget" name="budget" placeholder="e.g. 500000">
<small>Optional — helps suppliers tailor their proposals.</small>
</label>
<div>
<label for="budget" class="form-label">Estimated total budget</label>
<input type="text" id="budget" name="budget" class="form-input" placeholder="e.g. 500000">
<p class="form-hint">Optional — helps suppliers tailor their proposals.</p>
</div>
<label for="message">Tell us more about your project
<textarea id="message" name="message" rows="4" placeholder="Indoor or outdoor? New build or renovation? Timeline? Any specific requirements?"></textarea>
</label>
<div>
<label for="message" class="form-label">Tell us more about your project</label>
<textarea id="message" name="message" rows="4" class="form-input" placeholder="Indoor or outdoor? New build or renovation? Timeline? Any specific requirements?"></textarea>
</div>
<button type="submit">Request Supplier Quotes</button>
<button type="submit" class="btn">Request Supplier Quotes</button>
</form>
</article>
</div>
</main>
{% endblock %}

View File

@@ -3,33 +3,31 @@
{% block title %}About - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header style="text-align: center;">
<h1>About {{ config.APP_NAME }}</h1>
</header>
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-6 text-center">About {{ config.APP_NAME }}</h1>
<section>
<div class="space-y-4 text-slate-dark leading-relaxed">
<p>Padel is the fastest-growing sport in Europe, but opening a padel hall is still a leap of faith for most entrepreneurs. The financials are complex: CAPEX varies wildly depending on venue type, location drives utilization, and the difference between a 60% and 75% occupancy rate can mean the difference between a great investment and a money pit.</p>
<p>We built Padelnomics because we couldn't find a financial planning tool that was good enough. Existing calculators are either too simplistic (5 inputs, one output) or locked behind expensive consulting engagements. We wanted something with the depth of a professional financial model but the accessibility of a web app.</p>
<p>The result is a free financial planner with 60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and the professional metrics that banks and investors need to see. Every assumption is transparent and adjustable. No black boxes.</p>
<h3>Why free?</h3>
<h3 class="text-lg mt-6">Why free?</h3>
<p>The planner is free because we believe better planning leads to better padel venues, and that's good for the entire industry. We make money by connecting entrepreneurs with court suppliers and financing partners when they're ready to move from planning to building.</p>
<h3>What's next</h3>
<h3 class="text-lg mt-6">What's next</h3>
<p>Padelnomics is building the infrastructure for padel entrepreneurship. After planning comes financing, building, and operating. We're working on market intelligence powered by real booking data, a supplier marketplace for court equipment, and analytics tools for venue operators.</p>
</section>
</div>
<section style="text-align: center; margin-top: 3rem;">
<div class="text-center mt-10">
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
<a href="{{ url_for('planner.index') }}" class="btn">Open Planner</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button">Create Free Account</a>
<a href="{{ url_for('auth.signup') }}" class="btn">Create Free Account</a>
{% endif %}
</section>
</article>
</div>
</div>
</main>
{% endblock %}

View File

@@ -7,76 +7,70 @@
{% endblock %}
{% block content %}
<main class="container">
<header style="text-align: center; margin-bottom: 3rem;">
<h1>Everything You Need to Plan Your Padel Business</h1>
<p>Professional-grade financial modeling, completely free.</p>
<main class="container-page py-12">
<header class="text-center mb-12">
<h1 class="text-3xl mb-2">Everything You Need to Plan Your Padel Business</h1>
<p class="text-lg text-slate">Professional-grade financial modeling, completely free.</p>
</header>
<div class="grid">
<article>
<h2>60+ Variables</h2>
<p>Every assumption is adjustable. Court costs, rent, hourly pricing, utilization curves, financing terms, exit multiples. Nothing is hard-coded &mdash; your model reflects your reality.</p>
</article>
<article>
<h2>6 Analysis Tabs</h2>
<p>Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns &amp; Exit, and Key Metrics. Each tab with interactive charts that update in real time as you adjust inputs.</p>
</article>
<div class="grid-2">
<div class="card">
<h2 class="text-xl mb-2">60+ Variables</h2>
<p class="text-slate-dark">Every assumption is adjustable. Court costs, rent, hourly pricing, utilization curves, financing terms, exit multiples. Nothing is hard-coded &mdash; your model reflects your reality.</p>
</div>
<div class="card">
<h2 class="text-xl mb-2">6 Analysis Tabs</h2>
<p class="text-slate-dark">Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns &amp; Exit, and Key Metrics. Each tab with interactive charts that update in real time as you adjust inputs.</p>
</div>
</div>
<div class="grid">
<article>
<h2>Indoor &amp; Outdoor</h2>
<p>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.</p>
</article>
<article>
<h2>Sensitivity Analysis</h2>
<p>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.</p>
</article>
<div class="grid-2">
<div class="card">
<h2 class="text-xl mb-2">Indoor &amp; Outdoor</h2>
<p class="text-slate-dark">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.</p>
</div>
<div class="card">
<h2 class="text-xl mb-2">Sensitivity Analysis</h2>
<p class="text-slate-dark">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.</p>
</div>
</div>
<div class="grid">
<article>
<h2>Professional Metrics</h2>
<p>IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors expect to see in a padel court business plan.</p>
</article>
<article>
<h2>Save &amp; Compare</h2>
<p>Save unlimited scenarios. Test different locations, court counts, financing structures, and pricing strategies. Load and compare to find the optimal plan for your investment.</p>
</article>
<div class="grid-2">
<div class="card">
<h2 class="text-xl mb-2">Professional Metrics</h2>
<p class="text-slate-dark">IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors expect to see in a padel court business plan.</p>
</div>
<div class="card">
<h2 class="text-xl mb-2">Save &amp; Compare</h2>
<p class="text-slate-dark">Save unlimited scenarios. Test different locations, court counts, financing structures, and pricing strategies. Load and compare to find the optimal plan for your investment.</p>
</div>
</div>
<section>
<article>
<h2>Detailed CAPEX Breakdown</h2>
<p>Model every cost line individually: court installation, flooring, lighting, climate control, changing rooms, reception, parking, landscaping. Toggle between renting a building and constructing new. Adjust land costs, construction costs per sqm, and fit-out budgets independently.</p>
</article>
<div class="space-y-6 max-w-3xl mx-auto mt-12">
<div class="card">
<h2 class="text-xl mb-2">Detailed CAPEX Breakdown</h2>
<p class="text-slate-dark">Model every cost line individually: court installation, flooring, lighting, climate control, changing rooms, reception, parking, landscaping. Toggle between renting a building and constructing new. Adjust land costs, construction costs per sqm, and fit-out budgets independently.</p>
</div>
<div class="card">
<h2 class="text-xl mb-2">Operating Model</h2>
<p class="text-slate-dark">Peak and off-peak pricing with configurable hour splits. Monthly utilization ramp-up curves. Staff costs, maintenance, insurance, marketing, and utilities &mdash; all adjustable with sliders. Revenue from court rentals, coaching, equipment, and F&amp;B.</p>
</div>
<div class="card">
<h2 class="text-xl mb-2">Cash Flow &amp; Financing</h2>
<p class="text-slate-dark">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.</p>
</div>
<div class="card">
<h2 class="text-xl mb-2">Returns &amp; Exit</h2>
<p class="text-slate-dark">Calculate your equity IRR and MOIC under different exit scenarios. Model cap rate exits with configurable holding periods. See your equity waterfall from initial investment through to exit proceeds.</p>
</div>
</div>
<article>
<h2>Operating Model</h2>
<p>Peak and off-peak pricing with configurable hour splits. Monthly utilization ramp-up curves. Staff costs, maintenance, insurance, marketing, and utilities &mdash; all adjustable with sliders. Revenue from court rentals, coaching, equipment, and F&amp;B.</p>
</article>
<article>
<h2>Cash Flow &amp; Financing</h2>
<p>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.</p>
</article>
<article>
<h2>Returns &amp; Exit</h2>
<p>Calculate your equity IRR and MOIC under different exit scenarios. Model cap rate exits with configurable holding periods. See your equity waterfall from initial investment through to exit proceeds.</p>
</article>
</section>
<section style="text-align: center; margin-top: 3rem;">
<div class="text-center mt-12">
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button">Open Planner</a>
<a href="{{ url_for('planner.index') }}" class="btn">Open Planner</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button">Create Free Account</a>
<a href="{{ url_for('auth.signup') }}" class="btn">Create Free Account</a>
{% endif %}
</section>
</div>
</main>
{% endblock %}

View File

@@ -10,29 +10,8 @@
<meta property="og:url" content="{{ config.BASE_URL }}">
<link rel="canonical" href="{{ config.BASE_URL }}">
<style>
.teaser-calc {
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
padding: 2rem;
max-width: 900px;
margin: 0 auto;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.teaser-calc .slider-row {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.teaser-calc .slider-row label {
width: 140px;
flex-shrink: 0;
font-size: 13px;
color: #64748B;
}
.teaser-calc .slider-row input[type=range] {
flex: 1;
/* Teaser calculator — scoped styles for range inputs */
.teaser-calc input[type=range] {
-webkit-appearance: none;
appearance: none;
height: 5px;
@@ -41,7 +20,7 @@
outline: none;
cursor: pointer;
}
.teaser-calc .slider-row input[type=range]::-webkit-slider-thumb {
.teaser-calc input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
@@ -49,225 +28,172 @@
border: 2px solid #FFFFFF;
cursor: pointer;
}
.teaser-calc .slider-row input[type=range]::-moz-range-thumb {
.teaser-calc input[type=range]::-moz-range-thumb {
width: 16px; height: 16px;
border-radius: 50%;
background: #3B82F6;
border: 2px solid #FFFFFF;
cursor: pointer;
}
.teaser-calc .slider-row .val {
width: 70px;
text-align: right;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
color: #0F172A;
flex-shrink: 0;
}
.teaser-results {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid #E2E8F0;
}
.teaser-metric {
text-align: center;
}
.teaser-metric .tm-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748B;
margin-bottom: 4px;
}
.teaser-metric .tm-value {
font-family: 'JetBrains Mono', monospace;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.2;
}
.teaser-metric .tm-sub {
font-size: 11px;
color: #64748B;
margin-top: 2px;
}
.tm-green { color: #10B981; }
.tm-blue { color: #3B82F6; }
.tm-navy { color: #0F172A; }
.tm-red { color: #EF4444; }
.teaser-cta {
text-align: center;
margin-top: 1.5rem;
padding-top: 1rem;
}
.teaser-cta p {
font-size: 13px;
color: #64748B;
margin-bottom: 0.75rem;
}
@media (max-width: 768px) {
.teaser-results { grid-template-columns: repeat(2, 1fr); }
.teaser-calc .slider-row { flex-wrap: wrap; }
.teaser-calc .slider-row label { width: 100%; margin-bottom: -0.5rem; }
}
</style>
{% endblock %}
{% block content %}
<main class="container">
<main class="container-page">
<!-- Hero -->
<header style="text-align: center; padding: 4rem 0 2rem;">
<h1 style="font-size: 2.5rem; line-height: 1.15;">Plan Your Padel Business<br>in Minutes, Not Months</h1>
<p style="font-size: 1.2rem; max-width: 640px; margin: 1rem auto 0; color: #64748B;">
<header class="text-center py-16 pb-8">
<h1 class="text-4xl md:text-5xl leading-tight">Plan Your Padel Business<br>in Minutes, Not Months</h1>
<p class="text-xl text-slate max-w-xl mx-auto mt-4">
Model your padel court investment with 60+ variables, sensitivity analysis, and professional-grade projections.
</p>
</header>
<!-- Teaser Calculator -->
<section style="padding: 2rem 0 3rem;">
<div class="teaser-calc">
<h2 style="text-align: center; margin-bottom: 1.5rem; font-size: 1.25rem;">Quick ROI Estimate</h2>
<section class="py-8 pb-12">
<div class="teaser-calc card max-w-3xl mx-auto shadow-sm">
<h2 class="text-center text-xl mb-6">Quick ROI Estimate</h2>
<div class="slider-row">
<label>Courts</label>
<input type="range" id="tc-courts" min="2" max="12" step="1" value="6" oninput="tCalc()">
<span class="val" id="tv-courts">6</span>
<div class="space-y-5">
<div class="flex items-center gap-4">
<label class="w-36 shrink-0 text-sm text-slate">Courts</label>
<input type="range" id="tc-courts" min="2" max="12" step="1" value="6" oninput="tCalc()" class="flex-1">
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-courts">6</span>
</div>
<div class="slider-row">
<label>Peak Rate</label>
<input type="range" id="tc-rate" min="20" max="100" step="5" value="50" oninput="tCalc()">
<span class="val" id="tv-rate">&euro;50/hr</span>
<div class="flex items-center gap-4">
<label class="w-36 shrink-0 text-sm text-slate">Peak Rate</label>
<input type="range" id="tc-rate" min="20" max="100" step="5" value="50" oninput="tCalc()" class="flex-1">
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-rate">&euro;50/hr</span>
</div>
<div class="slider-row">
<label>Utilization</label>
<input type="range" id="tc-util" min="15" max="75" step="5" value="40" oninput="tCalc()">
<span class="val" id="tv-util">40%</span>
<div class="flex items-center gap-4">
<label class="w-36 shrink-0 text-sm text-slate">Utilization</label>
<input type="range" id="tc-util" min="15" max="75" step="5" value="40" oninput="tCalc()" class="flex-1">
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-util">40%</span>
</div>
<div class="slider-row">
<label>Build Cost / Court</label>
<input type="range" id="tc-buildcost" min="20000" max="50000" step="5000" value="30000" oninput="tCalc()">
<span class="val" id="tv-buildcost">&euro;30K</span>
<div class="flex items-center gap-4">
<label class="w-36 shrink-0 text-sm text-slate">Build Cost / Court</label>
<input type="range" id="tc-buildcost" min="20000" max="50000" step="5000" value="30000" oninput="tCalc()" class="flex-1">
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-buildcost">&euro;30K</span>
</div>
<div class="slider-row">
<label>Equity %</label>
<input type="range" id="tc-equity" min="15" max="50" step="5" value="25" oninput="tCalc()">
<span class="val" id="tv-equity">25%</span>
</div>
<p style="text-align: center; font-size: 11px; color: #94A3B8; margin-top: 0.5rem; margin-bottom: 0;">Assumes &euro;8/m&sup2; rent, 5% interest, 10-year loan, 300 m&sup2; per court</p>
<div class="teaser-results">
<div class="teaser-metric">
<div class="tm-label">Total Investment</div>
<div class="tm-value tm-navy" id="tr-invest">&mdash;</div>
<div class="tm-sub">CAPEX (rent model)</div>
</div>
<div class="teaser-metric">
<div class="tm-label">Monthly Cash Flow</div>
<div class="tm-value" id="tr-cf">&mdash;</div>
<div class="tm-sub">After debt service</div>
</div>
<div class="teaser-metric">
<div class="tm-label">Payback Period</div>
<div class="tm-value tm-blue" id="tr-payback">&mdash;</div>
<div class="tm-sub">Years to recover equity</div>
</div>
<div class="teaser-metric">
<div class="tm-label">Cash-on-Cash</div>
<div class="tm-value" id="tr-coc">&mdash;</div>
<div class="tm-sub">Annual return on equity</div>
<div class="flex items-center gap-4">
<label class="w-36 shrink-0 text-sm text-slate">Equity %</label>
<input type="range" id="tc-equity" min="15" max="50" step="5" value="25" oninput="tCalc()" class="flex-1">
<span class="w-18 text-right font-mono text-sm font-semibold text-navy shrink-0" id="tv-equity">25%</span>
</div>
</div>
<div class="teaser-cta">
<p>Want the full picture? The planner models 60+ variables with monthly projections, sensitivity analysis, and connects you with court suppliers.</p>
<p class="text-center text-xs text-slate mt-3">Assumes &euro;8/m&sup2; rent, 5% interest, 10-year loan, 300 m&sup2; per court</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6 pt-6 border-t border-light-gray">
<div class="text-center">
<div class="text-[10px] uppercase tracking-wider text-slate mb-1">Total Investment</div>
<div class="font-mono text-2xl font-bold text-navy" id="tr-invest">&mdash;</div>
<div class="text-xs text-slate mt-0.5">CAPEX (rent model)</div>
</div>
<div class="text-center">
<div class="text-[10px] uppercase tracking-wider text-slate mb-1">Monthly Cash Flow</div>
<div class="font-mono text-2xl font-bold" id="tr-cf">&mdash;</div>
<div class="text-xs text-slate mt-0.5">After debt service</div>
</div>
<div class="text-center">
<div class="text-[10px] uppercase tracking-wider text-slate mb-1">Payback Period</div>
<div class="font-mono text-2xl font-bold text-electric" id="tr-payback">&mdash;</div>
<div class="text-xs text-slate mt-0.5">Years to recover equity</div>
</div>
<div class="text-center">
<div class="text-[10px] uppercase tracking-wider text-slate mb-1">Cash-on-Cash</div>
<div class="font-mono text-2xl font-bold" id="tr-coc">&mdash;</div>
<div class="text-xs text-slate mt-0.5">Annual return on equity</div>
</div>
</div>
<div class="text-center mt-6 pt-4">
<p class="text-sm text-slate mb-3">Want the full picture? The planner models 60+ variables with monthly projections, sensitivity analysis, and connects you with court suppliers.</p>
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button">Start Planning</a>
<a href="{{ url_for('planner.index') }}" class="btn">Start Planning</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button">Create Your Plan</a>
<a href="{{ url_for('auth.signup') }}" class="btn">Create Your Plan</a>
{% endif %}
</div>
</div>
</section>
<!-- The Journey -->
<section style="padding: 3rem 0;">
<h2 style="text-align: center;">From Idea to Operating Hall</h2>
<div class="grid">
<article>
<header><strong>Plan</strong></header>
<p>Model your padel hall investment with our financial planner. CAPEX, operating costs, cash flow, returns, sensitivity analysis.</p>
</article>
<article>
<header><strong>Finance</strong></header>
<p>Connect with banks and investors experienced in sports facility loans. Your planner data becomes your business case.</p>
</article>
<article>
<header><strong>Build</strong></header>
<p>Get quotes from verified court suppliers. Compare pricing, quality, and delivery timelines for your specific project.</p>
</article>
<article>
<header><strong>Operate</strong></header>
<p>Coming soon: analytics powered by real booking data, benchmarking against similar venues, optimization recommendations.</p>
</article>
<section class="py-12">
<h2 class="text-2xl text-center mb-8">From Idea to Operating Hall</h2>
<div class="grid-4">
<div class="card border-l-4 border-l-electric">
<p class="font-semibold text-navy mb-2">&#x1F4CA; Plan</p>
<p class="text-sm text-slate-dark">Model your padel hall investment with our financial planner. CAPEX, operating costs, cash flow, returns, sensitivity analysis.</p>
</div>
<div class="card border-l-4 border-l-accent">
<p class="font-semibold text-navy mb-2">&#x1F4B0; Finance</p>
<p class="text-sm text-slate-dark">Connect with banks and investors experienced in sports facility loans. Your planner data becomes your business case.</p>
</div>
<div class="card border-l-4 border-l-warning">
<p class="font-semibold text-navy mb-2">&#x1F3D7;&#xFE0F; Build</p>
<p class="text-sm text-slate-dark">Get quotes from verified court suppliers. Compare pricing, quality, and delivery timelines for your specific project.</p>
</div>
<div class="card border-l-4 border-l-slate">
<p class="font-semibold text-navy mb-2">&#x1F3BE; Operate</p>
<p class="text-sm text-slate-dark">Coming soon: analytics powered by real booking data, benchmarking against similar venues, optimization recommendations.</p>
</div>
</div>
</section>
<!-- Feature Highlights -->
<section style="padding: 3rem 0;">
<h2 style="text-align: center;">Built for Serious Padel Entrepreneurs</h2>
<div class="grid">
<article>
<h3>60+ Variables</h3>
<p>Every assumption is adjustable. Court costs, rent, pricing, utilization, financing terms, exit scenarios. Nothing is hard-coded.</p>
</article>
<article>
<h3>6 Analysis Tabs</h3>
<p>Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns &amp; Exit, and Key Metrics. Each with interactive charts.</p>
</article>
<article>
<h3>Indoor &amp; Outdoor</h3>
<p>Model indoor halls (rent or build) and outdoor courts with seasonality. Compare scenarios side by side.</p>
</article>
<section class="py-12">
<h2 class="text-2xl text-center mb-8">Built for Serious Padel Entrepreneurs</h2>
<div class="grid-3">
<div class="card border-l-4 border-l-electric">
<h3 class="text-lg mb-2">&#x1F527; 60+ Variables</h3>
<p class="text-sm text-slate-dark">Every assumption is adjustable. Court costs, rent, pricing, utilization, financing terms, exit scenarios. Nothing is hard-coded.</p>
</div>
<div class="card border-l-4 border-l-accent">
<h3 class="text-lg mb-2">&#x1F4CB; 6 Analysis Tabs</h3>
<p class="text-sm text-slate-dark">Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns &amp; Exit, and Key Metrics. Each with interactive charts.</p>
</div>
<div class="card border-l-4 border-l-warning">
<h3 class="text-lg mb-2">&#x2600;&#xFE0F; Indoor &amp; Outdoor</h3>
<p class="text-sm text-slate-dark">Model indoor halls (rent or build) and outdoor courts with seasonality. Compare scenarios side by side.</p>
</div>
</div>
<div class="grid-3 mt-0">
<div class="card border-l-4 border-l-danger">
<h3 class="text-lg mb-2">&#x1F4C9; Sensitivity Analysis</h3>
<p class="text-sm text-slate-dark">See how your returns change with different utilization rates and pricing. Find your break-even point instantly.</p>
</div>
<div class="card border-l-4 border-l-electric">
<h3 class="text-lg mb-2">&#x1F3AF; Professional Metrics</h3>
<p class="text-sm text-slate-dark">IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors want to see.</p>
</div>
<div class="card border-l-4 border-l-accent">
<h3 class="text-lg mb-2">&#x1F4BE; Save &amp; Compare</h3>
<p class="text-sm text-slate-dark">Save unlimited scenarios. Test different locations, court counts, financing structures. Find the optimal plan.</p>
</div>
<div class="grid">
<article>
<h3>Sensitivity Analysis</h3>
<p>See how your returns change with different utilization rates and pricing. Find your break-even point instantly.</p>
</article>
<article>
<h3>Professional Metrics</h3>
<p>IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors want to see.</p>
</article>
<article>
<h3>Save &amp; Compare</h3>
<p>Save unlimited scenarios. Test different locations, court counts, financing structures. Find the optimal plan.</p>
</article>
</div>
</section>
<!-- SEO Content -->
<section style="padding: 3rem 0; max-width: 720px; margin: 0 auto;">
<h2>Padel Court Investment Planning</h2>
<section class="py-12 max-w-3xl mx-auto">
<h2 class="text-2xl mb-4">Padel Court Investment Planning</h2>
<div class="space-y-4 text-slate-dark leading-relaxed">
<p>
Padel is the fastest-growing sport in Europe, with demand for courts far outstripping supply in Germany, the UK, Scandinavia, and beyond. Opening a padel hall can be a lucrative investment, but the numbers need to work. A typical indoor padel venue with 6-8 courts requires between &euro;300K (renting an existing building) and &euro;2-3M (building new), with payback periods of 3-5 years for well-located venues.
</p>
<p>
The key variables that determine success are location (driving utilization), construction costs (CAPEX), rent or land costs, and pricing strategy. Our financial planner lets you model all of these variables interactively, seeing the impact on your IRR, MOIC, cash flow, and debt service coverage ratio in real time. Whether you're an entrepreneur exploring your first venue, a real estate developer adding padel to a mixed-use project, or an investor evaluating a padel hall acquisition, Padelnomics gives you the financial clarity to make informed decisions.
</p>
</div>
</section>
<!-- Final CTA -->
<section style="text-align: center; padding: 3rem 0;">
<h2>Start Planning Today</h2>
<p>Start with your plan. Then get quotes from verified court suppliers and connect with financing partners.</p>
<section class="text-center py-12">
<h2 class="text-2xl mb-2">Start Planning Today</h2>
<p class="text-slate mb-6">Start with your plan. Then get quotes from verified court suppliers and connect with financing partners.</p>
{% if user %}
<a href="{{ url_for('planner.index') }}" role="button">Start Planning</a>
<a href="{{ url_for('planner.index') }}" class="btn">Start Planning</a>
{% else %}
<a href="{{ url_for('auth.signup') }}" role="button">Create Your Plan</a>
<a href="{{ url_for('auth.signup') }}" class="btn">Create Your Plan</a>
{% endif %}
</section>
</main>
@@ -334,24 +260,24 @@ function tCalc() {
var cfEl = document.getElementById('tr-cf');
cfEl.innerHTML = fmt(netCF);
cfEl.className = 'tm-value ' + (netCF >= 0 ? 'tm-green' : 'tm-red');
cfEl.className = 'font-mono text-2xl font-bold ' + (netCF >= 0 ? 'text-accent' : 'text-danger');
var pbEl = document.getElementById('tr-payback');
if (payback > 0 && payback <= 30) {
pbEl.textContent = payback.toFixed(1) + 'yr';
pbEl.className = 'tm-value ' + (payback <= 5 ? 'tm-blue' : 'tm-navy');
pbEl.className = 'font-mono text-2xl font-bold ' + (payback <= 5 ? 'text-electric' : 'text-navy');
} else {
pbEl.innerHTML = '&mdash;';
pbEl.className = 'tm-value tm-red';
pbEl.className = 'font-mono text-2xl font-bold text-danger';
}
var cocEl = document.getElementById('tr-coc');
if (isFinite(coc)) {
cocEl.textContent = (coc * 100).toFixed(1) + '%';
cocEl.className = 'tm-value ' + (coc >= 0 ? 'tm-green' : 'tm-red');
cocEl.className = 'font-mono text-2xl font-bold ' + (coc >= 0 ? 'text-accent' : 'text-danger');
} else {
cocEl.innerHTML = '&mdash;';
cocEl.className = 'tm-value tm-navy';
cocEl.className = 'font-mono text-2xl font-bold text-navy';
}
}

View File

@@ -3,23 +3,22 @@
{% block title %}Privacy Policy - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header>
<h1>Privacy Policy</h1>
<p><small>Last updated: February 2026</small></p>
</header>
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Privacy Policy</h1>
<p class="text-sm text-slate mb-8">Last updated: February 2026</p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2>1. Information We Collect</h2>
<h2 class="text-lg mb-2">1. Information We Collect</h2>
<p>We collect information you provide directly:</p>
<ul>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Email address (required for account creation)</li>
<li>Name (optional)</li>
<li>Financial planning data (scenario inputs and projections)</li>
</ul>
<p>We automatically collect:</p>
<ul>
<p class="mt-3">We automatically collect:</p>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>IP address</li>
<li>Browser type</li>
<li>Usage data</li>
@@ -27,9 +26,9 @@
</section>
<section>
<h2>2. How We Use Information</h2>
<h2 class="text-lg mb-2">2. How We Use Information</h2>
<p>We use your information to:</p>
<ul>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Provide and maintain the service</li>
<li>Process payments</li>
<li>Send transactional emails</li>
@@ -39,33 +38,33 @@
</section>
<section>
<h2>3. Information Sharing</h2>
<h2 class="text-lg mb-2">3. Information Sharing</h2>
<p>We do not sell your personal information. We may share information with:</p>
<ul>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Service providers (Resend for email, Plausible for privacy-friendly analytics)</li>
<li>Law enforcement when required by law</li>
</ul>
</section>
<section>
<h2>4. Data Retention</h2>
<h2 class="text-lg mb-2">4. Data Retention</h2>
<p>We retain your data as long as your account is active. Upon deletion, we remove your data within 30 days.</p>
</section>
<section>
<h2>5. Security</h2>
<h2 class="text-lg mb-2">5. Security</h2>
<p>We implement industry-standard security measures including encryption, secure sessions, and regular backups.</p>
</section>
<section>
<h2>6. Cookies</h2>
<h2 class="text-lg mb-2">6. Cookies</h2>
<p>We use essential cookies for session management. We do not use tracking or advertising cookies.</p>
</section>
<section>
<h2>7. Your Rights</h2>
<h2 class="text-lg mb-2">7. Your Rights</h2>
<p>You have the right to:</p>
<ul>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Access your data</li>
<li>Correct inaccurate data</li>
<li>Delete your account and data</li>
@@ -74,19 +73,20 @@
</section>
<section>
<h2>8. GDPR Compliance</h2>
<h2 class="text-lg mb-2">8. GDPR Compliance</h2>
<p>For EU users: We process data based on consent and legitimate interest. You may contact us to exercise your GDPR rights.</p>
</section>
<section>
<h2>9. Changes</h2>
<h2 class="text-lg mb-2">9. Changes</h2>
<p>We may update this policy. We will notify you of significant changes via email.</p>
</section>
<section>
<h2>10. Contact</h2>
<h2 class="text-lg mb-2">10. Contact</h2>
<p>For privacy inquiries: {{ config.EMAIL_FROM }}</p>
</section>
</article>
</div>
</div>
</main>
{% endblock %}

View File

@@ -3,32 +3,31 @@
{% block title %}Terms of Service - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<main class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header>
<h1>Terms of Service</h1>
<p><small>Last updated: February 2026</small></p>
</header>
<main class="container-page py-12">
<div class="card max-w-3xl mx-auto">
<h1 class="text-2xl mb-1">Terms of Service</h1>
<p class="text-sm text-slate mb-8">Last updated: February 2026</p>
<div class="space-y-6 text-slate-dark leading-relaxed">
<section>
<h2>1. Acceptance of Terms</h2>
<h2 class="text-lg mb-2">1. Acceptance of Terms</h2>
<p>By accessing or using {{ config.APP_NAME }}, you agree to be bound by these Terms of Service. If you do not agree, do not use the service.</p>
</section>
<section>
<h2>2. Description of Service</h2>
<h2 class="text-lg mb-2">2. Description of Service</h2>
<p>{{ config.APP_NAME }} provides a software-as-a-service platform. Features and functionality may change over time.</p>
</section>
<section>
<h2>3. User Accounts</h2>
<h2 class="text-lg mb-2">3. User Accounts</h2>
<p>You are responsible for maintaining the security of your account. You must provide accurate information and keep it updated.</p>
</section>
<section>
<h2>4. Acceptable Use</h2>
<h2 class="text-lg mb-2">4. Acceptable Use</h2>
<p>You agree not to:</p>
<ul>
<ul class="list-disc pl-6 mt-2 space-y-1">
<li>Violate any laws or regulations</li>
<li>Infringe on intellectual property rights</li>
<li>Transmit harmful code or malware</li>
@@ -38,34 +37,35 @@
</section>
<section>
<h2>5. Financial Projections Disclaimer</h2>
<h2 class="text-lg mb-2">5. Financial Projections Disclaimer</h2>
<p>The financial planner provides estimates based on your inputs. Projections are not guarantees of future performance. Always consult qualified financial and legal advisors before making investment decisions.</p>
</section>
<section>
<h2>6. Termination</h2>
<h2 class="text-lg mb-2">6. Termination</h2>
<p>We may terminate or suspend your account for violations of these terms. You may cancel your account at any time.</p>
</section>
<section>
<h2>7. Disclaimer of Warranties</h2>
<h2 class="text-lg mb-2">7. Disclaimer of Warranties</h2>
<p>The service is provided "as is" without warranties of any kind. We do not guarantee uninterrupted or error-free operation.</p>
</section>
<section>
<h2>8. Limitation of Liability</h2>
<h2 class="text-lg mb-2">8. Limitation of Liability</h2>
<p>We shall not be liable for any indirect, incidental, special, or consequential damages arising from use of the service.</p>
</section>
<section>
<h2>9. Changes to Terms</h2>
<h2 class="text-lg mb-2">9. Changes to Terms</h2>
<p>We may modify these terms at any time. Continued use after changes constitutes acceptance of the new terms.</p>
</section>
<section>
<h2>10. Contact</h2>
<h2 class="text-lg mb-2">10. Contact</h2>
<p>For questions about these terms, please contact us at {{ config.EMAIL_FROM }}.</p>
</section>
</article>
</div>
</div>
</main>
{% endblock %}

View File

@@ -1,83 +0,0 @@
/* Padelnomics — Court Tech brand overrides for Pico CSS */
:root[data-theme="light"] {
/* Background layers — Soft White family */
--pico-background-color: #F8FAFC;
--pico-card-background-color: #FFFFFF;
--pico-card-sectioning-background-color: #F1F5F9;
/* Text — Slate / Deep Navy */
--pico-color: #475569;
--pico-muted-color: #64748B;
--pico-muted-border-color: #E2E8F0;
/* Headings — Deep Navy (overrides Pico's per-level grays) */
--pico-h1-color: #0F172A;
--pico-h2-color: #0F172A;
--pico-h3-color: #1E293B;
--pico-h4-color: #1E293B;
--pico-h5-color: #334155;
--pico-h6-color: #334155;
/* Primary accent — Electric Blue */
--pico-primary: #3B82F6;
--pico-primary-hover: #2563EB;
--pico-primary-focus: rgba(59,130,246,0.25);
--pico-primary-inverse: #fff;
/* Secondary — Slate */
--pico-secondary: #64748B;
--pico-secondary-hover: #475569;
--pico-secondary-focus: rgba(100,116,139,0.25);
--pico-secondary-inverse: #F8FAFC;
/* Typography — Inter */
--pico-font-family: 'Inter', system-ui, -apple-system, sans-serif;
--pico-font-family-monospace: 'JetBrains Mono', monospace;
/* Borders */
--pico-border-color: #E2E8F0;
/* Form styling */
--pico-form-element-background-color: #FFFFFF;
--pico-form-element-border-color: #CBD5E1;
--pico-form-element-focus-color: #3B82F6;
}
/* Headings — Deep Navy, Inter
Force heading colors with !important to beat Pico's multi-layer
variable cascade (theme selectors + prefers-color-scheme media). */
h1, h2, h3, h4, h5, h6 {
color: #0F172A !important;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
h4, h5, h6 {
color: #1E293B !important;
}
/* Data & metrics use monospace */
.metric, .mono, [data-mono] {
font-family: 'JetBrains Mono', monospace;
}
/* Success states — Vibrant Green */
.success, [data-success] {
color: #10B981;
}
article {
margin-bottom: 1.5rem;
}
table {
width: 100%;
}
/* HTMX loading indicators */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline;
}

View File

@@ -0,0 +1,204 @@
@import "tailwindcss";
/* ── Commit Mono (self-hosted) ── */
@font-face {
font-family: "Commit Mono";
src: url("../fonts/CommitMono-400-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Commit Mono";
src: url("../fonts/CommitMono-700-Regular.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* ── Brand Theme ── */
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Commit Mono", ui-monospace, monospace;
--color-navy: #0F172A;
--color-charcoal: #1E293B;
--color-electric: #3B82F6;
--color-electric-hover: #2563EB;
--color-accent: #10B981;
--color-soft-white: #F8FAFC;
--color-light-gray: #E2E8F0;
--color-mid-gray: #CBD5E1;
--color-slate: #64748B;
--color-slate-dark: #475569;
--color-danger: #EF4444;
--color-danger-hover: #DC2626;
--color-warning: #F59E0B;
}
/* ── Base layer ── */
@layer base {
body {
@apply bg-soft-white text-slate-dark font-sans antialiased;
}
h1, h2, h3 {
@apply text-navy font-bold tracking-tight;
}
h4, h5, h6 {
@apply text-charcoal font-semibold;
}
a {
@apply text-electric hover:text-electric-hover transition-colors;
}
hr {
@apply border-light-gray my-6;
}
}
/* ── Component classes ── */
@layer components {
/* Page container */
.container-page {
@apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;
}
/* Cards (replace Pico <article>) */
.card {
@apply bg-white border border-light-gray rounded-lg p-6 mb-6;
}
.card-header {
@apply border-b border-light-gray pb-3 mb-4 text-sm text-slate font-medium;
}
/* Buttons — shared base */
.btn, .btn-secondary, .btn-danger {
@apply inline-flex items-center justify-center px-5 py-2.5 rounded-lg
font-semibold text-sm transition-colors cursor-pointer
focus:outline-none focus:ring-2 focus:ring-electric/50;
}
.btn {
@apply bg-electric text-white hover:bg-electric-hover;
}
.btn-secondary {
@apply bg-slate-dark text-white hover:bg-navy;
}
.btn-danger {
@apply bg-danger text-white hover:bg-danger-hover;
}
.btn-outline {
@apply inline-flex items-center justify-center px-5 py-2.5 rounded-lg
font-semibold text-sm transition-colors cursor-pointer
bg-transparent text-slate-dark border border-mid-gray
hover:bg-light-gray hover:text-navy
focus:outline-none focus:ring-2 focus:ring-electric/50;
}
.btn-sm {
@apply px-3 py-1.5 text-xs;
}
/* Forms */
.form-label {
@apply block text-sm font-medium text-charcoal mb-1;
}
.form-input {
@apply w-full px-3 py-2 rounded-lg border border-mid-gray bg-white
text-slate-dark placeholder-slate
focus:outline-none focus:ring-2 focus:ring-electric/50 focus:border-electric
transition-colors;
}
.form-hint {
@apply text-xs text-slate mt-1;
}
/* Tables */
.table {
@apply w-full text-sm;
}
.table th {
@apply text-left px-3 py-2 text-xs font-semibold text-slate uppercase tracking-wider
border-b-2 border-light-gray;
}
.table td {
@apply px-3 py-2 border-b border-light-gray text-slate-dark;
}
.table tbody tr:hover {
@apply bg-soft-white;
}
/* Flash messages */
.flash, .flash-error, .flash-success, .flash-warning {
@apply px-4 py-3 rounded-lg mb-4 border-l-4 bg-white text-slate-dark text-sm;
}
.flash {
@apply border-electric;
}
.flash-error {
@apply border-danger;
}
.flash-success {
@apply border-accent;
}
.flash-warning {
@apply border-warning;
}
/* Badge (replaces Pico <mark>) */
.badge, .badge-success, .badge-danger, .badge-warning {
@apply inline-block px-2 py-0.5 text-xs font-semibold rounded-full;
}
.badge {
@apply bg-electric/10 text-electric;
}
.badge-success {
@apply bg-accent/10 text-accent;
}
.badge-danger {
@apply bg-danger/10 text-danger;
}
.badge-warning {
@apply bg-warning/10 text-warning;
}
/* Heading group (replaces Pico <hgroup>) */
.heading-group {
@apply mb-6;
}
.heading-group h1,
.heading-group h2 {
@apply mb-1;
}
.heading-group p {
@apply text-slate text-lg;
}
/* Grid helpers (replaces Pico .grid) */
.grid-auto {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;
}
.grid-2 {
@apply grid grid-cols-1 md:grid-cols-2 gap-6;
}
.grid-3 {
@apply grid grid-cols-1 md:grid-cols-3 gap-6;
}
.grid-4 {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6;
}
/* Monospace data display */
.metric {
@apply font-mono text-navy;
}
.mono {
@apply font-mono;
}
/* HTMX loading indicators */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline;
}
}

View File

@@ -0,0 +1,90 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -10,57 +10,46 @@
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32.png') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<!-- Pico CSS v2 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Brand overrides -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
<!-- Tailwind (compiled) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="container">
<ul>
<li><a href="{{ url_for('public.landing') }}" style="display:flex;align-items:center;text-decoration:none"><img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" style="height:32px;width:auto"></a></li>
</ul>
<ul>
<nav class="container-page flex items-center justify-between py-4">
<a href="{{ url_for('dashboard.index') if user else url_for('public.landing') }}" class="flex items-center no-underline">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" class="h-8 w-auto">
</a>
<div class="flex items-center gap-4 text-sm">
{% if user %}
<li><a href="{{ url_for('planner.index') }}">Planner</a></li>
<li><a href="{{ url_for('dashboard.index') }}">Dashboard</a></li>
<a href="{{ url_for('planner.index') }}">Planner</a>
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
{% if session.get('is_admin') %}
<li><a href="{{ url_for('admin.index') }}"><mark>Admin</mark></a></li>
<a href="{{ url_for('admin.index') }}"><span class="badge">Admin</span></a>
{% endif %}
<li>
<form method="post" action="{{ url_for('auth.logout') }}" style="margin: 0;">
<form method="post" action="{{ url_for('auth.logout') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.5rem 1rem; margin: 0;">Sign Out</button>
<button type="submit" class="btn-outline btn-sm">Sign Out</button>
</form>
</li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">Sign In</a></li>
<li><a href="{{ url_for('auth.signup') }}" role="button" style="white-space:nowrap">Get Started Free</a></li>
<a href="{{ url_for('auth.login') }}">Sign In</a>
<a href="{{ url_for('auth.signup') }}" class="btn btn-sm whitespace-nowrap">Get Started Free</a>
{% endif %}
</ul>
</div>
</nav>
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container">
<div class="container-page">
{% for category, message in messages %}
<article
style="padding: 1rem; margin-bottom: 1rem;
{% if category == 'error' %}border-left: 4px solid var(--pico-color-red-500);
{% elif category == 'success' %}border-left: 4px solid var(--pico-color-green-500);
{% elif category == 'warning' %}border-left: 4px solid var(--pico-color-amber-500);
{% else %}border-left: 4px solid var(--pico-primary);{% endif %}"
>
<div class="{% if category == 'error' %}flash-error{% elif category == 'success' %}flash-success{% elif category == 'warning' %}flash-warning{% else %}flash{% endif %}">
{{ message }}
</article>
</div>
{% endfor %}
</div>
{% endif %}
@@ -69,31 +58,31 @@
{% block content %}{% endblock %}
<!-- Footer -->
<footer class="container" style="margin-top: 4rem; padding: 2rem 0; border-top: 1px solid var(--pico-muted-border-color);">
<div class="grid">
<footer class="container-page mt-16 pt-8 border-t border-light-gray">
<div class="grid-3 mb-8">
<div>
<div style="margin-bottom:0.25rem"><img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" style="height:24px;width:auto"></div>
<p><small>Plan, finance, and build your padel business.</small></p>
<div class="mb-1"><img src="{{ url_for('static', filename='images/logo.png') }}" alt="{{ config.APP_NAME }}" class="h-6 w-auto"></div>
<p class="text-sm text-slate">Plan, finance, and build your padel business.</p>
</div>
<div>
<strong>Product</strong>
<ul style="list-style: none; padding: 0;">
<p class="font-semibold text-navy text-sm mb-2">Product</p>
<ul class="space-y-1 text-sm">
<li><a href="{{ url_for('planner.index') }}">Planner</a></li>
<li><a href="{{ url_for('leads.suppliers') }}">Suppliers</a></li>
<li><a href="{{ url_for('leads.financing') }}">Financing</a></li>
</ul>
</div>
<div>
<strong>Legal</strong>
<ul style="list-style: none; padding: 0;">
<p class="font-semibold text-navy text-sm mb-2">Legal</p>
<ul class="space-y-1 text-sm">
<li><a href="{{ url_for('public.terms') }}">Terms</a></li>
<li><a href="{{ url_for('public.privacy') }}">Privacy</a></li>
<li><a href="{{ url_for('public.about') }}">About</a></li>
</ul>
</div>
</div>
<p style="text-align: center; margin-top: 2rem;">
<small>&copy; {{ now.year }} {{ config.APP_NAME }}. All rights reserved.</small>
<p class="text-center text-xs text-slate pb-6">
&copy; {{ now.year }} {{ config.APP_NAME }}. All rights reserved.
</p>
</footer>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -116,7 +116,7 @@ def test_landing_light_background(live_server, page):
page.goto(live_server)
page.wait_for_load_state("networkidle")
# Pico sets background on <html>, body may be transparent
# Tailwind sets background on body via base layer
bg_color = page.evaluate("""
(() => {
const html_bg = getComputedStyle(document.documentElement).backgroundColor;
@@ -185,14 +185,14 @@ def test_landing_nav_no_overlap(live_server, page):
page.goto(live_server)
page.wait_for_load_state("networkidle")
# Get bounding boxes of all nav <li> items in the right <ul>
# Get bounding boxes of direct children in the nav's right-side flex container
boxes = page.evaluate("""
(() => {
const uls = document.querySelectorAll('nav ul');
if (uls.length < 2) return [];
const items = uls[1].querySelectorAll('li');
return Array.from(items).map(li => {
const r = li.getBoundingClientRect();
const navDiv = document.querySelector('nav > div');
if (!navDiv) return [];
const items = navDiv.children;
return Array.from(items).map(el => {
const r = el.getBoundingClientRect();
return {top: r.top, bottom: r.bottom, left: r.left, right: r.right};
});
})()
@@ -207,6 +207,32 @@ def test_landing_nav_no_overlap(live_server, page):
)
def test_landing_cards_have_colored_borders(live_server, page):
"""Verify landing page cards have a visible left border accent."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
border_widths = page.evaluate("""
Array.from(document.querySelectorAll('.card')).map(
el => parseFloat(getComputedStyle(el).borderLeftWidth)
)
""")
assert len(border_widths) > 0, "No .card elements found"
cards_with_accent = [w for w in border_widths if w >= 4]
assert len(cards_with_accent) >= 10, (
f"Expected >=10 cards with 4px left border, got {len(cards_with_accent)}"
)
def test_landing_logo_links_to_landing(live_server, page):
"""Verify logo links to landing page when not logged in."""
page.goto(live_server)
page.wait_for_load_state("networkidle")
href = page.locator("nav a").first.get_attribute("href")
assert href == "/", f"Expected logo to link to /, got {href}"
def test_landing_teaser_light_theme(live_server, page):
"""Verify teaser calculator has white/light background."""
page.goto(live_server)
@@ -262,7 +288,7 @@ def test_mobile_nav_no_overflow(live_server, browser):
})()
""")
page.close()
# Pico's nav may wrap on mobile, which is fine — just verify no JS errors
# Nav may wrap on mobile, which is fine — just verify no JS errors
def test_landing_no_dark_remnants(live_server, page):