refactor: flatten padelnomics/padelnomics/ → repo root
git mv all tracked files from the nested padelnomics/ workspace directory to the git repo root. Merged .gitignore files. No code changes — pure path rename. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
3
web/src/padelnomics/__init__.py
Normal file
3
web/src/padelnomics/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Padelnomics - Plan, finance, and build your padel business"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1653
web/src/padelnomics/admin/routes.py
Normal file
1653
web/src/padelnomics/admin/routes.py
Normal file
File diff suppressed because it is too large
Load Diff
82
web/src/padelnomics/admin/templates/admin/article_form.html
Normal file
82
web/src/padelnomics/admin/templates/admin/article_form.html
Normal file
@@ -0,0 +1,82 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "articles" %}
|
||||
|
||||
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width: 48rem; margin: 0 auto;">
|
||||
<a href="{{ url_for('admin.articles') }}" class="text-sm text-slate">← Back to articles</a>
|
||||
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article</h1>
|
||||
|
||||
<form method="post" class="card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||
<div>
|
||||
<label class="form-label" for="title">Title</label>
|
||||
<input type="text" id="title" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="slug">Slug</label>
|
||||
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
|
||||
placeholder="auto-generated from title" {% if editing %}readonly{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="url_path">URL Path</label>
|
||||
<input type="text" id="url_path" name="url_path" value="{{ data.get('url_path', '') }}" class="form-input"
|
||||
placeholder="e.g. /padel-court-cost-miami">
|
||||
<p class="form-hint">Defaults to /slug. Must not conflict with existing routes.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="meta_description">Meta Description</label>
|
||||
<input type="text" id="meta_description" name="meta_description" value="{{ data.get('meta_description', '') }}"
|
||||
class="form-input" maxlength="160">
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||
<div>
|
||||
<label class="form-label" for="country">Country</label>
|
||||
<input type="text" id="country" name="country" value="{{ data.get('country', '') }}" class="form-input"
|
||||
placeholder="e.g. US">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="region">Region</label>
|
||||
<input type="text" id="region" name="region" value="{{ data.get('region', '') }}" class="form-input"
|
||||
placeholder="e.g. North America">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="og_image_url">OG Image URL</label>
|
||||
<input type="text" id="og_image_url" name="og_image_url" value="{{ data.get('og_image_url', '') }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="body">Body (Markdown)</label>
|
||||
<textarea id="body" name="body" rows="20" class="form-input"
|
||||
style="font-family: var(--font-mono); font-size: 0.8125rem;" {% if not editing %}required{% endif %}>{{ data.get('body', '') }}</textarea>
|
||||
<p class="form-hint">Use [scenario:slug] to embed scenario widgets. Sections: :capex, :operating, :cashflow, :returns, :full</p>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||
<div>
|
||||
<label class="form-label" for="status">Status</label>
|
||||
<select id="status" name="status" class="form-input">
|
||||
<option value="draft" {% if data.get('status') == 'draft' %}selected{% endif %}>Draft</option>
|
||||
<option value="published" {% if data.get('status') == 'published' %}selected{% endif %}>Published</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="published_at">Publish Date</label>
|
||||
<input type="datetime-local" id="published_at" name="published_at"
|
||||
value="{{ data.get('published_at', '')[:16] if data.get('published_at') else '' }}" class="form-input">
|
||||
<p class="form-hint">Leave blank for now. Future date = scheduled.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update Article{% else %}Create Article{% endif %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
76
web/src/padelnomics/admin/templates/admin/articles.html
Normal file
76
web/src/padelnomics/admin/templates/admin/articles.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "articles" %}
|
||||
|
||||
{% block title %}Articles - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl">Articles</h1>
|
||||
<p class="text-slate text-sm">{{ articles | length }} article{{ 's' if articles | length != 1 }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.article_new') }}" class="btn">New Article</a>
|
||||
<form method="post" action="{{ url_for('admin.rebuild_all') }}" class="m-0" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline" onclick="return confirm('Rebuild all articles?')">Rebuild All</button>
|
||||
</form>
|
||||
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
{% if articles %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>URL</th>
|
||||
<th>Status</th>
|
||||
<th>Published</th>
|
||||
<th>Source</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in articles %}
|
||||
<tr>
|
||||
<td>{{ a.title }}</td>
|
||||
<td class="mono text-sm">{{ a.url_path }}</td>
|
||||
<td>
|
||||
{% if a.status == 'published' %}
|
||||
{% if a.published_at and a.published_at > now.isoformat() %}
|
||||
<span class="badge-warning">Scheduled</span>
|
||||
{% else %}
|
||||
<span class="badge-success">Published</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge">Draft</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono text-sm">{{ a.published_at[:10] if a.published_at else '-' }}</td>
|
||||
<td class="text-sm">{% if a.template_data_id %}Generated{% else %}Manual{% endif %}</td>
|
||||
<td class="text-right" style="white-space: nowrap;">
|
||||
<form method="post" action="{{ url_for('admin.article_publish', article_id=a.id) }}" class="m-0" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm">{% if a.status == 'published' %}Unpublish{% else %}Publish{% endif %}</button>
|
||||
</form>
|
||||
<a href="{{ url_for('admin.article_edit', article_id=a.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
<form method="post" action="{{ url_for('admin.article_rebuild', article_id=a.id) }}" class="m-0" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm">Rebuild</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('admin.article_delete', article_id=a.id) }}" class="m-0" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this article?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No articles yet. Create one or generate from a template.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
105
web/src/padelnomics/admin/templates/admin/base_admin.html
Normal file
105
web/src/padelnomics/admin/templates/admin/base_admin.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.admin-layout { display: flex; min-height: calc(100vh - 64px); }
|
||||
.admin-sidebar {
|
||||
width: 220px; flex-shrink: 0; background: #F8FAFC; border-right: 1px solid #E2E8F0;
|
||||
padding: 1.25rem 0; display: flex; flex-direction: column; overflow-y: auto;
|
||||
}
|
||||
.admin-sidebar__title {
|
||||
padding: 0 1rem 1rem; font-size: 0.8125rem; font-weight: 700; color: #0F172A;
|
||||
border-bottom: 1px solid #E2E8F0; margin-bottom: 0.5rem;
|
||||
}
|
||||
.admin-sidebar__section {
|
||||
padding: 0.5rem 0 0.25rem; font-size: 0.5625rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; color: #94A3B8;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.admin-nav a {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 1rem; font-size: 0.8125rem; color: #64748B;
|
||||
text-decoration: none; transition: all 0.1s;
|
||||
}
|
||||
.admin-nav a:hover { background: #EFF6FF; color: #1D4ED8; }
|
||||
.admin-nav a.active { background: #EFF6FF; color: #1D4ED8; font-weight: 600; border-right: 3px solid #1D4ED8; }
|
||||
.admin-nav a svg { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
|
||||
.admin-main { flex: 1; padding: 2rem; overflow-y: auto; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-layout { flex-direction: column; }
|
||||
.admin-sidebar {
|
||||
width: 100%; flex-direction: row; align-items: center; padding: 0.5rem;
|
||||
overflow-x: auto; border-right: none; border-bottom: 1px solid #E2E8F0;
|
||||
}
|
||||
.admin-sidebar__title { display: none; }
|
||||
.admin-sidebar__section { display: none; }
|
||||
.admin-nav { display: flex; flex: none; padding: 0; gap: 2px; }
|
||||
.admin-nav a { padding: 8px 12px; white-space: nowrap; border-right: none !important; border-radius: 6px; }
|
||||
.admin-main { padding: 1rem; }
|
||||
}
|
||||
</style>
|
||||
{% block admin_head %}{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-sidebar__title">Admin</div>
|
||||
<nav class="admin-nav">
|
||||
<div class="admin-sidebar__section">Overview</div>
|
||||
<a href="{{ url_for('admin.index') }}" class="{% if admin_page == 'dashboard' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25a2.25 2.25 0 0 1-2.25-2.25v-2.25Z"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Leads</div>
|
||||
<a href="{{ url_for('admin.leads') }}" class="{% if admin_page == 'leads' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
|
||||
Leads
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Suppliers</div>
|
||||
<a href="{{ url_for('admin.suppliers') }}" class="{% if admin_page == 'suppliers' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5M3.75 3v18m4.5-18v18M12 3v18m4.5-18v18m4.5-18v18M6 6.75h.008v.008H6V6.75Zm0 3h.008v.008H6V9.75Zm0 3h.008v.008H6v-.008Zm4.5-6h.008v.008H10.5V6.75Zm0 3h.008v.008H10.5V9.75Zm0 3h.008v.008H10.5v-.008Zm4.5-6h.008v.008H15V6.75Zm0 3h.008v.008H15V9.75Zm0 3h.008v.008H15v-.008Z"/></svg>
|
||||
Suppliers
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Users</div>
|
||||
<a href="{{ url_for('admin.users') }}" class="{% if admin_page == 'users' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/></svg>
|
||||
Users
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">Content</div>
|
||||
<a href="{{ url_for('admin.articles') }}" class="{% if admin_page == 'articles' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
|
||||
Articles
|
||||
</a>
|
||||
<a href="{{ url_for('admin.scenarios') }}" class="{% if admin_page == 'scenarios' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"/></svg>
|
||||
Scenarios
|
||||
</a>
|
||||
<a href="{{ url_for('admin.templates') }}" class="{% if admin_page == 'templates' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6Z"/></svg>
|
||||
Templates
|
||||
</a>
|
||||
|
||||
<div class="admin-sidebar__section">System</div>
|
||||
<a href="{{ url_for('admin.tasks') }}" class="{% if admin_page == 'tasks' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/></svg>
|
||||
Tasks
|
||||
</a>
|
||||
<a href="{{ url_for('admin.feedback') }}" class="{% if admin_page == 'feedback' %}active{% endif %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"/></svg>
|
||||
Feedback
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main">
|
||||
{% block admin_content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
50
web/src/padelnomics/admin/templates/admin/feedback.html
Normal file
50
web/src/padelnomics/admin/templates/admin/feedback.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "feedback" %}
|
||||
{% block title %}Feedback - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl">Feedback</h1>
|
||||
<p class="text-sm text-slate mt-1">{{ feedback_list | length }} submissions shown</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back to Dashboard</a>
|
||||
</header>
|
||||
|
||||
{% if feedback_list %}
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Message</th>
|
||||
<th>Page</th>
|
||||
<th>User</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in feedback_list %}
|
||||
<tr>
|
||||
<td style="max-width:400px">
|
||||
<p class="text-sm" style="white-space:pre-wrap;word-break:break-word">{{ f.message }}</p>
|
||||
</td>
|
||||
<td class="text-xs text-slate mono">{{ f.page_url or '-' }}</td>
|
||||
<td>
|
||||
{% if f.email %}
|
||||
<a href="{{ url_for('admin.user_detail', user_id=f.user_id) }}" class="text-sm">{{ f.email }}</a>
|
||||
{% else %}
|
||||
<span class="text-xs text-slate">Anonymous</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center" style="padding:2rem">
|
||||
<p class="text-slate">No feedback yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
46
web/src/padelnomics/admin/templates/admin/generate_form.html
Normal file
46
web/src/padelnomics/admin/templates/admin/generate_form.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}Generate Articles - {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width: 32rem; margin: 0 auto;">
|
||||
<a href="{{ url_for('admin.template_data', template_id=template.id) }}" class="text-sm text-slate">← Back to {{ template.name }}</a>
|
||||
<h1 class="text-2xl mt-4 mb-2">Generate Articles</h1>
|
||||
<p class="text-slate text-sm mb-6">{{ pending_count }} pending data row{{ 's' if pending_count != 1 }} ready to generate.</p>
|
||||
|
||||
{% if pending_count == 0 %}
|
||||
<div class="card">
|
||||
<p class="text-slate text-sm">All data rows have already been generated. Add more data rows first.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" class="card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="start_date">Start Date</label>
|
||||
<input type="date" id="start_date" name="start_date" value="{{ today }}" class="form-input" required>
|
||||
<p class="form-hint">First batch of articles will be published on this date.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="articles_per_day">Articles Per Day</label>
|
||||
<input type="number" id="articles_per_day" name="articles_per_day" value="2" min="1" max="50" class="form-input" required>
|
||||
<p class="form-hint">How many articles to publish per day. Remaining articles get staggered to following days.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" style="background: var(--color-soft-white); border: 1px dashed var(--color-mid-gray); margin-bottom: 1rem;">
|
||||
<p class="text-sm text-slate">
|
||||
This will generate <strong class="text-navy">{{ pending_count }}</strong> articles
|
||||
over <strong class="text-navy" id="days-estimate">{{ ((pending_count + 1) // 2) }}</strong> days,
|
||||
each with its own financial scenario computed from the data row's input values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%;" onclick="return confirm('Generate {{ pending_count }} articles? This cannot be undone.')">
|
||||
Generate {{ pending_count }} Articles
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
164
web/src/padelnomics/admin/templates/admin/index.html
Normal file
164
web/src/padelnomics/admin/templates/admin/index.html
Normal file
@@ -0,0 +1,164 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "dashboard" %}
|
||||
|
||||
{% block title %}Admin Dashboard - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl">Admin Dashboard</h1>
|
||||
{% if session.get('admin_impersonating') %}
|
||||
<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="btn-outline btn-sm">Stop</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline">Sign Out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<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 %}
|
||||
<p class="text-xs text-danger mt-1">{{ stats.tasks_failed }} failed</p>
|
||||
{% else %}
|
||||
<p class="text-xs text-accent mt-1">0 failed</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lead Funnel -->
|
||||
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Lead Funnel</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
||||
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Planner Users</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.planner_users }}</p>
|
||||
</div>
|
||||
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Total Leads</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.leads_total }}</p>
|
||||
</div>
|
||||
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">New</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.leads_new }}</p>
|
||||
</div>
|
||||
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Verified</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.leads_verified }}</p>
|
||||
</div>
|
||||
<div class="card text-center border-l-4 border-l-electric" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Unlocked</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.leads_unlocked }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Supplier Stats -->
|
||||
<p class="text-xs font-semibold text-slate uppercase tracking-wider mb-2">Supplier Funnel</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(5,1fr);gap:0.75rem" class="mb-8">
|
||||
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Claimed Suppliers</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.suppliers_claimed }}</p>
|
||||
</div>
|
||||
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Growth Tier</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.suppliers_growth }}</p>
|
||||
</div>
|
||||
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Pro Tier</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.suppliers_pro }}</p>
|
||||
</div>
|
||||
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Credits Spent</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.total_credits_spent }}</p>
|
||||
</div>
|
||||
<div class="card text-center border-l-4 border-l-accent" style="padding:0.75rem">
|
||||
<p class="text-xs text-slate">Leads Forwarded</p>
|
||||
<p class="text-xl font-bold text-navy">{{ stats.leads_unlocked_by_suppliers }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<!-- Recent Users -->
|
||||
<section>
|
||||
<h2 class="text-xl mb-4">Recent Users</h2>
|
||||
<div class="card">
|
||||
{% if recent_users %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Plan</th>
|
||||
<th>Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in recent_users %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('admin.user_detail', user_id=u.id) }}">{{ u.email }}</a></td>
|
||||
<td>{{ u.plan or 'free' }}</td>
|
||||
<td class="mono text-sm">{{ u.created_at[:10] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<a href="{{ url_for('admin.users') }}" class="text-sm mt-3 inline-block">View all →</a>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No users yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Failed Tasks -->
|
||||
<section>
|
||||
<h2 class="text-xl mb-4">Failed Tasks</h2>
|
||||
<div class="card">
|
||||
{% if failed_tasks %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Error</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in failed_tasks[:5] %}
|
||||
<tr>
|
||||
<td>{{ task.task_name }}</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) }}" class="m-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm">Retry</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<a href="{{ url_for('admin.tasks') }}" class="text-sm mt-3 inline-block">View all →</a>
|
||||
{% else %}
|
||||
<p class="text-accent text-sm">No failed tasks</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
123
web/src/padelnomics/admin/templates/admin/lead_detail.html
Normal file
123
web/src/padelnomics/admin/templates/admin/lead_detail.html
Normal file
@@ -0,0 +1,123 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "leads" %}
|
||||
{% block title %}Lead #{{ lead.id }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
||||
<h1 class="text-2xl mt-1">Lead #{{ lead.id }}
|
||||
{% if lead.heat_score == 'hot' %}<span class="badge-danger">HOT</span>
|
||||
{% elif lead.heat_score == 'warm' %}<span class="badge-warning">WARM</span>
|
||||
{% else %}<span class="badge">COOL</span>{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
<!-- Status update -->
|
||||
<form method="post" action="{{ url_for('admin.lead_status', lead_id=lead.id) }}" class="flex items-center gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select name="status" class="form-input" style="min-width:140px">
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if s == lead.status %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-outline btn-sm">Update</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div class="grid-2" style="gap:1.5rem">
|
||||
<!-- Project brief -->
|
||||
<div class="card" style="padding:1.5rem">
|
||||
<h2 class="text-lg mb-4">Project Brief</h2>
|
||||
<dl style="display:grid;grid-template-columns:140px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||
<dt class="text-slate">Facility</dt>
|
||||
<dd>{{ lead.facility_type or '-' }}</dd>
|
||||
<dt class="text-slate">Courts</dt>
|
||||
<dd>{{ lead.court_count or '-' }}</dd>
|
||||
<dt class="text-slate">Glass</dt>
|
||||
<dd>{{ lead.glass_type or '-' }}</dd>
|
||||
<dt class="text-slate">Lighting</dt>
|
||||
<dd>{{ lead.lighting_type or '-' }}</dd>
|
||||
<dt class="text-slate">Build Context</dt>
|
||||
<dd>{{ lead.build_context or '-' }}</dd>
|
||||
<dt class="text-slate">Location</dt>
|
||||
<dd>{{ lead.location or '-' }}, {{ lead.country or '-' }}</dd>
|
||||
<dt class="text-slate">Timeline</dt>
|
||||
<dd>{{ lead.timeline or '-' }}</dd>
|
||||
<dt class="text-slate">Phase</dt>
|
||||
<dd>{{ lead.location_status or '-' }}</dd>
|
||||
<dt class="text-slate">Budget</dt>
|
||||
<dd>{{ lead.budget_estimate or '-' }}</dd>
|
||||
<dt class="text-slate">Financing</dt>
|
||||
<dd>{{ lead.financing_status or '-' }}</dd>
|
||||
<dt class="text-slate">Services</dt>
|
||||
<dd>{{ lead.services_needed or '-' }}</dd>
|
||||
<dt class="text-slate">Additional Info</dt>
|
||||
<dd>{{ lead.additional_info or '-' }}</dd>
|
||||
<dt class="text-slate">Credit Cost</dt>
|
||||
<dd>{{ lead.credit_cost or '-' }} credits</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Contact info -->
|
||||
<div>
|
||||
<div class="card mb-4" style="padding:1.5rem">
|
||||
<h2 class="text-lg mb-4">Contact</h2>
|
||||
<dl style="display:grid;grid-template-columns:100px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||
<dt class="text-slate">Name</dt>
|
||||
<dd>{{ lead.contact_name or '-' }}</dd>
|
||||
<dt class="text-slate">Email</dt>
|
||||
<dd>{{ lead.contact_email or '-' }}</dd>
|
||||
<dt class="text-slate">Phone</dt>
|
||||
<dd>{{ lead.contact_phone or '-' }}</dd>
|
||||
<dt class="text-slate">Company</dt>
|
||||
<dd>{{ lead.contact_company or '-' }}</dd>
|
||||
<dt class="text-slate">Role</dt>
|
||||
<dd>{{ lead.stakeholder_type or '-' }}</dd>
|
||||
<dt class="text-slate">Created</dt>
|
||||
<dd class="mono">{{ lead.created_at or '-' }}</dd>
|
||||
<dt class="text-slate">Verified</dt>
|
||||
<dd class="mono">{{ lead.verified_at or 'Not verified' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Forward to supplier -->
|
||||
<div class="card" style="padding:1.5rem">
|
||||
<h2 class="text-lg mb-4">Forward to Supplier</h2>
|
||||
<form method="post" action="{{ url_for('admin.lead_forward', lead_id=lead.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select name="supplier_id" class="form-input mb-3" style="width:100%">
|
||||
<option value="">Select supplier...</option>
|
||||
{% for s in suppliers %}
|
||||
<option value="{{ s.id }}">{{ s.name }} ({{ s.country_code }}, {{ s.category }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn" style="width:100%">Forward Lead</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forward history -->
|
||||
{% if lead.forwards %}
|
||||
<section class="mt-6">
|
||||
<h2 class="text-lg mb-3">Forward History</h2>
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Supplier</th><th>Credits</th><th>Status</th><th>Sent</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in lead.forwards %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('admin.supplier_detail', supplier_id=f.supplier_id) }}">{{ f.supplier_name }}</a></td>
|
||||
<td>{{ f.credit_cost }}</td>
|
||||
<td><span class="badge">{{ f.status }}</span></td>
|
||||
<td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
118
web/src/padelnomics/admin/templates/admin/lead_form.html
Normal file
118
web/src/padelnomics/admin/templates/admin/lead_form.html
Normal file
@@ -0,0 +1,118 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "leads" %}
|
||||
{% block title %}New Lead - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width:640px">
|
||||
<a href="{{ url_for('admin.leads') }}" class="text-sm text-slate">← All Leads</a>
|
||||
<h1 class="text-2xl mt-2 mb-6">Create Lead</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:1.5rem">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="text-lg mb-3">Contact</h2>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Full Name *</label>
|
||||
<input type="text" name="contact_name" class="form-input" required value="{{ data.get('contact_name', '') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Email *</label>
|
||||
<input type="email" name="contact_email" class="form-input" required value="{{ data.get('contact_email', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Phone</label>
|
||||
<input type="text" name="contact_phone" class="form-input" value="{{ data.get('contact_phone', '') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Company</label>
|
||||
<input type="text" name="contact_company" class="form-input" value="{{ data.get('contact_company', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="margin:1.5rem 0">
|
||||
|
||||
<h2 class="text-lg mb-3">Project</h2>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Facility Type *</label>
|
||||
<select name="facility_type" class="form-input" required>
|
||||
<option value="indoor">Indoor</option>
|
||||
<option value="outdoor">Outdoor</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Courts</label>
|
||||
<input type="number" name="court_count" class="form-input" min="1" max="50" value="{{ data.get('court_count', '6') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Budget (€)</label>
|
||||
<input type="number" name="budget_estimate" class="form-input" value="{{ data.get('budget_estimate', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Country *</label>
|
||||
<select name="country" class="form-input" required>
|
||||
{% for code, name in [('DE', 'Germany'), ('ES', 'Spain'), ('IT', 'Italy'), ('FR', 'France'), ('NL', 'Netherlands'), ('SE', 'Sweden'), ('GB', 'United Kingdom'), ('PT', 'Portugal'), ('AE', 'UAE'), ('SA', 'Saudi Arabia')] %}
|
||||
<option value="{{ code }}">{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">City</label>
|
||||
<input type="text" name="city" class="form-input" value="{{ data.get('city', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Timeline *</label>
|
||||
<select name="timeline" class="form-input" required>
|
||||
<option value="asap">ASAP</option>
|
||||
<option value="3-6mo">3-6 Months</option>
|
||||
<option value="6-12mo">6-12 Months</option>
|
||||
<option value="12+mo">12+ Months</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Stakeholder Type *</label>
|
||||
<select name="stakeholder_type" class="form-input" required>
|
||||
<option value="entrepreneur">Entrepreneur</option>
|
||||
<option value="tennis_club">Tennis Club</option>
|
||||
<option value="municipality">Municipality</option>
|
||||
<option value="developer">Developer</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="architect">Architect</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Heat Score</label>
|
||||
<select name="heat_score" class="form-input">
|
||||
<option value="hot">Hot</option>
|
||||
<option value="warm" selected>Warm</option>
|
||||
<option value="cool">Cool</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Status</label>
|
||||
<select name="status" class="form-input">
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {{ 'selected' if s == 'new' }}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width:100%">Create Lead</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
67
web/src/padelnomics/admin/templates/admin/leads.html
Normal file
67
web/src/padelnomics/admin/templates/admin/leads.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "leads" %}
|
||||
{% block title %}Lead Management - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl">Lead Management</h1>
|
||||
<p class="text-sm text-slate mt-1">
|
||||
{{ leads | length }} leads shown
|
||||
{% if lead_stats %}
|
||||
· {{ lead_stats.get('new', 0) }} new
|
||||
· {{ lead_stats.get('forwarded', 0) }} forwarded
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.lead_new') }}" class="btn btn-sm">+ New Lead</a>
|
||||
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">Back to Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-6" style="padding:1rem 1.25rem;">
|
||||
<form class="flex flex-wrap gap-3 items-end"
|
||||
hx-get="{{ url_for('admin.lead_results') }}"
|
||||
hx-target="#lead-results"
|
||||
hx-trigger="change, input delay:300ms from:find input">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Status</label>
|
||||
<select name="status" class="form-input" style="min-width:140px">
|
||||
<option value="">All</option>
|
||||
{% for s in statuses %}
|
||||
<option value="{{ s }}" {% if s == current_status %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Heat</label>
|
||||
<select name="heat" class="form-input" style="min-width:100px">
|
||||
<option value="">All</option>
|
||||
{% for h in heat_options %}
|
||||
<option value="{{ h }}" {% if h == current_heat %}selected{% endif %}>{{ h | upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Country</label>
|
||||
<select name="country" class="form-input" style="min-width:120px">
|
||||
<option value="">All</option>
|
||||
{% for c in countries %}
|
||||
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="lead-results">
|
||||
{% include "admin/partials/lead_results.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% if leads %}
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Heat</th>
|
||||
<th>Contact</th>
|
||||
<th>Country</th>
|
||||
<th>Courts</th>
|
||||
<th>Budget</th>
|
||||
<th>Status</th>
|
||||
<th>Unlocks</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lead in leads %}
|
||||
<tr data-href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">
|
||||
<td><a href="{{ url_for('admin.lead_detail', lead_id=lead.id) }}">#{{ lead.id }}</a></td>
|
||||
<td>
|
||||
{% if lead.heat_score == 'hot' %}
|
||||
<span class="badge-danger">HOT</span>
|
||||
{% elif lead.heat_score == 'warm' %}
|
||||
<span class="badge-warning">WARM</span>
|
||||
{% else %}
|
||||
<span class="badge">COOL</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-sm">{{ lead.contact_name or '-' }}</span><br>
|
||||
<span class="text-xs text-slate">{{ lead.contact_email or '-' }}</span>
|
||||
</td>
|
||||
<td>{{ lead.country or '-' }}</td>
|
||||
<td>{{ lead.court_count or '-' }}</td>
|
||||
<td>{{ lead.budget_estimate or '-' }}</td>
|
||||
<td><span class="badge">{{ lead.status }}</span></td>
|
||||
<td>{{ lead.unlock_count or 0 }}</td>
|
||||
<td class="mono text-sm">{{ lead.created_at[:10] if lead.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center" style="padding:2rem">
|
||||
<p class="text-slate">No leads match the current filters.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% if suppliers %}
|
||||
<div class="card">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Country</th>
|
||||
<th>Category</th>
|
||||
<th>Tier</th>
|
||||
<th>Credits</th>
|
||||
<th>Claimed</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in suppliers %}
|
||||
<tr data-href="{{ url_for('admin.supplier_detail', supplier_id=s.id) }}">
|
||||
<td><a href="{{ url_for('admin.supplier_detail', supplier_id=s.id) }}">#{{ s.id }}</a></td>
|
||||
<td>
|
||||
<span class="text-sm font-semibold">{{ s.name }}</span>
|
||||
{% if s.slug %}<br><span class="text-xs text-slate">{{ s.slug }}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ s.country_code or '-' }}</td>
|
||||
<td>{{ s.category or '-' }}</td>
|
||||
<td>
|
||||
{% if s.tier == 'pro' %}
|
||||
<span class="badge-danger">PRO</span>
|
||||
{% elif s.tier == 'growth' %}
|
||||
<span class="badge-warning">GROWTH</span>
|
||||
{% else %}
|
||||
<span class="badge">FREE</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ s.credit_balance or 0 }}</td>
|
||||
<td>
|
||||
{% if s.claimed_by %}
|
||||
<span class="text-xs text-accent">Yes</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-slate">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono text-sm">{{ s.created_at[:10] if s.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center" style="padding:2rem">
|
||||
<p class="text-slate">No suppliers match the current filters.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
182
web/src/padelnomics/admin/templates/admin/scenario_form.html
Normal file
182
web/src/padelnomics/admin/templates/admin/scenario_form.html
Normal file
@@ -0,0 +1,182 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "scenarios" %}
|
||||
|
||||
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Scenario - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width: 48rem; margin: 0 auto;">
|
||||
<a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">← Back to scenarios</a>
|
||||
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Published Scenario</h1>
|
||||
|
||||
<form method="post" class="card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Metadata -->
|
||||
<h3 class="text-base font-semibold mb-3">Metadata</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-6">
|
||||
<div>
|
||||
<label class="form-label">Title</label>
|
||||
<input type="text" name="title" value="{{ data.get('title', '') }}" class="form-input" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Slug</label>
|
||||
<input type="text" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
|
||||
placeholder="auto-generated" {% if editing %}readonly{% endif %}>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Subtitle</label>
|
||||
<input type="text" name="subtitle" value="{{ data.get('subtitle', '') }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Location</label>
|
||||
<input type="text" name="location" value="{{ data.get('location', '') }}" class="form-input" required placeholder="e.g. Miami">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Country</label>
|
||||
<input type="text" name="country" value="{{ data.get('country', '') }}" class="form-input" required placeholder="e.g. US">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Venue -->
|
||||
<h3 class="text-base font-semibold mb-3">Venue</h3>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 1rem;" class="mb-6">
|
||||
<div>
|
||||
<label class="form-label">Venue Type</label>
|
||||
<select name="venue" class="form-input">
|
||||
<option value="indoor" {% if data.get('venue') == 'indoor' %}selected{% endif %}>Indoor</option>
|
||||
<option value="outdoor" {% if data.get('venue') == 'outdoor' %}selected{% endif %}>Outdoor</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Ownership</label>
|
||||
<select name="own" class="form-input">
|
||||
<option value="rent" {% if data.get('own') == 'rent' %}selected{% endif %}>Rent</option>
|
||||
<option value="buy" {% if data.get('own') == 'buy' %}selected{% endif %}>Buy</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Double Courts</label>
|
||||
<input type="number" name="dblCourts" value="{{ data.get('dblCourts', defaults.dblCourts) }}" class="form-input" min="0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Single Courts</label>
|
||||
<input type="number" name="sglCourts" value="{{ data.get('sglCourts', defaults.sglCourts) }}" class="form-input" min="0">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<h3 class="text-base font-semibold mb-3">Pricing</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;" class="mb-6">
|
||||
<div>
|
||||
<label class="form-label">Peak Rate (€/hr)</label>
|
||||
<input type="number" name="ratePeak" value="{{ data.get('ratePeak', defaults.ratePeak) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Off-Peak Rate (€/hr)</label>
|
||||
<input type="number" name="rateOffPeak" value="{{ data.get('rateOffPeak', defaults.rateOffPeak) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Single Rate (€/hr)</label>
|
||||
<input type="number" name="rateSingle" value="{{ data.get('rateSingle', defaults.rateSingle) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Peak %</label>
|
||||
<input type="number" name="peakPct" value="{{ data.get('peakPct', defaults.peakPct) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Hours/Day</label>
|
||||
<input type="number" name="hoursPerDay" value="{{ data.get('hoursPerDay', defaults.hoursPerDay) }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Utilization Target (%)</label>
|
||||
<input type="number" name="utilTarget" value="{{ data.get('utilTarget', defaults.utilTarget) }}" class="form-input" step="any">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OPEX -->
|
||||
<h3 class="text-base font-semibold mb-3">Operating Costs (monthly)</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem;" class="mb-6">
|
||||
<div>
|
||||
<label class="form-label">Rent/m²</label>
|
||||
<input type="number" name="rentSqm" value="{{ data.get('rentSqm', defaults.rentSqm) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Electricity</label>
|
||||
<input type="number" name="electricity" value="{{ data.get('electricity', defaults.electricity) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Heating</label>
|
||||
<input type="number" name="heating" value="{{ data.get('heating', defaults.heating) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Staff</label>
|
||||
<input type="number" name="staff" value="{{ data.get('staff', defaults.staff) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Insurance</label>
|
||||
<input type="number" name="insurance" value="{{ data.get('insurance', defaults.insurance) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Maintenance</label>
|
||||
<input type="number" name="maintenance" value="{{ data.get('maintenance', defaults.maintenance) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Cleaning</label>
|
||||
<input type="number" name="cleaning" value="{{ data.get('cleaning', defaults.cleaning) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Marketing</label>
|
||||
<input type="number" name="marketing" value="{{ data.get('marketing', defaults.marketing) }}" class="form-input" step="any">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CAPEX -->
|
||||
<h3 class="text-base font-semibold mb-3">Capital Costs</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;" class="mb-6">
|
||||
<div>
|
||||
<label class="form-label">Court Cost (dbl)</label>
|
||||
<input type="number" name="courtCostDbl" value="{{ data.get('courtCostDbl', defaults.courtCostDbl) }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Court Cost (sgl)</label>
|
||||
<input type="number" name="courtCostSgl" value="{{ data.get('courtCostSgl', defaults.courtCostSgl) }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Hall Cost/m²</label>
|
||||
<input type="number" name="hallCostSqm" value="{{ data.get('hallCostSqm', defaults.hallCostSqm) }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Land Price/m²</label>
|
||||
<input type="number" name="landPriceSqm" value="{{ data.get('landPriceSqm', defaults.landPriceSqm) }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Contingency (%)</label>
|
||||
<input type="number" name="contingencyPct" value="{{ data.get('contingencyPct', defaults.contingencyPct) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Fit-out</label>
|
||||
<input type="number" name="fitout" value="{{ data.get('fitout', defaults.fitout) }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financing -->
|
||||
<h3 class="text-base font-semibold mb-3">Financing</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;" class="mb-6">
|
||||
<div>
|
||||
<label class="form-label">Loan %</label>
|
||||
<input type="number" name="loanPct" value="{{ data.get('loanPct', defaults.loanPct) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Interest Rate (%)</label>
|
||||
<input type="number" name="interestRate" value="{{ data.get('interestRate', defaults.interestRate) }}" class="form-input" step="any">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Loan Term (years)</label>
|
||||
<input type="number" name="loanTerm" value="{{ data.get('loanTerm', defaults.loanTerm) }}" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update & Recalculate{% else %}Create Scenario{% endif %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "scenarios" %}
|
||||
|
||||
{% block title %}Preview: {{ scenario.title }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width: 48rem; margin: 0 auto;">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<a href="{{ url_for('admin.scenarios') }}" class="text-sm text-slate">← Back to scenarios</a>
|
||||
<a href="{{ url_for('admin.scenario_edit', scenario_id=scenario.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
</div>
|
||||
<h1 class="text-2xl mb-2">{{ scenario.title }}</h1>
|
||||
{% if scenario.subtitle %}
|
||||
<p class="text-slate mb-6">{{ scenario.subtitle }}</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="text-lg mb-4">Summary</h2>
|
||||
{% include "partials/scenario_summary.html" %}
|
||||
|
||||
<h2 class="text-lg mb-4 mt-8">CAPEX Breakdown</h2>
|
||||
{% include "partials/scenario_capex.html" %}
|
||||
|
||||
<h2 class="text-lg mb-4 mt-8">Revenue & Operating Costs</h2>
|
||||
{% include "partials/scenario_operating.html" %}
|
||||
|
||||
<h2 class="text-lg mb-4 mt-8">Cash Flow Projection</h2>
|
||||
{% include "partials/scenario_cashflow.html" %}
|
||||
|
||||
<h2 class="text-lg mb-4 mt-8">Returns & Financing</h2>
|
||||
{% include "partials/scenario_returns.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
55
web/src/padelnomics/admin/templates/admin/scenarios.html
Normal file
55
web/src/padelnomics/admin/templates/admin/scenarios.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "scenarios" %}
|
||||
|
||||
{% block title %}Published Scenarios - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl">Published Scenarios</h1>
|
||||
<p class="text-slate text-sm">{{ scenarios | length }} scenario{{ 's' if scenarios | length != 1 }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.scenario_new') }}" class="btn">New Scenario</a>
|
||||
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
{% if scenarios %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Slug</th>
|
||||
<th>Location</th>
|
||||
<th>Config</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in scenarios %}
|
||||
<tr>
|
||||
<td>{{ s.title }}</td>
|
||||
<td class="mono text-sm">{{ s.slug }}</td>
|
||||
<td>{{ s.location }}, {{ s.country }}</td>
|
||||
<td class="text-sm">{{ s.venue_type | capitalize }} · {{ s.court_config }}</td>
|
||||
<td class="mono text-sm">{{ s.created_at[:10] }}</td>
|
||||
<td class="text-right">
|
||||
<a href="{{ url_for('admin.scenario_preview', scenario_id=s.id) }}" class="btn-outline btn-sm">Preview</a>
|
||||
<a href="{{ url_for('admin.scenario_edit', scenario_id=s.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
<form method="post" action="{{ url_for('admin.scenario_delete', scenario_id=s.id) }}" class="m-0" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this scenario?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No published scenarios yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
196
web/src/padelnomics/admin/templates/admin/supplier_detail.html
Normal file
196
web/src/padelnomics/admin/templates/admin/supplier_detail.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "suppliers" %}
|
||||
{% block title %}{{ supplier.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">← All Suppliers</a>
|
||||
<h1 class="text-2xl mt-1">{{ supplier.name }}
|
||||
{% if supplier.tier == 'pro' %}<span class="badge-danger">PRO</span>
|
||||
{% elif supplier.tier == 'growth' %}<span class="badge-warning">GROWTH</span>
|
||||
{% else %}<span class="badge">FREE</span>{% endif %}
|
||||
</h1>
|
||||
<p class="text-sm text-slate mt-1">{{ supplier.slug }} · {{ supplier.country_code or '-' }}
|
||||
{% if supplier.tier == 'basic' %}<span class="badge" style="background:#E0F2FE;color:#0369A1">BASIC</span>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if supplier.claimed_by %}
|
||||
<form method="post" action="{{ url_for('admin.impersonate', user_id=supplier.claimed_by) }}" class="m-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline">Impersonate Owner</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('directory.supplier_detail', slug=supplier.slug) }}" class="btn-outline" target="_blank">View Profile</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid-2" style="gap:1.5rem">
|
||||
<!-- Profile info -->
|
||||
<div class="card" style="padding:1.5rem">
|
||||
<h2 class="text-lg mb-4">Company Info</h2>
|
||||
<dl style="display:grid;grid-template-columns:140px 1fr;gap:6px 12px;font-size:0.8125rem">
|
||||
<dt class="text-slate">Name</dt>
|
||||
<dd>{{ supplier.name }}</dd>
|
||||
<dt class="text-slate">Slug</dt>
|
||||
<dd class="mono">{{ supplier.slug }}</dd>
|
||||
<dt class="text-slate">Category</dt>
|
||||
<dd>{{ supplier.category or '-' }}</dd>
|
||||
<dt class="text-slate">Country</dt>
|
||||
<dd>{{ supplier.country_code or '-' }}</dd>
|
||||
<dt class="text-slate">City</dt>
|
||||
<dd>{{ supplier.city or '-' }}</dd>
|
||||
<dt class="text-slate">Website</dt>
|
||||
<dd>{% if supplier.website %}<a href="{{ supplier.website }}" target="_blank" class="text-sm">{{ supplier.website }}</a>{% else %}-{% endif %}</dd>
|
||||
<dt class="text-slate">Contact</dt>
|
||||
<dd>{{ supplier.contact_name or '-' }}<br>
|
||||
<span class="text-xs text-slate">{{ supplier.contact_email or '-' }}</span></dd>
|
||||
<dt class="text-slate">Tagline</dt>
|
||||
<dd>{{ supplier.tagline or '-' }}</dd>
|
||||
<dt class="text-slate">Description</dt>
|
||||
<dd>{{ supplier.short_description or '-' }}</dd>
|
||||
<dt class="text-slate">Years</dt>
|
||||
<dd>{{ supplier.years_in_business or '-' }}</dd>
|
||||
<dt class="text-slate">Projects</dt>
|
||||
<dd>{{ supplier.project_count or '-' }}</dd>
|
||||
{% if supplier.contact_role %}
|
||||
<dt class="text-slate">Contact Role</dt>
|
||||
<dd>{{ supplier.contact_role }}</dd>
|
||||
{% endif %}
|
||||
{% if supplier.services_offered %}
|
||||
<dt class="text-slate">Services</dt>
|
||||
<dd class="text-xs">{{ supplier.services_offered }}</dd>
|
||||
{% endif %}
|
||||
{% if supplier.linkedin_url or supplier.instagram_url or supplier.youtube_url %}
|
||||
<dt class="text-slate">Social</dt>
|
||||
<dd class="text-xs">
|
||||
{% if supplier.linkedin_url %}<a href="{{ supplier.linkedin_url }}" target="_blank" class="text-sm">LinkedIn</a> {% endif %}
|
||||
{% if supplier.instagram_url %}<a href="{{ supplier.instagram_url }}" target="_blank" class="text-sm">Instagram</a> {% endif %}
|
||||
{% if supplier.youtube_url %}<a href="{{ supplier.youtube_url }}" target="_blank" class="text-sm">YouTube</a> {% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt class="text-slate">Enquiries</dt>
|
||||
<dd>{{ enquiry_count }}</dd>
|
||||
<dt class="text-slate">Claimed By</dt>
|
||||
<dd>{% if supplier.claimed_by %}User #{{ supplier.claimed_by }}{% else %}Unclaimed{% endif %}</dd>
|
||||
<dt class="text-slate">Created</dt>
|
||||
<dd class="mono">{{ supplier.created_at or '-' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<!-- Tier management -->
|
||||
<div class="card mb-4" style="padding:1.5rem">
|
||||
<h2 class="text-lg mb-4">Tier</h2>
|
||||
<form method="post" action="{{ url_for('admin.supplier_tier', supplier_id=supplier.id) }}" class="flex items-center gap-2">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select name="tier" class="form-input" style="min-width:140px">
|
||||
{% for t in tiers %}
|
||||
<option value="{{ t }}" {% if t == supplier.tier %}selected{% endif %}>{{ t | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn-outline btn-sm">Update Tier</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Credit management -->
|
||||
<div class="card mb-4" style="padding:1.5rem">
|
||||
<h2 class="text-lg mb-3">Credits</h2>
|
||||
<p class="text-2xl font-bold text-navy mb-3">{{ credit_balance }} <span class="text-sm font-normal text-slate">credits</span></p>
|
||||
<form method="post" action="{{ url_for('admin.supplier_credits', supplier_id=supplier.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input type="number" name="amount" placeholder="Amount" class="form-input" style="width:100px" required>
|
||||
<select name="action" class="form-input" style="min-width:100px">
|
||||
<option value="add">Add</option>
|
||||
<option value="subtract">Subtract</option>
|
||||
</select>
|
||||
</div>
|
||||
<input type="text" name="note" placeholder="Admin note (optional)" class="form-input mb-2" style="width:100%">
|
||||
<button type="submit" class="btn-outline btn-sm">Adjust Credits</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Active boosts -->
|
||||
<div class="card" style="padding:1.5rem">
|
||||
<h2 class="text-lg mb-3">Active Boosts</h2>
|
||||
{% if boosts %}
|
||||
<table class="table">
|
||||
<thead><tr><th>Boost</th><th>Status</th><th>Activated</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in boosts %}
|
||||
<tr>
|
||||
<td>{{ b.boost_type }}</td>
|
||||
<td><span class="badge">{{ b.status }}</span></td>
|
||||
<td class="mono text-sm">{{ b.created_at[:10] if b.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate">No active boosts.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credit ledger -->
|
||||
<section class="mt-6">
|
||||
<h2 class="text-lg mb-3">Credit Ledger (last 50)</h2>
|
||||
<div class="card">
|
||||
{% if ledger %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Type</th><th>Amount</th><th>Balance After</th><th>Note</th><th>Date</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in ledger %}
|
||||
<tr>
|
||||
<td>{{ entry.event_type }}</td>
|
||||
<td>
|
||||
{% if entry.delta > 0 %}
|
||||
<span style="color:#16A34A">+{{ entry.delta }}</span>
|
||||
{% else %}
|
||||
<span style="color:#DC2626">{{ entry.delta }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.balance_after }}</td>
|
||||
<td class="text-xs text-slate">{{ entry.note or '-' }}</td>
|
||||
<td class="mono text-sm">{{ entry.created_at[:16] if entry.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate" style="padding:1rem">No credit history.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Lead forwards -->
|
||||
<section class="mt-6">
|
||||
<h2 class="text-lg mb-3">Lead Forward History</h2>
|
||||
<div class="card">
|
||||
{% if forwards %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Lead</th><th>Credits</th><th>Status</th><th>Date</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in forwards %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('admin.lead_detail', lead_id=f.lead_id) }}">#{{ f.lead_id }}</a></td>
|
||||
<td>{{ f.credit_cost }}</td>
|
||||
<td><span class="badge">{{ f.status }}</span></td>
|
||||
<td class="mono text-sm">{{ f.created_at[:16] if f.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-sm text-slate" style="padding:1rem">No leads forwarded yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
119
web/src/padelnomics/admin/templates/admin/supplier_form.html
Normal file
119
web/src/padelnomics/admin/templates/admin/supplier_form.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "suppliers" %}
|
||||
{% block title %}New Supplier - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width:640px">
|
||||
<a href="{{ url_for('admin.suppliers') }}" class="text-sm text-slate">← All Suppliers</a>
|
||||
<h1 class="text-2xl mt-2 mb-6">Create Supplier</h1>
|
||||
|
||||
<form method="post" class="card" style="padding:1.5rem">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div style="margin-bottom:1rem">
|
||||
<label class="form-label">Company Name *</label>
|
||||
<input type="text" name="name" class="form-input" required value="{{ data.get('name', '') }}">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem">
|
||||
<label class="form-label">Slug *</label>
|
||||
<input type="text" name="slug" class="form-input" required value="{{ data.get('slug', '') }}"
|
||||
placeholder="company-name (auto-generated if blank)">
|
||||
<p class="form-hint">URL-safe identifier. Leave blank to auto-generate from name.</p>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Country Code *</label>
|
||||
<input type="text" name="country_code" class="form-input" required maxlength="3"
|
||||
value="{{ data.get('country_code', '') }}" placeholder="DE">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">City</label>
|
||||
<input type="text" name="city" class="form-input" value="{{ data.get('city', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Region *</label>
|
||||
<select name="region" class="form-input" required>
|
||||
{% for r in ['Europe', 'North America', 'Latin America', 'Middle East', 'Asia Pacific', 'Africa'] %}
|
||||
<option value="{{ r }}" {{ 'selected' if data.get('region') == r }}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Category *</label>
|
||||
<select name="category" class="form-input" required>
|
||||
{% for c in ['manufacturer', 'turnkey', 'consultant', 'hall_builder', 'turf', 'lighting', 'software', 'industry_body', 'franchise'] %}
|
||||
<option value="{{ c }}" {{ 'selected' if data.get('category') == c }}>{{ c | replace('_', ' ') | title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem">
|
||||
<label class="form-label">Tier</label>
|
||||
<select name="tier" class="form-input">
|
||||
{% for t in ['free', 'basic', 'growth', 'pro'] %}
|
||||
<option value="{{ t }}" {{ 'selected' if data.get('tier') == t }}>{{ t | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem">
|
||||
<label class="form-label">Website</label>
|
||||
<input type="url" name="website" class="form-input" value="{{ data.get('website', '') }}" placeholder="https://...">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea name="description" class="form-input" rows="3">{{ data.get('description', '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Contact Name</label>
|
||||
<input type="text" name="contact_name" class="form-input" value="{{ data.get('contact_name', '') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Contact Email</label>
|
||||
<input type="email" name="contact_email" class="form-input" value="{{ data.get('contact_email', '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">Contact Role</label>
|
||||
<input type="text" name="contact_role" class="form-input" value="{{ data.get('contact_role', '') }}"
|
||||
placeholder="e.g. Managing Director">
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem">
|
||||
<label class="form-label">Services Offered <span class="form-hint">(comma-separated)</span></label>
|
||||
<input type="text" name="services_offered" class="form-input" value="{{ data.get('services_offered', '') }}"
|
||||
placeholder="Installation & commissioning, Lighting systems">
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:1rem;margin-bottom:1rem">
|
||||
<div>
|
||||
<label class="form-label">LinkedIn URL</label>
|
||||
<input type="url" name="linkedin_url" class="form-input" value="{{ data.get('linkedin_url', '') }}" placeholder="https://linkedin.com/company/...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Instagram URL</label>
|
||||
<input type="url" name="instagram_url" class="form-input" value="{{ data.get('instagram_url', '') }}" placeholder="https://instagram.com/...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">YouTube URL</label>
|
||||
<input type="url" name="youtube_url" class="form-input" value="{{ data.get('youtube_url', '') }}" placeholder="https://youtube.com/...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width:100%">Create Supplier</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
62
web/src/padelnomics/admin/templates/admin/suppliers.html
Normal file
62
web/src/padelnomics/admin/templates/admin/suppliers.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "suppliers" %}
|
||||
{% block title %}Supplier Management - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl">Supplier Management</h1>
|
||||
<p class="text-sm text-slate mt-1">
|
||||
{{ suppliers | length }} suppliers shown
|
||||
· {{ supplier_stats.claimed }} claimed
|
||||
· {{ supplier_stats.growth }} Growth
|
||||
· {{ supplier_stats.pro }} Pro
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.supplier_new') }}" class="btn btn-sm">+ New Supplier</a>
|
||||
<a href="{{ url_for('admin.index') }}" class="btn-outline btn-sm">Back to Dashboard</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-6" style="padding:1rem 1.25rem;">
|
||||
<form class="flex flex-wrap gap-3 items-end"
|
||||
hx-get="{{ url_for('admin.supplier_results') }}"
|
||||
hx-target="#supplier-results"
|
||||
hx-trigger="change, input delay:300ms from:find input">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Search</label>
|
||||
<input type="text" name="search" value="{{ current_search }}" placeholder="Company name..."
|
||||
class="form-input" style="min-width:180px">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Tier</label>
|
||||
<select name="tier" class="form-input" style="min-width:120px">
|
||||
<option value="">All</option>
|
||||
{% for t in tiers %}
|
||||
<option value="{{ t }}" {% if t == current_tier %}selected{% endif %}>{{ t | capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate block mb-1">Country</label>
|
||||
<select name="country" class="form-input" style="min-width:120px">
|
||||
<option value="">All</option>
|
||||
{% for c in countries %}
|
||||
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="supplier-results">
|
||||
{% include "admin/partials/supplier_results.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
109
web/src/padelnomics/admin/templates/admin/tasks.html
Normal file
109
web/src/padelnomics/admin/templates/admin/tasks.html
Normal file
@@ -0,0 +1,109 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "tasks" %}
|
||||
|
||||
{% block title %}Tasks - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<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">← Dashboard</a>
|
||||
</header>
|
||||
|
||||
<!-- Failed Tasks -->
|
||||
{% if failed_tasks %}
|
||||
<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>
|
||||
<th>Task</th>
|
||||
<th>Error</th>
|
||||
<th>Retries</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in failed_tasks %}
|
||||
<tr>
|
||||
<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 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 class="mono text-sm">{{ task.retries }}</td>
|
||||
<td class="mono text-sm">{{ task.created_at[:16] }}</td>
|
||||
<td>
|
||||
<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="btn-outline btn-sm">Retry</button>
|
||||
</form>
|
||||
<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="btn-outline btn-sm text-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- All Tasks -->
|
||||
<section>
|
||||
<h2 class="text-xl mb-4">Recent Tasks</h2>
|
||||
<div class="card">
|
||||
{% if tasks %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Task</th>
|
||||
<th>Status</th>
|
||||
<th>Run At</th>
|
||||
<th>Created</th>
|
||||
<th>Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for task in tasks %}
|
||||
<tr>
|
||||
<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 class="badge-success">complete</span>
|
||||
{% elif task.status == 'failed' %}
|
||||
<span class="badge-danger">failed</span>
|
||||
{% elif task.status == 'pending' %}
|
||||
<span class="badge-warning">pending</span>
|
||||
{% else %}
|
||||
<span class="badge">{{ task.status }}</span>
|
||||
{% endif %}
|
||||
</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 class="text-slate text-sm">No tasks in queue.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
103
web/src/padelnomics/admin/templates/admin/template_data.html
Normal file
103
web/src/padelnomics/admin/templates/admin/template_data.html
Normal file
@@ -0,0 +1,103 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}Data: {{ template.name }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">← Back to templates</a>
|
||||
<h1 class="text-2xl mt-2">{{ template.name }}</h1>
|
||||
<p class="text-slate text-sm">{{ data_rows | length }} data row{{ 's' if data_rows | length != 1 }} · <span class="mono">{{ template.slug }}</span></p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.template_generate', template_id=template.id) }}" class="btn">Generate Articles</a>
|
||||
<a href="{{ url_for('admin.template_edit', template_id=template.id) }}" class="btn-outline">Edit Template</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Add Single Row -->
|
||||
<div class="card mb-6">
|
||||
<h3 class="text-base font-semibold mb-4">Add Data Row</h3>
|
||||
<form method="post" action="{{ url_for('admin.template_data_add', template_id=template.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.75rem;" class="mb-4">
|
||||
{% for field in schema %}
|
||||
<div>
|
||||
<label class="form-label">{{ field.label }}</label>
|
||||
<input type="{{ 'number' if field.get('field_type') in ('number', 'float') else 'text' }}"
|
||||
name="{{ field.name }}" class="form-input"
|
||||
{% if field.get('field_type') == 'float' %}step="any"{% endif %}
|
||||
{% if field.get('required') %}required{% endif %}>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm">Add Row</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- CSV Upload -->
|
||||
<div class="card mb-6">
|
||||
<h3 class="text-base font-semibold mb-4">Bulk Upload (CSV)</h3>
|
||||
<form method="post" action="{{ url_for('admin.template_data_upload', template_id=template.id) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="flex items-end gap-3">
|
||||
<div style="flex: 1;">
|
||||
<label class="form-label" for="csv_file">CSV File</label>
|
||||
<input type="file" id="csv_file" name="csv_file" accept=".csv" class="form-input" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm">Upload</button>
|
||||
</div>
|
||||
<p class="form-hint mt-1">CSV headers should match field names: {{ schema | map(attribute='name') | join(', ') }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Data Rows -->
|
||||
<div class="card">
|
||||
<h3 class="text-base font-semibold mb-4">Data Rows</h3>
|
||||
{% if data_rows %}
|
||||
<div class="scenario-widget__table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
{% for field in schema[:5] %}
|
||||
<th>{{ field.label }}</th>
|
||||
{% endfor %}
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in data_rows %}
|
||||
<tr>
|
||||
<td class="mono text-sm">{{ row.id }}</td>
|
||||
{% for field in schema[:5] %}
|
||||
<td class="text-sm">{{ row.parsed_data.get(field.name, '') }}</td>
|
||||
{% endfor %}
|
||||
<td>
|
||||
{% if row.article_id %}
|
||||
<span class="badge-success">Generated</span>
|
||||
{% if row.article_url %}
|
||||
<a href="{{ row.article_url }}" class="text-xs ml-1">View</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="badge-warning">Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<form method="post" action="{{ url_for('admin.template_data_delete', template_id=template.id, data_id=row.id) }}" class="m-0" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm" onclick="return confirm('Delete this data row?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No data rows yet. Add some above or upload a CSV.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
70
web/src/padelnomics/admin/templates/admin/template_form.html
Normal file
70
web/src/padelnomics/admin/templates/admin/template_form.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}{% if editing %}Edit{% else %}New{% endif %} Article Template - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div style="max-width: 48rem; margin: 0 auto;">
|
||||
<a href="{{ url_for('admin.templates') }}" class="text-sm text-slate">← Back to templates</a>
|
||||
<h1 class="text-2xl mt-4 mb-6">{% if editing %}Edit{% else %}New{% endif %} Article Template</h1>
|
||||
|
||||
<form method="post" class="card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;" class="mb-4">
|
||||
<div>
|
||||
<label class="form-label" for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ data.get('name', '') }}" class="form-input" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="slug">Slug</label>
|
||||
<input type="text" id="slug" name="slug" value="{{ data.get('slug', '') }}" class="form-input"
|
||||
placeholder="auto-generated from name" {% if editing %}readonly{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="content_type">Content Type</label>
|
||||
<select id="content_type" name="content_type" class="form-input" {% if editing %}disabled{% endif %}>
|
||||
<option value="calculator" {% if data.get('content_type') == 'calculator' %}selected{% endif %}>Calculator</option>
|
||||
<option value="map" {% if data.get('content_type') == 'map' %}selected{% endif %}>Map (future)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="input_schema">Input Schema (JSON)</label>
|
||||
<textarea id="input_schema" name="input_schema" rows="6" class="form-input" style="font-family: var(--font-mono); font-size: 0.8125rem;">{{ data.get('input_schema', '[{"name": "city", "label": "City", "field_type": "text", "required": true}, {"name": "city_slug", "label": "City Slug", "field_type": "text", "required": true}, {"name": "country", "label": "Country", "field_type": "text", "required": true}, {"name": "region", "label": "Region", "field_type": "text", "required": false}]') }}</textarea>
|
||||
<p class="form-hint">JSON array of field definitions: [{name, label, field_type, required}]</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="url_pattern">URL Pattern</label>
|
||||
<input type="text" id="url_pattern" name="url_pattern" value="{{ data.get('url_pattern', '') }}"
|
||||
class="form-input" placeholder="/padel-court-cost-{{ '{{' }} city_slug {{ '}}' }}" required>
|
||||
<p class="form-hint">Jinja2 template string. Use {{ '{{' }} variable {{ '}}' }} placeholders from data rows.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="title_pattern">Title Pattern</label>
|
||||
<input type="text" id="title_pattern" name="title_pattern" value="{{ data.get('title_pattern', '') }}"
|
||||
class="form-input" placeholder="Padel Center Cost in {{ '{{' }} city {{ '}}' }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="meta_description_pattern">Meta Description Pattern</label>
|
||||
<input type="text" id="meta_description_pattern" name="meta_description_pattern"
|
||||
value="{{ data.get('meta_description_pattern', '') }}" class="form-input"
|
||||
placeholder="How much does it cost to build a padel center in {{ '{{' }} city {{ '}}' }}?">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label" for="body_template">Body Template (Markdown + Jinja2)</label>
|
||||
<textarea id="body_template" name="body_template" rows="20" class="form-input"
|
||||
style="font-family: var(--font-mono); font-size: 0.8125rem;" required>{{ data.get('body_template', '') }}</textarea>
|
||||
<p class="form-hint">Markdown with {{ '{{' }} variable {{ '}}' }} placeholders. Use [scenario:{{ '{{' }} scenario_slug {{ '}}' }}] to embed financial widgets. Sections: [scenario:slug:capex], [scenario:slug:operating], [scenario:slug:cashflow], [scenario:slug:returns], [scenario:slug:full].</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%;">{% if editing %}Update{% else %}Create{% endif %} Template</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
51
web/src/padelnomics/admin/templates/admin/templates.html
Normal file
51
web/src/padelnomics/admin/templates/admin/templates.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "templates" %}
|
||||
|
||||
{% block title %}Article Templates - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-2xl">Article Templates</h1>
|
||||
<p class="text-slate text-sm">{{ templates | length }} template{{ 's' if templates | length != 1 }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.template_new') }}" class="btn">New Template</a>
|
||||
<a href="{{ url_for('admin.index') }}" class="btn-outline">Back</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
{% if templates %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th>Type</th>
|
||||
<th>Data Rows</th>
|
||||
<th>Generated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in templates %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('admin.template_data', template_id=t.id) }}">{{ t.name }}</a></td>
|
||||
<td class="mono text-sm">{{ t.slug }}</td>
|
||||
<td><span class="badge">{{ t.content_type }}</span></td>
|
||||
<td class="mono">{{ t.data_count }}</td>
|
||||
<td class="mono">{{ t.generated_count }}</td>
|
||||
<td class="text-right">
|
||||
<a href="{{ url_for('admin.template_edit', template_id=t.id) }}" class="btn-outline btn-sm">Edit</a>
|
||||
<a href="{{ url_for('admin.template_generate', template_id=t.id) }}" class="btn btn-sm">Generate</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No templates yet. Create one to get started.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
76
web/src/padelnomics/admin/templates/admin/user_detail.html
Normal file
76
web/src/padelnomics/admin/templates/admin/user_detail.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "users" %}
|
||||
|
||||
{% block title %}User: {{ user.email }} - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<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">← Users</a>
|
||||
</header>
|
||||
|
||||
<div class="grid-2 mb-8">
|
||||
<!-- User Info -->
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-slate">Name</dt>
|
||||
<dd>{{ user.name or '-' }}</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>
|
||||
</div>
|
||||
|
||||
<!-- Subscription -->
|
||||
<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 %}
|
||||
<span class="badge">{{ user.plan }}</span>
|
||||
{% else %}
|
||||
<span class="text-slate">free</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-slate">Status</dt>
|
||||
<dd>{{ user.sub_status or 'N/A' }}</dd>
|
||||
</div>
|
||||
{% if user.provider_customer_id %}
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-slate">Paddle Customer</dt>
|
||||
<dd class="mono">{{ user.provider_customer_id }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<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="btn-secondary">Impersonate User</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
77
web/src/padelnomics/admin/templates/admin/users.html
Normal file
77
web/src/padelnomics/admin/templates/admin/users.html
Normal file
@@ -0,0 +1,77 @@
|
||||
{% extends "admin/base_admin.html" %}
|
||||
{% set admin_page = "users" %}
|
||||
|
||||
{% block title %}Users - Admin - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<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">← Dashboard</a>
|
||||
</header>
|
||||
|
||||
<!-- Search -->
|
||||
<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 -->
|
||||
<div class="card">
|
||||
{% if users %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Plan</th>
|
||||
<th>Joined</th>
|
||||
<th>Last Login</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr data-href="{{ url_for('admin.user_detail', user_id=u.id) }}">
|
||||
<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 %}
|
||||
<span class="badge">{{ u.plan }}</span>
|
||||
{% else %}
|
||||
<span class="text-sm text-slate">free</span>
|
||||
{% endif %}
|
||||
</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) }}" class="m-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-outline btn-sm">Impersonate</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<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>
|
||||
{% endif %}
|
||||
<span class="text-slate">Page {{ page }}</span>
|
||||
{% if users | length == 50 %}
|
||||
<a href="?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}">Next →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate text-sm">No users found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
61
web/src/padelnomics/analytics.py
Normal file
61
web/src/padelnomics/analytics.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
DuckDB read-only analytics reader.
|
||||
|
||||
Opens a single long-lived DuckDB connection at startup (read_only=True).
|
||||
All queries run via asyncio.to_thread() to avoid blocking the event loop.
|
||||
|
||||
Usage:
|
||||
from .analytics import fetch_analytics
|
||||
|
||||
rows = await fetch_analytics("SELECT * FROM padelnomics.planner_defaults WHERE city_slug = ?", ["berlin"])
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import duckdb
|
||||
|
||||
_conn: duckdb.DuckDBPyConnection | None = None
|
||||
_DUCKDB_PATH = os.environ.get("DUCKDB_PATH", "data/lakehouse.duckdb")
|
||||
|
||||
|
||||
def open_analytics_db() -> None:
|
||||
"""Open the DuckDB connection. Call once at app startup."""
|
||||
global _conn
|
||||
path = Path(_DUCKDB_PATH)
|
||||
if not path.exists():
|
||||
# Database doesn't exist yet — skip silently. Queries will return empty.
|
||||
return
|
||||
_conn = duckdb.connect(str(path), read_only=True)
|
||||
|
||||
|
||||
def close_analytics_db() -> None:
|
||||
"""Close the DuckDB connection. Call at app shutdown."""
|
||||
global _conn
|
||||
if _conn is not None:
|
||||
_conn.close()
|
||||
_conn = None
|
||||
|
||||
|
||||
async def fetch_analytics(sql: str, params: list | None = None) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Run a read-only DuckDB query and return rows as dicts.
|
||||
|
||||
Returns [] if analytics DB is unavailable (not yet built, or DUCKDB_PATH unset).
|
||||
Never raises — callers should treat empty results as "no data yet".
|
||||
"""
|
||||
assert sql, "sql must not be empty"
|
||||
|
||||
if _conn is None:
|
||||
return []
|
||||
|
||||
def _run() -> list[dict]:
|
||||
rel = _conn.execute(sql, params or [])
|
||||
cols = [d[0] for d in rel.description]
|
||||
return [dict(zip(cols, row)) for row in rel.fetchall()]
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_run)
|
||||
except Exception:
|
||||
return []
|
||||
386
web/src/padelnomics/app.py
Normal file
386
web/src/padelnomics/app.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""
|
||||
Padelnomics - Application factory and entry point.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Quart, Response, abort, g, redirect, request, session, url_for
|
||||
|
||||
from .analytics import close_analytics_db, open_analytics_db
|
||||
from .core import close_db, config, get_csrf_token, init_db, setup_request_id
|
||||
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_translations
|
||||
|
||||
|
||||
def _detect_lang() -> str:
|
||||
"""Detect preferred language from cookie then Accept-Language header."""
|
||||
cookie_lang = request.cookies.get("lang", "")
|
||||
if cookie_lang in SUPPORTED_LANGS:
|
||||
return cookie_lang
|
||||
accept = request.headers.get("Accept-Language", "")
|
||||
for part in accept.split(","):
|
||||
tag = part.split(";")[0].strip()[:2].lower()
|
||||
if tag in SUPPORTED_LANGS:
|
||||
return tag
|
||||
return "en"
|
||||
|
||||
|
||||
def _fmt_currency(n) -> str:
|
||||
"""Format currency using request-context symbol and locale style."""
|
||||
sym = getattr(g, "currency_sym", "\u20ac")
|
||||
eu_style = getattr(g, "currency_eu_style", True)
|
||||
n = round(float(n))
|
||||
s = f"{abs(n):,}"
|
||||
if eu_style:
|
||||
s = s.replace(",", ".")
|
||||
return f"-{sym}{s}" if n < 0 else f"{sym}{s}"
|
||||
|
||||
|
||||
def _fmt_k(n) -> str:
|
||||
"""Short currency: €50K, €1.2M, or full _fmt_currency."""
|
||||
sym = getattr(g, "currency_sym", "\u20ac")
|
||||
n = float(n)
|
||||
if abs(n) >= 1_000_000:
|
||||
return f"{sym}{n/1_000_000:.1f}M"
|
||||
if abs(n) >= 1_000:
|
||||
return f"{sym}{n/1_000:.0f}K"
|
||||
return _fmt_currency(n)
|
||||
|
||||
|
||||
def _fmt_pct(n) -> str:
|
||||
"""Format fraction as percentage: 0.152 → '15.2%'."""
|
||||
return f"{float(n) * 100:.1f}%"
|
||||
|
||||
|
||||
def _fmt_x(n) -> str:
|
||||
"""Format as MOIC multiple: 2.30x."""
|
||||
return f"{float(n):.2f}x"
|
||||
|
||||
|
||||
def _fmt_n(n) -> str:
|
||||
"""Format integer with locale-aware thousands separator: 1.234 or 1,234."""
|
||||
eu_style = getattr(g, "currency_eu_style", True)
|
||||
s = f"{round(float(n)):,}"
|
||||
return s.replace(",", ".") if eu_style else s
|
||||
|
||||
|
||||
def _tformat(s: str, **kwargs) -> str:
|
||||
"""Format a translation string with named placeholders.
|
||||
|
||||
Usage: {{ t.some_key | tformat(count=total, name=supplier.name) }}
|
||||
JSON value: "Browse {count}+ suppliers from {name}"
|
||||
"""
|
||||
return s.format_map(kwargs)
|
||||
|
||||
|
||||
def create_app() -> Quart:
|
||||
"""Create and configure the Quart application."""
|
||||
|
||||
pkg_dir = Path(__file__).parent
|
||||
|
||||
app = Quart(
|
||||
__name__,
|
||||
template_folder=str(pkg_dir / "templates"),
|
||||
static_folder=str(pkg_dir / "static"),
|
||||
)
|
||||
|
||||
app.secret_key = config.SECRET_KEY
|
||||
|
||||
# Jinja2 filters
|
||||
app.jinja_env.filters["fmt_currency"] = _fmt_currency
|
||||
app.jinja_env.filters["fmt_k"] = _fmt_k
|
||||
app.jinja_env.filters["fmt_pct"] = _fmt_pct
|
||||
app.jinja_env.filters["fmt_x"] = _fmt_x
|
||||
app.jinja_env.filters["fmt_n"] = _fmt_n
|
||||
app.jinja_env.filters["tformat"] = _tformat # translate with placeholders: {{ t.key | tformat(count=n) }}
|
||||
|
||||
# Session config
|
||||
app.config["SESSION_COOKIE_SECURE"] = not config.DEBUG
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * config.SESSION_LIFETIME_DAYS
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Language URL routing
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@app.url_value_preprocessor
|
||||
def pull_lang(endpoint, values):
|
||||
"""Pop <lang> from URL values and stash in g.lang."""
|
||||
if values and "lang" in values:
|
||||
g.lang = values.pop("lang")
|
||||
|
||||
@app.url_defaults
|
||||
def inject_lang(endpoint, values):
|
||||
"""Auto-inject g.lang into url_for() calls on lang-prefixed blueprints."""
|
||||
if endpoint and endpoint.partition(".")[0] in LANG_BLUEPRINTS:
|
||||
values.setdefault("lang", g.get("lang", "en"))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Database lifecycle
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@app.before_serving
|
||||
async def startup():
|
||||
await init_db()
|
||||
open_analytics_db()
|
||||
|
||||
@app.after_serving
|
||||
async def shutdown():
|
||||
await close_db()
|
||||
close_analytics_db()
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Per-request hooks
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@app.before_request
|
||||
async def validate_lang():
|
||||
"""404 unsupported language prefixes (e.g. /fr/terms)."""
|
||||
lang = g.get("lang")
|
||||
if lang is not None and lang not in SUPPORTED_LANGS:
|
||||
abort(404)
|
||||
|
||||
@app.before_request
|
||||
async def load_user():
|
||||
"""Load current user + subscription + roles before each request."""
|
||||
g.user = None
|
||||
g.subscription = None
|
||||
user_id = session.get("user_id")
|
||||
if user_id:
|
||||
from .core import fetch_one as _fetch_one
|
||||
row = await _fetch_one(
|
||||
"""SELECT u.*,
|
||||
bc.provider_customer_id,
|
||||
(SELECT GROUP_CONCAT(role) FROM user_roles WHERE user_id = u.id) AS roles_csv,
|
||||
s.id AS sub_id, s.plan, s.status AS sub_status,
|
||||
s.provider_subscription_id, s.current_period_end
|
||||
FROM users u
|
||||
LEFT JOIN billing_customers bc ON bc.user_id = u.id
|
||||
LEFT JOIN subscriptions s ON s.id = (
|
||||
SELECT id FROM subscriptions
|
||||
WHERE user_id = u.id
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
)
|
||||
WHERE u.id = ? AND u.deleted_at IS NULL""",
|
||||
(user_id,),
|
||||
)
|
||||
if row:
|
||||
g.user = dict(row)
|
||||
g.user["roles"] = row["roles_csv"].split(",") if row["roles_csv"] else []
|
||||
if row["sub_id"]:
|
||||
g.subscription = {
|
||||
"id": row["sub_id"], "plan": row["plan"],
|
||||
"status": row["sub_status"],
|
||||
"provider_subscription_id": row["provider_subscription_id"],
|
||||
"current_period_end": row["current_period_end"],
|
||||
}
|
||||
|
||||
@app.after_request
|
||||
async def add_security_headers(response):
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
if not config.DEBUG:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
|
||||
@app.after_request
|
||||
async def set_lang_cookie(response):
|
||||
"""Persist detected/current language in a long-lived cookie."""
|
||||
lang = g.get("lang")
|
||||
if lang and lang in SUPPORTED_LANGS:
|
||||
current_cookie = request.cookies.get("lang", "")
|
||||
if current_cookie != lang:
|
||||
response.set_cookie(
|
||||
"lang", lang,
|
||||
max_age=60 * 60 * 24 * 365, # 1 year
|
||||
samesite="Lax",
|
||||
secure=not config.DEBUG,
|
||||
httponly=False, # JS may read for analytics
|
||||
)
|
||||
return response
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Template context globals
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
from datetime import datetime
|
||||
lang = g.get("lang") or _detect_lang()
|
||||
g.lang = lang # ensure g.lang is always set (e.g. for dashboard/billing routes)
|
||||
effective_lang = lang if lang in SUPPORTED_LANGS else "en"
|
||||
return {
|
||||
"config": config,
|
||||
"user": g.get("user"),
|
||||
"subscription": g.get("subscription"),
|
||||
"is_admin": "admin" in (g.get("user") or {}).get("roles", []),
|
||||
"now": datetime.utcnow(),
|
||||
"csrf_token": get_csrf_token,
|
||||
"ab_variant": getattr(g, "ab_variant", None),
|
||||
"ab_tag": getattr(g, "ab_tag", None),
|
||||
"lang": effective_lang,
|
||||
"t": get_translations(effective_lang),
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# App-level routes (no lang prefix)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
# Root: detect language and redirect
|
||||
@app.route("/")
|
||||
async def root():
|
||||
lang = _detect_lang()
|
||||
return redirect(url_for("public.landing", lang=lang), 301)
|
||||
|
||||
# robots.txt must live at root (not under /<lang>)
|
||||
@app.route("/robots.txt")
|
||||
async def robots_txt():
|
||||
base = config.BASE_URL.rstrip("/")
|
||||
body = (
|
||||
"User-agent: *\n"
|
||||
"Disallow: /admin/\n"
|
||||
"Disallow: /auth/\n"
|
||||
"Disallow: /dashboard/\n"
|
||||
"Disallow: /billing/\n"
|
||||
"Disallow: /directory/results\n"
|
||||
f"Sitemap: {base}/sitemap.xml\n"
|
||||
)
|
||||
return Response(body, content_type="text/plain")
|
||||
|
||||
# sitemap.xml must live at root
|
||||
@app.route("/sitemap.xml")
|
||||
async def sitemap():
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .core import fetch_all
|
||||
base = config.BASE_URL.rstrip("/")
|
||||
today = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
|
||||
# Both language variants of all SEO pages
|
||||
static_paths = [
|
||||
"", # landing
|
||||
"/features",
|
||||
"/about",
|
||||
"/terms",
|
||||
"/privacy",
|
||||
"/imprint",
|
||||
"/suppliers",
|
||||
"/markets",
|
||||
]
|
||||
entries: list[tuple[str, str]] = []
|
||||
for path in static_paths:
|
||||
for lang in ("en", "de"):
|
||||
entries.append((f"{base}/{lang}{path}", today))
|
||||
|
||||
# Planner + directory lang variants, billing (no lang)
|
||||
for lang in ("en", "de"):
|
||||
entries.append((f"{base}/{lang}/planner/", today))
|
||||
entries.append((f"{base}/{lang}/directory/", today))
|
||||
entries.append((f"{base}/billing/pricing", today))
|
||||
|
||||
# Published articles — both lang variants
|
||||
articles = await fetch_all(
|
||||
"""SELECT url_path, COALESCE(updated_at, published_at) as lastmod
|
||||
FROM articles
|
||||
WHERE status = 'published' AND published_at <= datetime('now')
|
||||
ORDER BY published_at DESC"""
|
||||
)
|
||||
for article in articles:
|
||||
lastmod = article["lastmod"][:10] if article["lastmod"] else today
|
||||
for lang in ("en", "de"):
|
||||
entries.append((f"{base}/{lang}{article['url_path']}", lastmod))
|
||||
|
||||
# Supplier detail pages (English only — canonical)
|
||||
suppliers = await fetch_all(
|
||||
"SELECT slug, created_at FROM suppliers ORDER BY name LIMIT 5000"
|
||||
)
|
||||
for supplier in suppliers:
|
||||
lastmod = supplier["created_at"][:10] if supplier["created_at"] else today
|
||||
entries.append((f"{base}/en/directory/{supplier['slug']}", lastmod))
|
||||
|
||||
xml = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
|
||||
for loc, lastmod in entries:
|
||||
xml += f" <url><loc>{loc}</loc><lastmod>{lastmod}</lastmod></url>\n"
|
||||
xml += "</urlset>"
|
||||
return Response(xml, content_type="application/xml")
|
||||
|
||||
# Health check
|
||||
@app.route("/health")
|
||||
async def health():
|
||||
from .core import fetch_one
|
||||
try:
|
||||
await fetch_one("SELECT 1")
|
||||
return {"status": "healthy", "db": "ok"}
|
||||
except Exception as e:
|
||||
return {"status": "unhealthy", "db": str(e)}, 500
|
||||
|
||||
# Legacy 301 redirects — bookmarked/cached URLs before lang prefixes existed
|
||||
@app.route("/terms")
|
||||
async def legacy_terms():
|
||||
return redirect("/en/terms", 301)
|
||||
|
||||
@app.route("/privacy")
|
||||
async def legacy_privacy():
|
||||
return redirect("/en/privacy", 301)
|
||||
|
||||
@app.route("/imprint")
|
||||
async def legacy_imprint():
|
||||
return redirect("/en/imprint", 301)
|
||||
|
||||
@app.route("/about")
|
||||
async def legacy_about():
|
||||
return redirect("/en/about", 301)
|
||||
|
||||
@app.route("/features")
|
||||
async def legacy_features():
|
||||
return redirect("/en/features", 301)
|
||||
|
||||
@app.route("/suppliers")
|
||||
async def legacy_suppliers():
|
||||
return redirect("/en/suppliers", 301)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Blueprint registration
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
from .admin.routes import bp as admin_bp
|
||||
from .auth.routes import bp as auth_bp
|
||||
from .billing.routes import bp as billing_bp
|
||||
from .content.routes import bp as content_bp
|
||||
from .dashboard.routes import bp as dashboard_bp
|
||||
from .directory.routes import bp as directory_bp
|
||||
from .leads.routes import bp as leads_bp
|
||||
from .planner.routes import bp as planner_bp
|
||||
from .public.routes import bp as public_bp
|
||||
from .suppliers.routes import bp as suppliers_bp
|
||||
|
||||
# Lang-prefixed blueprints (SEO-relevant, public-facing)
|
||||
app.register_blueprint(public_bp, url_prefix="/<lang>")
|
||||
app.register_blueprint(planner_bp, url_prefix="/<lang>/planner")
|
||||
app.register_blueprint(directory_bp, url_prefix="/<lang>/directory")
|
||||
app.register_blueprint(leads_bp, url_prefix="/<lang>/leads")
|
||||
app.register_blueprint(suppliers_bp, url_prefix="/<lang>/suppliers")
|
||||
|
||||
# Non-prefixed blueprints (internal / behind auth)
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(billing_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# Content catch-all LAST — lives under /<lang> too
|
||||
app.register_blueprint(content_bp, url_prefix="/<lang>")
|
||||
|
||||
# Request ID tracking
|
||||
setup_request_id(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
port = int(os.environ.get("PORT", 5000))
|
||||
app.run(debug=config.DEBUG, port=port)
|
||||
404
web/src/padelnomics/auth/routes.py
Normal file
404
web/src/padelnomics/auth/routes.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Auth domain: magic link authentication, user management, decorators.
|
||||
"""
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
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,
|
||||
is_disposable_email,
|
||||
waitlist_gate,
|
||||
)
|
||||
from ..i18n import SUPPORTED_LANGS, get_translations
|
||||
|
||||
# Blueprint with its own template folder
|
||||
bp = Blueprint(
|
||||
"auth",
|
||||
__name__,
|
||||
template_folder=str(Path(__file__).parent / "templates"),
|
||||
url_prefix="/auth",
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
# =============================================================================
|
||||
|
||||
async def get_user_by_id(user_id: int) -> dict | None:
|
||||
"""Get user by ID."""
|
||||
return await fetch_one(
|
||||
"SELECT * FROM users WHERE id = ? AND deleted_at IS NULL",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
|
||||
async def get_user_by_email(email: str) -> dict | None:
|
||||
"""Get user by email."""
|
||||
return await fetch_one(
|
||||
"SELECT * FROM users WHERE email = ? AND deleted_at IS NULL",
|
||||
(email.lower(),)
|
||||
)
|
||||
|
||||
|
||||
async def create_user(email: str) -> int:
|
||||
"""Create new user, return ID."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
return await execute(
|
||||
"INSERT INTO users (email, created_at) VALUES (?, ?)",
|
||||
(email.lower(), now)
|
||||
)
|
||||
|
||||
|
||||
async def update_user(user_id: int, **fields) -> None:
|
||||
"""Update user fields."""
|
||||
if not fields:
|
||||
return
|
||||
sets = ", ".join(f"{k} = ?" for k in fields.keys())
|
||||
values = list(fields.values()) + [user_id]
|
||||
await execute(f"UPDATE users SET {sets} WHERE id = ?", tuple(values))
|
||||
|
||||
|
||||
async def create_auth_token(user_id: int, token: str, minutes: int = None) -> int:
|
||||
"""Create auth token for user."""
|
||||
minutes = minutes or config.MAGIC_LINK_EXPIRY_MINUTES
|
||||
expires = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
return await execute(
|
||||
"INSERT INTO auth_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
|
||||
(user_id, token, expires.isoformat())
|
||||
)
|
||||
|
||||
|
||||
async def get_valid_token(token: str) -> dict | None:
|
||||
"""Get token if valid and not expired."""
|
||||
return await fetch_one(
|
||||
"""
|
||||
SELECT at.*, u.email
|
||||
FROM auth_tokens at
|
||||
JOIN users u ON u.id = at.user_id
|
||||
WHERE at.token = ? AND at.expires_at > ? AND at.used_at IS NULL
|
||||
""",
|
||||
(token, datetime.utcnow().isoformat())
|
||||
)
|
||||
|
||||
|
||||
async def mark_token_used(token_id: int) -> None:
|
||||
"""Mark token as used."""
|
||||
await execute(
|
||||
"UPDATE auth_tokens SET used_at = ? WHERE id = ?",
|
||||
(datetime.utcnow().isoformat(), token_id)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Decorators
|
||||
# =============================================================================
|
||||
|
||||
def login_required(f):
|
||||
"""Require authenticated user."""
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if not g.get("user"):
|
||||
await flash("Please sign in to continue.", "warning")
|
||||
return redirect(url_for("auth.login", next=request.path))
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
"""Require user to have at least one of the given roles."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if not g.get("user"):
|
||||
await flash("Please sign in to continue.", "warning")
|
||||
return redirect(url_for("auth.login", next=request.path))
|
||||
user_roles = g.user.get("roles", [])
|
||||
if not any(r in user_roles for r in roles):
|
||||
await flash("You don't have permission to access that page.", "error")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
|
||||
|
||||
async def grant_role(user_id: int, role: str) -> None:
|
||||
"""Grant a role to a user (idempotent)."""
|
||||
await execute(
|
||||
"INSERT OR IGNORE INTO user_roles (user_id, role) VALUES (?, ?)",
|
||||
(user_id, role),
|
||||
)
|
||||
|
||||
|
||||
async def revoke_role(user_id: int, role: str) -> None:
|
||||
"""Revoke a role from a user."""
|
||||
await execute(
|
||||
"DELETE FROM user_roles WHERE user_id = ? AND role = ?",
|
||||
(user_id, role),
|
||||
)
|
||||
|
||||
|
||||
async def ensure_admin_role(user_id: int, email: str) -> None:
|
||||
"""Grant admin role if email is in ADMIN_EMAILS."""
|
||||
if email.lower() in config.ADMIN_EMAILS:
|
||||
await grant_role(user_id, "admin")
|
||||
|
||||
|
||||
def subscription_required(
|
||||
plans: list[str] = None,
|
||||
allowed: tuple[str, ...] = ("active", "on_trial", "cancelled"),
|
||||
):
|
||||
"""Require active subscription, optionally of specific plan(s) and/or statuses.
|
||||
|
||||
Reads from g.subscription (eager-loaded in load_user) — zero extra queries.
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if not g.get("user"):
|
||||
await flash("Please sign in to continue.", "warning")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
sub = g.get("subscription")
|
||||
if not sub or sub["status"] not in allowed:
|
||||
await flash("Please subscribe to access this feature.", "warning")
|
||||
return redirect(url_for("billing.pricing"))
|
||||
|
||||
if plans and sub["plan"] not in plans:
|
||||
await flash(f"This feature requires a {' or '.join(plans)} plan.", "warning")
|
||||
return redirect(url_for("billing.pricing"))
|
||||
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
async def login():
|
||||
"""Login page - request magic link."""
|
||||
if g.get("user"):
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
if request.method == "POST":
|
||||
_t = get_translations(g.lang)
|
||||
form = await request.form
|
||||
email = form.get("email", "").strip().lower()
|
||||
|
||||
if not email or "@" not in email:
|
||||
await flash(_t["auth_flash_invalid_email"], "error")
|
||||
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
|
||||
user = await get_user_by_email(email)
|
||||
if not user:
|
||||
user_id = await create_user(email)
|
||||
else:
|
||||
user_id = user["id"]
|
||||
|
||||
# Create magic link token
|
||||
token = secrets.token_urlsafe(32)
|
||||
await create_auth_token(user_id, token)
|
||||
|
||||
# Queue email
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_magic_link", {"email": email, "token": token})
|
||||
|
||||
await flash(_t["auth_flash_login_sent"], "success")
|
||||
return redirect(url_for("auth.magic_link_sent", email=email))
|
||||
|
||||
return await render_template("login.html")
|
||||
|
||||
|
||||
@bp.route("/signup", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
async def signup():
|
||||
"""Signup page - same as login but with different messaging."""
|
||||
if g.get("user"):
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
# Waitlist POST handling
|
||||
if config.WAITLIST_MODE and request.method == "POST":
|
||||
_t = get_translations(g.lang)
|
||||
form = await request.form
|
||||
email = form.get("email", "").strip().lower()
|
||||
plan = form.get("plan", "signup")
|
||||
|
||||
if not email or "@" not in email:
|
||||
await flash(_t["auth_flash_invalid_email"], "error")
|
||||
return redirect(url_for("auth.signup"))
|
||||
|
||||
await capture_waitlist_email(email, intent=plan)
|
||||
return await render_template("waitlist_confirmed.html", email=email)
|
||||
|
||||
# Normal signup flow below
|
||||
plan = request.args.get("plan", "free")
|
||||
|
||||
if request.method == "POST":
|
||||
_t = get_translations(g.lang)
|
||||
form = await request.form
|
||||
email = form.get("email", "").strip().lower()
|
||||
selected_plan = form.get("plan", "free")
|
||||
|
||||
if not email or "@" not in email:
|
||||
await flash(_t["auth_flash_invalid_email"], "error")
|
||||
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
|
||||
user = await get_user_by_email(email)
|
||||
if user:
|
||||
await flash(_t["auth_flash_account_exists"], "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# Create user
|
||||
user_id = await create_user(email)
|
||||
|
||||
# Create magic link token
|
||||
token = secrets.token_urlsafe(32)
|
||||
await create_auth_token(user_id, token)
|
||||
|
||||
# Queue emails
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_magic_link", {"email": email, "token": token})
|
||||
await enqueue("send_welcome", {"email": email})
|
||||
|
||||
await flash(_t["auth_flash_signup_sent"], "success")
|
||||
return redirect(url_for("auth.magic_link_sent", email=email))
|
||||
|
||||
return await render_template("signup.html", plan=plan)
|
||||
|
||||
|
||||
@bp.route("/verify")
|
||||
async def verify():
|
||||
"""Verify magic link token."""
|
||||
token = request.args.get("token")
|
||||
|
||||
_t = get_translations(g.lang)
|
||||
|
||||
if not token:
|
||||
await flash(_t["auth_flash_invalid_token"], "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
token_data = await get_valid_token(token)
|
||||
|
||||
if not token_data:
|
||||
await flash(_t["auth_flash_invalid_token_detail"], "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
# Mark token as used
|
||||
await mark_token_used(token_data["id"])
|
||||
|
||||
# Update last login
|
||||
await update_user(token_data["user_id"], last_login_at=datetime.utcnow().isoformat())
|
||||
|
||||
# Set session
|
||||
session.permanent = True
|
||||
session["user_id"] = token_data["user_id"]
|
||||
|
||||
# Auto-grant admin role if email is in ADMIN_EMAILS
|
||||
await ensure_admin_role(token_data["user_id"], token_data["email"])
|
||||
|
||||
await flash(_t["auth_flash_signed_in"], "success")
|
||||
|
||||
# Redirect to intended page or dashboard
|
||||
next_url = request.args.get("next", url_for("dashboard.index"))
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@bp.route("/logout", methods=["POST"])
|
||||
@csrf_protect
|
||||
async def logout():
|
||||
"""Log out user."""
|
||||
_t = get_translations(g.lang)
|
||||
session.clear()
|
||||
await flash(_t["auth_flash_signed_out"], "info")
|
||||
return redirect(url_for("public.landing"))
|
||||
|
||||
|
||||
@bp.route("/magic-link-sent")
|
||||
async def magic_link_sent():
|
||||
"""Confirmation page after magic link sent."""
|
||||
email = request.args.get("email", "")
|
||||
return await render_template("magic_link_sent.html", email=email)
|
||||
|
||||
|
||||
@bp.route("/dev-login")
|
||||
async def dev_login():
|
||||
"""Instant login for development. Only works in DEBUG mode."""
|
||||
if not config.DEBUG:
|
||||
return "Not available", 404
|
||||
|
||||
email = request.args.get("email", "dev@localhost")
|
||||
|
||||
user = await get_user_by_email(email)
|
||||
if not user:
|
||||
user_id = await create_user(email)
|
||||
else:
|
||||
user_id = user["id"]
|
||||
|
||||
session.permanent = True
|
||||
session["user_id"] = user_id
|
||||
|
||||
# Auto-grant admin role if email is in ADMIN_EMAILS
|
||||
await ensure_admin_role(user_id, email)
|
||||
|
||||
await flash(f"Dev login as {email}", "success")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
|
||||
@bp.route("/resend", methods=["POST"])
|
||||
@csrf_protect
|
||||
async def resend():
|
||||
"""Resend magic link."""
|
||||
_t = get_translations(g.lang)
|
||||
form = await request.form
|
||||
email = form.get("email", "").strip().lower()
|
||||
|
||||
if not email:
|
||||
await flash(_t["auth_flash_invalid_email"], "error")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
user = await get_user_by_email(email)
|
||||
if user:
|
||||
token = secrets.token_urlsafe(32)
|
||||
await create_auth_token(user["id"], token)
|
||||
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_magic_link", {"email": email, "token": token})
|
||||
|
||||
# Always show success (don't reveal if email exists)
|
||||
await flash(_t["auth_flash_resend_sent"], "success")
|
||||
return redirect(url_for("auth.magic_link_sent", email=email))
|
||||
36
web/src/padelnomics/auth/templates/login.html
Normal file
36
web/src/padelnomics/auth/templates/login.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.auth_login_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-sm mx-auto mt-8">
|
||||
<h1 class="text-2xl mb-1">{{ t.auth_login_title }}</h1>
|
||||
<p class="text-slate mb-6">{{ t.auth_login_sub }}</p>
|
||||
|
||||
<form method="post" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div>
|
||||
<label for="email" class="form-label">{{ t.auth_login_email_label }}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="form-input"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn w-full">{{ t.auth_login_btn }}</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate mt-6">
|
||||
{{ t.auth_login_no_account }}
|
||||
<a href="{{ url_for('auth.signup') }}">{{ t.auth_login_signup_link }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
33
web/src/padelnomics/auth/templates/magic_link_sent.html
Normal file
33
web/src/padelnomics/auth/templates/magic_link_sent.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.auth_magic_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-sm mx-auto mt-8 text-center">
|
||||
<h1 class="text-2xl mb-4">{{ t.auth_magic_title }}</h1>
|
||||
|
||||
<p class="text-slate-dark">{{ t.auth_magic_sent_to }}</p>
|
||||
<p class="font-semibold text-navy my-2">{{ email }}</p>
|
||||
|
||||
<p class="text-slate text-sm">{{ t.auth_magic_instructions.replace('{minutes}', config.MAGIC_LINK_EXPIRY_MINUTES | string) }}</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<details class="text-left">
|
||||
<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">
|
||||
<li>{{ t.auth_magic_check_spam }}</li>
|
||||
<li>{{ t.auth_magic_correct_email }}</li>
|
||||
<li>{{ t.auth_magic_wait }}</li>
|
||||
</ul>
|
||||
|
||||
<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="btn-outline w-full">{{ t.auth_magic_resend_btn }}</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
37
web/src/padelnomics/auth/templates/signup.html
Normal file
37
web/src/padelnomics/auth/templates/signup.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.auth_signup_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-sm mx-auto mt-8">
|
||||
<h1 class="text-2xl mb-1">{{ t.auth_signup_title }}</h1>
|
||||
<p class="text-slate mb-6">{{ t.auth_signup_sub }}</p>
|
||||
|
||||
<form method="post" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div>
|
||||
<label for="email" class="form-label">{{ t.auth_login_email_label }}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="form-input"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
<p class="form-hint">{{ t.auth_signup_hint }}</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn w-full">{{ t.auth_signup_btn }}</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate mt-6">
|
||||
{{ t.auth_signup_have_account }}
|
||||
<a href="{{ url_for('auth.login') }}">{{ t.auth_signup_signin_link }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
38
web/src/padelnomics/auth/templates/waitlist.html
Normal file
38
web/src/padelnomics/auth/templates/waitlist.html
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.auth_waitlist_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-sm mx-auto mt-8">
|
||||
<h1 class="text-2xl mb-1">{{ t.auth_waitlist_title }}</h1>
|
||||
<p class="text-slate mb-6">{{ t.auth_waitlist_sub }}</p>
|
||||
|
||||
<form method="post" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="plan" value="{{ plan }}">
|
||||
|
||||
<div>
|
||||
<label for="email" class="form-label">{{ t.auth_login_email_label }}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="form-input"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
<p class="form-hint">{{ t.auth_waitlist_hint }}</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn w-full">{{ t.auth_waitlist_btn }}</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate mt-6">
|
||||
{{ t.auth_signup_have_account }}
|
||||
<a href="{{ url_for('auth.login') }}">{{ t.auth_signup_signin_link }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
29
web/src/padelnomics/auth/templates/waitlist_confirmed.html
Normal file
29
web/src/padelnomics/auth/templates/waitlist_confirmed.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.auth_waitlist_confirmed_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-sm mx-auto mt-8 text-center">
|
||||
<h1 class="text-2xl mb-4">{{ t.auth_waitlist_confirmed_title }}</h1>
|
||||
|
||||
<p class="text-slate-dark">{{ t.auth_waitlist_confirmed_sent_to }}</p>
|
||||
<p class="font-semibold text-navy my-2">{{ email }}</p>
|
||||
|
||||
<p class="text-slate text-sm mb-6">{{ t.auth_waitlist_confirmed_sub }}</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="text-left mt-6">
|
||||
<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">
|
||||
<li>{{ t.auth_waitlist_confirmed_step1 }}</li>
|
||||
<li>{{ t.auth_waitlist_confirmed_step2 }}</li>
|
||||
<li>{{ t.auth_waitlist_confirmed_step3 }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('public.landing') }}" class="btn-outline w-full mt-6">{{ t.auth_waitlist_confirmed_back }}</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
468
web/src/padelnomics/billing/routes.py
Normal file
468
web/src/padelnomics/billing/routes.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""
|
||||
Billing domain: checkout, webhooks, subscription management.
|
||||
Payment provider: paddle
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from paddle_billing import Client as PaddleClient
|
||||
from paddle_billing import Environment, Options
|
||||
from paddle_billing.Notifications import Secret, Verifier
|
||||
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..auth.routes import login_required
|
||||
from ..core import config, execute, fetch_one, get_paddle_price
|
||||
from ..i18n import get_translations
|
||||
|
||||
|
||||
def _paddle_client() -> PaddleClient:
|
||||
"""Create a Paddle SDK client. Used only for subscription management + webhook verification."""
|
||||
env = Environment.SANDBOX if config.PADDLE_ENVIRONMENT == "sandbox" else Environment.PRODUCTION
|
||||
return PaddleClient(config.PADDLE_API_KEY, options=Options(env))
|
||||
|
||||
# Blueprint with its own template folder
|
||||
bp = Blueprint(
|
||||
"billing",
|
||||
__name__,
|
||||
template_folder=str(Path(__file__).parent / "templates"),
|
||||
url_prefix="/billing",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SQL Queries
|
||||
# =============================================================================
|
||||
|
||||
async def get_subscription(user_id: int) -> dict | None:
|
||||
"""Get user's most recent subscription."""
|
||||
return await fetch_one(
|
||||
"SELECT * FROM subscriptions WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
(user_id,)
|
||||
)
|
||||
|
||||
|
||||
async def upsert_billing_customer(user_id: int, provider_customer_id: str) -> None:
|
||||
"""Create or update billing customer record (idempotent)."""
|
||||
await execute(
|
||||
"""INSERT INTO billing_customers (user_id, provider_customer_id)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET provider_customer_id = excluded.provider_customer_id""",
|
||||
(user_id, provider_customer_id),
|
||||
)
|
||||
|
||||
|
||||
async def get_billing_customer(user_id: int) -> dict | None:
|
||||
"""Get billing customer for a user."""
|
||||
return await fetch_one(
|
||||
"SELECT * FROM billing_customers WHERE user_id = ?", (user_id,)
|
||||
)
|
||||
|
||||
|
||||
async def upsert_subscription(
|
||||
user_id: int,
|
||||
plan: str,
|
||||
status: str,
|
||||
provider_subscription_id: str,
|
||||
current_period_end: str = None,
|
||||
) -> int:
|
||||
"""Create or update subscription. Finds existing by provider_subscription_id."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
existing = await fetch_one(
|
||||
"SELECT id FROM subscriptions WHERE provider_subscription_id = ?",
|
||||
(provider_subscription_id,),
|
||||
)
|
||||
|
||||
if existing:
|
||||
await execute(
|
||||
"""UPDATE subscriptions
|
||||
SET plan = ?, status = ?, current_period_end = ?, updated_at = ?
|
||||
WHERE id = ?""",
|
||||
(plan, status, current_period_end, now, existing["id"]),
|
||||
)
|
||||
return existing["id"]
|
||||
else:
|
||||
return await execute(
|
||||
"""INSERT INTO subscriptions
|
||||
(user_id, plan, status, provider_subscription_id,
|
||||
current_period_end, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, plan, status, provider_subscription_id,
|
||||
current_period_end, now, now),
|
||||
)
|
||||
|
||||
|
||||
async def get_subscription_by_provider_id(subscription_id: str) -> dict | None:
|
||||
return await fetch_one(
|
||||
"SELECT * FROM subscriptions WHERE provider_subscription_id = ?",
|
||||
(subscription_id,)
|
||||
)
|
||||
|
||||
|
||||
async def update_subscription_status(provider_subscription_id: str, status: str, **extra) -> None:
|
||||
"""Update subscription status by provider subscription ID."""
|
||||
extra["updated_at"] = datetime.utcnow().isoformat()
|
||||
extra["status"] = status
|
||||
sets = ", ".join(f"{k} = ?" for k in extra)
|
||||
values = list(extra.values())
|
||||
|
||||
values.append(provider_subscription_id)
|
||||
await execute(f"UPDATE subscriptions SET {sets} WHERE provider_subscription_id = ?", tuple(values))
|
||||
|
||||
|
||||
|
||||
async def can_access_feature(user_id: int, feature: str) -> bool:
|
||||
"""Check if user can access a feature based on their plan."""
|
||||
sub = await get_subscription(user_id)
|
||||
plan = sub["plan"] if sub and sub["status"] in ("active", "on_trial", "cancelled") else "free"
|
||||
return feature in config.PLAN_FEATURES.get(plan, [])
|
||||
|
||||
|
||||
async def is_within_limits(user_id: int, resource: str, current_count: int) -> bool:
|
||||
"""Check if user is within their plan limits."""
|
||||
sub = await get_subscription(user_id)
|
||||
plan = sub["plan"] if sub and sub["status"] in ("active", "on_trial", "cancelled") else "free"
|
||||
limit = config.PLAN_LIMITS.get(plan, {}).get(resource, 0)
|
||||
if limit == -1:
|
||||
return True
|
||||
return current_count < limit
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/pricing")
|
||||
async def pricing():
|
||||
"""Pricing page."""
|
||||
user_sub = None
|
||||
if "user_id" in session:
|
||||
user_sub = await get_subscription(session["user_id"])
|
||||
return await render_template("pricing.html", subscription=user_sub)
|
||||
|
||||
|
||||
@bp.route("/success")
|
||||
@login_required
|
||||
async def success():
|
||||
"""Checkout success page."""
|
||||
return await render_template("success.html")
|
||||
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Paddle Implementation — Paddle.js Overlay Checkout
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/checkout/<plan>", methods=["POST"])
|
||||
@login_required
|
||||
async def checkout(plan: str):
|
||||
"""Return JSON for Paddle.js overlay checkout."""
|
||||
price_id = await get_paddle_price(plan)
|
||||
if not price_id:
|
||||
return jsonify({"error": "Invalid plan selected."}), 400
|
||||
|
||||
return jsonify({
|
||||
"items": [{"priceId": price_id, "quantity": 1}],
|
||||
"customData": {"user_id": str(g.user["id"]), "plan": plan},
|
||||
"settings": {
|
||||
"successUrl": f"{config.BASE_URL}/billing/success",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/manage", methods=["POST"])
|
||||
@login_required
|
||||
async def manage():
|
||||
"""Redirect to Paddle customer portal."""
|
||||
sub = await get_subscription(g.user["id"])
|
||||
if not sub or not sub.get("provider_subscription_id"):
|
||||
t = get_translations(g.get("lang") or "en")
|
||||
await flash(t["billing_no_subscription"], "error")
|
||||
return redirect(url_for("dashboard.settings"))
|
||||
|
||||
paddle = _paddle_client()
|
||||
paddle_sub = paddle.subscriptions.get(sub["provider_subscription_id"])
|
||||
portal_url = paddle_sub.management_urls.update_payment_method
|
||||
return redirect(portal_url)
|
||||
|
||||
|
||||
@bp.route("/cancel", methods=["POST"])
|
||||
@login_required
|
||||
async def cancel():
|
||||
"""Cancel subscription via Paddle API."""
|
||||
sub = await get_subscription(g.user["id"])
|
||||
if sub and sub.get("provider_subscription_id"):
|
||||
from paddle_billing.Resources.Subscriptions.Operations import CancelSubscription
|
||||
paddle = _paddle_client()
|
||||
paddle.subscriptions.cancel(
|
||||
sub["provider_subscription_id"],
|
||||
CancelSubscription(effective_from="next_billing_period"),
|
||||
)
|
||||
return redirect(url_for("dashboard.settings"))
|
||||
|
||||
|
||||
class _WebhookRequest:
|
||||
"""Minimal wrapper satisfying paddle_billing's Request Protocol."""
|
||||
def __init__(self, body: bytes, headers):
|
||||
self.body = body
|
||||
self.headers = headers
|
||||
|
||||
|
||||
_verifier = Verifier(maximum_variance=300)
|
||||
|
||||
|
||||
@bp.route("/webhook/paddle", methods=["POST"])
|
||||
async def webhook():
|
||||
"""Handle Paddle webhooks."""
|
||||
payload = await request.get_data()
|
||||
|
||||
if config.PADDLE_WEBHOOK_SECRET:
|
||||
try:
|
||||
ok = _verifier.verify(
|
||||
_WebhookRequest(payload, request.headers),
|
||||
Secret(config.PADDLE_WEBHOOK_SECRET),
|
||||
)
|
||||
except (ConnectionRefusedError, ValueError):
|
||||
ok = False
|
||||
if not ok:
|
||||
return jsonify({"error": "Invalid signature"}), 400
|
||||
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return jsonify({"error": "Invalid JSON payload"}), 400
|
||||
event_type = event.get("event_type")
|
||||
data = event.get("data") or {}
|
||||
custom_data = data.get("custom_data") or {}
|
||||
user_id = custom_data.get("user_id")
|
||||
plan = custom_data.get("plan", "")
|
||||
|
||||
# Store billing customer for any subscription event with a customer_id
|
||||
customer_id = str(data.get("customer_id", ""))
|
||||
if customer_id and user_id:
|
||||
await upsert_billing_customer(int(user_id), customer_id)
|
||||
|
||||
if event_type == "subscription.activated":
|
||||
if plan.startswith("supplier_"):
|
||||
await _handle_supplier_subscription_activated(data, custom_data)
|
||||
elif user_id:
|
||||
await upsert_subscription(
|
||||
user_id=int(user_id),
|
||||
plan=plan or "starter",
|
||||
status="active",
|
||||
provider_subscription_id=data.get("id", ""),
|
||||
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"),
|
||||
)
|
||||
|
||||
elif event_type == "subscription.updated":
|
||||
await update_subscription_status(
|
||||
data.get("id", ""),
|
||||
status=data.get("status", "active"),
|
||||
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"),
|
||||
)
|
||||
|
||||
elif event_type == "subscription.canceled":
|
||||
await update_subscription_status(data.get("id", ""), status="cancelled")
|
||||
|
||||
elif event_type == "subscription.past_due":
|
||||
await update_subscription_status(data.get("id", ""), status="past_due")
|
||||
|
||||
elif event_type == "transaction.completed":
|
||||
await _handle_transaction_completed(data, custom_data)
|
||||
|
||||
return jsonify({"received": True})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Supplier Webhook Handlers
|
||||
# =============================================================================
|
||||
|
||||
# Map product keys to credit pack amounts
|
||||
CREDIT_PACK_AMOUNTS = {
|
||||
"credits_25": 25,
|
||||
"credits_50": 50,
|
||||
"credits_100": 100,
|
||||
"credits_250": 250,
|
||||
}
|
||||
|
||||
PLAN_MONTHLY_CREDITS = {"supplier_basic": 0, "supplier_growth": 30, "supplier_pro": 100}
|
||||
|
||||
BOOST_PRICE_KEYS = {
|
||||
"boost_logo": "logo",
|
||||
"boost_highlight": "highlight",
|
||||
"boost_verified": "verified",
|
||||
"boost_card_color": "card_color",
|
||||
"boost_sticky_week": "sticky_week",
|
||||
"boost_sticky_month": "sticky_month",
|
||||
}
|
||||
|
||||
|
||||
async def _price_id_to_key(price_id: str) -> str | None:
|
||||
"""Reverse-lookup a paddle_products key from a Paddle price ID."""
|
||||
row = await fetch_one(
|
||||
"SELECT key FROM paddle_products WHERE paddle_price_id = ?", (price_id,)
|
||||
)
|
||||
return row["key"] if row else None
|
||||
|
||||
|
||||
def _derive_tier_from_plan(plan: str) -> tuple[str, str]:
|
||||
"""Derive (base_plan, tier) from a plan key, stripping _monthly/_yearly suffixes.
|
||||
|
||||
Returns (base_plan, tier) where base_plan is the canonical key used for
|
||||
PLAN_MONTHLY_CREDITS lookup and tier is the DB tier string.
|
||||
"""
|
||||
base = plan
|
||||
for suffix in ("_monthly", "_yearly"):
|
||||
if plan.endswith(suffix):
|
||||
base = plan[: -len(suffix)]
|
||||
break
|
||||
|
||||
if base == "supplier_pro":
|
||||
tier = "pro"
|
||||
elif base == "supplier_basic":
|
||||
tier = "basic"
|
||||
else:
|
||||
tier = "growth"
|
||||
|
||||
return base, tier
|
||||
|
||||
|
||||
async def _handle_supplier_subscription_activated(data: dict, custom_data: dict) -> None:
|
||||
"""Handle supplier plan subscription activation."""
|
||||
from ..core import transaction as db_transaction
|
||||
|
||||
supplier_id = custom_data.get("supplier_id")
|
||||
plan = custom_data.get("plan", "supplier_growth")
|
||||
user_id = custom_data.get("user_id")
|
||||
|
||||
if not supplier_id:
|
||||
return
|
||||
|
||||
base_plan, tier = _derive_tier_from_plan(plan)
|
||||
monthly_credits = PLAN_MONTHLY_CREDITS.get(base_plan, 0)
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
async with db_transaction() as db:
|
||||
# Update supplier record — Basic tier also gets is_verified = 1
|
||||
await db.execute(
|
||||
"""UPDATE suppliers SET tier = ?, claimed_at = ?, claimed_by = ?,
|
||||
monthly_credits = ?, credit_balance = ?, last_credit_refill = ?,
|
||||
is_verified = 1
|
||||
WHERE id = ?""",
|
||||
(tier, now, int(user_id) if user_id else None,
|
||||
monthly_credits, monthly_credits, now, int(supplier_id)),
|
||||
)
|
||||
|
||||
# Initial credit allocation — skip for Basic (0 credits)
|
||||
if monthly_credits > 0:
|
||||
await db.execute(
|
||||
"""INSERT INTO credit_ledger (supplier_id, delta, balance_after, event_type, note, created_at)
|
||||
VALUES (?, ?, ?, 'monthly_allocation', 'Initial credit allocation', ?)""",
|
||||
(int(supplier_id), monthly_credits, monthly_credits, now),
|
||||
)
|
||||
|
||||
# Create boost records for items included in the subscription
|
||||
items = data.get("items", [])
|
||||
for item in items:
|
||||
price_id = item.get("price", {}).get("id", "")
|
||||
key = await _price_id_to_key(price_id)
|
||||
if key in BOOST_PRICE_KEYS:
|
||||
boost_type = BOOST_PRICE_KEYS[key]
|
||||
await db.execute(
|
||||
"""INSERT INTO supplier_boosts
|
||||
(supplier_id, boost_type, paddle_subscription_id, status, starts_at, created_at)
|
||||
VALUES (?, ?, ?, 'active', ?, ?)""",
|
||||
(int(supplier_id), boost_type, data.get("id", ""), now, now),
|
||||
)
|
||||
# Update denormalized columns
|
||||
if boost_type == "highlight":
|
||||
await db.execute(
|
||||
"UPDATE suppliers SET highlight = 1 WHERE id = ?", (int(supplier_id),)
|
||||
)
|
||||
elif boost_type == "verified":
|
||||
await db.execute(
|
||||
"UPDATE suppliers SET is_verified = 1 WHERE id = ?", (int(supplier_id),)
|
||||
)
|
||||
|
||||
|
||||
async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
|
||||
"""Handle one-time transaction completion (credit packs, sticky boosts, business plan)."""
|
||||
supplier_id = custom_data.get("supplier_id")
|
||||
user_id = custom_data.get("user_id")
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
items = data.get("items", [])
|
||||
for item in items:
|
||||
price_id = item.get("price", {}).get("id", "")
|
||||
key = await _price_id_to_key(price_id)
|
||||
|
||||
if not key:
|
||||
continue
|
||||
|
||||
# Credit pack purchases
|
||||
if key in CREDIT_PACK_AMOUNTS and supplier_id:
|
||||
from ..credits import add_credits
|
||||
await add_credits(
|
||||
int(supplier_id), CREDIT_PACK_AMOUNTS[key],
|
||||
"pack_purchase", note=f"Credit pack: {key}",
|
||||
)
|
||||
|
||||
# Sticky boost purchases
|
||||
elif key == "boost_sticky_week" and supplier_id:
|
||||
from datetime import timedelta
|
||||
|
||||
from ..core import transaction as db_transaction
|
||||
expires = (datetime.utcnow() + timedelta(weeks=1)).isoformat()
|
||||
country = custom_data.get("sticky_country", "")
|
||||
async with db_transaction() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO supplier_boosts
|
||||
(supplier_id, boost_type, status, starts_at, expires_at, created_at)
|
||||
VALUES (?, 'sticky_week', 'active', ?, ?, ?)""",
|
||||
(int(supplier_id), now, expires, now),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?",
|
||||
(expires, country, int(supplier_id)),
|
||||
)
|
||||
|
||||
elif key == "boost_sticky_month" and supplier_id:
|
||||
from datetime import timedelta
|
||||
|
||||
from ..core import transaction as db_transaction
|
||||
expires = (datetime.utcnow() + timedelta(days=30)).isoformat()
|
||||
country = custom_data.get("sticky_country", "")
|
||||
async with db_transaction() as db:
|
||||
await db.execute(
|
||||
"""INSERT INTO supplier_boosts
|
||||
(supplier_id, boost_type, status, starts_at, expires_at, created_at)
|
||||
VALUES (?, 'sticky_month', 'active', ?, ?, ?)""",
|
||||
(int(supplier_id), now, expires, now),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?",
|
||||
(expires, country, int(supplier_id)),
|
||||
)
|
||||
|
||||
# Business plan PDF purchase
|
||||
elif key == "business_plan" and user_id:
|
||||
scenario_id = custom_data.get("scenario_id")
|
||||
language = custom_data.get("language", "en")
|
||||
transaction_id = data.get("id", "")
|
||||
export_id = await execute(
|
||||
"""INSERT INTO business_plan_exports
|
||||
(user_id, scenario_id, paddle_transaction_id, language, status, created_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?)""",
|
||||
(int(user_id), int(scenario_id) if scenario_id else 0,
|
||||
transaction_id, language, now),
|
||||
)
|
||||
# Enqueue PDF generation
|
||||
from ..worker import enqueue
|
||||
await enqueue("generate_business_plan", {
|
||||
"export_id": export_id,
|
||||
"user_id": int(user_id),
|
||||
"scenario_id": int(scenario_id) if scenario_id else 0,
|
||||
"language": language,
|
||||
})
|
||||
53
web/src/padelnomics/billing/templates/pricing.html
Normal file
53
web/src/padelnomics/billing/templates/pricing.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t.billing_pricing_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="{{ t.billing_pricing_meta_desc }}">
|
||||
<meta property="og:title" content="{{ t.billing_pricing_og_title }} - {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="{{ t.billing_pricing_og_desc }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="heading-group text-center">
|
||||
<h1 class="text-3xl">{{ t.billing_pricing_h1 }}</h1>
|
||||
<p>{{ t.billing_pricing_subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid-2 mt-8">
|
||||
<div class="card">
|
||||
<p class="card-header">{{ t.billing_planner_card }}</p>
|
||||
<p class="text-lg font-bold text-navy mb-4">{{ t.billing_planner_free }} <span class="text-sm font-normal text-slate">{{ t.billing_planner_forever }}</span></p>
|
||||
<ul class="space-y-2 text-sm text-slate-dark mb-6">
|
||||
<li>{{ t.billing_feature_1 }}</li>
|
||||
<li>{{ t.billing_feature_2 }}</li>
|
||||
<li>{{ t.billing_feature_3 }}</li>
|
||||
<li>{{ t.billing_feature_4 }}</li>
|
||||
<li>{{ t.billing_feature_5 }}</li>
|
||||
<li>{{ t.billing_feature_6 }}</li>
|
||||
</ul>
|
||||
{% if user %}
|
||||
<a href="{{ url_for('planner.index') }}" class="btn w-full text-center">{{ t.billing_open_planner }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.signup') }}" class="btn w-full text-center">{{ t.billing_create_account }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="card-header">{{ t.billing_help_card }}</p>
|
||||
<p class="text-slate-dark mb-4">{{ t.billing_help_subtitle }}</p>
|
||||
<ul class="space-y-2 text-sm text-slate-dark mb-6">
|
||||
<li>{{ t.billing_help_feature_1 }}</li>
|
||||
<li>{{ t.billing_help_feature_2 }}</li>
|
||||
<li>{{ t.billing_help_feature_3 }}</li>
|
||||
<li>{{ t.billing_help_feature_4 }}</li>
|
||||
</ul>
|
||||
{% if user %}
|
||||
<a href="{{ url_for('leads.suppliers') }}" class="btn-outline w-full text-center">{{ t.billing_get_quotes }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.signup') }}" class="btn-outline w-full text-center">{{ t.billing_signup }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
15
web/src/padelnomics/billing/templates/success.html
Normal file
15
web/src/padelnomics/billing/templates/success.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.billing_success_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-md mx-auto mt-8 text-center">
|
||||
<h1 class="text-2xl mb-4">{{ t.billing_success_h1 }}</h1>
|
||||
|
||||
<p class="text-slate-dark mb-6">{{ t.billing_success_body }}</p>
|
||||
|
||||
<a href="{{ url_for('planner.index') }}" class="btn">{{ t.billing_success_btn }}</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
266
web/src/padelnomics/businessplan.py
Normal file
266
web/src/padelnomics/businessplan.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Business Plan PDF generation engine.
|
||||
|
||||
Renders an HTML template with planner data, converts to PDF via WeasyPrint.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .core import fetch_one
|
||||
from .i18n import get_translations
|
||||
from .planner.calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).parent / "templates" / "businessplan"
|
||||
|
||||
|
||||
def _fmt_cur(n, sym: str = "\u20ac", eu_style: bool = True) -> str:
|
||||
"""Format number as currency with locale-aware thousands separator."""
|
||||
if n is None:
|
||||
return "-"
|
||||
v = round(float(n))
|
||||
s = f"{abs(v):,}"
|
||||
if eu_style:
|
||||
s = s.replace(",", ".")
|
||||
return f"-{sym}{s}" if v < 0 else f"{sym}{s}"
|
||||
|
||||
|
||||
def _fmt_pct(n) -> str:
|
||||
"""Format decimal as percentage."""
|
||||
if n is None:
|
||||
return "-"
|
||||
return f"{n * 100:.1f}%"
|
||||
|
||||
|
||||
def _fmt_months(idx: int, t: dict) -> str:
|
||||
"""Format payback month index as readable string."""
|
||||
if idx < 0:
|
||||
return t["bp_payback_not_reached"]
|
||||
months = idx + 1
|
||||
if months <= 12:
|
||||
return t["bp_months"].format(n=months)
|
||||
years = months / 12
|
||||
return t["bp_years"].format(n=f"{years:.1f}")
|
||||
|
||||
|
||||
def get_plan_sections(state: dict, d: dict, language: str = "en") -> dict:
|
||||
"""Extract and format all business plan sections from planner data."""
|
||||
s = state
|
||||
t = get_translations(language)
|
||||
|
||||
cur = COUNTRY_CURRENCY.get(s.get("country", "DE"), CURRENCY_DEFAULT)
|
||||
sym, eu_style = cur["sym"], cur["eu_style"]
|
||||
fmt = lambda n: _fmt_cur(n, sym, eu_style) # noqa: E731
|
||||
|
||||
venue_type = t["bp_indoor"] if s["venue"] == "indoor" else t["bp_outdoor"]
|
||||
own_type = t["bp_own"] if s["own"] == "buy" else t["bp_rent"]
|
||||
|
||||
payback_str = _fmt_months(d["paybackIdx"], t)
|
||||
irr_str = _fmt_pct(d["irr"])
|
||||
total_capex_str = fmt(d["capex"])
|
||||
equity_str = fmt(d["equity"])
|
||||
loan_str = fmt(d["loanAmount"])
|
||||
per_court_str = fmt(d["capexPerCourt"])
|
||||
per_sqm_str = fmt(d["capexPerSqm"])
|
||||
|
||||
sections = {
|
||||
"lang": language,
|
||||
"title": t["bp_title"],
|
||||
"subtitle": f"{venue_type} ({own_type}) \u2014 {s.get('country', 'DE')}",
|
||||
"courts": t["bp_courts_desc"].format(dbl=s["dblCourts"], sgl=s["sglCourts"], total=d["totalCourts"]),
|
||||
|
||||
# Executive Summary
|
||||
"executive_summary": {
|
||||
"heading": t["bp_exec_summary"],
|
||||
"facility_type": f"{venue_type} ({own_type})",
|
||||
"courts": d["totalCourts"],
|
||||
"sqm": d["sqm"],
|
||||
"total_capex": total_capex_str,
|
||||
"equity": equity_str,
|
||||
"loan": loan_str,
|
||||
"y1_revenue": fmt(d["annuals"][0]["revenue"]) if d["annuals"] else "-",
|
||||
"y3_ebitda": fmt(d["stabEbitda"]),
|
||||
"irr": irr_str,
|
||||
"payback": payback_str,
|
||||
},
|
||||
|
||||
# Investment Plan (CAPEX)
|
||||
"investment": {
|
||||
"heading": t["bp_investment"],
|
||||
"items": [{**i, "formatted_amount": fmt(i["amount"])} for i in d["capexItems"]],
|
||||
"total": total_capex_str,
|
||||
"per_court": per_court_str,
|
||||
"per_sqm": per_sqm_str,
|
||||
},
|
||||
|
||||
# Operating Costs
|
||||
"operations": {
|
||||
"heading": t["bp_operations"],
|
||||
"items": [{**i, "formatted_amount": fmt(i["amount"])} for i in d["opexItems"]],
|
||||
"monthly_total": fmt(d["opex"]),
|
||||
"annual_total": fmt(d["annualOpex"]),
|
||||
},
|
||||
|
||||
# Revenue Model
|
||||
"revenue": {
|
||||
"heading": t["bp_revenue"],
|
||||
"weighted_rate": fmt(d["weightedRate"]),
|
||||
"utilization": _fmt_pct(s["utilTarget"] / 100),
|
||||
"gross_monthly": fmt(d["grossRevMonth"]),
|
||||
"net_monthly": fmt(d["netRevMonth"]),
|
||||
"ebitda_monthly": fmt(d["ebitdaMonth"]),
|
||||
"net_cf_monthly": fmt(d["netCFMonth"]),
|
||||
},
|
||||
|
||||
# 5-Year P&L
|
||||
"annuals": {
|
||||
"heading": t["bp_annuals"],
|
||||
"years": [
|
||||
{
|
||||
"year": a["year"],
|
||||
"revenue": fmt(a["revenue"]),
|
||||
"ebitda": fmt(a["ebitda"]),
|
||||
"debt_service": fmt(a["ds"]),
|
||||
"net_cf": fmt(a["ncf"]),
|
||||
}
|
||||
for a in d["annuals"]
|
||||
],
|
||||
},
|
||||
|
||||
# Financing
|
||||
"financing": {
|
||||
"heading": t["bp_financing"],
|
||||
"loan_pct": _fmt_pct(s["loanPct"] / 100),
|
||||
"equity": equity_str,
|
||||
"loan": loan_str,
|
||||
"interest_rate": f"{s['interestRate']}%",
|
||||
"term": t["bp_years"].format(n=s["loanTerm"]),
|
||||
"monthly_payment": fmt(d["monthlyPayment"]),
|
||||
"annual_debt_service": fmt(d["annualDebtService"]),
|
||||
"ltv": _fmt_pct(d["ltv"]),
|
||||
},
|
||||
|
||||
# Key Metrics
|
||||
"metrics": {
|
||||
"heading": t["bp_metrics"],
|
||||
"irr": irr_str,
|
||||
"moic": f"{d['moic']:.2f}x",
|
||||
"cash_on_cash": _fmt_pct(d["cashOnCash"]),
|
||||
"payback": payback_str,
|
||||
"break_even_util": _fmt_pct(d["breakEvenUtil"]),
|
||||
"ebitda_margin": _fmt_pct(d["ebitdaMargin"]),
|
||||
"dscr_y3": f"{d['dscr'][2]['dscr']:.2f}x" if len(d["dscr"]) >= 3 else "-",
|
||||
"yield_on_cost": _fmt_pct(d["yieldOnCost"]),
|
||||
},
|
||||
|
||||
# 12-Month Cash Flow
|
||||
"cashflow_12m": {
|
||||
"heading": t["bp_cashflow_12m"],
|
||||
"months": [
|
||||
{
|
||||
"month": m["m"],
|
||||
"revenue": fmt(m["totalRev"]),
|
||||
"opex": fmt(abs(m["opex"])),
|
||||
"ebitda": fmt(m["ebitda"]),
|
||||
"debt": fmt(abs(m["loan"])),
|
||||
"ncf": fmt(m["ncf"]),
|
||||
"cumulative": fmt(m["cum"]),
|
||||
}
|
||||
for m in d["months"][:12]
|
||||
],
|
||||
},
|
||||
|
||||
# Template labels
|
||||
"labels": {
|
||||
"scenario": t["bp_lbl_scenario"],
|
||||
"generated_by": t["bp_lbl_generated_by"],
|
||||
"exec_paragraph": t["bp_exec_paragraph"].format(
|
||||
facility_type=f"{venue_type} ({own_type})",
|
||||
courts=d["totalCourts"],
|
||||
sqm=d["sqm"],
|
||||
total_capex=total_capex_str,
|
||||
equity=equity_str,
|
||||
loan=loan_str,
|
||||
irr=irr_str,
|
||||
payback=payback_str,
|
||||
),
|
||||
"total_investment": t["bp_lbl_total_investment"],
|
||||
"equity_required": t["bp_lbl_equity_required"],
|
||||
"year3_ebitda": t["bp_lbl_year3_ebitda"],
|
||||
"irr": t["bp_lbl_irr"],
|
||||
"payback_period": t["bp_lbl_payback_period"],
|
||||
"year1_revenue": t["bp_lbl_year1_revenue"],
|
||||
"item": t["bp_lbl_item"],
|
||||
"amount": t["bp_lbl_amount"],
|
||||
"notes": t["bp_lbl_notes"],
|
||||
"total_capex": t["bp_lbl_total_capex"],
|
||||
"capex_stats": t["bp_lbl_capex_stats"].format(per_court=per_court_str, per_sqm=per_sqm_str),
|
||||
"equity": t["bp_lbl_equity"],
|
||||
"loan": t["bp_lbl_loan"],
|
||||
"interest_rate": t["bp_lbl_interest_rate"],
|
||||
"loan_term": t["bp_lbl_loan_term"],
|
||||
"monthly_payment": t["bp_lbl_monthly_payment"],
|
||||
"annual_debt_service": t["bp_lbl_annual_debt_service"],
|
||||
"ltv": t["bp_lbl_ltv"],
|
||||
"monthly": t["bp_lbl_monthly"],
|
||||
"total_monthly_opex": t["bp_lbl_total_monthly_opex"],
|
||||
"annual_opex": t["bp_lbl_annual_opex"],
|
||||
"weighted_hourly_rate": t["bp_lbl_weighted_hourly_rate"],
|
||||
"target_utilization": t["bp_lbl_target_utilization"],
|
||||
"gross_monthly_revenue": t["bp_lbl_gross_monthly_revenue"],
|
||||
"net_monthly_revenue": t["bp_lbl_net_monthly_revenue"],
|
||||
"monthly_ebitda": t["bp_lbl_monthly_ebitda"],
|
||||
"monthly_net_cf": t["bp_lbl_monthly_net_cf"],
|
||||
"year": t["bp_lbl_year"],
|
||||
"revenue": t["bp_lbl_revenue"],
|
||||
"ebitda": t["bp_lbl_ebitda"],
|
||||
"debt_service": t["bp_lbl_debt_service"],
|
||||
"net_cf": t["bp_lbl_net_cf"],
|
||||
"moic": t["bp_lbl_moic"],
|
||||
"cash_on_cash": t["bp_lbl_cash_on_cash"],
|
||||
"payback": t["bp_lbl_payback"],
|
||||
"break_even_util": t["bp_lbl_break_even_util"],
|
||||
"ebitda_margin": t["bp_lbl_ebitda_margin"],
|
||||
"dscr_y3": t["bp_lbl_dscr_y3"],
|
||||
"yield_on_cost": t["bp_lbl_yield_on_cost"],
|
||||
"month": t["bp_lbl_month"],
|
||||
"opex": t["bp_lbl_opex"],
|
||||
"debt": t["bp_lbl_debt"],
|
||||
"cumulative": t["bp_lbl_cumulative"],
|
||||
"disclaimer": t["bp_lbl_disclaimer"],
|
||||
"currency_sym": sym,
|
||||
},
|
||||
}
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
async def generate_business_plan(scenario_id: int, user_id: int, language: str = "en") -> bytes:
|
||||
"""Generate a business plan PDF from a saved scenario. Returns PDF bytes."""
|
||||
scenario = await fetch_one(
|
||||
"SELECT * FROM scenarios WHERE id = ? AND user_id = ?",
|
||||
(scenario_id, user_id),
|
||||
)
|
||||
if not scenario:
|
||||
raise ValueError(f"Scenario {scenario_id} not found for user {user_id}")
|
||||
|
||||
state = validate_state(json.loads(scenario["state_json"]))
|
||||
d = calc(state)
|
||||
sections = get_plan_sections(state, d, language)
|
||||
sections["scenario_name"] = scenario["name"]
|
||||
sections["location"] = scenario.get("location", "")
|
||||
|
||||
# Read HTML + CSS template
|
||||
html_template = (TEMPLATE_DIR / "plan.html").read_text()
|
||||
css = (TEMPLATE_DIR / "plan.css").read_text()
|
||||
|
||||
# Render with Jinja
|
||||
from jinja2 import Template
|
||||
template = Template(html_template)
|
||||
rendered_html = template.render(s=sections, css=css)
|
||||
|
||||
# Convert to PDF via WeasyPrint
|
||||
from weasyprint import HTML
|
||||
pdf_bytes = HTML(string=rendered_html).write_pdf()
|
||||
|
||||
return pdf_bytes
|
||||
0
web/src/padelnomics/content/__init__.py
Normal file
0
web/src/padelnomics/content/__init__.py
Normal file
228
web/src/padelnomics/content/routes.py
Normal file
228
web/src/padelnomics/content/routes.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Content domain: public article serving, markets hub, scenario widget rendering.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from markupsafe import Markup
|
||||
from quart import Blueprint, abort, render_template, request
|
||||
|
||||
from ..core import capture_waitlist_email, config, csrf_protect, fetch_all, fetch_one, waitlist_gate
|
||||
from ..i18n import get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
"content",
|
||||
__name__,
|
||||
template_folder=str(Path(__file__).parent / "templates"),
|
||||
)
|
||||
|
||||
BUILD_DIR = Path("data/content/_build")
|
||||
|
||||
RESERVED_PREFIXES = (
|
||||
"/admin", "/auth", "/planner", "/billing", "/dashboard",
|
||||
"/directory", "/leads", "/suppliers", "/health",
|
||||
"/sitemap", "/static", "/markets", "/features", "/feedback",
|
||||
)
|
||||
|
||||
SCENARIO_RE = re.compile(r'\[scenario:([a-z0-9_-]+)(?::([a-z]+))?\]')
|
||||
|
||||
SECTION_TEMPLATES = {
|
||||
None: "partials/scenario_summary.html",
|
||||
"capex": "partials/scenario_capex.html",
|
||||
"operating": "partials/scenario_operating.html",
|
||||
"cashflow": "partials/scenario_cashflow.html",
|
||||
"returns": "partials/scenario_returns.html",
|
||||
"full": "partials/scenario_full.html",
|
||||
}
|
||||
|
||||
# Standalone Jinja2 env for baking scenario cards into static HTML.
|
||||
# Does not use a Quart request context, so url_for and t are injected
|
||||
# explicitly. Baked content is always EN (admin operation).
|
||||
_TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||
_bake_env = Environment(loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=True)
|
||||
_bake_env.filters["tformat"] = lambda s, **kw: s.format_map(kw)
|
||||
|
||||
# Hardcoded EN URL stubs — the bake env has no request context so Quart's
|
||||
# url_for cannot be used. Only endpoints referenced by scenario card templates
|
||||
# need to be listed here.
|
||||
_BAKE_URLS: dict[str, str] = {
|
||||
"planner.index": "/en/planner/",
|
||||
"directory.index": "/en/directory/",
|
||||
}
|
||||
_bake_env.globals["url_for"] = lambda endpoint, **kw: _BAKE_URLS.get(endpoint, f"/{endpoint}")
|
||||
|
||||
|
||||
def is_reserved_path(url_path: str) -> bool:
|
||||
"""Check if a url_path starts with a reserved prefix."""
|
||||
clean = "/" + url_path.strip("/")
|
||||
return any(clean.startswith(p) for p in RESERVED_PREFIXES)
|
||||
|
||||
|
||||
async def bake_scenario_cards(html: str, lang: str = "en") -> str:
|
||||
"""Replace [scenario:slug] and [scenario:slug:section] markers with rendered HTML."""
|
||||
matches = list(SCENARIO_RE.finditer(html))
|
||||
if not matches:
|
||||
return html
|
||||
|
||||
# Batch-fetch all referenced scenarios
|
||||
slugs = list({m.group(1) for m in matches})
|
||||
placeholders = ",".join("?" * len(slugs))
|
||||
rows = await fetch_all(
|
||||
f"SELECT * FROM published_scenarios WHERE slug IN ({placeholders})",
|
||||
tuple(slugs),
|
||||
)
|
||||
scenarios = {row["slug"]: row for row in rows}
|
||||
|
||||
for match in reversed(matches):
|
||||
slug = match.group(1)
|
||||
section = match.group(2)
|
||||
|
||||
scenario = scenarios.get(slug)
|
||||
if not scenario:
|
||||
continue
|
||||
|
||||
template_name = SECTION_TEMPLATES.get(section)
|
||||
if not template_name:
|
||||
continue
|
||||
|
||||
calc_data = json.loads(scenario["calc_json"])
|
||||
state_data = json.loads(scenario["state_json"])
|
||||
|
||||
tmpl = _bake_env.get_template(template_name)
|
||||
card_html = tmpl.render(
|
||||
scenario=scenario, d=calc_data, s=state_data,
|
||||
lang=lang, t=get_translations(lang),
|
||||
)
|
||||
html = html[:match.start()] + card_html + html[match.end():]
|
||||
|
||||
return html
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Markets Hub
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/markets", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
@waitlist_gate("markets_waitlist.html")
|
||||
async def markets():
|
||||
"""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()
|
||||
country = request.args.get("country", "")
|
||||
region = request.args.get("region", "")
|
||||
|
||||
countries = await fetch_all(
|
||||
"""SELECT DISTINCT country FROM articles
|
||||
WHERE country IS NOT NULL AND country != ''
|
||||
AND status = 'published' AND published_at <= datetime('now')
|
||||
ORDER BY country"""
|
||||
)
|
||||
regions = await fetch_all(
|
||||
"""SELECT DISTINCT region FROM articles
|
||||
WHERE region IS NOT NULL AND region != ''
|
||||
AND status = 'published' AND published_at <= datetime('now')
|
||||
ORDER BY region"""
|
||||
)
|
||||
|
||||
articles = await _filter_articles(q, country, region)
|
||||
|
||||
return await render_template(
|
||||
"markets.html",
|
||||
articles=articles,
|
||||
countries=[c["country"] for c in countries],
|
||||
regions=[r["region"] for r in regions],
|
||||
current_q=q,
|
||||
current_country=country,
|
||||
current_region=region,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/markets/results")
|
||||
@waitlist_gate("markets_waitlist.html")
|
||||
async def market_results():
|
||||
"""HTMX partial: filtered article cards."""
|
||||
q = request.args.get("q", "").strip()
|
||||
country = request.args.get("country", "")
|
||||
region = request.args.get("region", "")
|
||||
|
||||
articles = await _filter_articles(q, country, region)
|
||||
return await render_template("partials/market_results.html", articles=articles)
|
||||
|
||||
|
||||
async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
|
||||
"""Query published articles with optional FTS + country/region filters."""
|
||||
if q:
|
||||
# FTS query
|
||||
wheres = ["articles_fts MATCH ?"]
|
||||
params: list = [q]
|
||||
if country:
|
||||
wheres.append("a.country = ?")
|
||||
params.append(country)
|
||||
if region:
|
||||
wheres.append("a.region = ?")
|
||||
params.append(region)
|
||||
where = " AND ".join(wheres)
|
||||
return await fetch_all(
|
||||
f"""SELECT a.* FROM articles a
|
||||
JOIN articles_fts ON articles_fts.rowid = a.id
|
||||
WHERE {where}
|
||||
AND a.status = 'published' AND a.published_at <= datetime('now')
|
||||
ORDER BY a.published_at DESC
|
||||
LIMIT 100""",
|
||||
tuple(params),
|
||||
)
|
||||
else:
|
||||
wheres = ["status = 'published'", "published_at <= datetime('now')"]
|
||||
params = []
|
||||
if country:
|
||||
wheres.append("country = ?")
|
||||
params.append(country)
|
||||
if region:
|
||||
wheres.append("region = ?")
|
||||
params.append(region)
|
||||
where = " AND ".join(wheres)
|
||||
return await fetch_all(
|
||||
f"""SELECT * FROM articles WHERE {where}
|
||||
ORDER BY published_at DESC LIMIT 100""",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Catch-all Article Serving (must be registered LAST)
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/<path:url_path>")
|
||||
async def article_page(url_path: str):
|
||||
"""Serve a published article by its url_path."""
|
||||
clean_path = "/" + url_path.strip("/")
|
||||
|
||||
article = await fetch_one(
|
||||
"""SELECT * FROM articles
|
||||
WHERE url_path = ? AND status = 'published'
|
||||
AND published_at <= datetime('now')""",
|
||||
(clean_path,),
|
||||
)
|
||||
if not article:
|
||||
abort(404)
|
||||
|
||||
build_path = BUILD_DIR / f"{article['slug']}.html"
|
||||
if not build_path.exists():
|
||||
abort(404)
|
||||
|
||||
body_html = build_path.read_text()
|
||||
|
||||
return await render_template(
|
||||
"article_detail.html",
|
||||
article=article,
|
||||
body_html=Markup(body_html),
|
||||
)
|
||||
60
web/src/padelnomics/content/templates/article_detail.html
Normal file
60
web/src/padelnomics/content/templates/article_detail.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ article.title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="{{ article.meta_description or '' }}">
|
||||
<meta property="og:title" content="{{ article.title }}">
|
||||
<meta property="og:description" content="{{ article.meta_description or '' }}">
|
||||
<meta property="og:type" content="article">
|
||||
{% if article.og_image_url %}
|
||||
<meta property="og:image" content="{{ article.og_image_url }}">
|
||||
{% endif %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": {{ article.title | tojson }},
|
||||
"description": {{ (article.meta_description or '') | tojson }},
|
||||
{% if article.og_image_url %}"image": {{ article.og_image_url | tojson }},{% endif %}
|
||||
"datePublished": "{{ article.published_at[:10] if article.published_at else '' }}",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "Padelnomics"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "Padelnomics",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "{{ url_for('static', filename='images/logo.png', _external=True) }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<article class="article-content" style="max-width: 48rem; margin: 0 auto;">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl mb-2">{{ article.title }}</h1>
|
||||
<p class="text-sm text-slate">
|
||||
{% if article.published_at %}{{ t.article_detail_published_label }} {{ article.published_at[:10] }} · {% endif %}{{ t.article_detail_research_label }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="article-body">
|
||||
{{ body_html }}
|
||||
</div>
|
||||
|
||||
<footer class="mt-12 pt-8 border-t border-light-gray">
|
||||
<div class="card" style="text-align: center; padding: 2rem;">
|
||||
<h3 class="text-xl mb-2">{{ t.art_run_numbers_h2 }}</h3>
|
||||
<p class="text-slate text-sm mb-4">{{ t.art_run_numbers_text }}</p>
|
||||
<a href="{{ url_for('planner.index') }}" class="btn">{{ t.art_open_planner_btn }}</a>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
{% endblock %}
|
||||
64
web/src/padelnomics/content/templates/markets.html
Normal file
64
web/src/padelnomics/content/templates/markets.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.markets_page_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="{{ t.markets_page_description }}">
|
||||
<meta property="og:title" content="{{ t.markets_page_og_title }} - {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="{{ t.markets_page_og_description }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl mb-2">{{ t.mkt_heading }}</h1>
|
||||
<p class="text-slate">{{ t.mkt_subheading }}</p>
|
||||
</header>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-8">
|
||||
<div style="display: grid; grid-template-columns: 1fr auto auto; gap: 1rem; align-items: end;">
|
||||
<div>
|
||||
<label class="form-label" for="market-q">{{ t.markets_search_label }}</label>
|
||||
<input type="text" id="market-q" name="q" value="{{ current_q }}" placeholder="{{ t.mkt_search_placeholder }}"
|
||||
class="form-input"
|
||||
hx-get="{{ url_for('content.market_results') }}"
|
||||
hx-target="#market-results"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-include="#market-country, #market-region">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="market-country">{{ t.markets_country_label }}</label>
|
||||
<select id="market-country" name="country" class="form-input"
|
||||
hx-get="{{ url_for('content.market_results') }}"
|
||||
hx-target="#market-results"
|
||||
hx-trigger="change"
|
||||
hx-include="#market-q, #market-region">
|
||||
<option value="">{{ t.mkt_all_countries }}</option>
|
||||
{% for c in countries %}
|
||||
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="market-region">Region</label>
|
||||
<select id="market-region" name="region" class="form-input"
|
||||
hx-get="{{ url_for('content.market_results') }}"
|
||||
hx-target="#market-results"
|
||||
hx-trigger="change"
|
||||
hx-include="#market-q, #market-country">
|
||||
<option value="">{{ t.mkt_all_regions }}</option>
|
||||
{% for r in regions %}
|
||||
<option value="{{ r }}" {% if r == current_region %}selected{% endif %}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="market-results">
|
||||
{% include "partials/market_results.html" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
50
web/src/padelnomics/content/templates/markets_waitlist.html
Normal file
50
web/src/padelnomics/content/templates/markets_waitlist.html
Normal file
@@ -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 %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% if articles %}
|
||||
<div class="grid-3">
|
||||
{% for article in articles %}
|
||||
<a href="{{ article.url_path }}" class="card" style="text-decoration: none; display: block;">
|
||||
<h3 class="text-base font-semibold text-navy mb-1">{{ article.title }}</h3>
|
||||
{% if article.meta_description %}
|
||||
<p class="text-sm text-slate mb-3" style="display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;">{{ article.meta_description }}</p>
|
||||
{% endif %}
|
||||
<div class="flex items-center gap-2">
|
||||
{% if article.country %}
|
||||
<span class="badge">{{ article.country }}</span>
|
||||
{% endif %}
|
||||
{% if article.region %}
|
||||
<span class="text-xs text-slate">{{ article.region }}</span>
|
||||
{% endif %}
|
||||
{% if article.published_at %}
|
||||
<span class="text-xs text-slate ml-auto mono">{{ article.published_at[:10] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center">
|
||||
<p class="text-slate text-sm">{{ t.mkt_no_results }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,66 @@
|
||||
<div class="scenario-widget scenario-capex">
|
||||
<div class="scenario-widget__header">
|
||||
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||
<span class="scenario-widget__config">{{ t.scenario_investment_breakdown_title }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__body">
|
||||
<div class="scenario-widget__table-wrap">
|
||||
<table class="scenario-widget__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t.scenario_table_item_label }}</th>
|
||||
<th class="text-right">{{ t.scenario_table_amount_label }}</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in d.capexItems %}
|
||||
<tr>
|
||||
<td>{{ item.name }}</td>
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(item.amount) }}</td>
|
||||
<td class="text-sm text-slate">{{ item.info }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>{{ t.scenario_total_capex_label }}</strong></td>
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.capex) }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="scenario-widget__metrics" style="margin-top: 1rem;">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_per_court_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capexPerCourt) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Per m²</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capexPerSqm) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">LTV</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ltv * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_equity_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.equity) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_loan_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.loanAmount) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_total_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capex) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="{{ url_for('planner.index') }}">{{ t.scenario_cta_try_numbers }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,65 @@
|
||||
<div class="scenario-widget scenario-cashflow">
|
||||
<div class="scenario-widget__header">
|
||||
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||
<span class="scenario-widget__config">{{ t.scenario_cashflow_config_title | tformat(years=s.holdYears) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__body">
|
||||
<div class="scenario-widget__table-wrap">
|
||||
<table class="scenario-widget__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{% for a in d.annuals %}
|
||||
<th class="text-right">{{ t.scenario_year_label }} {{ a.year }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ t.scenario_revenue_label }}</td>
|
||||
{% for a in d.annuals %}
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(a.revenue) }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>EBITDA</td>
|
||||
{% for a in d.annuals %}
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(a.ebitda) }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ t.scenario_debt_service_label }}</td>
|
||||
{% for a in d.annuals %}
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(a.ds) }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>{{ t.scenario_net_cashflow_label }}</strong></td>
|
||||
{% for a in d.annuals %}
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(a.ncf) }}</strong></td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ t.scenario_cumulative_ncf_label }}</td>
|
||||
{% set cum = namespace(total=-d.capex) %}
|
||||
{% for a in d.annuals %}
|
||||
{% set cum.total = cum.total + a.ncf %}
|
||||
<td class="text-right mono {% if cum.total >= 0 %}text-accent{% else %}text-danger{% endif %}">€{{ "{:,.0f}".format(cum.total) }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td>DSCR</td>
|
||||
{% for entry in d.dscr %}
|
||||
<td class="text-right mono">{{ "{:.2f}".format(entry.dscr) }}x</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="{{ url_for('planner.index') }}">{{ t.scenario_cta_try_numbers }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
{% include "partials/scenario_summary.html" %}
|
||||
{% include "partials/scenario_capex.html" %}
|
||||
{% include "partials/scenario_operating.html" %}
|
||||
{% include "partials/scenario_cashflow.html" %}
|
||||
{% include "partials/scenario_returns.html" %}
|
||||
@@ -0,0 +1,91 @@
|
||||
<div class="scenario-widget scenario-operating">
|
||||
<div class="scenario-widget__header">
|
||||
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||
<span class="scenario-widget__config">{{ t.scenario_revenue_opex_title }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__body">
|
||||
<h4 class="scenario-widget__section-title">{{ t.scenario_revenue_model_title }}</h4>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_weighted_rate_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:.0f}".format(d.weightedRate) }}/hr</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_utilization_target_label }}</span>
|
||||
<span class="scenario-widget__metric-value">{{ s.utilTarget }}%</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_booked_hours_label }}</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:,.0f}".format(d.bookedHoursMonth) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="scenario-widget__section-title">{{ t.scenario_monthly_opex_title }}</h4>
|
||||
<div class="scenario-widget__table-wrap">
|
||||
<table class="scenario-widget__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t.scenario_table_item_label }}</th>
|
||||
<th class="text-right">{{ t.scenario_table_monthly_label }}</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in d.opexItems %}
|
||||
<tr>
|
||||
<td>{{ item.name }}</td>
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(item.amount) }}</td>
|
||||
<td class="text-sm text-slate">{{ item.info }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>{{ t.scenario_total_monthly_opex_label }}</strong></td>
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.opex) }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="scenario-widget__section-title">{{ t.scenario_monthly_summary_title }}</h4>
|
||||
<div class="scenario-widget__table-wrap">
|
||||
<table class="scenario-widget__table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ t.scenario_gross_revenue_label }}</td>
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(d.grossRevMonth) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ t.scenario_booking_fees_label }}</td>
|
||||
<td class="text-right mono">-€{{ "{:,.0f}".format(d.feeDeduction) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ t.scenario_net_revenue_label }}</td>
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(d.netRevMonth) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ t.scenario_operating_costs_label }}</td>
|
||||
<td class="text-right mono">-€{{ "{:,.0f}".format(d.opex) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>EBITDA</strong></td>
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.ebitdaMonth) }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ t.scenario_debt_service_label }}</td>
|
||||
<td class="text-right mono">-€{{ "{:,.0f}".format(d.monthlyPayment) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>{{ t.scenario_net_cashflow_label }}</strong></td>
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.netCFMonth) }}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="{{ url_for('planner.index') }}">{{ t.scenario_cta_try_numbers }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
<div class="scenario-widget scenario-returns">
|
||||
<div class="scenario-widget__header">
|
||||
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||
<span class="scenario-widget__config">{{ t.scenario_returns_config_title }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__body">
|
||||
<h4 class="scenario-widget__section-title">{{ t.scenario_return_metrics_title }}</h4>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">IRR ({{ s.holdYears }}yr)</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.irr * 100) }}%</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">MOIC</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.2f}".format(d.moic) }}x</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_payback_label }}</span>
|
||||
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} {{ t.scenario_months_unit }}{% else %}N/A{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Cash-on-Cash</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.cashOnCash * 100) }}%</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_yield_on_cost_label }}</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.yieldOnCost * 100) }}%</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_ebitda_margin_label }}</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ebitdaMargin * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="scenario-widget__section-title">{{ t.scenario_exit_analysis_title }}</h4>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_exit_value_label | tformat(multiple=s.exitMultiple) }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.exitValue) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_remaining_loan_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.remainingLoan) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_net_exit_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.netExit) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="scenario-widget__section-title">{{ t.scenario_financing_title }}</h4>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_loan_amount_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.loanAmount) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_rate_term_label }}</span>
|
||||
<span class="scenario-widget__metric-value">{{ s.interestRate }}% / {{ s.loanTerm }}yr</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_monthly_payment_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.monthlyPayment) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="{{ url_for('planner.index') }}">{{ t.scenario_cta_try_numbers }}</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<div class="scenario-widget scenario-summary">
|
||||
<div class="scenario-widget__header">
|
||||
<span class="scenario-widget__location">{{ scenario.location }}, {{ scenario.country }}</span>
|
||||
<span class="scenario-widget__config">{{ scenario.venue_type | capitalize }} · {{ scenario.court_config }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__body">
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_total_capex_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capex) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_monthly_ebitda_label }}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.ebitdaMonth) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">IRR ({{ s.holdYears }}yr)</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.irr * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_payback_label }}</span>
|
||||
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} {{ t.scenario_months_unit }}{% else %}N/A{% endif %}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Cash-on-Cash</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.cashOnCash * 100) }}%</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">{{ t.scenario_ebitda_margin_label }}</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ebitdaMargin * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="{{ url_for('planner.index') }}">{{ t.scenario_cta_try_numbers }}</a>
|
||||
</div>
|
||||
</div>
|
||||
642
web/src/padelnomics/core.py
Normal file
642
web/src/padelnomics/core.py
Normal file
@@ -0,0 +1,642 @@
|
||||
"""
|
||||
Core infrastructure: database, config, email, and shared utilities.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import secrets
|
||||
import unicodedata
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timedelta
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
import resend
|
||||
from dotenv import load_dotenv
|
||||
from quart import g, make_response, render_template, request, session
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _env(key: str, default: str) -> str:
|
||||
"""Get env var, treating empty string same as unset."""
|
||||
return os.getenv(key, "") or default
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
|
||||
class Config:
|
||||
APP_NAME: str = _env("APP_NAME", "Padelnomics")
|
||||
SECRET_KEY: str = _env("SECRET_KEY", "change-me-in-production")
|
||||
BASE_URL: str = _env("BASE_URL", "http://localhost:5000")
|
||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db")
|
||||
|
||||
MAGIC_LINK_EXPIRY_MINUTES: int = int(os.getenv("MAGIC_LINK_EXPIRY_MINUTES", "15"))
|
||||
SESSION_LIFETIME_DAYS: int = int(os.getenv("SESSION_LIFETIME_DAYS", "30"))
|
||||
|
||||
PAYMENT_PROVIDER: str = "paddle"
|
||||
|
||||
PADDLE_API_KEY: str = os.getenv("PADDLE_API_KEY", "")
|
||||
PADDLE_CLIENT_TOKEN: str = os.getenv("PADDLE_CLIENT_TOKEN", "")
|
||||
PADDLE_WEBHOOK_SECRET: str = os.getenv("PADDLE_WEBHOOK_SECRET", "")
|
||||
PADDLE_ENVIRONMENT: str = _env("PADDLE_ENVIRONMENT", "sandbox")
|
||||
|
||||
UMAMI_API_URL: str = os.getenv("UMAMI_API_URL", "https://umami.padelnomics.io")
|
||||
UMAMI_API_TOKEN: str = os.getenv("UMAMI_API_TOKEN", "")
|
||||
UMAMI_WEBSITE_ID: str = "4474414b-58d6-4c6e-89a1-df5ea1f49d70"
|
||||
|
||||
RESEND_API_KEY: str = os.getenv("RESEND_API_KEY", "")
|
||||
EMAIL_FROM: str = _env("EMAIL_FROM", "hello@padelnomics.io")
|
||||
LEADS_EMAIL: str = _env("LEADS_EMAIL", "leads@padelnomics.io")
|
||||
ADMIN_EMAILS: list[str] = [
|
||||
e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip()
|
||||
]
|
||||
RESEND_AUDIENCE_PLANNER: str = os.getenv("RESEND_AUDIENCE_PLANNER", "")
|
||||
WAITLIST_MODE: bool = os.getenv("WAITLIST_MODE", "false").lower() == "true"
|
||||
|
||||
RATE_LIMIT_REQUESTS: int = int(os.getenv("RATE_LIMIT_REQUESTS", "100"))
|
||||
RATE_LIMIT_WINDOW: int = int(os.getenv("RATE_LIMIT_WINDOW", "60"))
|
||||
|
||||
PLAN_FEATURES: dict = {
|
||||
"free": ["basic"],
|
||||
"starter": ["basic", "export"],
|
||||
"pro": ["basic", "export", "api", "priority_support"],
|
||||
}
|
||||
|
||||
PLAN_LIMITS: dict = {
|
||||
"free": {"items": 100, "api_calls": 1000},
|
||||
"starter": {"items": 1000, "api_calls": 10000},
|
||||
"pro": {"items": -1, "api_calls": -1}, # -1 = unlimited
|
||||
}
|
||||
|
||||
|
||||
config = Config()
|
||||
|
||||
# =============================================================================
|
||||
# Database
|
||||
# =============================================================================
|
||||
|
||||
_db: aiosqlite.Connection | None = None
|
||||
|
||||
|
||||
async def init_db(path: str = None) -> None:
|
||||
"""Initialize database connection with WAL mode."""
|
||||
global _db
|
||||
db_path = path or config.DATABASE_PATH
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_db = await aiosqlite.connect(db_path)
|
||||
_db.row_factory = aiosqlite.Row
|
||||
|
||||
await _db.execute("PRAGMA journal_mode=WAL")
|
||||
await _db.execute("PRAGMA foreign_keys=ON")
|
||||
await _db.execute("PRAGMA busy_timeout=5000")
|
||||
await _db.execute("PRAGMA synchronous=NORMAL")
|
||||
await _db.execute("PRAGMA cache_size=-64000")
|
||||
await _db.execute("PRAGMA temp_store=MEMORY")
|
||||
await _db.execute("PRAGMA mmap_size=268435456")
|
||||
await _db.commit()
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close database connection."""
|
||||
global _db
|
||||
if _db:
|
||||
await _db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
await _db.close()
|
||||
_db = None
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
"""Get database connection."""
|
||||
if _db is None:
|
||||
await init_db()
|
||||
return _db
|
||||
|
||||
|
||||
async def fetch_one(sql: str, params: tuple = ()) -> dict | None:
|
||||
"""Fetch a single row as dict."""
|
||||
db = await get_db()
|
||||
async with db.execute(sql, params) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def fetch_all(sql: str, params: tuple = ()) -> list[dict]:
|
||||
"""Fetch all rows as list of dicts."""
|
||||
db = await get_db()
|
||||
async with db.execute(sql, params) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
async def execute(sql: str, params: tuple = ()) -> int:
|
||||
"""Execute SQL and return lastrowid."""
|
||||
db = await get_db()
|
||||
async with db.execute(sql, params) as cursor:
|
||||
await db.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
async def execute_many(sql: str, params_list: list[tuple]) -> None:
|
||||
"""Execute SQL for multiple parameter sets."""
|
||||
db = await get_db()
|
||||
await db.executemany(sql, params_list)
|
||||
await db.commit()
|
||||
|
||||
|
||||
class transaction:
|
||||
"""Async context manager for transactions."""
|
||||
|
||||
async def __aenter__(self):
|
||||
self.db = await get_db()
|
||||
return self.db
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
if exc_type is None:
|
||||
await self.db.commit()
|
||||
else:
|
||||
await self.db.rollback()
|
||||
return False
|
||||
|
||||
# =============================================================================
|
||||
# Email
|
||||
# =============================================================================
|
||||
|
||||
EMAIL_ADDRESSES = {
|
||||
"transactional": "Padelnomics <hello@notifications.padelnomics.io>",
|
||||
"leads": "Padelnomics Leads <leads@notifications.padelnomics.io>",
|
||||
"nurture": "Padelnomics <coach@notifications.padelnomics.io>",
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# 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(
|
||||
to: str, subject: str, html: str, text: str = None, from_addr: str = None
|
||||
) -> bool:
|
||||
"""Send email via Resend SDK."""
|
||||
if not config.RESEND_API_KEY:
|
||||
print(f"[EMAIL] Would send to {to}: {subject}")
|
||||
return True
|
||||
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
try:
|
||||
resend.Emails.send({
|
||||
"from": from_addr or config.EMAIL_FROM,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"html": html,
|
||||
"text": text or html,
|
||||
})
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[EMAIL] Error sending to {to}: {e}")
|
||||
return False
|
||||
|
||||
# =============================================================================
|
||||
# Waitlist
|
||||
# =============================================================================
|
||||
|
||||
async def _get_or_create_resend_audience(name: str) -> str | None:
|
||||
"""Get cached Resend audience ID, or create one via API. Returns None on failure."""
|
||||
row = await fetch_one("SELECT audience_id FROM resend_audiences WHERE name = ?", (name,))
|
||||
if row:
|
||||
return row["audience_id"]
|
||||
|
||||
try:
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
result = resend.Audiences.create({"name": name})
|
||||
audience_id = result["id"]
|
||||
await execute(
|
||||
"INSERT OR IGNORE INTO resend_audiences (name, audience_id) VALUES (?, ?)",
|
||||
(name, audience_id),
|
||||
)
|
||||
return audience_id
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def capture_waitlist_email(email: str, intent: str, plan: str = None, email_intent: str = None) -> bool:
|
||||
"""Insert email into waitlist, enqueue confirmation, add to Resend audience.
|
||||
|
||||
Args:
|
||||
email: Email address to capture
|
||||
intent: Intent value stored in database
|
||||
plan: Optional plan name stored in database
|
||||
email_intent: Optional intent value for email (defaults to `intent`)
|
||||
|
||||
Returns:
|
||||
True if new row inserted, False if duplicate.
|
||||
"""
|
||||
# INSERT OR IGNORE
|
||||
try:
|
||||
cursor_result = await execute(
|
||||
"INSERT OR IGNORE INTO waitlist (email, intent, plan, ip_address) VALUES (?, ?, ?, ?)",
|
||||
(email, intent, plan, request.remote_addr)
|
||||
)
|
||||
is_new = cursor_result > 0
|
||||
except Exception:
|
||||
# If anything fails, treat as not-new to avoid double-sending
|
||||
is_new = False
|
||||
|
||||
# Enqueue confirmation email only if new
|
||||
if is_new:
|
||||
from .worker import enqueue
|
||||
email_intent_value = email_intent if email_intent is not None else intent
|
||||
await enqueue("send_waitlist_confirmation", {"email": email, "intent": email_intent_value})
|
||||
|
||||
# Add to Resend audience (silent fail - not critical)
|
||||
if config.RESEND_API_KEY:
|
||||
blueprint = request.blueprints[0] if request.blueprints else "default"
|
||||
audience_name = f"waitlist-{blueprint}"
|
||||
audience_id = await _get_or_create_resend_audience(audience_name)
|
||||
if audience_id:
|
||||
try:
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
resend.Contacts.create({"email": email, "audience_id": audience_id})
|
||||
except Exception:
|
||||
pass # Silent fail
|
||||
|
||||
return is_new
|
||||
|
||||
# =============================================================================
|
||||
# CSRF Protection
|
||||
# =============================================================================
|
||||
|
||||
def get_csrf_token() -> str:
|
||||
"""Get or create CSRF token for current session."""
|
||||
if "csrf_token" not in session:
|
||||
session["csrf_token"] = secrets.token_urlsafe(32)
|
||||
return session["csrf_token"]
|
||||
|
||||
|
||||
def validate_csrf_token(token: str) -> bool:
|
||||
"""Validate CSRF token."""
|
||||
return token and secrets.compare_digest(token, session.get("csrf_token", ""))
|
||||
|
||||
|
||||
def csrf_protect(f):
|
||||
"""Decorator to require valid CSRF token for POST requests."""
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
token = form.get("csrf_token") or request.headers.get("X-CSRF-Token")
|
||||
if not validate_csrf_token(token):
|
||||
return {"error": "Invalid CSRF token"}, 403
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
# =============================================================================
|
||||
# Rate Limiting (SQLite-based)
|
||||
# =============================================================================
|
||||
|
||||
async def check_rate_limit(key: str, limit: int = None, window: int = None) -> tuple[bool, dict]:
|
||||
"""
|
||||
Check if rate limit exceeded. Returns (is_allowed, info).
|
||||
Uses SQLite for storage - no Redis needed.
|
||||
"""
|
||||
limit = limit or config.RATE_LIMIT_REQUESTS
|
||||
window = window or config.RATE_LIMIT_WINDOW
|
||||
now = datetime.utcnow()
|
||||
window_start = now - timedelta(seconds=window)
|
||||
|
||||
# Clean old entries and count recent
|
||||
await execute(
|
||||
"DELETE FROM rate_limits WHERE key = ? AND timestamp < ?",
|
||||
(key, window_start.isoformat())
|
||||
)
|
||||
|
||||
result = await fetch_one(
|
||||
"SELECT COUNT(*) as count FROM rate_limits WHERE key = ? AND timestamp > ?",
|
||||
(key, window_start.isoformat())
|
||||
)
|
||||
count = result["count"] if result else 0
|
||||
|
||||
info = {
|
||||
"limit": limit,
|
||||
"remaining": max(0, limit - count - 1),
|
||||
"reset": int((window_start + timedelta(seconds=window)).timestamp()),
|
||||
}
|
||||
|
||||
if count >= limit:
|
||||
return False, info
|
||||
|
||||
# Record this request
|
||||
await execute(
|
||||
"INSERT INTO rate_limits (key, timestamp) VALUES (?, ?)",
|
||||
(key, now.isoformat())
|
||||
)
|
||||
|
||||
return True, info
|
||||
|
||||
|
||||
def rate_limit(limit: int = None, window: int = None, key_func=None):
|
||||
"""Decorator for rate limiting routes."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if key_func:
|
||||
key = key_func()
|
||||
else:
|
||||
key = f"ip:{request.remote_addr}"
|
||||
|
||||
allowed, info = await check_rate_limit(key, limit, window)
|
||||
|
||||
if not allowed:
|
||||
response = {"error": "Rate limit exceeded", **info}
|
||||
return response, 429
|
||||
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
|
||||
# =============================================================================
|
||||
# Request ID Tracking
|
||||
# =============================================================================
|
||||
|
||||
request_id_var: ContextVar[str] = ContextVar("request_id", default="")
|
||||
|
||||
|
||||
def get_request_id() -> str:
|
||||
"""Get current request ID."""
|
||||
return request_id_var.get()
|
||||
|
||||
|
||||
def setup_request_id(app):
|
||||
"""Setup request ID middleware."""
|
||||
@app.before_request
|
||||
async def set_request_id():
|
||||
rid = request.headers.get("X-Request-ID") or secrets.token_hex(8)
|
||||
request_id_var.set(rid)
|
||||
g.request_id = rid
|
||||
|
||||
@app.after_request
|
||||
async def add_request_id_header(response):
|
||||
response.headers["X-Request-ID"] = get_request_id()
|
||||
return response
|
||||
|
||||
# =============================================================================
|
||||
# Webhook Signature Verification
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def verify_hmac_signature(payload: bytes, signature: str, secret: str) -> bool:
|
||||
"""Verify HMAC-SHA256 webhook signature."""
|
||||
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(signature, expected)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Soft Delete Helpers
|
||||
# =============================================================================
|
||||
|
||||
async def soft_delete(table: str, id: int) -> bool:
|
||||
"""Mark record as deleted."""
|
||||
result = await execute(
|
||||
f"UPDATE {table} SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL",
|
||||
(datetime.utcnow().isoformat(), id)
|
||||
)
|
||||
return result > 0
|
||||
|
||||
|
||||
async def restore(table: str, id: int) -> bool:
|
||||
"""Restore soft-deleted record."""
|
||||
result = await execute(
|
||||
f"UPDATE {table} SET deleted_at = NULL WHERE id = ?",
|
||||
(id,)
|
||||
)
|
||||
return result > 0
|
||||
|
||||
|
||||
async def hard_delete(table: str, id: int) -> bool:
|
||||
"""Permanently delete record."""
|
||||
result = await execute(f"DELETE FROM {table} WHERE id = ?", (id,))
|
||||
return result > 0
|
||||
|
||||
|
||||
async def purge_deleted(table: str, days: int = 30) -> int:
|
||||
"""Purge records deleted more than X days ago."""
|
||||
cutoff = (datetime.utcnow() - timedelta(days=days)).isoformat()
|
||||
return await execute(
|
||||
f"DELETE FROM {table} WHERE deleted_at IS NOT NULL AND deleted_at < ?",
|
||||
(cutoff,)
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Paddle Product Lookup
|
||||
# =============================================================================
|
||||
|
||||
async def get_paddle_price(key: str) -> str | None:
|
||||
"""Look up a Paddle price ID by product key from the paddle_products table."""
|
||||
row = await fetch_one(
|
||||
"SELECT paddle_price_id FROM paddle_products WHERE key = ?", (key,)
|
||||
)
|
||||
return row["paddle_price_id"] if row else None
|
||||
|
||||
|
||||
async def get_all_paddle_prices() -> dict[str, str]:
|
||||
"""Load all Paddle price IDs as a {key: price_id} dict."""
|
||||
rows = await fetch_all("SELECT key, paddle_price_id FROM paddle_products")
|
||||
return {r["key"]: r["paddle_price_id"] for r in rows}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Text Utilities
|
||||
# =============================================================================
|
||||
|
||||
def slugify(text: str, max_length_chars: int = 80) -> str:
|
||||
"""Convert text to URL-safe slug."""
|
||||
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||
text = re.sub(r"[^\w\s-]", "", text.lower())
|
||||
text = re.sub(r"[-\s]+", "-", text).strip("-")
|
||||
return text[:max_length_chars]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# A/B Testing
|
||||
# =============================================================================
|
||||
|
||||
def _has_functional_consent() -> bool:
|
||||
"""Return True if the visitor has accepted functional cookies."""
|
||||
return "functional" in request.cookies.get("cookie_consent", "")
|
||||
|
||||
|
||||
def ab_test(experiment: str, variants: tuple = ("control", "treatment")):
|
||||
"""Assign visitor to an A/B test variant, tag Umami pageviews.
|
||||
|
||||
Only persists the variant cookie when the visitor has given functional
|
||||
cookie consent. Without consent a random variant is picked per-request
|
||||
(so the page renders fine and Umami is tagged), but no cookie is set.
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def wrapper(*args, **kwargs):
|
||||
cookie_key = f"ab_{experiment}"
|
||||
has_consent = _has_functional_consent()
|
||||
|
||||
assigned = request.cookies.get(cookie_key) if has_consent else None
|
||||
if assigned not in variants:
|
||||
assigned = random.choice(variants)
|
||||
|
||||
g.ab_variant = assigned
|
||||
g.ab_tag = f"{experiment}-{assigned}"
|
||||
|
||||
response = await make_response(await f(*args, **kwargs))
|
||||
if has_consent:
|
||||
response.set_cookie(cookie_key, assigned, max_age=30 * 24 * 60 * 60)
|
||||
return response
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def waitlist_gate(template: str, **extra_context):
|
||||
"""Parameterized decorator that intercepts GET requests when WAITLIST_MODE is enabled.
|
||||
|
||||
If WAITLIST_MODE is true and the request is a GET, renders the given template
|
||||
instead of calling the wrapped function. POST requests and non-waitlist mode
|
||||
always pass through.
|
||||
|
||||
Args:
|
||||
template: Template path to render in waitlist mode (e.g., "waitlist.html")
|
||||
**extra_context: Additional context variables to pass to template.
|
||||
Values can be callables (evaluated at request time) or static.
|
||||
|
||||
Usage:
|
||||
@bp.route("/signup", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
@waitlist_gate("waitlist.html", plan=lambda: request.args.get("plan", "free"))
|
||||
async def signup():
|
||||
# POST handling and normal signup code here
|
||||
...
|
||||
"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
if config.WAITLIST_MODE and request.method == "GET":
|
||||
ctx = {}
|
||||
for key, val in extra_context.items():
|
||||
ctx[key] = val() if callable(val) else val
|
||||
return await render_template(template, **ctx)
|
||||
return await f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
206
web/src/padelnomics/credits.py
Normal file
206
web/src/padelnomics/credits.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""
|
||||
Credit system: balance tracking, lead unlocking, and ledger management.
|
||||
|
||||
All balance mutations go through this module to keep credit_ledger (source of truth)
|
||||
and suppliers.credit_balance (denormalized cache) in sync within a single transaction.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from .core import execute, fetch_all, fetch_one, transaction
|
||||
|
||||
# Credit cost per heat tier
|
||||
HEAT_CREDIT_COSTS = {"hot": 35, "warm": 20, "cool": 8}
|
||||
|
||||
# Monthly credits by supplier plan
|
||||
PLAN_MONTHLY_CREDITS = {"growth": 30, "pro": 100}
|
||||
|
||||
# Credit pack prices (amount -> EUR cents, for reference)
|
||||
CREDIT_PACKS = {25: 99, 50: 179, 100: 329, 250: 749}
|
||||
|
||||
|
||||
class InsufficientCredits(Exception):
|
||||
"""Raised when a supplier doesn't have enough credits."""
|
||||
|
||||
def __init__(self, balance: int, required: int):
|
||||
self.balance = balance
|
||||
self.required = required
|
||||
super().__init__(f"Need {required} credits, have {balance}")
|
||||
|
||||
|
||||
async def get_balance(supplier_id: int) -> int:
|
||||
"""Get current credit balance for a supplier."""
|
||||
row = await fetch_one(
|
||||
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
||||
)
|
||||
return row["credit_balance"] if row else 0
|
||||
|
||||
|
||||
async def add_credits(
|
||||
supplier_id: int,
|
||||
amount: int,
|
||||
event_type: str,
|
||||
reference_id: int = None,
|
||||
note: str = None,
|
||||
) -> int:
|
||||
"""Add credits to a supplier. Returns new balance."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
async with transaction() as db:
|
||||
row = await db.execute_fetchall(
|
||||
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
||||
)
|
||||
current = row[0][0] if row else 0
|
||||
new_balance = current + amount
|
||||
|
||||
await db.execute(
|
||||
"""INSERT INTO credit_ledger
|
||||
(supplier_id, delta, balance_after, event_type, reference_id, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(supplier_id, amount, new_balance, event_type, reference_id, note, now),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE suppliers SET credit_balance = ? WHERE id = ?",
|
||||
(new_balance, supplier_id),
|
||||
)
|
||||
return new_balance
|
||||
|
||||
|
||||
async def spend_credits(
|
||||
supplier_id: int,
|
||||
amount: int,
|
||||
event_type: str,
|
||||
reference_id: int = None,
|
||||
note: str = None,
|
||||
) -> int:
|
||||
"""Spend credits from a supplier. Returns new balance. Raises InsufficientCredits."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
async with transaction() as db:
|
||||
row = await db.execute_fetchall(
|
||||
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
||||
)
|
||||
current = row[0][0] if row else 0
|
||||
|
||||
if current < amount:
|
||||
raise InsufficientCredits(current, amount)
|
||||
|
||||
new_balance = current - amount
|
||||
await db.execute(
|
||||
"""INSERT INTO credit_ledger
|
||||
(supplier_id, delta, balance_after, event_type, reference_id, note, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(supplier_id, -amount, new_balance, event_type, reference_id, note, now),
|
||||
)
|
||||
await db.execute(
|
||||
"UPDATE suppliers SET credit_balance = ? WHERE id = ?",
|
||||
(new_balance, supplier_id),
|
||||
)
|
||||
return new_balance
|
||||
|
||||
|
||||
async def already_unlocked(supplier_id: int, lead_id: int) -> bool:
|
||||
"""Check if a supplier has already unlocked a lead."""
|
||||
row = await fetch_one(
|
||||
"SELECT 1 FROM lead_forwards WHERE supplier_id = ? AND lead_id = ?",
|
||||
(supplier_id, lead_id),
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
async def unlock_lead(supplier_id: int, lead_id: int) -> dict:
|
||||
"""Unlock a lead for a supplier. Atomic: check, spend, insert forward, increment unlock_count."""
|
||||
if await already_unlocked(supplier_id, lead_id):
|
||||
raise ValueError("Lead already unlocked by this supplier")
|
||||
|
||||
lead = await fetch_one("SELECT * FROM lead_requests WHERE id = ?", (lead_id,))
|
||||
if not lead:
|
||||
raise ValueError("Lead not found")
|
||||
|
||||
cost = lead["credit_cost"] or compute_credit_cost(lead)
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
async with transaction() as db:
|
||||
# Check balance
|
||||
row = await db.execute_fetchall(
|
||||
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier_id,)
|
||||
)
|
||||
current = row[0][0] if row else 0
|
||||
if current < cost:
|
||||
raise InsufficientCredits(current, cost)
|
||||
|
||||
new_balance = current - cost
|
||||
|
||||
# Insert lead forward
|
||||
cursor = await db.execute(
|
||||
"""INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, created_at)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(lead_id, supplier_id, cost, now),
|
||||
)
|
||||
forward_id = cursor.lastrowid
|
||||
|
||||
# Record in ledger
|
||||
await db.execute(
|
||||
"""INSERT INTO credit_ledger
|
||||
(supplier_id, delta, balance_after, event_type, reference_id, note, created_at)
|
||||
VALUES (?, ?, ?, 'lead_unlock', ?, ?, ?)""",
|
||||
(supplier_id, -cost, new_balance, forward_id,
|
||||
f"Unlocked lead #{lead_id}", now),
|
||||
)
|
||||
|
||||
# Update supplier balance
|
||||
await db.execute(
|
||||
"UPDATE suppliers SET credit_balance = ? WHERE id = ?",
|
||||
(new_balance, supplier_id),
|
||||
)
|
||||
|
||||
# Increment unlock count on lead
|
||||
await db.execute(
|
||||
"UPDATE lead_requests SET unlock_count = unlock_count + 1 WHERE id = ?",
|
||||
(lead_id,),
|
||||
)
|
||||
|
||||
return {
|
||||
"forward_id": forward_id,
|
||||
"credit_cost": cost,
|
||||
"new_balance": new_balance,
|
||||
"lead": dict(lead),
|
||||
}
|
||||
|
||||
|
||||
def compute_credit_cost(lead: dict) -> int:
|
||||
"""Compute credit cost from lead heat score."""
|
||||
heat = (lead.get("heat_score") or "cool").lower()
|
||||
return HEAT_CREDIT_COSTS.get(heat, HEAT_CREDIT_COSTS["cool"])
|
||||
|
||||
|
||||
async def monthly_credit_refill(supplier_id: int) -> int:
|
||||
"""Refill monthly credits for a supplier. Returns new balance."""
|
||||
row = await fetch_one(
|
||||
"SELECT monthly_credits, tier FROM suppliers WHERE id = ?", (supplier_id,)
|
||||
)
|
||||
if not row or not row["monthly_credits"]:
|
||||
return 0
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
new_balance = await add_credits(
|
||||
supplier_id,
|
||||
row["monthly_credits"],
|
||||
"monthly_allocation",
|
||||
note=f"Monthly refill ({row['tier']} plan)",
|
||||
)
|
||||
await execute(
|
||||
"UPDATE suppliers SET last_credit_refill = ? WHERE id = ?",
|
||||
(now, supplier_id),
|
||||
)
|
||||
return new_balance
|
||||
|
||||
|
||||
async def get_ledger(supplier_id: int, limit: int = 50) -> list[dict]:
|
||||
"""Get credit ledger entries for a supplier."""
|
||||
return await fetch_all(
|
||||
"""SELECT cl.*, lf.lead_id
|
||||
FROM credit_ledger cl
|
||||
LEFT JOIN lead_forwards lf ON cl.reference_id = lf.id AND cl.event_type = 'lead_unlock'
|
||||
WHERE cl.supplier_id = ?
|
||||
ORDER BY cl.created_at DESC LIMIT ?""",
|
||||
(supplier_id, limit),
|
||||
)
|
||||
78
web/src/padelnomics/dashboard/routes.py
Normal file
78
web/src/padelnomics/dashboard/routes.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Dashboard domain: user dashboard and settings.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||
|
||||
from ..auth.routes import login_required, update_user
|
||||
from ..core import csrf_protect, fetch_one, soft_delete
|
||||
from ..i18n import get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
"dashboard",
|
||||
__name__,
|
||||
template_folder=str(Path(__file__).parent / "templates"),
|
||||
url_prefix="/dashboard",
|
||||
)
|
||||
|
||||
|
||||
async def get_user_stats(user_id: int) -> dict:
|
||||
scenarios = await fetch_one(
|
||||
"SELECT COUNT(*) as count FROM scenarios WHERE user_id = ? AND deleted_at IS NULL",
|
||||
(user_id,),
|
||||
)
|
||||
leads = await fetch_one(
|
||||
"SELECT COUNT(*) as count FROM lead_requests WHERE user_id = ?",
|
||||
(user_id,),
|
||||
)
|
||||
return {
|
||||
"scenarios": scenarios["count"] if scenarios else 0,
|
||||
"leads": leads["count"] if leads else 0,
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
async def index():
|
||||
# Supplier users go straight to the supplier dashboard
|
||||
supplier = await fetch_one(
|
||||
"SELECT id FROM suppliers WHERE claimed_by = ? AND tier IN ('growth', 'pro')",
|
||||
(g.user["id"],),
|
||||
)
|
||||
if supplier:
|
||||
return redirect(url_for("suppliers.dashboard"))
|
||||
|
||||
stats = await get_user_stats(g.user["id"])
|
||||
return await render_template("index.html", stats=stats)
|
||||
|
||||
|
||||
@bp.route("/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@csrf_protect
|
||||
async def settings():
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
await update_user(
|
||||
g.user["id"],
|
||||
name=form.get("name", "").strip() or None,
|
||||
updated_at=datetime.utcnow().isoformat(),
|
||||
)
|
||||
t = get_translations(g.get("lang") or "en")
|
||||
await flash(t["dash_settings_saved"], "success")
|
||||
return redirect(url_for("dashboard.settings"))
|
||||
|
||||
return await render_template("settings.html")
|
||||
|
||||
|
||||
@bp.route("/delete-account", methods=["POST"])
|
||||
@login_required
|
||||
@csrf_protect
|
||||
async def delete_account():
|
||||
from quart import session
|
||||
await soft_delete("users", g.user["id"])
|
||||
session.clear()
|
||||
t = get_translations(g.lang)
|
||||
await flash(t["dash_account_deleted"], "info")
|
||||
return redirect(url_for("public.landing"))
|
||||
34
web/src/padelnomics/dashboard/templates/index.html
Normal file
34
web/src/padelnomics/dashboard/templates/index.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t.dash_page_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<h1 class="text-2xl mb-1">{{ t.dash_h1 }}</h1>
|
||||
<p class="text-slate mb-8">{{ t.dash_welcome }}{% if user.name %}, {{ user.name }}{% endif %}!</p>
|
||||
|
||||
<div class="grid-3 mb-10">
|
||||
<div class="card text-center">
|
||||
<p class="card-header">{{ t.dash_saved_scenarios }}</p>
|
||||
<p class="text-3xl font-bold text-navy metric">{{ stats.scenarios }}</p>
|
||||
<p class="text-xs text-slate mt-1">{{ t.dash_no_limits }}</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<p class="card-header">{{ t.dash_lead_requests }}</p>
|
||||
<p class="text-3xl font-bold text-navy metric">{{ stats.leads }}</p>
|
||||
<p class="text-xs text-slate mt-1">{{ t.dash_lead_requests_sub }}</p>
|
||||
</div>
|
||||
<div class="card text-center">
|
||||
<p class="card-header">{{ t.dash_plan }}</p>
|
||||
<p class="text-3xl font-bold text-navy">{{ t.dash_plan_free }}</p>
|
||||
<p class="text-xs text-slate mt-1">{{ t.dash_plan_free_sub }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl mb-4">{{ t.dash_quick_actions }}</h2>
|
||||
<div class="grid-3">
|
||||
<a href="{{ url_for('planner.index') }}" class="btn text-center">{{ t.dash_open_planner }}</a>
|
||||
<a href="{{ url_for('leads.suppliers') }}" class="btn-outline text-center">{{ t.dash_get_quotes }}</a>
|
||||
<a href="{{ url_for('dashboard.settings') }}" class="btn-outline text-center">{{ t.dash_settings }}</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
46
web/src/padelnomics/dashboard/templates/settings.html
Normal file
46
web/src/padelnomics/dashboard/templates/settings.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t.dash_settings_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<h1 class="text-2xl mb-8">{{ t.dash_settings_h1 }}</h1>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-xl mb-4">{{ t.dash_profile }}</h2>
|
||||
<div class="card">
|
||||
<form method="post" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div>
|
||||
<label for="email" class="form-label">{{ t.dash_email_label }}</label>
|
||||
<input type="email" id="email" value="{{ user.email }}" class="form-input bg-soft-white" disabled>
|
||||
<p class="form-hint">{{ t.dash_email_hint }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="name" class="form-label">{{ t.dash_name_label }}</label>
|
||||
<input type="text" id="name" name="name" value="{{ user.name or '' }}" placeholder="{{ t.dash_name_placeholder }}" class="form-input">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">{{ t.dash_save_changes }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="text-xl mb-4">{{ t.dash_danger_zone }}</h2>
|
||||
<div class="card border-danger/30">
|
||||
<p class="text-slate-dark mb-4">{{ t.dash_delete_warning }}</p>
|
||||
|
||||
<details>
|
||||
<summary class="cursor-pointer text-sm font-semibold text-danger">{{ t.dash_delete_account }}</summary>
|
||||
<p class="text-sm text-slate-dark mt-3 mb-3">{{ t.dash_delete_confirm }}</p>
|
||||
<form method="post" action="{{ url_for('dashboard.delete_account') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-danger btn-sm">{{ t.dash_delete_btn }}</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
0
web/src/padelnomics/directory/__init__.py
Normal file
0
web/src/padelnomics/directory/__init__.py
Normal file
344
web/src/padelnomics/directory/routes.py
Normal file
344
web/src/padelnomics/directory/routes.py
Normal file
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
Supplier directory: public, searchable listing of padel court suppliers.
|
||||
"""
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, g, make_response, redirect, render_template, request, url_for
|
||||
|
||||
from ..core import csrf_protect, execute, fetch_all, fetch_one
|
||||
from ..i18n import get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
"directory",
|
||||
__name__,
|
||||
url_prefix="/directory",
|
||||
template_folder=str(Path(__file__).parent / "templates"),
|
||||
)
|
||||
|
||||
COUNTRY_LABELS = {
|
||||
"DE": "Germany", "ES": "Spain", "IT": "Italy", "FR": "France",
|
||||
"PT": "Portugal", "GB": "United Kingdom", "NL": "Netherlands",
|
||||
"BE": "Belgium", "SE": "Sweden", "DK": "Denmark", "FI": "Finland",
|
||||
"NO": "Norway", "AT": "Austria", "SI": "Slovenia", "IS": "Iceland",
|
||||
"CH": "Switzerland", "EE": "Estonia",
|
||||
"US": "United States", "CA": "Canada",
|
||||
"MX": "Mexico", "BR": "Brazil", "AR": "Argentina",
|
||||
"AE": "UAE", "SA": "Saudi Arabia", "TR": "Turkey",
|
||||
"CN": "China", "IN": "India", "SG": "Singapore",
|
||||
"ID": "Indonesia", "TH": "Thailand", "AU": "Australia",
|
||||
"ZA": "South Africa", "EG": "Egypt",
|
||||
}
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
"manufacturer": "Manufacturer",
|
||||
"turnkey": "Turnkey Provider",
|
||||
"consultant": "Consultant",
|
||||
"hall_builder": "Hall Builder",
|
||||
"turf": "Turf / Surfaces",
|
||||
"lighting": "Lighting",
|
||||
"software": "Software",
|
||||
"industry_body": "Industry Body",
|
||||
"franchise": "Franchise / Operator",
|
||||
}
|
||||
|
||||
REGION_LABELS = {
|
||||
"Europe": "Europe",
|
||||
"North America": "North America",
|
||||
"Latin America": "Latin America",
|
||||
"Middle East": "Middle East",
|
||||
"Asia Pacific": "Asia Pacific",
|
||||
"Africa": "Africa",
|
||||
}
|
||||
|
||||
|
||||
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):
|
||||
"""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()
|
||||
|
||||
params: list = []
|
||||
wheres: list[str] = []
|
||||
|
||||
if q:
|
||||
terms = [t for t in q.split() if t]
|
||||
if terms:
|
||||
fts_q = " ".join(t + "*" for t in terms)
|
||||
wheres.append(
|
||||
"s.id IN (SELECT rowid FROM suppliers_fts WHERE suppliers_fts MATCH ?)"
|
||||
)
|
||||
params.append(fts_q)
|
||||
|
||||
if country:
|
||||
wheres.append("s.country_code = ?")
|
||||
params.append(country)
|
||||
|
||||
if category:
|
||||
wheres.append("s.category = ?")
|
||||
params.append(category)
|
||||
|
||||
if region:
|
||||
wheres.append("s.region = ?")
|
||||
params.append(region)
|
||||
|
||||
where = " AND ".join(wheres) if wheres else "1=1"
|
||||
|
||||
count_row = await fetch_one(
|
||||
f"SELECT COUNT(*) as cnt FROM suppliers s WHERE {where}",
|
||||
tuple(params),
|
||||
)
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
offset = (page - 1) * per_page
|
||||
# Tier-based ordering: sticky first, then pro > growth > free, then name
|
||||
order_params = params + [now, country or "", per_page, offset]
|
||||
suppliers = await fetch_all(
|
||||
f"""SELECT * FROM suppliers s WHERE {where}
|
||||
ORDER BY
|
||||
CASE WHEN s.sticky_until > ? AND (s.sticky_country IS NULL OR s.sticky_country = '' OR s.sticky_country = ?) THEN 0 ELSE 1 END,
|
||||
CASE s.tier WHEN 'pro' THEN 0 WHEN 'growth' THEN 1 WHEN 'basic' THEN 2 ELSE 3 END,
|
||||
s.name
|
||||
LIMIT ? OFFSET ?""",
|
||||
tuple(order_params),
|
||||
)
|
||||
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
|
||||
# Fetch card_color boosts for displayed suppliers
|
||||
card_colors = {}
|
||||
if suppliers:
|
||||
supplier_ids = [s["id"] for s in suppliers]
|
||||
placeholders = ",".join("?" * len(supplier_ids))
|
||||
color_rows = await fetch_all(
|
||||
f"""SELECT supplier_id, metadata FROM supplier_boosts
|
||||
WHERE supplier_id IN ({placeholders})
|
||||
AND boost_type = 'card_color' AND status = 'active'""",
|
||||
tuple(supplier_ids),
|
||||
)
|
||||
import json
|
||||
for row in color_rows:
|
||||
meta = {}
|
||||
if row["metadata"]:
|
||||
try:
|
||||
meta = json.loads(row["metadata"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if meta.get("color"):
|
||||
card_colors[row["supplier_id"]] = meta["color"]
|
||||
|
||||
return {
|
||||
"suppliers": suppliers,
|
||||
"q": q,
|
||||
"country": country,
|
||||
"category": category,
|
||||
"region": region,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"total": total,
|
||||
"now": now,
|
||||
"country_labels": country_labels,
|
||||
"category_labels": cat_labels,
|
||||
"region_labels": region_labels,
|
||||
"card_colors": card_colors,
|
||||
}
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
async def index():
|
||||
q = request.args.get("q", "").strip()
|
||||
country = request.args.get("country", "")
|
||||
category = request.args.get("category", "")
|
||||
region = request.args.get("region", "")
|
||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||
|
||||
ctx = await _build_directory_query(q, country, category, region, page)
|
||||
|
||||
country_counts = await fetch_all(
|
||||
"SELECT country_code, COUNT(*) as cnt FROM suppliers"
|
||||
" GROUP BY country_code ORDER BY cnt DESC"
|
||||
)
|
||||
|
||||
category_counts = await fetch_all(
|
||||
"SELECT category, COUNT(*) as cnt FROM suppliers"
|
||||
" GROUP BY category ORDER BY cnt DESC"
|
||||
)
|
||||
|
||||
total_suppliers = await fetch_one("SELECT COUNT(*) as cnt FROM suppliers")
|
||||
total_countries = await fetch_one(
|
||||
"SELECT COUNT(DISTINCT country_code) as cnt FROM suppliers"
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"directory.html",
|
||||
**ctx,
|
||||
country_counts=country_counts,
|
||||
category_counts=category_counts,
|
||||
total_suppliers=total_suppliers["cnt"] if total_suppliers else 0,
|
||||
total_countries=total_countries["cnt"] if total_countries else 0,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<slug>")
|
||||
async def supplier_detail(slug: str):
|
||||
"""Public supplier profile page."""
|
||||
supplier = await fetch_one("SELECT * FROM suppliers WHERE slug = ?", (slug,))
|
||||
if not supplier:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
|
||||
# Get active boosts
|
||||
boosts = await fetch_all(
|
||||
"SELECT boost_type FROM supplier_boosts WHERE supplier_id = ? AND status = 'active'",
|
||||
(supplier["id"],),
|
||||
)
|
||||
active_boosts = [b["boost_type"] for b in boosts]
|
||||
|
||||
# Parse services_offered into list
|
||||
raw_services = (supplier.get("services_offered") or "").strip()
|
||||
services_list = [s.strip() for s in raw_services.split(",") if s.strip()] if raw_services else []
|
||||
|
||||
# Build social links dict
|
||||
social_links = {
|
||||
"linkedin": supplier.get("linkedin_url") or "",
|
||||
"instagram": supplier.get("instagram_url") or "",
|
||||
"youtube": supplier.get("youtube_url") or "",
|
||||
}
|
||||
|
||||
# Enquiry count (Basic+)
|
||||
enquiry_count = 0
|
||||
if supplier.get("tier") in ("basic", "growth", "pro"):
|
||||
row = await fetch_one(
|
||||
"SELECT COUNT(*) as cnt FROM supplier_enquiries WHERE supplier_id = ?",
|
||||
(supplier["id"],),
|
||||
)
|
||||
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(
|
||||
"supplier_detail.html",
|
||||
supplier=supplier,
|
||||
active_boosts=active_boosts,
|
||||
country_labels=country_labels,
|
||||
category_labels=cat_labels,
|
||||
region_labels=region_labels,
|
||||
services_list=services_list,
|
||||
social_links=social_links,
|
||||
enquiry_count=enquiry_count,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<slug>/enquiry", methods=["POST"])
|
||||
@csrf_protect
|
||||
async def supplier_enquiry(slug: str):
|
||||
"""Handle enquiry form submission for Basic+ supplier listings."""
|
||||
supplier = await fetch_one(
|
||||
"SELECT * FROM suppliers WHERE slug = ? AND tier IN ('basic', 'growth', 'pro')",
|
||||
(slug,),
|
||||
)
|
||||
if not supplier:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
|
||||
form = await request.form
|
||||
contact_name = (form.get("contact_name", "") or "").strip()
|
||||
contact_email = (form.get("contact_email", "") or "").strip().lower()
|
||||
message = (form.get("message", "") or "").strip()
|
||||
|
||||
errors = []
|
||||
if not contact_name:
|
||||
errors.append("Name is required.")
|
||||
if not contact_email or "@" not in contact_email:
|
||||
errors.append("Valid email is required.")
|
||||
if not message:
|
||||
errors.append("Message is required.")
|
||||
|
||||
if errors:
|
||||
return await render_template(
|
||||
"partials/enquiry_result.html",
|
||||
success=False,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# Rate-limit: max 5 enquiries per email per 24 h
|
||||
row = await fetch_one(
|
||||
"""SELECT COUNT(*) as cnt FROM supplier_enquiries
|
||||
WHERE contact_email = ? AND created_at >= datetime('now', '-1 day')""",
|
||||
(contact_email,),
|
||||
)
|
||||
if row and row["cnt"] >= 5:
|
||||
return await render_template(
|
||||
"partials/enquiry_result.html",
|
||||
success=False,
|
||||
errors=["Too many enquiries from this address. Please wait 24 hours."],
|
||||
)
|
||||
|
||||
await execute(
|
||||
"""INSERT INTO supplier_enquiries (supplier_id, contact_name, contact_email, message)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(supplier["id"], contact_name, contact_email, message),
|
||||
)
|
||||
|
||||
# Enqueue email to supplier
|
||||
if supplier.get("contact_email"):
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_supplier_enquiry_email", {
|
||||
"supplier_id": supplier["id"],
|
||||
"supplier_name": supplier["name"],
|
||||
"supplier_email": supplier["contact_email"],
|
||||
"contact_name": contact_name,
|
||||
"contact_email": contact_email,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
return await render_template(
|
||||
"partials/enquiry_result.html",
|
||||
success=True,
|
||||
supplier=supplier,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/<slug>/website")
|
||||
async def supplier_website(slug: str):
|
||||
"""Redirect to supplier website — tracked as Umami event."""
|
||||
supplier = await fetch_one("SELECT website FROM suppliers WHERE slug = ?", (slug,))
|
||||
if not supplier or not supplier["website"]:
|
||||
from quart import abort
|
||||
abort(404)
|
||||
url = supplier["website"]
|
||||
if not url.startswith("http"):
|
||||
url = "https://" + url
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@bp.route("/<slug>/quote")
|
||||
async def supplier_quote(slug: str):
|
||||
"""Redirect to quote request form — tracked as Umami event."""
|
||||
return redirect(url_for("leads.quote_request", supplier=slug))
|
||||
|
||||
|
||||
@bp.route("/results")
|
||||
async def results():
|
||||
"""HTMX endpoint — returns only the results partial."""
|
||||
q = request.args.get("q", "").strip()
|
||||
country = request.args.get("country", "")
|
||||
category = request.args.get("category", "")
|
||||
region = request.args.get("region", "")
|
||||
page = max(1, int(request.args.get("page", "1") or "1"))
|
||||
|
||||
ctx = await _build_directory_query(q, country, category, region, page)
|
||||
resp = await make_response(await render_template("partials/results.html", **ctx))
|
||||
resp.headers["X-Robots-Tag"] = "noindex"
|
||||
return resp
|
||||
363
web/src/padelnomics/directory/templates/directory.html
Normal file
363
web/src/padelnomics/directory/templates/directory.html
Normal file
@@ -0,0 +1,363 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.dir_page_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="{{ t.dir_page_meta_desc | tformat(count=total_suppliers, countries=total_countries) }}">
|
||||
<meta property="og:title" content="{{ t.dir_page_title }} - {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="{{ t.dir_page_og_desc | tformat(count=total_suppliers, countries=total_countries) }}">
|
||||
<style>
|
||||
:root {
|
||||
--dir-green: #15803D;
|
||||
--dir-green-mid: #16a34a;
|
||||
--dir-green-dark: #0D5228;
|
||||
--dir-blue: #1D4ED8;
|
||||
--dir-text-1: #111827;
|
||||
--dir-text-2: #6B7280;
|
||||
--dir-text-3: #9CA3AF;
|
||||
--dir-card-bg: #FFFFFF;
|
||||
--dir-card-bdr: #E6E2D8;
|
||||
--dir-r: 16px;
|
||||
--dir-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--dir-shadow-hov: 0 14px 36px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06);
|
||||
--dir-shadow-pro: 0 14px 36px rgba(21,128,61,0.13), 0 2px 8px rgba(21,128,61,0.08);
|
||||
}
|
||||
|
||||
.dir-hero { text-align: center; padding: 2rem 0 1.5rem; }
|
||||
.dir-hero h1 { font-size: 2rem; line-height: 1.3; }
|
||||
.dir-hero p { color: #64748B; margin-top: 0.5rem; }
|
||||
.dir-stats { display: flex; justify-content: center; gap: 2rem; margin-top: 1rem; font-size: 0.875rem; color: #64748B; }
|
||||
.dir-stats strong { color: #1E293B; }
|
||||
|
||||
.dir-search { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.dir-search input[type="search"] {
|
||||
flex: 1; min-width: 200px; padding: 10px 14px; border: 1px solid #CBD5E1;
|
||||
border-radius: 12px; font-size: 0.875rem;
|
||||
}
|
||||
.dir-search input[type="search"]:focus { outline: none; border-color: #1D4ED8; box-shadow: 0 0 0 3px rgba(29,78,216,0.1); }
|
||||
.dir-search select {
|
||||
padding: 10px 12px; border: 1px solid #CBD5E1; border-radius: 12px;
|
||||
font-size: 0.8125rem; background: white; min-width: 140px;
|
||||
}
|
||||
.dir-search button[type="submit"] {
|
||||
padding: 10px 20px; background: #1D4ED8; color: white; border: none;
|
||||
border-radius: 12px; font-weight: 600; cursor: pointer; font-size: 0.875rem;
|
||||
box-shadow: 0 2px 10px rgba(29,78,216,0.25);
|
||||
}
|
||||
|
||||
.dir-active-filters { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 1rem; }
|
||||
.dir-filter-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; background: #EFF6FF; border: 1px solid #BFDBFE;
|
||||
border-radius: 999px; font-size: 0.75rem; color: #1D4ED8;
|
||||
}
|
||||
.dir-filter-tag a { color: #1D4ED8; text-decoration: none; font-weight: 700; }
|
||||
|
||||
.dir-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.25rem; margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* ---- Base card ---- */
|
||||
.dir-card {
|
||||
background: var(--dir-card-bg);
|
||||
border: 1px solid var(--dir-card-bdr);
|
||||
border-radius: var(--dir-r);
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
box-shadow: var(--dir-shadow);
|
||||
}
|
||||
.dir-card:hover { transform: translateY(-3px); box-shadow: var(--dir-shadow-hov); }
|
||||
|
||||
/* ---- Cover media ---- */
|
||||
.dir-card__media {
|
||||
position: relative; width: 100%; aspect-ratio: 16 / 9; overflow: hidden;
|
||||
}
|
||||
.dir-card__media img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
transition: transform 0.45s ease;
|
||||
}
|
||||
.dir-card:hover .dir-card__media img { transform: scale(1.04); }
|
||||
.dir-card__media::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background: linear-gradient(to bottom, transparent 45%, rgba(0,0,0,0.45) 100%);
|
||||
pointer-events: none; z-index: 2;
|
||||
}
|
||||
|
||||
/* CSS court visualization (pro default placeholder) */
|
||||
.dir-card__media--court {
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 20%, rgba(74,222,128,0.15) 0%, transparent 60%),
|
||||
linear-gradient(160deg, #1a5c30 0%, #2d7a45 35%, #1e6338 65%, #143d22 100%);
|
||||
}
|
||||
.dir-card__media--court .court-lines {
|
||||
position: absolute; inset: 0; z-index: 1;
|
||||
background-image:
|
||||
linear-gradient(transparent calc(49.5% - 1px), rgba(255,255,255,0.92) calc(49.5% - 1px), rgba(255,255,255,0.92) calc(50.5% + 1px), transparent calc(50.5% + 1px)),
|
||||
linear-gradient(transparent calc(8% - 0.5px), rgba(255,255,255,0.65) calc(8% - 0.5px), rgba(255,255,255,0.65) calc(8% + 0.75px), transparent calc(8% + 0.75px)),
|
||||
linear-gradient(transparent calc(92% - 0.5px), rgba(255,255,255,0.65) calc(92% - 0.5px), rgba(255,255,255,0.65) calc(92% + 0.75px), transparent calc(92% + 0.75px)),
|
||||
linear-gradient(90deg, transparent calc(10% - 0.5px), rgba(255,255,255,0.55) calc(10% - 0.5px), rgba(255,255,255,0.55) calc(10% + 0.75px), transparent calc(10% + 0.75px)),
|
||||
linear-gradient(90deg, transparent calc(90% - 0.5px), rgba(255,255,255,0.55) calc(90% - 0.5px), rgba(255,255,255,0.55) calc(90% + 0.75px), transparent calc(90% + 0.75px));
|
||||
}
|
||||
.dir-card__media--court .court-lines::before {
|
||||
content: ''; position: absolute; left: 10%; right: 10%; top: calc(50% - 1.5px); height: 3px;
|
||||
background-image: repeating-linear-gradient(90deg, rgba(255,255,255,0.18), rgba(255,255,255,0.18) 3px, transparent 3px, transparent 8px);
|
||||
z-index: 2;
|
||||
}
|
||||
.dir-card__media--court .court-lines::after {
|
||||
content: ''; position: absolute; inset: 0;
|
||||
background-image: repeating-linear-gradient(0deg, transparent, transparent 5px, rgba(0,0,0,0.015) 5px, rgba(0,0,0,0.015) 6px);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Dark-green grid placeholder (paid tiers without cover photo) */
|
||||
.dir-card__media--placeholder {
|
||||
background: linear-gradient(135deg, #0f2218 0%, #1a3828 50%, #0d1e14 100%);
|
||||
}
|
||||
.dir-card__media--placeholder .ph-grid {
|
||||
position: absolute; inset: 0; z-index: 1;
|
||||
background-image:
|
||||
repeating-linear-gradient(0deg, transparent, transparent 39px, rgba(255,255,255,0.04) 39px, rgba(255,255,255,0.04) 40px),
|
||||
repeating-linear-gradient(90deg, transparent, transparent 39px, rgba(255,255,255,0.04) 39px, rgba(255,255,255,0.04) 40px);
|
||||
}
|
||||
.dir-card__media--placeholder .ph-label {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
z-index: 2; font-size: 1.625rem; font-weight: 800;
|
||||
color: rgba(255,255,255,0.07); letter-spacing: -0.02em; text-transform: uppercase;
|
||||
}
|
||||
/* Free tier placeholder — grey/desaturated */
|
||||
.dir-card--free .dir-card__media--placeholder {
|
||||
background: linear-gradient(135deg, #1f2937 0%, #374151 50%, #1f2937 100%);
|
||||
}
|
||||
|
||||
/* Example card media */
|
||||
.dir-card__media--example {
|
||||
background: linear-gradient(135deg, #1e2e4a 0%, #2d4270 50%, #1a273e 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.dir-card__media--example .example-icon {
|
||||
text-align: center; z-index: 3; position: relative;
|
||||
}
|
||||
.dir-card__media--example .example-icon svg { opacity: 0.25; }
|
||||
.dir-card__media--example .example-icon p {
|
||||
margin-top: 6px; font-size: 0.6875rem; font-weight: 600;
|
||||
color: rgba(255,255,255,0.25); letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ---- Overlays ---- */
|
||||
/* Category badge — frosted pill, top-right of media */
|
||||
.dir-card__cat {
|
||||
position: absolute; top: 10px; right: 10px; z-index: 5;
|
||||
font-size: 0.6375rem; font-weight: 700; letter-spacing: 0.03em;
|
||||
text-transform: uppercase; padding: 3.5px 9px; border-radius: 5px;
|
||||
backdrop-filter: blur(8px) saturate(180%); -webkit-backdrop-filter: blur(8px) saturate(180%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dir-card__cat--manufacturer { background: rgba(219,234,254,0.92); color: #1e40af; }
|
||||
.dir-card__cat--turnkey { background: rgba(220,252,231,0.92); color: #065f46; }
|
||||
.dir-card__cat--turf { background: rgba(254,243,199,0.92); color: #78350f; }
|
||||
.dir-card__cat--lighting { background: rgba(254,226,226,0.92); color: #991b1b; }
|
||||
.dir-card__cat--software { background: rgba(237,233,254,0.92); color: #4c1d95; }
|
||||
.dir-card__cat--hall_builder { background: rgba(255,237,213,0.92); color: #7c2d12; }
|
||||
.dir-card__cat--consultant { background: rgba(240,253,250,0.92); color: #134e4a; }
|
||||
.dir-card__cat--franchise { background: rgba(252,231,243,0.92); color: #831843; }
|
||||
.dir-card__cat--example { background: rgba(219,234,254,0.85); color: #1e40af; }
|
||||
|
||||
/* Featured ribbon — top-left */
|
||||
.dir-card__featured {
|
||||
position: absolute; top: 10px; left: 10px; z-index: 5;
|
||||
background: var(--dir-blue); color: white;
|
||||
font-size: 0.5875rem; font-weight: 700; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; padding: 3.5px 9px; border-radius: 5px;
|
||||
box-shadow: 0 2px 8px rgba(29,78,216,0.35);
|
||||
}
|
||||
|
||||
/* Logo avatar — body-relative so it straddles the media/body border */
|
||||
.dir-card__logo-wrap {
|
||||
position: absolute; top: -22px; left: 14px; z-index: 6;
|
||||
}
|
||||
.dir-card__logo,
|
||||
.dir-card__logo-ph {
|
||||
width: 44px; height: 44px; border-radius: 10px;
|
||||
border: 2.5px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.18);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.dir-card__logo { object-fit: contain; background: white; }
|
||||
.dir-card__logo-ph {
|
||||
background: #EFF6FF; color: var(--dir-blue);
|
||||
font-size: 0.875rem; font-weight: 800; letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* ---- Card body ---- */
|
||||
.dir-card__body {
|
||||
position: relative;
|
||||
padding: 30px 16px 16px; /* 30px top = logo overhang room */
|
||||
}
|
||||
.dir-card__name {
|
||||
font-size: 0.9375rem; font-weight: 700; color: var(--dir-text-1);
|
||||
line-height: 1.25; letter-spacing: -0.01em;
|
||||
}
|
||||
.dir-card__loc {
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
font-size: 0.75rem; color: var(--dir-text-2); margin-top: 3px;
|
||||
}
|
||||
.dir-card__loc svg { flex-shrink: 0; }
|
||||
.dir-card__stats {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
margin-top: 10px; padding-top: 10px; border-top: 1px solid #F1F5F9;
|
||||
}
|
||||
.dir-card__stat {
|
||||
display: flex; align-items: center; gap: 3.5px;
|
||||
font-size: 0.6875rem; font-weight: 600; color: var(--dir-text-2); white-space: nowrap;
|
||||
}
|
||||
.dir-card__stat svg { flex-shrink: 0; color: var(--dir-text-3); }
|
||||
.dir-card__stat--verified { color: var(--dir-green); }
|
||||
.dir-card__stat--verified svg { color: var(--dir-green); }
|
||||
.dir-card__tier-chip {
|
||||
font-size: 0.5875rem; font-weight: 700; letter-spacing: 0.05em;
|
||||
text-transform: uppercase; padding: 2.5px 7px; border-radius: 4px;
|
||||
}
|
||||
.dir-card__tier-chip--growth { background: #EFF6FF; color: #1e40af; }
|
||||
.dir-card__tier-chip--unverified { background: #F9FAFB; color: var(--dir-text-3); }
|
||||
.dir-card__desc {
|
||||
font-size: 0.8125rem; color: var(--dir-text-2); line-height: 1.55; margin-top: 8px;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden;
|
||||
}
|
||||
.dir-card__foot {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-top: 12px; padding-top: 12px; border-top: 1px solid #F1F5F9;
|
||||
}
|
||||
.dir-card__web {
|
||||
font-size: 0.75rem; color: var(--dir-text-3);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px;
|
||||
}
|
||||
.dir-card__action {
|
||||
font-size: 0.75rem; font-weight: 700; letter-spacing: 0.01em;
|
||||
white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.dir-card__action--quote { color: var(--dir-green); }
|
||||
.dir-card__action--claim { color: var(--dir-text-3); }
|
||||
.dir-card__action--example { color: var(--dir-blue); }
|
||||
|
||||
/* ---- Tier variants ---- */
|
||||
/* PRO — green top line + subtle green glow */
|
||||
.dir-card--pro::before {
|
||||
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2.5px;
|
||||
background: linear-gradient(90deg, var(--dir-green-dark), var(--dir-green-mid));
|
||||
z-index: 7; border-radius: var(--dir-r) var(--dir-r) 0 0;
|
||||
}
|
||||
.dir-card--pro {
|
||||
box-shadow: 0 1px 3px rgba(21,128,61,0.06), 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
.dir-card--pro:hover { box-shadow: var(--dir-shadow-pro); }
|
||||
|
||||
/* HIGHLIGHT — green ring (boost add-on) */
|
||||
.dir-card--highlight {
|
||||
box-shadow: 0 0 0 2px rgba(21,128,61,0.2), 0 6px 24px rgba(21,128,61,0.1);
|
||||
}
|
||||
.dir-card--highlight:hover {
|
||||
box-shadow: 0 0 0 2px rgba(21,128,61,0.3), var(--dir-shadow-pro);
|
||||
}
|
||||
|
||||
/* FREE — muted */
|
||||
.dir-card--free { opacity: 0.62; }
|
||||
.dir-card--free:hover {
|
||||
opacity: 0.82; transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* EXAMPLE — dashed CTA card */
|
||||
.dir-card--example { border: 2px dashed #93C5FD; background: #F0F7FF; }
|
||||
.dir-card--example::before { display: none; }
|
||||
|
||||
.dir-pagination { display: flex; justify-content: center; gap: 4px; margin: 2rem 0; }
|
||||
.dir-pagination a, .dir-pagination span {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 36px; height: 36px; border-radius: 10px; font-size: 0.8125rem;
|
||||
text-decoration: none; border: 1px solid #E2E8F0; color: #475569;
|
||||
}
|
||||
.dir-pagination span.current { background: #1D4ED8; color: white; border-color: #1D4ED8; }
|
||||
.dir-pagination a:hover { background: #F1F5F9; }
|
||||
|
||||
.dir-empty { text-align: center; padding: 3rem 1rem; color: #64748B; }
|
||||
.dir-empty h3 { color: #1E293B; margin-bottom: 0.5rem; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dir-search { flex-direction: column; }
|
||||
.dir-grid { grid-template-columns: 1fr; }
|
||||
.dir-stats { flex-direction: column; gap: 0.25rem; align-items: center; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page">
|
||||
<div class="dir-hero">
|
||||
<h1>{{ t.dir_heading }}</h1>
|
||||
<p>{{ t.dir_subheading | tformat(n=total_suppliers, c=total_countries) }}</p>
|
||||
<div class="dir-stats">
|
||||
<span><strong>{{ total_suppliers }}</strong> {{ t.dir_stat_suppliers }}</span>
|
||||
<span><strong>{{ total_countries }}</strong> {{ t.dir_stat_countries }}</span>
|
||||
<span><strong>{{ category_counts | length }}</strong> {{ t.dir_stat_categories }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Filters -->
|
||||
<form method="get" class="dir-search" id="dir-search-form">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="{{ t.dir_search_placeholder }}"
|
||||
hx-get="{{ url_for('directory.results') }}"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-target="#dir-results"
|
||||
hx-include="#dir-search-form">
|
||||
<select name="country"
|
||||
hx-get="{{ url_for('directory.results') }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#dir-results"
|
||||
hx-include="#dir-search-form">
|
||||
<option value="">{{ t.dir_filter_all_countries }}</option>
|
||||
{% for cc in country_counts %}
|
||||
<option value="{{ cc.country_code }}" {{ 'selected' if country == cc.country_code }}>{{ country_labels.get(cc.country_code, cc.country_code) }} ({{ cc.cnt }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="category"
|
||||
hx-get="{{ url_for('directory.results') }}"
|
||||
hx-trigger="change"
|
||||
hx-target="#dir-results"
|
||||
hx-include="#dir-search-form">
|
||||
<option value="">{{ t.dir_filter_all_categories }}</option>
|
||||
{% for cat in category_counts %}
|
||||
<option value="{{ cat.category }}" {{ 'selected' if category == cat.category }}>{{ category_labels.get(cat.category, cat.category) }} ({{ cat.cnt }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if region %}<input type="hidden" name="region" value="{{ region }}">{% endif %}
|
||||
<button type="submit">{{ t.dir_search_btn }}</button>
|
||||
</form>
|
||||
|
||||
<!-- Active filters -->
|
||||
{% if q or country or category or region %}
|
||||
<div class="dir-active-filters">
|
||||
{% if q %}<span class="dir-filter-tag">Search: "{{ q }}" <a href="{{ request.path }}?{{ request.query_string.decode().replace('q=' + q, 'q=') }}">×</a></span>{% endif %}
|
||||
{% if country %}<span class="dir-filter-tag">{{ country_labels.get(country, country) }} <a href="{{ request.path }}?q={{ q }}&category={{ category }}">×</a></span>{% endif %}
|
||||
{% if category %}<span class="dir-filter-tag">{{ category_labels.get(category, category) }} <a href="{{ request.path }}?q={{ q }}&country={{ country }}">×</a></span>{% endif %}
|
||||
<a href="{{ request.path }}" style="font-size:0.75rem;color:#64748B;text-decoration:none;padding:4px 8px">{{ t.dir_filter_clear }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Results (swappable via HTMX) -->
|
||||
<div id="dir-results">
|
||||
{% include "partials/results.html" %}
|
||||
</div>
|
||||
|
||||
<!-- Supplier CTA -->
|
||||
<section style="text-align:center;padding:2rem 0;border-top:1px solid #E2E8F0;margin-top:1rem">
|
||||
<h2 style="font-size:1.25rem;margin-bottom:0.5rem">{{ t.dir_cta_heading }}</h2>
|
||||
<p style="color:#64748B;font-size:0.875rem;margin-bottom:1rem">{{ t.dir_cta_subheading }}</p>
|
||||
<a href="{{ url_for('public.suppliers') }}" class="btn">{{ t.dir_cta_btn }}</a>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% if success %}
|
||||
<div style="background:#DCFCE7;border:1px solid #BBF7D0;border-radius:12px;padding:1.25rem 1.5rem;text-align:center">
|
||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">✓</div>
|
||||
<p style="font-weight:700;color:#16A34A;margin-bottom:4px">{{ t.enquiry_success_title }}</p>
|
||||
<p style="font-size:0.8125rem;color:#166534">
|
||||
{% if supplier and supplier.contact_email %}
|
||||
{{ t.enquiry_forwarded_msg | tformat(name=supplier.name) }}
|
||||
{% else %}
|
||||
{{ t.enquiry_received_msg }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="background:#FEF2F2;border:1px solid #FECACA;border-radius:12px;padding:1.25rem 1.5rem">
|
||||
<p style="font-weight:700;color:#DC2626;margin-bottom:0.5rem">{{ t.enquiry_error_title }}</p>
|
||||
<ul style="margin:0;padding-left:1.25rem;font-size:0.8125rem;color:#991B1B">
|
||||
{% for e in errors %}
|
||||
<li>{{ e }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
300
web/src/padelnomics/directory/templates/partials/results.html
Normal file
300
web/src/padelnomics/directory/templates/partials/results.html
Normal file
@@ -0,0 +1,300 @@
|
||||
<p style="font-size:0.8125rem;color:#64748B;margin-bottom:1rem">
|
||||
{% if total != 1 %}{{ t.dir_results_count_plural | tformat(shown=suppliers|length, total=total) }}{% else %}{{ t.dir_results_count_singular | tformat(shown=suppliers|length, total=total) }}{% endif %}
|
||||
{% if page > 1 %} (page {{ page }}){% endif %}
|
||||
</p>
|
||||
|
||||
{# Category abbrev for placeholder label #}
|
||||
{% set ph_labels = {
|
||||
'manufacturer': 'Mfr',
|
||||
'turnkey': 'Turn',
|
||||
'turf': 'Turf',
|
||||
'lighting': 'Lgts',
|
||||
'software': 'Sftw',
|
||||
'hall_builder': 'Hall',
|
||||
'consultant': 'Cons',
|
||||
'franchise': 'Frch',
|
||||
'industry_body': 'Ind',
|
||||
} %}
|
||||
|
||||
{# Logo placeholder background/color by category #}
|
||||
{% set logo_ph_styles = {
|
||||
'manufacturer': 'background:#DBEAFE;color:#1e40af',
|
||||
'turnkey': 'background:#D1FAE5;color:#065f46',
|
||||
'turf': 'background:#FEF3C7;color:#78350f',
|
||||
'lighting': 'background:#FEE2E2;color:#991b1b',
|
||||
'software': 'background:#EDE9FE;color:#4c1d95',
|
||||
'hall_builder': 'background:#FFEDD5;color:#7c2d12',
|
||||
'consultant': 'background:#CCFBF1;color:#134e4a',
|
||||
'franchise': 'background:#FCE7F3;color:#831843',
|
||||
} %}
|
||||
|
||||
{% if suppliers %}
|
||||
<div class="dir-grid">
|
||||
|
||||
{# Example card on first page with no active filters #}
|
||||
{% if page == 1 and not q and not country and not category %}
|
||||
<a href="{{ url_for('public.suppliers') }}" class="dir-card dir-card--example">
|
||||
<div class="dir-card__media dir-card__media--example">
|
||||
<div class="ph-grid"></div>
|
||||
<div class="example-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
<p>{{ t.dir_ex_photo }}</p>
|
||||
</div>
|
||||
<div class="dir-card__featured" style="background:#3B82F6">{{ t.dir_ex_badge }}</div>
|
||||
<div class="dir-card__cat dir-card__cat--example">{{ t.dir_ex_category }}</div>
|
||||
</div>
|
||||
<div class="dir-card__body">
|
||||
<div class="dir-card__logo-wrap">
|
||||
<div class="dir-card__logo-ph">?</div>
|
||||
</div>
|
||||
<h3 class="dir-card__name">{{ t.dir_ex_company }}</h3>
|
||||
<p class="dir-card__loc">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ t.dir_ex_location }}
|
||||
</p>
|
||||
<div class="dir-card__stats">
|
||||
<span class="dir-card__stat dir-card__stat--verified">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
{{ t.dir_card_verified }}
|
||||
</span>
|
||||
<span class="dir-card__stat">12 projects · 8 yrs</span>
|
||||
</div>
|
||||
<p class="dir-card__desc">{{ t.dir_ex_desc }}</p>
|
||||
<div class="dir-card__foot">
|
||||
<span></span>
|
||||
<span class="dir-card__action dir-card__action--example">{{ t.dir_ex_cta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for s in suppliers %}
|
||||
{% set logo_ph_style = logo_ph_styles.get(s.category, 'background:#EFF6FF;color:#1e40af') %}
|
||||
{% set ph_label = ph_labels.get(s.category, s.category[:4] | upper) %}
|
||||
|
||||
{# ---- Pro tier ---- #}
|
||||
{% if s.tier == 'pro' %}
|
||||
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}"
|
||||
class="dir-card dir-card--pro{% if s.sticky_until and s.sticky_until > now %} dir-card--sticky{% endif %}{% if s.highlight %} dir-card--highlight{% endif %}"{% if card_colors.get(s.id) %} style="border-color:{{ card_colors[s.id] }}"{% endif %}>
|
||||
{% if s.cover_image %}
|
||||
<div class="dir-card__media">
|
||||
<img src="{{ s.cover_image }}" alt="{{ s.name }}">
|
||||
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">{{ t.dir_card_featured }}</div>{% endif %}
|
||||
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="dir-card__media dir-card__media--court">
|
||||
<div class="court-lines"></div>
|
||||
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">{{ t.dir_card_featured }}</div>{% endif %}
|
||||
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="dir-card__body">
|
||||
<div class="dir-card__logo-wrap">
|
||||
{% if s.logo_file or s.logo_url %}
|
||||
<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">
|
||||
{% else %}
|
||||
<div class="dir-card__logo-ph" style="{{ logo_ph_style }}">{{ s.name[0] | upper }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3 class="dir-card__name">{{ s.name }}</h3>
|
||||
<p class="dir-card__loc">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}
|
||||
</p>
|
||||
<div class="dir-card__stats">
|
||||
<span class="dir-card__stat dir-card__stat--verified">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
{{ t.dir_card_verified }}
|
||||
</span>
|
||||
{% if s.project_count %}
|
||||
<span class="dir-card__stat">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
|
||||
{{ s.project_count }} projects
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if s.years_in_business %}
|
||||
<span class="dir-card__stat">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{{ s.years_in_business }} yrs
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if s.service_area %}
|
||||
<span class="dir-card__stat">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||
{{ s.service_area.split(',')[0] }}{% if s.service_area.split(',')|length > 1 %} +{{ s.service_area.split(',')|length - 1 }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if s.short_description or s.description %}
|
||||
<p class="dir-card__desc">{{ s.short_description or s.description }}</p>
|
||||
{% endif %}
|
||||
<div class="dir-card__foot">
|
||||
<span class="dir-card__web">{{ s.website or '' }}</span>
|
||||
<span class="dir-card__action dir-card__action--quote">{{ t.dir_card_quote_btn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{# ---- Growth tier ---- #}
|
||||
{% elif s.tier == 'growth' %}
|
||||
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}"
|
||||
class="dir-card{% if s.sticky_until and s.sticky_until > now %} dir-card--sticky{% endif %}"{% if card_colors.get(s.id) %} style="border-color:{{ card_colors[s.id] }}"{% endif %}>
|
||||
{% if s.cover_image %}
|
||||
<div class="dir-card__media">
|
||||
<img src="{{ s.cover_image }}" alt="{{ s.name }}">
|
||||
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">{{ t.dir_card_featured }}</div>{% endif %}
|
||||
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="dir-card__media dir-card__media--placeholder">
|
||||
<div class="ph-grid"></div>
|
||||
<div class="ph-label">{{ ph_label }}</div>
|
||||
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">{{ t.dir_card_featured }}</div>{% endif %}
|
||||
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="dir-card__body">
|
||||
<div class="dir-card__logo-wrap">
|
||||
{% if s.logo_file or s.logo_url %}
|
||||
<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">
|
||||
{% else %}
|
||||
<div class="dir-card__logo-ph" style="{{ logo_ph_style }}">{{ s.name[0] | upper }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3 class="dir-card__name">{{ s.name }}</h3>
|
||||
<p class="dir-card__loc">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}
|
||||
</p>
|
||||
<div class="dir-card__stats">
|
||||
<span class="dir-card__tier-chip dir-card__tier-chip--growth">{{ t.dir_card_growth }}</span>
|
||||
{% if s.project_count %}
|
||||
<span class="dir-card__stat">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
|
||||
{{ s.project_count }} projects
|
||||
</span>
|
||||
{% elif s.years_in_business %}
|
||||
<span class="dir-card__stat">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
{{ s.years_in_business }} yrs
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if s.short_description or s.description %}
|
||||
<p class="dir-card__desc">{{ s.short_description or s.description }}</p>
|
||||
{% endif %}
|
||||
<div class="dir-card__foot">
|
||||
<span class="dir-card__web">{{ s.website or '' }}</span>
|
||||
<span class="dir-card__action dir-card__action--quote">{{ t.dir_card_quote_btn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{# ---- Basic tier ---- #}
|
||||
{% elif s.tier == 'basic' %}
|
||||
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}"
|
||||
class="dir-card{% if s.sticky_until and s.sticky_until > now %} dir-card--sticky{% endif %}">
|
||||
{% if s.cover_image %}
|
||||
<div class="dir-card__media">
|
||||
<img src="{{ s.cover_image }}" alt="{{ s.name }}">
|
||||
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">{{ t.dir_card_featured }}</div>{% endif %}
|
||||
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="dir-card__media dir-card__media--placeholder">
|
||||
<div class="ph-grid"></div>
|
||||
<div class="ph-label">{{ ph_label }}</div>
|
||||
{% if s.sticky_until and s.sticky_until > now %}<div class="dir-card__featured">{{ t.dir_card_featured }}</div>{% endif %}
|
||||
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="dir-card__body">
|
||||
<div class="dir-card__logo-wrap">
|
||||
{% if s.logo_file or s.logo_url %}
|
||||
<img src="{{ s.logo_file or s.logo_url }}" alt="" class="dir-card__logo">
|
||||
{% else %}
|
||||
<div class="dir-card__logo-ph" style="{{ logo_ph_style }}">{{ s.name[0] | upper }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3 class="dir-card__name">{{ s.name }}</h3>
|
||||
<p class="dir-card__loc">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}
|
||||
</p>
|
||||
<div class="dir-card__stats">
|
||||
<span class="dir-card__stat dir-card__stat--verified">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
{{ t.dir_card_verified }}
|
||||
</span>
|
||||
</div>
|
||||
{% if s.short_description or s.description %}
|
||||
<p class="dir-card__desc">{{ s.short_description or s.description }}</p>
|
||||
{% endif %}
|
||||
<div class="dir-card__foot">
|
||||
<span class="dir-card__web">{{ s.website or '' }}</span>
|
||||
<span class="dir-card__action dir-card__action--claim">{{ t.dir_card_view_btn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{# ---- Free / unclaimed tier ---- #}
|
||||
{% else %}
|
||||
<a href="{{ url_for('directory.supplier_detail', slug=s.slug) }}"
|
||||
class="dir-card dir-card--free">
|
||||
<div class="dir-card__media dir-card__media--placeholder">
|
||||
<div class="ph-grid"></div>
|
||||
<div class="ph-label">{{ ph_label }}</div>
|
||||
<div class="dir-card__cat dir-card__cat--{{ s.category }}">{{ category_labels.get(s.category, s.category) }}</div>
|
||||
</div>
|
||||
<div class="dir-card__body">
|
||||
<div class="dir-card__logo-wrap">
|
||||
<div class="dir-card__logo-ph" style="{{ logo_ph_style }}">{{ s.name[0] | upper }}</div>
|
||||
</div>
|
||||
<h3 class="dir-card__name">{{ s.name }}</h3>
|
||||
<p class="dir-card__loc">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
{{ country_labels.get(s.country_code, s.country_code) }}{% if s.city %}, {{ s.city }}{% endif %}
|
||||
</p>
|
||||
<div class="dir-card__stats">
|
||||
<span class="dir-card__tier-chip dir-card__tier-chip--unverified">{{ t.dir_card_unverified }}</span>
|
||||
</div>
|
||||
<div class="dir-card__foot">
|
||||
<span></span>
|
||||
<span class="dir-card__action dir-card__action--claim">{{ t.dir_card_claim_btn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav class="dir-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="?q={{ q }}&country={{ country }}&category={{ category }}®ion={{ region }}&page={{ page - 1 }}">«</a>
|
||||
{% endif %}
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span class="current">{{ p }}</span>
|
||||
{% elif p <= 3 or p >= total_pages - 1 or (p >= page - 1 and p <= page + 1) %}
|
||||
<a href="?q={{ q }}&country={{ country }}&category={{ category }}®ion={{ region }}&page={{ p }}">{{ p }}</a>
|
||||
{% elif p == 4 or p == total_pages - 2 %}
|
||||
<span style="border:none">…</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if page < total_pages %}
|
||||
<a href="?q={{ q }}&country={{ country }}&category={{ category }}®ion={{ region }}&page={{ page + 1 }}">»</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="dir-empty">
|
||||
<h3>{{ t.dir_empty_heading }}</h3>
|
||||
<p>{{ t.dir_empty_sub }}</p>
|
||||
<a href="/directory/" style="color:#1D4ED8">{{ t.dir_empty_clear }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
542
web/src/padelnomics/directory/templates/supplier_detail.html
Normal file
542
web/src/padelnomics/directory/templates/supplier_detail.html
Normal file
@@ -0,0 +1,542 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ supplier.name }} - {{ t.dir_page_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% set _sup_country = country_labels.get(supplier.country_code, supplier.country_code) %}
|
||||
{% set _sup_category = category_labels.get(supplier.category, supplier.category) %}
|
||||
{% set _sup_desc = supplier.tagline if supplier.tagline else supplier.name ~ " — " ~ _sup_category ~ " in " ~ _sup_country %}
|
||||
{% set _sup_image = (config.BASE_URL ~ "/static/uploads/covers/" ~ supplier.cover_image_file) if supplier.cover_image_file else (supplier.logo_url if supplier.logo_url else url_for('static', filename='images/logo.png', _external=True)) %}
|
||||
<meta name="description" content="{{ _sup_desc | truncate(155, True, '...') }}">
|
||||
<meta property="og:title" content="{{ supplier.name }} — {{ _sup_category }} | {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="{{ _sup_desc | truncate(155, True, '...') }}">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:image" content="{{ _sup_image }}">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": {{ supplier.name | tojson }},
|
||||
{% if supplier.website %}"url": {{ supplier.website | tojson }},{% endif %}
|
||||
{% if supplier.logo_url %}"logo": {{ supplier.logo_url | tojson }},{% endif %}
|
||||
"description": {{ _sup_desc | tojson }},
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressCountry": {{ supplier.country_code | tojson }}
|
||||
{% if supplier.city %}, "addressLocality": {{ supplier.city | tojson }}{% endif %}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* ── Hero ─────────────────────────────────────────────────── */
|
||||
.sp-hero {
|
||||
position: relative; overflow: hidden;
|
||||
background: #0F172A;
|
||||
border-radius: 0 0 20px 20px;
|
||||
margin-bottom: 2rem; padding: 2.5rem 0 2rem;
|
||||
}
|
||||
.sp-hero::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sp-hero-inner {
|
||||
max-width: 1120px; margin: 0 auto; padding: 0 2rem;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.sp-hero-back {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 0.8125rem; color: #94A3B8; text-decoration: none;
|
||||
margin-bottom: 1.5rem; transition: color 0.15s;
|
||||
}
|
||||
.sp-hero-back:hover { color: #CBD5E1; text-decoration: none; }
|
||||
.sp-hero-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 1.5rem; flex-wrap: wrap; }
|
||||
.sp-hero-left { display: flex; align-items: flex-start; gap: 1.25rem; }
|
||||
.sp-hero-logo {
|
||||
width: 72px; height: 72px; border-radius: 14px; object-fit: cover;
|
||||
background: #1E293B; flex-shrink: 0; border: 1px solid rgba(255,255,255,0.12);
|
||||
}
|
||||
.sp-hero-logo-placeholder {
|
||||
width: 72px; height: 72px; border-radius: 14px; background: #1E293B;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 28px; font-weight: 800; color: #475569; flex-shrink: 0;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.sp-hero-name {
|
||||
font-size: 1.75rem; font-weight: 800; color: #fff; margin: 0 0 4px;
|
||||
letter-spacing: -0.02em; line-height: 1.15;
|
||||
}
|
||||
.sp-hero-loc { font-size: 0.875rem; color: #94A3B8; margin: 0 0 0.5rem; }
|
||||
.sp-hero-badges { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.sp-hero-badge {
|
||||
font-size: 0.6875rem; font-weight: 600; padding: 3px 10px; border-radius: 999px;
|
||||
}
|
||||
.sp-hero-badge--category { background: rgba(29,78,216,0.3); color: #93C5FD; }
|
||||
.sp-hero-badge--verified { background: rgba(22,163,74,0.3); color: #86EFAC; }
|
||||
.sp-hero-tagline { font-size: 0.9375rem; color: #CBD5E1; margin: 0.5rem 0 0; max-width: 500px; }
|
||||
.sp-hero-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: flex-start; }
|
||||
.sp-hero-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 18px; border-radius: 9px; font-size: 0.8125rem; font-weight: 600;
|
||||
text-decoration: none; transition: all 0.15s; border: 1px solid transparent;
|
||||
font-family: 'DM Sans', sans-serif; cursor: pointer;
|
||||
}
|
||||
.sp-hero-btn--primary { background: #1D4ED8; color: #fff; border-color: #1E40AF; }
|
||||
.sp-hero-btn--primary:hover { background: #1E40AF; text-decoration: none; color: #fff; }
|
||||
.sp-hero-btn--outline { background: rgba(255,255,255,0.08); color: #CBD5E1; border-color: rgba(255,255,255,0.15); }
|
||||
.sp-hero-btn--outline:hover { background: rgba(255,255,255,0.14); text-decoration: none; color: #fff; }
|
||||
|
||||
/* ── Body layout ──────────────────────────────────────────── */
|
||||
.sp-body { max-width: 1120px; margin: 0 auto; padding: 0 2rem 3rem; }
|
||||
.sp-grid { display: grid; grid-template-columns: 1fr 320px; gap: 1.5rem; align-items: start; }
|
||||
@media (max-width: 768px) {
|
||||
.sp-grid { grid-template-columns: 1fr; }
|
||||
.sp-hero-name { font-size: 1.375rem; }
|
||||
}
|
||||
|
||||
/* ── Cards ────────────────────────────────────────────────── */
|
||||
.sp-card {
|
||||
background: #fff; border: 1px solid #E2E8F0; border-radius: 14px;
|
||||
padding: 1.5rem; margin-bottom: 1rem;
|
||||
}
|
||||
.sp-card h2 {
|
||||
font-size: 0.75rem; font-weight: 700; color: #94A3B8;
|
||||
text-transform: uppercase; letter-spacing: 0.06em; margin: 0 0 1rem;
|
||||
}
|
||||
.sp-desc { font-size: 0.9375rem; color: #475569; line-height: 1.75; margin: 0; }
|
||||
.sp-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 1rem; }
|
||||
.sp-pill {
|
||||
font-size: 0.6875rem; font-weight: 600; padding: 4px 12px;
|
||||
border-radius: 999px; background: #EFF6FF; color: #1D4ED8;
|
||||
}
|
||||
|
||||
/* Services checklist */
|
||||
.sp-services { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 1rem; }
|
||||
.sp-services li { font-size: 0.8125rem; color: #475569; display: flex; align-items: center; gap: 6px; }
|
||||
.sp-services li::before { content: '✓'; color: #16A34A; font-weight: 700; flex-shrink: 0; }
|
||||
@media (max-width: 480px) { .sp-services { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Service area */
|
||||
.sp-area-pills { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.sp-area-pill {
|
||||
font-size: 0.6875rem; font-weight: 600; padding: 3px 10px;
|
||||
border-radius: 999px; background: #F1F5F9; color: #475569;
|
||||
}
|
||||
|
||||
/* ── Sidebar ──────────────────────────────────────────────── */
|
||||
.sp-sidebar { position: sticky; top: 80px; }
|
||||
.sp-contact-avatar {
|
||||
width: 40px; height: 40px; border-radius: 999px; background: #E2E8F0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; color: #64748B; font-size: 1rem; flex-shrink: 0;
|
||||
}
|
||||
.sp-contact-name { font-size: 0.9375rem; font-weight: 700; color: #1E293B; }
|
||||
.sp-contact-role { font-size: 0.75rem; color: #94A3B8; }
|
||||
.sp-contact-links { margin-top: 0.75rem; }
|
||||
.sp-contact-link {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 0.8125rem; color: #475569; padding: 5px 0;
|
||||
text-decoration: none; border-bottom: 1px solid #F1F5F9;
|
||||
}
|
||||
.sp-contact-link:last-child { border-bottom: none; }
|
||||
.sp-contact-link:hover { color: #1D4ED8; text-decoration: none; }
|
||||
.sp-contact-link svg { width: 14px; height: 14px; flex-shrink: 0; color: #94A3B8; }
|
||||
|
||||
.sp-stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||
.sp-stat { background: #F8FAFC; border-radius: 10px; padding: 0.75rem; text-align: center; }
|
||||
.sp-stat__value { font-size: 1.375rem; font-weight: 800; color: #1E293B; }
|
||||
.sp-stat__label { font-size: 0.625rem; color: #94A3B8; text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2px; }
|
||||
|
||||
.sp-social { display: flex; gap: 8px; margin-top: 0.75rem; }
|
||||
.sp-social-link {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 32px; height: 32px; border-radius: 8px; background: #F1F5F9;
|
||||
color: #64748B; text-decoration: none; transition: all 0.15s;
|
||||
}
|
||||
.sp-social-link:hover { background: #EFF6FF; color: #1D4ED8; }
|
||||
.sp-social-link svg { width: 15px; height: 15px; }
|
||||
|
||||
.sp-trust {
|
||||
display: flex; align-items: center; gap: 6px; margin-top: 0.75rem;
|
||||
font-size: 0.6875rem; color: #94A3B8;
|
||||
}
|
||||
.sp-trust svg { width: 12px; height: 12px; color: #16A34A; flex-shrink: 0; }
|
||||
|
||||
/* ── Enquiry form ─────────────────────────────────────────── */
|
||||
.sp-enquiry { }
|
||||
.sp-enquiry-field { margin-bottom: 0.75rem; }
|
||||
.sp-enquiry-label { display: block; font-size: 0.75rem; font-weight: 600; color: #475569; margin-bottom: 4px; }
|
||||
.sp-enquiry-input {
|
||||
width: 100%; border: 1px solid #E2E8F0; border-radius: 8px; padding: 8px 12px;
|
||||
font-size: 0.8125rem; font-family: inherit; box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.sp-enquiry-input:focus { outline: none; border-color: #1D4ED8; }
|
||||
.sp-enquiry-textarea { min-height: 80px; resize: vertical; }
|
||||
.sp-enquiry-submit {
|
||||
width: 100%; padding: 10px; border-radius: 9px; font-size: 0.875rem; font-weight: 600;
|
||||
background: #1D4ED8; color: #fff; border: none; cursor: pointer; font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.sp-enquiry-submit:hover { background: #1E40AF; }
|
||||
|
||||
/* ── CTA strip ────────────────────────────────────────────── */
|
||||
.sp-cta-strip {
|
||||
background: #0F172A; border-radius: 16px; padding: 2rem;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem;
|
||||
flex-wrap: wrap; margin-top: 1.5rem;
|
||||
}
|
||||
.sp-cta-strip__text h3 { color: #fff; font-size: 1.125rem; margin: 0 0 4px; }
|
||||
.sp-cta-strip__text p { color: #94A3B8; font-size: 0.875rem; margin: 0; }
|
||||
.sp-cta-strip__btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: #1D4ED8; color: #fff; padding: 10px 22px;
|
||||
border-radius: 9px; font-weight: 600; font-size: 0.875rem;
|
||||
text-decoration: none; transition: background 0.15s; white-space: nowrap;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
}
|
||||
.sp-cta-strip__btn:hover { background: #1E40AF; text-decoration: none; color: #fff; }
|
||||
.sp-cta-strip__btn--green { background: #16A34A; }
|
||||
.sp-cta-strip__btn--green:hover { background: #15803D; }
|
||||
|
||||
/* ── Locked popover (free tier) ───────────────────────────── */
|
||||
.sp-cta-wrap { position: relative; }
|
||||
.btn--locked {
|
||||
display: block; width: 100%; text-align: center;
|
||||
padding: 10px 20px; border-radius: 10px; font-size: 0.875rem; font-weight: 600;
|
||||
font-family: 'DM Sans', sans-serif; cursor: not-allowed;
|
||||
background: #E2E8F0; color: #94A3B8; border: 1px solid #CBD5E1;
|
||||
user-select: none; transition: opacity 0.15s;
|
||||
}
|
||||
.btn--locked:hover { opacity: 0.85; }
|
||||
.sp-locked-hint { font-size: 0.6875rem; color: #94A3B8; text-align: center; margin-top: 5px; }
|
||||
.sp-locked-popover {
|
||||
display: none; position: absolute; top: calc(100% + 8px); left: 0; right: 0;
|
||||
background: #fff; border: 1px solid #E2E8F0;
|
||||
border-radius: 12px; padding: 1rem 1.125rem;
|
||||
box-shadow: 0 8px 24px rgba(15,23,42,0.08); z-index: 50;
|
||||
}
|
||||
.sp-locked-popover.open { display: block; animation: popoverIn 0.15s ease; }
|
||||
@keyframes popoverIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.sp-locked-popover__title { font-size: 0.8125rem; font-weight: 700; color: #0F172A; margin: 0 0 4px; }
|
||||
.sp-locked-popover__body { font-size: 0.75rem; color: #64748B; line-height: 1.55; margin: 0 0 0.75rem; }
|
||||
.sp-locked-popover__link {
|
||||
display: block; width: 100%; text-align: center;
|
||||
padding: 8px 16px; border-radius: 8px; font-size: 0.8125rem; font-weight: 600;
|
||||
background: #1D4ED8; color: #fff; text-decoration: none;
|
||||
}
|
||||
.sp-locked-popover__link:hover { background: #1E40AF; text-decoration: none; }
|
||||
.sp-locked-popover__dismiss {
|
||||
display: block; text-align: center; margin-top: 8px;
|
||||
font-size: 0.6875rem; color: #94A3B8; cursor: pointer;
|
||||
background: none; border: none; font-family: inherit; padding: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# ── Hero ─────────────────────────────────────────────────────── #}
|
||||
<div class="sp-hero">
|
||||
<div class="sp-hero-inner">
|
||||
<a href="{{ url_for('directory.index') }}" class="sp-hero-back">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/></svg>
|
||||
{{ t.sp_back }}
|
||||
</a>
|
||||
|
||||
<div class="sp-hero-row">
|
||||
<div class="sp-hero-left">
|
||||
{% if supplier.logo_file or supplier.logo_url %}
|
||||
<img src="{{ supplier.logo_file or supplier.logo_url }}" alt="" class="sp-hero-logo">
|
||||
{% else %}
|
||||
<div class="sp-hero-logo-placeholder">{{ supplier.name[0] }}</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<h1 class="sp-hero-name">{{ supplier.name }}</h1>
|
||||
<p class="sp-hero-loc">{{ country_labels.get(supplier.country_code, supplier.country_code) }}{% if supplier.city %}, {{ supplier.city }}{% endif %}</p>
|
||||
<div class="sp-hero-badges">
|
||||
<span class="sp-hero-badge sp-hero-badge--category">{{ category_labels.get(supplier.category, supplier.category) }}</span>
|
||||
{% if supplier.is_verified %}
|
||||
<span class="sp-hero-badge sp-hero-badge--verified">{{ t.sp_verified }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if supplier.tagline %}
|
||||
<p class="sp-hero-tagline">{{ supplier.tagline }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sp-hero-actions">
|
||||
{% if supplier.tier in ('growth', 'pro') %}
|
||||
<a href="{{ url_for('leads.quote_request', country=supplier.country_code) }}" class="sp-hero-btn sp-hero-btn--primary">
|
||||
{{ t.sp_request_quote }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if supplier.website %}
|
||||
<a href="{{ url_for('directory.supplier_website', slug=supplier.slug) }}" class="sp-hero-btn sp-hero-btn--outline" target="_blank" rel="noopener">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||
{{ t.sp_visit_website }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Body ──────────────────────────────────────────────────────── #}
|
||||
<div class="sp-body">
|
||||
{% if supplier.tier in ('basic', 'growth', 'pro') %}
|
||||
{# Full two-column layout for paid tiers #}
|
||||
<div class="sp-grid">
|
||||
{# Main column #}
|
||||
<div>
|
||||
{# About #}
|
||||
{% set desc = supplier.long_description or supplier.short_description or supplier.description %}
|
||||
{% if desc or supplier.service_categories %}
|
||||
<div class="sp-card">
|
||||
<h2>{{ t.sp_about }}</h2>
|
||||
{% if desc %}
|
||||
<p class="sp-desc">{{ desc }}</p>
|
||||
{% endif %}
|
||||
{% if supplier.service_categories %}
|
||||
<div class="sp-pills">
|
||||
{% for cat in (supplier.service_categories or '').split(',') %}
|
||||
{% if cat.strip() %}
|
||||
<span class="sp-pill">{{ cat.strip() | replace('_', ' ') | title }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Services offered #}
|
||||
{% if services_list %}
|
||||
<div class="sp-card">
|
||||
<h2>{{ t.sp_services }}</h2>
|
||||
<ul class="sp-services">
|
||||
{% for s in services_list %}
|
||||
<li>{{ s }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Service area #}
|
||||
{% if supplier.service_area %}
|
||||
<div class="sp-card">
|
||||
<h2>{{ t.sp_service_area }}</h2>
|
||||
<div class="sp-area-pills">
|
||||
{% for area in (supplier.service_area or '').split(',') %}
|
||||
{% if area.strip() %}
|
||||
<span class="sp-area-pill">{{ area.strip() }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Enquiry form for Basic+ #}
|
||||
<div class="sp-card sp-enquiry">
|
||||
<h2>{{ t.sp_enquiry_heading }}</h2>
|
||||
<form hx-post="{{ url_for('directory.supplier_enquiry', slug=supplier.slug) }}"
|
||||
hx-target="#enquiry-result"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div id="enquiry-result"></div>
|
||||
<div class="sp-enquiry-field">
|
||||
<label class="sp-enquiry-label">{{ t.sp_enquiry_name }} <span style="color:#EF4444">*</span></label>
|
||||
<input type="text" name="contact_name" class="sp-enquiry-input" required placeholder="Jane Smith">
|
||||
</div>
|
||||
<div class="sp-enquiry-field">
|
||||
<label class="sp-enquiry-label">{{ t.sp_enquiry_email }} <span style="color:#EF4444">*</span></label>
|
||||
<input type="email" name="contact_email" class="sp-enquiry-input" required placeholder="jane@company.com">
|
||||
</div>
|
||||
<div class="sp-enquiry-field">
|
||||
<label class="sp-enquiry-label">{{ t.sp_enquiry_message }} <span style="color:#EF4444">*</span></label>
|
||||
<textarea name="message" class="sp-enquiry-input sp-enquiry-textarea" required
|
||||
placeholder="{{ t.sp_enquiry_placeholder | tformat(name=supplier.name) }}"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="sp-enquiry-submit">{{ t.sp_enquiry_submit }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sidebar #}
|
||||
<aside class="sp-sidebar">
|
||||
{# Contact card #}
|
||||
<div class="sp-card">
|
||||
<h2>{{ t.sp_contact }}</h2>
|
||||
{% if supplier.contact_name %}
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:0.75rem">
|
||||
<div class="sp-contact-avatar">{{ supplier.contact_name[0] | upper }}</div>
|
||||
<div>
|
||||
<div class="sp-contact-name">{{ supplier.contact_name }}</div>
|
||||
{% if supplier.contact_role %}
|
||||
<div class="sp-contact-role">{{ supplier.contact_role }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="sp-contact-links">
|
||||
{% if supplier.contact_email %}
|
||||
<a href="mailto:{{ supplier.contact_email }}" class="sp-contact-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
{{ supplier.contact_email }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if supplier.contact_phone %}
|
||||
<a href="tel:{{ supplier.contact_phone }}" class="sp-contact-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 6.75Z"/></svg>
|
||||
{{ supplier.contact_phone }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if supplier.website %}
|
||||
<a href="{{ url_for('directory.supplier_website', slug=supplier.slug) }}" target="_blank" rel="noopener" class="sp-contact-link">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"/></svg>
|
||||
{{ supplier.website }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Social links #}
|
||||
{% if social_links.linkedin or social_links.instagram or social_links.youtube %}
|
||||
<div class="sp-social">
|
||||
{% if social_links.linkedin %}
|
||||
<a href="{{ social_links.linkedin }}" target="_blank" rel="noopener" class="sp-social-link" title="LinkedIn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if social_links.instagram %}
|
||||
<a href="{{ social_links.instagram }}" target="_blank" rel="noopener" class="sp-social-link" title="Instagram">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if social_links.youtube %}
|
||||
<a href="{{ social_links.youtube }}" target="_blank" rel="noopener" class="sp-social-link" title="YouTube">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Stats #}
|
||||
{% if supplier.years_in_business or supplier.project_count %}
|
||||
<div class="sp-stats-grid" style="margin-top:0.75rem">
|
||||
{% if supplier.years_in_business %}
|
||||
<div class="sp-stat">
|
||||
<div class="sp-stat__value">{{ supplier.years_in_business }}</div>
|
||||
<div class="sp-stat__label">{{ t.sp_years }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if supplier.project_count %}
|
||||
<div class="sp-stat">
|
||||
<div class="sp-stat__value">{{ supplier.project_count }}</div>
|
||||
<div class="sp-stat__label">{{ t.sp_projects }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Verified trust note #}
|
||||
{% if supplier.is_verified %}
|
||||
<div class="sp-trust">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"/></svg>
|
||||
{{ t.sp_trust }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{# CTA strip — tier-dependent #}
|
||||
{% if supplier.tier == 'basic' %}
|
||||
<div class="sp-cta-strip">
|
||||
<div class="sp-cta-strip__text">
|
||||
<h3>{{ t.sp_cta_basic_h3 }}</h3>
|
||||
<p>{{ t.sp_cta_basic_desc }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('suppliers.signup') }}" class="sp-cta-strip__btn">
|
||||
{{ t.sp_cta_basic_btn }}
|
||||
</a>
|
||||
</div>
|
||||
{% elif supplier.tier == 'growth' %}
|
||||
{# Subtle upgrade nudge — optional #}
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
{# Free / unclaimed tier — minimal layout #}
|
||||
<div style="max-width:800px;margin:0 auto">
|
||||
<div class="sp-card">
|
||||
{% set desc = supplier.long_description or supplier.short_description or supplier.description %}
|
||||
{% if desc %}
|
||||
<p class="sp-desc" style="margin-bottom:1.5rem">{{ desc }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if supplier.service_categories %}
|
||||
<div class="sp-pills" style="margin-bottom:1.5rem">
|
||||
{% for cat in (supplier.service_categories or '').split(',') %}
|
||||
{% if cat.strip() %}
|
||||
<span class="sp-pill">{{ cat.strip() | replace('_', ' ') | title }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Locked quote CTA #}
|
||||
<div class="sp-cta-wrap" id="quote-wrap">
|
||||
<button type="button" class="btn--locked" id="locked-quote-btn"
|
||||
aria-describedby="locked-popover" aria-disabled="true">
|
||||
{{ t.sp_request_quote }}
|
||||
</button>
|
||||
<p class="sp-locked-hint">{{ t.sp_locked_hint }}</p>
|
||||
<div class="sp-locked-popover" id="locked-popover" role="tooltip">
|
||||
<p class="sp-locked-popover__title">{{ t.sp_locked_popover_title }}</p>
|
||||
<p class="sp-locked-popover__body">
|
||||
{{ t.sp_locked_popover_desc }}
|
||||
</p>
|
||||
<a href="{{ url_for('leads.quote_request', country=supplier.country_code) }}"
|
||||
class="sp-locked-popover__link">{{ t.sp_locked_popover_link }}</a>
|
||||
<button type="button" class="sp-locked-popover__dismiss" id="dismiss-popover">{{ t.sp_locked_popover_dismiss }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Claim CTA strip #}
|
||||
{% if not supplier.claimed_by %}
|
||||
<div class="sp-cta-strip">
|
||||
<div class="sp-cta-strip__text">
|
||||
<h3>{{ t.sp_cta_claim_h3 }}</h3>
|
||||
<p>{{ t.sp_cta_claim_desc }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('suppliers.claim', slug=supplier.slug) }}" class="sp-cta-strip__btn sp-cta-strip__btn--green">
|
||||
{{ t.sp_cta_claim_btn }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var btn = document.getElementById('locked-quote-btn');
|
||||
var pop = document.getElementById('locked-popover');
|
||||
var dis = document.getElementById('dismiss-popover');
|
||||
if (!btn || !pop) return;
|
||||
btn.addEventListener('click', function(e) { e.stopPropagation(); pop.classList.toggle('open'); });
|
||||
if (dis) dis.addEventListener('click', function() { pop.classList.remove('open'); });
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!document.getElementById('quote-wrap').contains(e.target)) pop.classList.remove('open');
|
||||
});
|
||||
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') pop.classList.remove('open'); });
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
140
web/src/padelnomics/i18n.py
Normal file
140
web/src/padelnomics/i18n.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
UI translation strings.
|
||||
|
||||
Usage:
|
||||
from .i18n import get_translations, get_calc_item_names, SUPPORTED_LANGS
|
||||
|
||||
t = get_translations("de") # → dict[str, str]
|
||||
t["nav_planner"] # → "Finanzplaner"
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
SUPPORTED_LANGS = {"en", "de"}
|
||||
LANG_BLUEPRINTS = {"public", "planner", "directory", "content", "leads", "suppliers"}
|
||||
|
||||
_LOCALES_DIR = Path(__file__).parent / "locales"
|
||||
|
||||
|
||||
def _load_locale(filename: str) -> dict[str, str]:
|
||||
path = _LOCALES_DIR / filename
|
||||
assert path.is_file(), f"Missing locale file: {path}"
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert isinstance(data, dict), f"Locale file must be a flat JSON object: {path}"
|
||||
return data
|
||||
|
||||
|
||||
_TRANSLATIONS: dict[str, dict[str, str]] = {
|
||||
lang: _load_locale(f"{lang}.json") for lang in SUPPORTED_LANGS
|
||||
}
|
||||
assert _TRANSLATIONS["en"].keys() == _TRANSLATIONS["de"].keys(), (
|
||||
"EN/DE key mismatch — "
|
||||
f"EN-only: {sorted(_TRANSLATIONS['en'].keys() - _TRANSLATIONS['de'].keys())}, "
|
||||
f"DE-only: {sorted(_TRANSLATIONS['de'].keys() - _TRANSLATIONS['en'].keys())}"
|
||||
)
|
||||
|
||||
|
||||
def get_translations(lang: str) -> dict[str, str]:
|
||||
"""Return UI translation strings for the given language.
|
||||
|
||||
Falls back to English for unsupported languages (should never happen
|
||||
in practice since we 404 unsupported langs in before_request).
|
||||
"""
|
||||
assert lang in _TRANSLATIONS, f"Unknown lang: {lang!r}"
|
||||
return _TRANSLATIONS[lang]
|
||||
|
||||
|
||||
# ── Calculator item names ─────────────────────────────────────────────────────
|
||||
# Used in calculator.py calc(s, lang) to localise CAPEX/OPEX line item names.
|
||||
# Kept inline (36 keys per lang) — different namespace from UI strings,
|
||||
# used only by calculator.py, no benefit from externalising.
|
||||
|
||||
_CALC_ITEM_NAMES: dict[str, dict[str, str]] = {
|
||||
"en": {
|
||||
# CAPEX
|
||||
"padel_courts": "Padel Courts",
|
||||
"shipping": "Shipping",
|
||||
"hall_construction": "Hall Construction",
|
||||
"foundation": "Foundation",
|
||||
"land_purchase": "Land Purchase",
|
||||
"transaction_costs": "Transaction Costs",
|
||||
"hvac_system": "HVAC System",
|
||||
"electrical_lighting": "Electrical + Lighting",
|
||||
"sanitary_changing": "Sanitary / Changing",
|
||||
"parking_exterior": "Parking + Exterior",
|
||||
"planning_permits": "Planning + Permits",
|
||||
"fire_protection": "Fire Protection",
|
||||
"floor_preparation": "Floor Preparation",
|
||||
"hvac_upgrade": "HVAC Upgrade",
|
||||
"lighting_upgrade": "Lighting Upgrade",
|
||||
"fitout_reception": "Fit-Out & Reception",
|
||||
"permits_compliance": "Permits & Compliance",
|
||||
"concrete_foundation": "Concrete Foundation",
|
||||
"site_work": "Site Work",
|
||||
"outdoor_lighting": "Lighting",
|
||||
"fencing": "Fencing",
|
||||
"equipment": "Equipment",
|
||||
"working_capital": "Working Capital",
|
||||
"miscellaneous": "Miscellaneous",
|
||||
"contingency": "Contingency",
|
||||
# OPEX
|
||||
"rent": "Rent",
|
||||
"property_tax": "Property Tax",
|
||||
"insurance": "Insurance",
|
||||
"electricity": "Electricity",
|
||||
"heating": "Heating",
|
||||
"water": "Water",
|
||||
"maintenance": "Maintenance",
|
||||
"cleaning": "Cleaning",
|
||||
"marketing_misc": "Marketing / Software / Misc",
|
||||
"staff": "Staff",
|
||||
},
|
||||
"de": {
|
||||
# CAPEX
|
||||
"padel_courts": "Padelplätze",
|
||||
"shipping": "Transport & Lieferung",
|
||||
"hall_construction": "Hallenbau",
|
||||
"foundation": "Fundament",
|
||||
"land_purchase": "Grundstücks-kauf",
|
||||
"transaction_costs": "Erwerbsnebenkosten",
|
||||
"hvac_system": "Lüftung & Klimaanlage",
|
||||
"electrical_lighting": "Elektro + Beleuchtung",
|
||||
"sanitary_changing": "Sanitär / Umkleide",
|
||||
"parking_exterior": "Parkplatz + Außenanlage",
|
||||
"planning_permits": "Planung + Genehmigungen",
|
||||
"fire_protection": "Brandschutz",
|
||||
"floor_preparation": "Bodenvorbereitung",
|
||||
"hvac_upgrade": "Lüftungsausbau",
|
||||
"lighting_upgrade": "Beleuchtungsausbau",
|
||||
"fitout_reception": "Ausbau & Empfang",
|
||||
"permits_compliance": "Genehmigungen & Auflagen",
|
||||
"concrete_foundation": "Betonfundament",
|
||||
"site_work": "Erschließung",
|
||||
"outdoor_lighting": "Beleuchtung",
|
||||
"fencing": "Einzäunung",
|
||||
"equipment": "Ausstattung",
|
||||
"working_capital": "Betriebskapital",
|
||||
"miscellaneous": "Sonstiges",
|
||||
"contingency": "Reserve",
|
||||
# OPEX
|
||||
"rent": "Miete",
|
||||
"property_tax": "Grundsteuer",
|
||||
"insurance": "Versicherung",
|
||||
"electricity": "Strom",
|
||||
"heating": "Heizung",
|
||||
"water": "Wasser",
|
||||
"maintenance": "Wartung",
|
||||
"cleaning": "Reinigung",
|
||||
"marketing_misc": "Marketing / Software / Sonstiges",
|
||||
"staff": "Personal",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_calc_item_names(lang: str) -> dict[str, str]:
|
||||
"""Return CAPEX/OPEX item name translations for the given language.
|
||||
|
||||
Used by calculator.py: calc(s, lang) looks up item names here.
|
||||
"""
|
||||
assert lang in _CALC_ITEM_NAMES, f"Unknown lang: {lang!r}"
|
||||
return _CALC_ITEM_NAMES[lang]
|
||||
533
web/src/padelnomics/leads/routes.py
Normal file
533
web/src/padelnomics/leads/routes.py
Normal file
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
Leads domain: capture interest in court suppliers and financing.
|
||||
"""
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
|
||||
|
||||
from ..auth.routes import (
|
||||
create_auth_token,
|
||||
create_user,
|
||||
get_user_by_email,
|
||||
get_user_by_id,
|
||||
get_valid_token,
|
||||
login_required,
|
||||
mark_token_used,
|
||||
update_user,
|
||||
)
|
||||
from ..core import (
|
||||
config,
|
||||
csrf_protect,
|
||||
execute,
|
||||
fetch_one,
|
||||
is_disposable_email,
|
||||
is_plausible_phone,
|
||||
send_email,
|
||||
)
|
||||
from ..i18n import get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
"leads",
|
||||
__name__,
|
||||
template_folder=str(Path(__file__).parent / "templates"),
|
||||
url_prefix="/leads",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Heat Score Calculation
|
||||
# =============================================================================
|
||||
|
||||
def calculate_heat_score(form: dict) -> str:
|
||||
"""Score lead readiness from form data. Returns 'hot', 'warm', or 'cool'."""
|
||||
score = 0
|
||||
if form.get("timeline") in ("asap", "3-6mo"):
|
||||
score += 3
|
||||
elif form.get("timeline") == "6-12mo":
|
||||
score += 1
|
||||
phase = form.get("location_status", "")
|
||||
if phase in ("permit_granted",):
|
||||
score += 4
|
||||
elif phase in ("lease_signed", "permit_pending"):
|
||||
score += 3
|
||||
elif phase in ("converting_existing", "permit_not_filed"):
|
||||
score += 2
|
||||
elif phase in ("location_found",):
|
||||
score += 1
|
||||
if form.get("financing_status") in ("self_funded", "loan_approved"):
|
||||
score += 3
|
||||
elif form.get("financing_status") == "seeking":
|
||||
score += 1
|
||||
if form.get("decision_process") == "solo":
|
||||
score += 2
|
||||
elif form.get("decision_process") == "partners":
|
||||
score += 1
|
||||
if form.get("previous_supplier_contact") == "received_quotes":
|
||||
score += 2
|
||||
budget = int(form.get("budget_estimate", 0) or 0)
|
||||
if budget >= 250000:
|
||||
score += 2
|
||||
elif budget >= 100000:
|
||||
score += 1
|
||||
if score >= 10:
|
||||
return "hot"
|
||||
if score >= 5:
|
||||
return "warm"
|
||||
return "cool"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/suppliers", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@csrf_protect
|
||||
async def suppliers():
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
await execute(
|
||||
"""INSERT INTO lead_requests
|
||||
(user_id, lead_type, location, court_count, budget_estimate, message, created_at)
|
||||
VALUES (?, 'supplier', ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
g.user["id"],
|
||||
form.get("location", ""),
|
||||
form.get("court_count", 0),
|
||||
form.get("budget", 0),
|
||||
form.get("message", ""),
|
||||
datetime.utcnow().isoformat(),
|
||||
),
|
||||
)
|
||||
# Notify admin
|
||||
await send_email(
|
||||
config.LEADS_EMAIL,
|
||||
f"New supplier lead from {g.user['email']}",
|
||||
f"<p>Location: {form.get('location')}<br>Courts: {form.get('court_count')}<br>Budget: {form.get('budget')}<br>Message: {form.get('message')}</p>",
|
||||
)
|
||||
_t = get_translations(g.get("lang", "en"))
|
||||
await flash(_t["flash_suppliers_success"], "success")
|
||||
return redirect(url_for("leads.suppliers"))
|
||||
|
||||
# Pre-fill from latest scenario
|
||||
scenario = await fetch_one(
|
||||
"SELECT state_json FROM scenarios WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 1",
|
||||
(g.user["id"],),
|
||||
)
|
||||
prefill = {}
|
||||
if scenario:
|
||||
try:
|
||||
state = json.loads(scenario["state_json"])
|
||||
prefill["court_count"] = state.get("dblCourts", 0) + state.get("sglCourts", 0)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return await render_template("suppliers.html", prefill=prefill)
|
||||
|
||||
|
||||
@bp.route("/financing", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@csrf_protect
|
||||
async def financing():
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
await execute(
|
||||
"""INSERT INTO lead_requests
|
||||
(user_id, lead_type, location, court_count, budget_estimate, message, created_at)
|
||||
VALUES (?, 'financing', ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
g.user["id"],
|
||||
form.get("location", ""),
|
||||
form.get("court_count", 0),
|
||||
form.get("budget", 0),
|
||||
form.get("message", ""),
|
||||
datetime.utcnow().isoformat(),
|
||||
),
|
||||
)
|
||||
await send_email(
|
||||
config.LEADS_EMAIL,
|
||||
f"New financing lead from {g.user['email']}",
|
||||
f"<p>Location: {form.get('location')}<br>Courts: {form.get('court_count')}<br>Budget: {form.get('budget')}<br>Message: {form.get('message')}</p>",
|
||||
)
|
||||
_t = get_translations(g.get("lang", "en"))
|
||||
await flash(_t["flash_financing_success"], "success")
|
||||
return redirect(url_for("leads.financing"))
|
||||
|
||||
scenario = await fetch_one(
|
||||
"SELECT state_json FROM scenarios WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 1",
|
||||
(g.user["id"],),
|
||||
)
|
||||
prefill = {}
|
||||
if scenario:
|
||||
try:
|
||||
state = json.loads(scenario["state_json"])
|
||||
prefill["court_count"] = state.get("dblCourts", 0) + state.get("sglCourts", 0)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return await render_template("financing.html", prefill=prefill)
|
||||
|
||||
|
||||
def _get_quote_steps(lang: str) -> list:
|
||||
"""Return translated QUOTE_STEPS for the given language."""
|
||||
t = get_translations(lang)
|
||||
return [
|
||||
{"n": 1, "title": t["q1_heading"], "required": ["facility_type"]},
|
||||
{"n": 2, "title": t["q2_heading"], "required": ["country"]},
|
||||
{"n": 3, "title": t["q3_heading"], "required": []},
|
||||
{"n": 4, "title": t["q4_heading"], "required": []},
|
||||
{"n": 5, "title": t["q5_heading"], "required": ["timeline"]},
|
||||
{"n": 6, "title": t["q6_heading"], "required": ["financing_status", "decision_process"]},
|
||||
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},
|
||||
{"n": 8, "title": t["q8_heading"], "required": ["services_needed"]},
|
||||
{"n": 9, "title": t["q9_heading"], "required": ["contact_name", "contact_email", "contact_phone"]},
|
||||
]
|
||||
|
||||
|
||||
def _parse_accumulated(form_or_args):
|
||||
"""Parse accumulated JSON from form data or query args."""
|
||||
raw = form_or_args.get("_accumulated", "{}")
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
@bp.route("/quote/step/<int:step>", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
async def quote_step(step):
|
||||
"""HTMX endpoint — validate current step and return next step partial."""
|
||||
lang = g.get("lang", "en")
|
||||
steps = _get_quote_steps(lang)
|
||||
if step < 1 or step > len(steps):
|
||||
return "Invalid step", 400
|
||||
|
||||
if request.method == "POST":
|
||||
form = await request.form
|
||||
accumulated = _parse_accumulated(form)
|
||||
# Merge current step's fields into accumulated
|
||||
for k, v in form.items():
|
||||
if k.startswith("_") or k == "csrf_token":
|
||||
continue
|
||||
if k == "services_needed":
|
||||
accumulated.setdefault("services_needed", [])
|
||||
if v not in accumulated["services_needed"]:
|
||||
accumulated["services_needed"].append(v)
|
||||
else:
|
||||
accumulated[k] = v
|
||||
# Handle services_needed as getlist for checkboxes
|
||||
services = form.getlist("services_needed")
|
||||
if services:
|
||||
accumulated["services_needed"] = services
|
||||
|
||||
# Validate required fields for current step
|
||||
step_def = steps[step - 1]
|
||||
errors = []
|
||||
for field in step_def["required"]:
|
||||
val = accumulated.get(field, "")
|
||||
if isinstance(val, str) and not val.strip():
|
||||
errors.append(field)
|
||||
elif not val:
|
||||
errors.append(field)
|
||||
if errors:
|
||||
return await render_template(
|
||||
f"partials/quote_step_{step}.html",
|
||||
data=accumulated, step=step, steps=steps,
|
||||
errors=errors,
|
||||
)
|
||||
# Return next step
|
||||
next_step = step + 1
|
||||
if next_step > len(steps):
|
||||
next_step = len(steps)
|
||||
return await render_template(
|
||||
f"partials/quote_step_{next_step}.html",
|
||||
data=accumulated, step=next_step, steps=steps,
|
||||
errors=[],
|
||||
)
|
||||
|
||||
# GET — render requested step (for back navigation / dot clicks)
|
||||
accumulated = _parse_accumulated(request.args)
|
||||
return await render_template(
|
||||
f"partials/quote_step_{step}.html",
|
||||
data=accumulated, step=step, steps=steps,
|
||||
errors=[],
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/quote", methods=["GET", "POST"])
|
||||
@csrf_protect
|
||||
async def quote_request():
|
||||
"""Multi-step quote wizard. No login required — guests provide contact info."""
|
||||
if request.method == "POST":
|
||||
is_json = request.content_type and "application/json" in request.content_type
|
||||
|
||||
if is_json:
|
||||
form = await request.get_json()
|
||||
services = form.get("services_needed", [])
|
||||
if isinstance(services, str):
|
||||
services = [services]
|
||||
else:
|
||||
form = await request.form
|
||||
services = form.getlist("services_needed")
|
||||
|
||||
# Validate mandatory fields
|
||||
errors = []
|
||||
if not form.get("country"):
|
||||
errors.append("Country is required")
|
||||
if not form.get("timeline"):
|
||||
errors.append("Timeline is required")
|
||||
if not form.get("stakeholder_type"):
|
||||
errors.append("Stakeholder type is required")
|
||||
if not form.get("contact_name", "").strip():
|
||||
errors.append("Full name is required")
|
||||
if not form.get("contact_email", "").strip():
|
||||
errors.append("Email is required")
|
||||
if not form.get("contact_phone", "").strip():
|
||||
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 is_json:
|
||||
return jsonify({"ok": False, "errors": errors}), 422
|
||||
await flash("; ".join(errors), "error")
|
||||
return redirect(url_for("leads.quote_request"))
|
||||
|
||||
heat = calculate_heat_score(form)
|
||||
|
||||
# Compute credit cost from heat tier
|
||||
from ..credits import HEAT_CREDIT_COSTS
|
||||
credit_cost = HEAT_CREDIT_COSTS.get(heat, HEAT_CREDIT_COSTS["cool"])
|
||||
|
||||
services_json = json.dumps(services) if services else None
|
||||
|
||||
user_id = g.user["id"] if g.user else None
|
||||
contact_email = form.get("contact_email", "").strip().lower()
|
||||
|
||||
# Logged-in user with matching email → skip verification
|
||||
is_verified_user = (
|
||||
g.user is not None
|
||||
and g.user["email"].lower() == contact_email
|
||||
)
|
||||
status = "new" if is_verified_user else "pending_verification"
|
||||
|
||||
lead_id = await execute(
|
||||
"""INSERT INTO lead_requests
|
||||
(user_id, lead_type, court_count, budget_estimate,
|
||||
facility_type, glass_type, lighting_type, build_context,
|
||||
location, country, timeline, location_status,
|
||||
financing_status, wants_financing_help, decision_process,
|
||||
previous_supplier_contact, services_needed, additional_info,
|
||||
contact_name, contact_email, contact_phone, contact_company,
|
||||
stakeholder_type,
|
||||
heat_score, status, credit_cost, created_at)
|
||||
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
user_id,
|
||||
form.get("court_count", 0),
|
||||
form.get("budget_estimate", 0),
|
||||
form.get("facility_type", ""),
|
||||
form.get("glass_type", ""),
|
||||
form.get("lighting_type", ""),
|
||||
form.get("build_context", ""),
|
||||
form.get("city", ""),
|
||||
form.get("country", ""),
|
||||
form.get("timeline", ""),
|
||||
form.get("location_status", ""),
|
||||
form.get("financing_status", ""),
|
||||
1 if form.get("wants_financing_help") else 0,
|
||||
form.get("decision_process", ""),
|
||||
form.get("previous_supplier_contact", ""),
|
||||
services_json,
|
||||
form.get("additional_info", ""),
|
||||
form.get("contact_name", ""),
|
||||
contact_email,
|
||||
form.get("contact_phone", ""),
|
||||
form.get("contact_company", ""),
|
||||
form.get("stakeholder_type", ""),
|
||||
heat,
|
||||
status,
|
||||
credit_cost,
|
||||
datetime.utcnow().isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
# Remove from nurture audience (stop drip emails)
|
||||
if config.RESEND_AUDIENCE_PLANNER and config.RESEND_API_KEY:
|
||||
try:
|
||||
import resend
|
||||
resend.api_key = config.RESEND_API_KEY
|
||||
resend.Contacts.remove(
|
||||
audience_id=config.RESEND_AUDIENCE_PLANNER,
|
||||
email=contact_email,
|
||||
)
|
||||
except Exception:
|
||||
pass # Best-effort removal
|
||||
|
||||
if is_verified_user:
|
||||
# Existing flow: notify admin immediately
|
||||
await send_email(
|
||||
config.LEADS_EMAIL,
|
||||
f"[{heat.upper()}] New quote request from {contact_email}",
|
||||
f"<p><b>Heat:</b> {heat}<br>"
|
||||
f"<b>Contact:</b> {form.get('contact_name')} <{contact_email}><br>"
|
||||
f"<b>Stakeholder:</b> {form.get('stakeholder_type')}<br>"
|
||||
f"<b>Facility:</b> {form.get('facility_type')} / {form.get('court_count')} courts<br>"
|
||||
f"<b>Glass:</b> {form.get('glass_type')} | <b>Lighting:</b> {form.get('lighting_type')}<br>"
|
||||
f"<b>Phase:</b> {form.get('location_status')} | <b>Timeline:</b> {form.get('timeline')}<br>"
|
||||
f"<b>Financing:</b> {form.get('financing_status')} | <b>Budget:</b> {form.get('budget_estimate')}<br>"
|
||||
f"<b>City:</b> {form.get('city')} | <b>Country:</b> {form.get('country')}</p>",
|
||||
)
|
||||
|
||||
if is_json:
|
||||
return jsonify({"ok": True, "heat": heat})
|
||||
|
||||
return await render_template(
|
||||
"quote_submitted.html",
|
||||
heat=heat,
|
||||
court_count=form.get("court_count", ""),
|
||||
facility_type=form.get("facility_type", ""),
|
||||
country=form.get("country", ""),
|
||||
contact_email=contact_email,
|
||||
)
|
||||
|
||||
# --- Verification needed ---
|
||||
# Get-or-create user for contact_email
|
||||
existing_user = await get_user_by_email(contact_email)
|
||||
if not existing_user:
|
||||
new_user_id = await create_user(contact_email)
|
||||
else:
|
||||
new_user_id = existing_user["id"]
|
||||
|
||||
# Link lead to user if guest submission
|
||||
if user_id is None:
|
||||
await execute(
|
||||
"UPDATE lead_requests SET user_id = ? WHERE id = ?",
|
||||
(new_user_id, lead_id),
|
||||
)
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
await create_auth_token(new_user_id, token, minutes=60)
|
||||
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_quote_verification", {
|
||||
"email": contact_email,
|
||||
"token": token,
|
||||
"lead_id": lead_id,
|
||||
"lang": g.get("lang", "en"),
|
||||
"contact_name": form.get("contact_name", ""),
|
||||
"facility_type": form.get("facility_type", ""),
|
||||
"court_count": form.get("court_count", ""),
|
||||
"country": form.get("country", ""),
|
||||
})
|
||||
|
||||
if is_json:
|
||||
return jsonify({"ok": True, "pending_verification": True})
|
||||
|
||||
return await render_template(
|
||||
"quote_verify_sent.html",
|
||||
contact_email=contact_email,
|
||||
)
|
||||
|
||||
# GET — render wizard shell with starting step
|
||||
data = {}
|
||||
start_step = 1
|
||||
venue = request.args.get("venue", "")
|
||||
if venue:
|
||||
data = {
|
||||
"facility_type": venue,
|
||||
"court_count": request.args.get("courts", ""),
|
||||
"glass_type": request.args.get("glass", ""),
|
||||
"lighting_type": request.args.get("lighting", ""),
|
||||
"country": request.args.get("country", ""),
|
||||
"budget_estimate": request.args.get("budget", ""),
|
||||
}
|
||||
start_step = 2 # skip project step, already filled
|
||||
return await render_template(
|
||||
"quote_request.html",
|
||||
data=data, step=start_step, steps=_get_quote_steps(g.get("lang", "en")),
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/verify")
|
||||
async def verify_quote():
|
||||
"""Verify email from quote submission — activates lead and logs user in."""
|
||||
token_str = request.args.get("token")
|
||||
lead_id = request.args.get("lead")
|
||||
|
||||
_t = get_translations(g.get("lang", "en"))
|
||||
if not token_str or not lead_id:
|
||||
await flash(_t["flash_verify_invalid"], "error")
|
||||
return redirect(url_for("leads.quote_request"))
|
||||
|
||||
# Validate token
|
||||
token_data = await get_valid_token(token_str)
|
||||
if not token_data:
|
||||
await flash(_t["flash_verify_expired"], "error")
|
||||
return redirect(url_for("leads.quote_request"))
|
||||
|
||||
# Validate lead exists and is pending
|
||||
lead = await fetch_one(
|
||||
"SELECT * FROM lead_requests WHERE id = ? AND status = 'pending_verification'",
|
||||
(lead_id,),
|
||||
)
|
||||
if not lead:
|
||||
await flash(_t["flash_verify_invalid_lead"], "error")
|
||||
return redirect(url_for("leads.quote_request"))
|
||||
|
||||
# Mark token used
|
||||
await mark_token_used(token_data["id"])
|
||||
|
||||
# Compute credit cost and activate lead
|
||||
from ..credits import compute_credit_cost
|
||||
credit_cost = compute_credit_cost(dict(lead))
|
||||
now = datetime.utcnow().isoformat()
|
||||
await execute(
|
||||
"UPDATE lead_requests SET status = 'new', verified_at = ?, credit_cost = ? WHERE id = ?",
|
||||
(now, credit_cost, lead_id),
|
||||
)
|
||||
|
||||
# Set user name from contact_name if not already set
|
||||
user = await get_user_by_id(token_data["user_id"])
|
||||
if user and not user.get("name"):
|
||||
await update_user(token_data["user_id"], name=lead["contact_name"])
|
||||
|
||||
# Log user in
|
||||
session.permanent = True
|
||||
session["user_id"] = token_data["user_id"]
|
||||
await update_user(token_data["user_id"], last_login_at=now)
|
||||
|
||||
# Send admin notification (deferred from submission)
|
||||
heat = lead["heat_score"] or "cool"
|
||||
contact_email = lead["contact_email"]
|
||||
await send_email(
|
||||
config.LEADS_EMAIL,
|
||||
f"[{heat.upper()}] New quote request from {contact_email}",
|
||||
f"<p><b>Heat:</b> {heat}<br>"
|
||||
f"<b>Contact:</b> {lead['contact_name']} <{contact_email}><br>"
|
||||
f"<b>Stakeholder:</b> {lead['stakeholder_type']}<br>"
|
||||
f"<b>Facility:</b> {lead['facility_type']} / {lead['court_count']} courts<br>"
|
||||
f"<b>Glass:</b> {lead['glass_type']} | <b>Lighting:</b> {lead['lighting_type']}<br>"
|
||||
f"<b>Phase:</b> {lead['location_status']} | <b>Timeline:</b> {lead['timeline']}<br>"
|
||||
f"<b>Financing:</b> {lead['financing_status']} | <b>Budget:</b> {lead['budget_estimate']}<br>"
|
||||
f"<b>City:</b> {lead['location']} | <b>Country:</b> {lead['country']}</p>",
|
||||
)
|
||||
|
||||
# Send welcome email
|
||||
from ..worker import enqueue
|
||||
await enqueue("send_welcome", {"email": contact_email})
|
||||
|
||||
return await render_template(
|
||||
"quote_submitted.html",
|
||||
heat=heat,
|
||||
court_count=lead["court_count"] or "",
|
||||
facility_type=lead["facility_type"] or "",
|
||||
country=lead["country"] or "",
|
||||
contact_email=contact_email,
|
||||
)
|
||||
40
web/src/padelnomics/leads/templates/financing.html
Normal file
40
web/src/padelnomics/leads/templates/financing.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Find Financing - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="card max-w-2xl">
|
||||
<form method="post" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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" class="btn">Find Financing Partners</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{# Step 1: Your Project #}
|
||||
<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() }}">
|
||||
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">{{ t.q1_facility_label }} <span class="required">*</span></span>
|
||||
{% if 'facility_type' in errors %}<p class="q-error-hint">{{ t.q1_error_facility }}</p>{% endif %}
|
||||
<div class="q-pills">
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-label" for="court_count">{{ t.q1_court_count }}</label>
|
||||
<input type="number" id="court_count" name="court_count" class="q-input" min="1" max="50" value="{{ data.get('court_count', '6') }}">
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">{{ t.q1_glass_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('standard', t.q1_glass_standard), ('panoramic', t.q1_glass_panoramic), ('no_preference', t.q1_glass_no_pref)] %}
|
||||
<label><input type="radio" name="glass_type" value="{{ val }}" {{ 'checked' if data.get('glass_type') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">{{ t.q1_lighting_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('led_standard', t.q1_lighting_led_std), ('led_competition', t.q1_lighting_led_comp), ('natural', t.q1_lighting_natural), ('not_sure', t.q1_lighting_not_sure)] %}
|
||||
<label><input type="radio" name="lighting_type" value="{{ val }}" {{ 'checked' if data.get('lighting_type') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-nav">
|
||||
<div></div>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||
<div class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
{# Step 2: Location #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=2) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">{{ t.q2_heading }}</h2>
|
||||
<p class="q-step-sub">{{ t.q2_subheading }}</p>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-label" for="city">{{ t.q2_city_label }}</label>
|
||||
<input type="text" id="city" name="city" class="q-input" placeholder="{{ t.q2_city_placeholder }}" value="{{ data.get('city', '') }}">
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-label" for="country">{{ t.q2_country_label }} <span class="required">*</span></label>
|
||||
{% if 'country' in errors %}<p class="q-error-hint">{{ t.q2_error_country }}</p>{% endif %}
|
||||
<select id="country" name="country" class="q-input {% if 'country' in errors %}q-input--error{% endif %}">
|
||||
<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')] %}
|
||||
<option value="{{ code }}" {{ 'selected' if data.get('country') == code }}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="q-nav">
|
||||
<button type="button" class="q-btn-back"
|
||||
hx-get="{{ url_for('leads.quote_step', step=1, _accumulated=data | tojson) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||
<div class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
{# Step 3: Build Context #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=3) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">{{ t.q3_heading }}</h2>
|
||||
<p class="q-step-sub">{{ t.q3_subheading }}</p>
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">{{ t.q3_context_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('new_standalone', t.q3_context_new), ('adding_to_club', t.q3_context_adding), ('converting_building', t.q3_context_converting), ('venue_search', t.q3_context_venue_search)] %}
|
||||
<label><input type="radio" name="build_context" value="{{ val }}" {{ 'checked' if data.get('build_context') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-nav">
|
||||
<button type="button" class="q-btn-back"
|
||||
hx-get="{{ url_for('leads.quote_step', step=2, _accumulated=data | tojson) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||
<div class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
{# Step 4: Project Phase #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=4) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">{{ t.q4_heading }}</h2>
|
||||
<p class="q-step-sub">{{ t.q4_subheading }}</p>
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">{{ t.q4_phase_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('still_searching', t.q4_phase_searching), ('location_found', t.q4_phase_found), ('converting_existing', t.q4_phase_converting), ('lease_signed', t.q4_phase_lease_signed), ('permit_not_filed', t.q4_phase_permit_not_filed), ('permit_pending', t.q4_phase_permit_pending), ('permit_granted', t.q4_phase_permit_granted)] %}
|
||||
<label><input type="radio" name="location_status" value="{{ val }}" {{ 'checked' if data.get('location_status') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-nav">
|
||||
<button type="button" class="q-btn-back"
|
||||
hx-get="{{ url_for('leads.quote_step', step=3, _accumulated=data | tojson) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||
<div class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
{# Step 5: Timeline #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=5) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">{{ t.q5_heading }}</h2>
|
||||
<p class="q-step-sub">{{ t.q5_subheading }}</p>
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">{{ t.q5_timeline_label }} <span class="required">*</span></span>
|
||||
{% if 'timeline' in errors %}<p class="q-error-hint">{{ t.q5_error_timeline }}</p>{% endif %}
|
||||
<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)] %}
|
||||
<label><input type="radio" name="timeline" value="{{ val }}" {{ 'checked' if data.get('timeline') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<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" min="0" step="10000" value="{{ data.get('budget_estimate', '') }}">
|
||||
</div>
|
||||
|
||||
<div class="q-nav">
|
||||
<button type="button" class="q-btn-back"
|
||||
hx-get="{{ url_for('leads.quote_step', step=4, _accumulated=data | tojson) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||
<div class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
{# Step 6: Financing #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=6) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">{{ t.q6_heading }}</h2>
|
||||
<p class="q-step-sub">{{ t.q6_subheading }}</p>
|
||||
|
||||
<div class="q-field-group">
|
||||
<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">
|
||||
{% 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 }} required><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-checkbox-label">
|
||||
<input type="checkbox" name="wants_financing_help" value="1" {{ 'checked' if data.get('wants_financing_help') == '1' }}>
|
||||
<span>{{ t.q6_help_checkbox }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<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">
|
||||
{% 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 }} required><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-nav">
|
||||
<button type="button" class="q-btn-back"
|
||||
hx-get="{{ url_for('leads.quote_step', step=5, _accumulated=data | tojson) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||
<div class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
{# Step 7: About You #}
|
||||
<form hx-post="{{ url_for('leads.quote_step', step=7) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">{{ t.q7_heading }}</h2>
|
||||
<p class="q-step-sub">{{ t.q7_subheading }}</p>
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">{{ t.q7_role_label }} <span class="required">*</span></span>
|
||||
{% if 'stakeholder_type' in errors %}<p class="q-error-hint">{{ t.q7_error_role }}</p>{% endif %}
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('entrepreneur', t.q7_role_entrepreneur), ('tennis_club', t.q7_role_tennis), ('municipality', t.q7_role_municipality), ('developer', t.q7_role_developer), ('operator', t.q7_role_operator), ('architect', t.q7_role_architect)] %}
|
||||
<label><input type="radio" name="stakeholder_type" value="{{ val }}" {{ 'checked' if data.get('stakeholder_type') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">{{ t.q7_contact_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('first_time', t.q7_contact_first), ('researching', t.q7_contact_researching), ('received_quotes', t.q7_contact_received)] %}
|
||||
<label><input type="radio" name="previous_supplier_contact" value="{{ val }}" {{ 'checked' if data.get('previous_supplier_contact') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-nav">
|
||||
<button type="button" class="q-btn-back"
|
||||
hx-get="{{ url_for('leads.quote_step', step=6, _accumulated=data | tojson) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||
<div class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
{# Step 8: Services Needed #}
|
||||
<form id="q-step-8-form"
|
||||
hx-post="{{ url_for('leads.quote_step', step=8) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">{{ t.q8_heading }}</h2>
|
||||
<p class="q-step-sub">{{ t.q8_subheading }}</p>
|
||||
|
||||
<div class="q-field-group">
|
||||
<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">
|
||||
{% 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)] %}
|
||||
<label><input type="checkbox" name="services_needed" value="{{ val }}" {{ 'checked' if val in selected_services }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-label" for="additional_info">{{ t.q8_additional_label }}</label>
|
||||
<textarea id="additional_info" name="additional_info" class="q-input" rows="3" placeholder="{{ t.q8_additional_placeholder }}">{{ data.get('additional_info', '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="q-nav">
|
||||
<button type="button" class="q-btn-back"
|
||||
hx-get="{{ url_for('leads.quote_step', step=7, _accumulated=data | tojson) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next" id="q8-next-btn">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</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 class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
{# Step 9: Contact Details — final submit #}
|
||||
<form method="post" action="{{ url_for('leads.quote_request') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{# Expand accumulated data into individual hidden fields for the POST handler #}
|
||||
{% for key, val in data.items() %}
|
||||
{% if key != 'services_needed' %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ val }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for svc in data.get('services_needed', []) %}
|
||||
<input type="hidden" name="services_needed" value="{{ svc }}">
|
||||
{% endfor %}
|
||||
|
||||
<h2 class="q-step-title">{{ t.q9_heading }}</h2>
|
||||
<p class="q-step-sub">{{ t.q9_subheading }}</p>
|
||||
|
||||
<div class="q-privacy-box">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5 7V5a3.5 3.5 0 10-7 0v2M4 7h8a1 1 0 011 1v5a1 1 0 01-1 1H4a1 1 0 01-1-1V8a1 1 0 011-1z" stroke="#3B82F6" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>{{ t.q9_privacy_msg }}</span>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-label" for="contact_name">{{ t.q9_name_label }} <span class="required">*</span></label>
|
||||
{% if 'contact_name' in errors %}<p class="q-error-hint">{{ t.q9_error_name }}</p>{% endif %}
|
||||
<input type="text" id="contact_name" name="contact_name" class="q-input {% if 'contact_name' in errors %}q-input--error{% endif %}" value="{{ data.get('contact_name', '') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-label" for="contact_email">{{ t.q9_email_label }} <span class="required">*</span></label>
|
||||
{% if 'contact_email' in errors %}<p class="q-error-hint">{{ t.q9_error_email }}</p>{% endif %}
|
||||
<input type="email" id="contact_email" name="contact_email" class="q-input {% if 'contact_email' in errors %}q-input--error{% endif %}" value="{{ data.get('contact_email', '') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-label" for="contact_phone">{{ t.q9_phone_label }} <span class="required">*</span></label>
|
||||
{% if 'contact_phone' in errors %}<p class="q-error-hint">{{ t.q9_error_phone }}</p>{% endif %}
|
||||
<input type="tel" id="contact_phone" name="contact_phone" class="q-input {% if 'contact_phone' in errors %}q-input--error{% endif %}" value="{{ data.get('contact_phone', '') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-label" for="contact_company">{{ t.q9_company_label }} <span style="color:#94A3B8;font-weight:400">{{ t.q9_company_note }}</span></label>
|
||||
<input type="text" id="contact_company" name="contact_company" class="q-input" value="{{ data.get('contact_company', '') }}">
|
||||
</div>
|
||||
|
||||
<div class="q-consent">
|
||||
<label>
|
||||
<input type="checkbox" name="consent" value="1" required>
|
||||
<span>{{ t.q9_consent_text }} <a href="{{ url_for('public.privacy') }}">{{ t.q9_consent_privacy }}</a> · <a href="{{ url_for('public.terms') }}">{{ t.q9_consent_terms }}</a></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="q-nav">
|
||||
<button type="button" class="q-btn-back"
|
||||
hx-get="{{ url_for('leads.quote_step', step=8, _accumulated=data | tojson) }}"
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-submit">{{ t.q_btn_submit }}</button>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; font-size: 11px; color: #94A3B8; margin-top: 1rem;">{{ t.q9_no_obligation }}</p>
|
||||
</form>
|
||||
|
||||
<div id="q-progress" hx-swap-oob="innerHTML">
|
||||
<div class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
122
web/src/padelnomics/leads/templates/quote_request.html
Normal file
122
web/src/padelnomics/leads/templates/quote_request.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t.q_page_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.q-wizard { max-width: 560px; margin: 2rem auto; }
|
||||
.q-progress { margin-bottom: 1.5rem; }
|
||||
.q-progress__meta {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.q-progress__label { font-size: 12px; font-weight: 600; color: #334155; }
|
||||
.q-progress__count { font-size: 11px; color: #94A3B8; }
|
||||
.q-progress__track {
|
||||
height: 4px; border-radius: 2px; background: #E2E8F0; overflow: hidden;
|
||||
}
|
||||
.q-progress__fill {
|
||||
height: 100%; border-radius: 2px; background: #1D4ED8;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.q-step-title { font-size: 1.25rem; font-weight: 800; color: #0F172A; margin: 0 0 4px; }
|
||||
.q-step-sub { font-size: 13px; color: #64748B; margin: 0 0 1.5rem; }
|
||||
.q-field-group { margin-bottom: 1.25rem; }
|
||||
.q-label {
|
||||
display: block; font-size: 12px; font-weight: 600;
|
||||
color: #334155; margin-bottom: 4px;
|
||||
}
|
||||
.q-label .required { color: #EF4444; }
|
||||
.q-pills { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 1rem; }
|
||||
.q-pills label { cursor: pointer; }
|
||||
.q-pills input[type="radio"],
|
||||
.q-pills input[type="checkbox"] { display: none; }
|
||||
.q-pill {
|
||||
display: inline-block; padding: 7px 14px; border-radius: 999px;
|
||||
border: 1px solid #E2E8F0; font-size: 12px; font-weight: 600;
|
||||
color: #94A3B8; transition: all 0.15s; background: transparent;
|
||||
}
|
||||
.q-pill:hover { background: #F1F5F9; color: #64748B; }
|
||||
input:checked + .q-pill { background: #1D4ED8; border-color: #1D4ED8; color: white; }
|
||||
.q-input {
|
||||
width: 100%; background: #F1F5F9; border: 1px solid #CBD5E1;
|
||||
border-radius: 10px; padding: 8px 12px; font-size: 13px;
|
||||
font-family: 'Inter', sans-serif; color: #0F172A; outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.q-input:focus { border-color: rgba(29,78,216,0.5); }
|
||||
.q-input--error { border-color: #EF4444; }
|
||||
.q-error-hint { font-size: 11px; color: #EF4444; margin-top: 4px; }
|
||||
textarea.q-input { resize: vertical; }
|
||||
select.q-input { appearance: auto; }
|
||||
.q-nav { display: flex; justify-content: space-between; margin-top: 1.5rem; }
|
||||
.q-btn-back {
|
||||
padding: 8px 20px; font-size: 12px; font-weight: 600;
|
||||
border: 1px solid #CBD5E1; background: transparent; color: #64748B;
|
||||
border-radius: 10px; cursor: pointer; font-family: 'Inter', sans-serif;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.q-btn-back:hover { background: #F1F5F9; color: #475569; }
|
||||
.q-btn-next, .q-btn-submit {
|
||||
padding: 10px 24px; font-size: 13px; font-weight: 700;
|
||||
border: none; background: #1D4ED8; color: white; border-radius: 10px;
|
||||
cursor: pointer; font-family: 'Inter', sans-serif;
|
||||
box-shadow: 0 2px 10px rgba(29,78,216,0.25); transition: all 0.15s;
|
||||
}
|
||||
.q-btn-next:hover, .q-btn-submit:hover { background: #1E40AF; }
|
||||
.q-prefill-card {
|
||||
background: #EFF6FF; border: 1.5px solid #BFDBFE;
|
||||
border-radius: 14px; padding: 1rem 1.25rem; margin-bottom: 1.5rem;
|
||||
font-size: 12px;
|
||||
}
|
||||
.q-prefill-card dt { color: #94A3B8; font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.q-prefill-card dd { color: #0F172A; font-weight: 600; margin: 0 0 6px; }
|
||||
.q-privacy-box {
|
||||
background: #EFF6FF; border: 1px solid #BFDBFE; border-radius: 14px;
|
||||
padding: 12px 14px; margin-bottom: 1rem; font-size: 12px;
|
||||
color: #1E40AF; display: flex; gap: 8px; align-items: flex-start;
|
||||
}
|
||||
.q-privacy-box svg { flex-shrink: 0; margin-top: 1px; }
|
||||
.q-consent { margin-bottom: 1.25rem; }
|
||||
.q-consent label {
|
||||
display: flex; gap: 8px; align-items: flex-start;
|
||||
font-size: 12px; color: #475569; cursor: pointer;
|
||||
}
|
||||
.q-consent input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
|
||||
.q-consent a { color: #1D4ED8; }
|
||||
.q-checkbox-label {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; color: #475569; cursor: pointer;
|
||||
}
|
||||
.q-checkbox-label input[type="checkbox"] { margin: 0; flex-shrink: 0; }
|
||||
.q-card {
|
||||
background: white; border-radius: 16px; padding: 36px 32px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.q-card { padding: 24px 16px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 80vh;">
|
||||
<div class="container-page py-12">
|
||||
<div class="q-wizard">
|
||||
<div class="q-progress" id="q-progress">
|
||||
<div class="q-progress__meta">
|
||||
<span class="q-progress__label">{{ steps[step - 1].title }}</span>
|
||||
<span class="q-progress__count">{{ t.q_step_counter | tformat(step=step, total=steps|length) }}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="q-card">
|
||||
<div id="quote-step">
|
||||
{% include "partials/quote_step_" ~ step ~ ".html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
114
web/src/padelnomics/leads/templates/quote_submitted.html
Normal file
114
web/src/padelnomics/leads/templates/quote_submitted.html
Normal file
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t.qs_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.submitted-flow { max-width: 580px; margin: 0 auto; text-align: center; }
|
||||
.check-circle {
|
||||
width: 64px; height: 64px; border-radius: 50%;
|
||||
background: #D1FAE5; display: inline-flex; align-items: center;
|
||||
justify-content: center; margin-bottom: 1rem;
|
||||
}
|
||||
.check-circle svg { width: 32px; height: 32px; color: #059669; }
|
||||
.next-steps {
|
||||
background: #F8FAFC; border: 1px solid #E2E8F0; border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem; text-align: left; margin: 1.5rem 0;
|
||||
}
|
||||
.next-steps h3 { font-size: 0.875rem; font-weight: 700; color: #334155; margin-bottom: 0.75rem; }
|
||||
.next-steps ol { padding-left: 0; list-style: none; margin: 0; }
|
||||
.next-steps li {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
padding: 8px 0; font-size: 0.8125rem; color: #475569;
|
||||
border-bottom: 1px solid #F1F5F9;
|
||||
}
|
||||
.next-steps li:last-child { border-bottom: none; }
|
||||
.next-steps .step-num {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
background: #EFF6FF; color: #3B82F6; font-weight: 700;
|
||||
font-size: 0.75rem; display: flex; align-items: center;
|
||||
justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.next-steps .step-time {
|
||||
margin-left: auto; font-size: 0.6875rem; color: #94A3B8;
|
||||
white-space: nowrap; padding-left: 8px;
|
||||
}
|
||||
.email-box {
|
||||
background: #FFF7ED; border: 1px solid #FED7AA; border-radius: 10px;
|
||||
padding: 14px 16px; text-align: left; margin: 1rem 0;
|
||||
font-size: 0.8125rem; color: #9A3412;
|
||||
}
|
||||
.signup-box {
|
||||
background: white; border: 1px solid #E2E8F0; border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem; text-align: center; margin: 1.5rem 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.signup-box h3 { font-size: 0.9375rem; font-weight: 700; color: #334155; margin-bottom: 0.25rem; }
|
||||
.signup-box p { font-size: 0.8125rem; color: #64748B; margin-bottom: 1rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 80vh;">
|
||||
<div class="container-page py-12">
|
||||
<div class="submitted-flow">
|
||||
<div class="check-circle">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl" style="margin-bottom: 0.5rem;">{{ t.qs_title }}</h1>
|
||||
<p style="color: #64748B; font-size: 0.9375rem;">
|
||||
{{ t.qs_matched_pre }}
|
||||
{% if court_count %}{{ court_count }}{{ t.qs_matched_court_suffix }}{% endif %}
|
||||
{% if facility_type %}{{ t.qs_matched_facility_fmt | tformat(type=facility_type) }}{% endif %}
|
||||
{{ t.qs_matched_project }}
|
||||
{% if country %}in {{ country }}{% endif %}
|
||||
{{ t.qs_matched_post }}
|
||||
</p>
|
||||
|
||||
<div class="next-steps">
|
||||
<h3>{{ t.qs_next_h2 }}</h3>
|
||||
<ol>
|
||||
<li>
|
||||
<span class="step-num">1</span>
|
||||
<span>{{ t.qs_step_1 }}</span>
|
||||
<span class="step-time">{{ t.qs_step_1_time }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="step-num">2</span>
|
||||
<span>{{ t.qs_step_2 }}</span>
|
||||
<span class="step-time">{{ t.qs_step_2_time }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="step-num">3</span>
|
||||
<span>{{ t.qs_step_3 }}</span>
|
||||
<span class="step-time">{{ t.qs_step_3_time }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="step-num">4</span>
|
||||
<span>{{ t.qs_step_4 }}</span>
|
||||
<span class="step-time">{{ t.qs_step_4_time }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{% if contact_email %}
|
||||
<div class="email-box">
|
||||
📧 Your email <strong>{{ contact_email }}</strong> has been verified.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not user %}
|
||||
<div class="signup-box">
|
||||
<h3>{{ t.qs_signup_h3 }}</h3>
|
||||
<p>{{ t.qs_signup_text }}</p>
|
||||
<a href="{{ url_for('auth.signup') }}?next={{ url_for('planner.index') }}" class="btn">{{ t.qs_signup_btn }}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="margin-top: 1.5rem;">
|
||||
<a href="{{ url_for('planner.index') }}" class="btn">{{ t.qs_back_planner }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
37
web/src/padelnomics/leads/templates/quote_verify_sent.html
Normal file
37
web/src/padelnomics/leads/templates/quote_verify_sent.html
Normal file
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t.qv_heading }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-sm mx-auto mt-8 text-center">
|
||||
<div style="font-size: 2.5rem; margin-bottom: 1rem;">✉</div>
|
||||
|
||||
<h1 class="text-2xl mb-4">{{ t.qv_heading }}</h1>
|
||||
|
||||
<p class="text-slate-dark">{{ t.qv_sent_msg }}</p>
|
||||
<p class="font-semibold text-navy my-2">{{ contact_email }}</p>
|
||||
|
||||
<p class="text-slate text-sm" style="margin-top: 1rem;">
|
||||
{{ t.qv_instructions | tformat(app_name=config.APP_NAME) }}
|
||||
</p>
|
||||
|
||||
<p class="text-slate text-sm" style="margin-top: 0.5rem;">
|
||||
{{ t.qv_link_expiry }}
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<details class="text-left">
|
||||
<summary class="cursor-pointer text-sm font-medium text-navy">{{ t.qv_no_email }}</summary>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1 text-sm text-slate-dark">
|
||||
<li>{{ t.qv_spam }}</li>
|
||||
<li>{{ t.qv_check_email_pre }}<strong>{{ contact_email }}</strong>{{ t.qv_check_email_post }}</li>
|
||||
<li>{{ t.qv_wait }}</li>
|
||||
</ul>
|
||||
<p class="text-sm text-slate mt-3">
|
||||
{{ t.qv_wrong_email }} <a href="{{ url_for('leads.quote_request') }}">{{ t.qv_wrong_email_link }}</a>.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
40
web/src/padelnomics/leads/templates/suppliers.html
Normal file
40
web/src/padelnomics/leads/templates/suppliers.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Get Court Supplier Quotes - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="card max-w-2xl">
|
||||
<form method="post" class="space-y-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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" class="btn">Request Supplier Quotes</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
1536
web/src/padelnomics/locales/de.json
Normal file
1536
web/src/padelnomics/locales/de.json
Normal file
File diff suppressed because it is too large
Load Diff
1536
web/src/padelnomics/locales/en.json
Normal file
1536
web/src/padelnomics/locales/en.json
Normal file
File diff suppressed because it is too large
Load Diff
118
web/src/padelnomics/migrations/migrate.py
Normal file
118
web/src/padelnomics/migrations/migrate.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Sequential migration runner for Padelnomics.
|
||||
|
||||
Manages SQLite schema evolution by replaying migrations in order.
|
||||
All databases — fresh and existing — go through the same path.
|
||||
|
||||
Algorithm
|
||||
---------
|
||||
1. Connect to the SQLite database (create file if missing).
|
||||
2. Set WAL journal mode and enable foreign keys.
|
||||
3. Create the _migrations tracking table if it doesn't exist.
|
||||
4. Discover version files in versions/ matching NNNN_*.py.
|
||||
5. Diff discovered versions against the _migrations tracking table.
|
||||
6. Run pending migrations in order, record each in _migrations.
|
||||
7. Commit the transaction and print a summary with table names.
|
||||
|
||||
Adding a new migration
|
||||
----------------------
|
||||
1. Create ``versions/NNNN_description.py`` with a single ``up(conn)``
|
||||
function that receives an *uncommitted* ``sqlite3.Connection``.
|
||||
The runner commits after all pending migrations succeed (batch
|
||||
atomicity), so do NOT call ``conn.commit()`` inside ``up()``.
|
||||
2. Use IF NOT EXISTS / IF EXISTS guards for idempotency.
|
||||
|
||||
Design decisions
|
||||
----------------
|
||||
- **Single code path**: Fresh and existing databases both replay
|
||||
migrations. 0000_initial_schema.py is the baseline.
|
||||
- **Sync sqlite3, not aiosqlite**: Migrations run at startup *before*
|
||||
the async event loop, so we use the stdlib sqlite3 module directly.
|
||||
- **up(conn) receives an uncommitted connection**: All pending migrations
|
||||
share a single transaction. If any migration fails, the entire batch
|
||||
rolls back, leaving the DB in its previous consistent state.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
VERSIONS_DIR = Path(__file__).parent / "versions"
|
||||
VERSION_RE = re.compile(r"^(\d{4})_.+\.py$")
|
||||
|
||||
|
||||
def _discover_versions():
|
||||
"""Return sorted list of (name, module_path) for version files."""
|
||||
if not VERSIONS_DIR.is_dir():
|
||||
return []
|
||||
versions = []
|
||||
for f in sorted(VERSIONS_DIR.iterdir()):
|
||||
if VERSION_RE.match(f.name):
|
||||
versions.append(f.stem) # e.g. "0001_rename_ls_to_paddle"
|
||||
return versions
|
||||
|
||||
|
||||
def migrate(db_path=None):
|
||||
if db_path is None:
|
||||
db_path = os.getenv("DATABASE_PATH", "data/app.db")
|
||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
# Ensure tracking table exists before anything else
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
versions = _discover_versions()
|
||||
applied = {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM _migrations").fetchall()
|
||||
}
|
||||
pending = [v for v in versions if v not in applied]
|
||||
|
||||
if pending:
|
||||
for name in pending:
|
||||
print(f" Applying {name}...")
|
||||
mod = importlib.import_module(
|
||||
f"padelnomics.migrations.versions.{name}"
|
||||
)
|
||||
mod.up(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO _migrations (name) VALUES (?)", (name,)
|
||||
)
|
||||
conn.commit()
|
||||
print(f"✓ Applied {len(pending)} migration(s): {db_path}")
|
||||
else:
|
||||
print(f"✓ All migrations already applied: {db_path}")
|
||||
|
||||
# Show tables (excluding internal sqlite/fts tables)
|
||||
cursor = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
" AND name NOT LIKE 'sqlite_%'"
|
||||
" ORDER BY name"
|
||||
)
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
print(f" Tables: {', '.join(tables)}")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
540
web/src/padelnomics/migrations/versions/0000_initial_schema.py
Normal file
540
web/src/padelnomics/migrations/versions/0000_initial_schema.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""Initial schema baseline — all tables as of migration 0012."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
# Users
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
last_login_at TEXT,
|
||||
deleted_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at)")
|
||||
|
||||
# User Roles (RBAC)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, role)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_roles_role ON user_roles(role)")
|
||||
|
||||
# Billing Customers
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS billing_customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
|
||||
provider_customer_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_billing_customers_user ON billing_customers(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_billing_customers_provider"
|
||||
" ON billing_customers(provider_customer_id)"
|
||||
)
|
||||
|
||||
# Auth Tokens (magic links)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_auth_tokens_token ON auth_tokens(token)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id)")
|
||||
|
||||
# Subscriptions
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
status TEXT NOT NULL DEFAULT 'free',
|
||||
provider_subscription_id TEXT,
|
||||
current_period_end TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_provider"
|
||||
" ON subscriptions(provider_subscription_id)"
|
||||
)
|
||||
|
||||
# API Keys
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT UNIQUE NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
scopes TEXT DEFAULT 'read',
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
deleted_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)")
|
||||
|
||||
# API Request Log
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
endpoint TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_requests_user ON api_requests(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_requests_date ON api_requests(created_at)"
|
||||
)
|
||||
|
||||
# Rate Limits
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_rate_limits_key ON rate_limits(key, timestamp)"
|
||||
)
|
||||
|
||||
# Background Tasks
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_name TEXT NOT NULL,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
run_at TEXT NOT NULL,
|
||||
retries INTEGER DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status, run_at)")
|
||||
|
||||
# Scenarios (core domain entity)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scenarios (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL DEFAULT 'Untitled Scenario',
|
||||
state_json TEXT NOT NULL,
|
||||
location TEXT,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT,
|
||||
deleted_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_scenarios_user ON scenarios(user_id)")
|
||||
|
||||
# Lead requests
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lead_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
lead_type TEXT NOT NULL,
|
||||
scenario_id INTEGER REFERENCES scenarios(id),
|
||||
location TEXT,
|
||||
court_count INTEGER,
|
||||
budget_estimate INTEGER,
|
||||
message TEXT,
|
||||
status TEXT DEFAULT 'new',
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
-- Phase 0: expanded quote qualification fields
|
||||
facility_type TEXT,
|
||||
glass_type TEXT,
|
||||
lighting_type TEXT,
|
||||
build_context TEXT,
|
||||
country TEXT,
|
||||
timeline TEXT,
|
||||
location_status TEXT,
|
||||
financing_status TEXT,
|
||||
wants_financing_help INTEGER DEFAULT 0,
|
||||
decision_process TEXT,
|
||||
previous_supplier_contact TEXT,
|
||||
services_needed TEXT,
|
||||
additional_info TEXT,
|
||||
contact_name TEXT,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
contact_company TEXT,
|
||||
stakeholder_type TEXT,
|
||||
heat_score TEXT DEFAULT 'cool',
|
||||
verified_at TEXT,
|
||||
|
||||
-- Phase 1: credit cost and unlock tracking
|
||||
credit_cost INTEGER,
|
||||
unlock_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_leads_status ON lead_requests(status)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_leads_heat ON lead_requests(heat_score)"
|
||||
)
|
||||
|
||||
# Suppliers directory
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
country_code TEXT NOT NULL,
|
||||
city TEXT,
|
||||
region TEXT NOT NULL,
|
||||
website TEXT,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL,
|
||||
contact TEXT,
|
||||
claimed_at TEXT,
|
||||
claimed_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
tier TEXT NOT NULL DEFAULT 'free',
|
||||
logo_url TEXT,
|
||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||
highlight INTEGER NOT NULL DEFAULT 0,
|
||||
sticky_until TEXT,
|
||||
sticky_country TEXT,
|
||||
|
||||
-- Phase 1: expanded supplier profile and credits
|
||||
service_categories TEXT,
|
||||
service_area TEXT,
|
||||
years_in_business INTEGER,
|
||||
project_count INTEGER,
|
||||
short_description TEXT,
|
||||
long_description TEXT,
|
||||
contact_name TEXT,
|
||||
contact_email TEXT,
|
||||
contact_phone TEXT,
|
||||
credit_balance INTEGER NOT NULL DEFAULT 0,
|
||||
monthly_credits INTEGER NOT NULL DEFAULT 0,
|
||||
last_credit_refill TEXT,
|
||||
|
||||
-- Phase 2: editable profile fields
|
||||
logo_file TEXT,
|
||||
tagline TEXT,
|
||||
|
||||
-- Phase 3: Basic tier fields
|
||||
services_offered TEXT,
|
||||
contact_role TEXT,
|
||||
linkedin_url TEXT,
|
||||
instagram_url TEXT,
|
||||
youtube_url TEXT,
|
||||
|
||||
-- Phase 4: Directory card cover image
|
||||
cover_image TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_suppliers_country ON suppliers(country_code)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_suppliers_category ON suppliers(category)"
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_suppliers_slug ON suppliers(slug)")
|
||||
|
||||
# FTS5 full-text search for suppliers
|
||||
conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS suppliers_fts USING fts5(
|
||||
name, description, city, country_code, category,
|
||||
content='suppliers', content_rowid='id'
|
||||
)
|
||||
""")
|
||||
|
||||
# Keep FTS in sync with suppliers table
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS suppliers_ai AFTER INSERT ON suppliers BEGIN
|
||||
INSERT INTO suppliers_fts(rowid, name, description, city, country_code, category)
|
||||
VALUES (new.id, new.name, new.description, new.city, new.country_code, new.category);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS suppliers_ad AFTER DELETE ON suppliers BEGIN
|
||||
INSERT INTO suppliers_fts(suppliers_fts, rowid, name, description, city, country_code, category)
|
||||
VALUES ('delete', old.id, old.name, old.description, old.city, old.country_code, old.category);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS suppliers_au AFTER UPDATE ON suppliers BEGIN
|
||||
INSERT INTO suppliers_fts(suppliers_fts, rowid, name, description, city, country_code, category)
|
||||
VALUES ('delete', old.id, old.name, old.description, old.city, old.country_code, old.category);
|
||||
INSERT INTO suppliers_fts(rowid, name, description, city, country_code, category)
|
||||
VALUES (new.id, new.name, new.description, new.city, new.country_code, new.category);
|
||||
END
|
||||
""")
|
||||
|
||||
# Credit ledger
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS credit_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||
delta INTEGER NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
reference_id INTEGER,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_credit_ledger_supplier ON credit_ledger(supplier_id)"
|
||||
)
|
||||
|
||||
# Lead forwards
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lead_forwards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lead_id INTEGER NOT NULL REFERENCES lead_requests(id),
|
||||
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||
credit_cost INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'sent',
|
||||
email_sent_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(lead_id, supplier_id)
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_lead_forwards_lead ON lead_forwards(lead_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_lead_forwards_supplier ON lead_forwards(supplier_id)"
|
||||
)
|
||||
|
||||
# Supplier enquiries (Basic+ listing contact form)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS supplier_enquiries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||
contact_name TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'new',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_supplier_enquiries_supplier"
|
||||
" ON supplier_enquiries(supplier_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_supplier_enquiries_email"
|
||||
" ON supplier_enquiries(contact_email, created_at)"
|
||||
)
|
||||
|
||||
# Supplier boost subscriptions/purchases
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS supplier_boosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||
boost_type TEXT NOT NULL,
|
||||
paddle_subscription_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
starts_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_supplier_boosts_supplier ON supplier_boosts(supplier_id)"
|
||||
)
|
||||
|
||||
# Paddle products
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS paddle_products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
paddle_product_id TEXT NOT NULL,
|
||||
paddle_price_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
price_cents INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
billing_type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# Business plan PDF exports
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS business_plan_exports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
scenario_id INTEGER NOT NULL REFERENCES scenarios(id),
|
||||
paddle_transaction_id TEXT,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
file_path TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_bpe_user ON business_plan_exports(user_id)")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_bpe_scenario ON business_plan_exports(scenario_id)"
|
||||
)
|
||||
|
||||
# In-app feedback
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
page_url TEXT,
|
||||
message TEXT NOT NULL,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# ==========================================================================
|
||||
# Content / Programmatic SEO
|
||||
# ==========================================================================
|
||||
|
||||
# Published scenarios
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS published_scenarios (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
location TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
venue_type TEXT NOT NULL DEFAULT 'indoor',
|
||||
ownership TEXT NOT NULL DEFAULT 'rent',
|
||||
court_config TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
calc_json TEXT NOT NULL,
|
||||
template_data_id INTEGER REFERENCES template_data(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pub_scenarios_slug ON published_scenarios(slug)"
|
||||
)
|
||||
|
||||
# Article templates
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS article_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'calculator',
|
||||
input_schema TEXT NOT NULL,
|
||||
url_pattern TEXT NOT NULL,
|
||||
title_pattern TEXT NOT NULL,
|
||||
meta_description_pattern TEXT,
|
||||
body_template TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_article_templates_slug ON article_templates(slug)"
|
||||
)
|
||||
|
||||
# Template data
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS template_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL REFERENCES article_templates(id),
|
||||
data_json TEXT NOT NULL,
|
||||
scenario_id INTEGER REFERENCES published_scenarios(id),
|
||||
article_id INTEGER REFERENCES articles(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_data_template ON template_data(template_id)"
|
||||
)
|
||||
|
||||
# Articles
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url_path TEXT UNIQUE NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
meta_description TEXT,
|
||||
country TEXT,
|
||||
region TEXT,
|
||||
og_image_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
published_at TEXT,
|
||||
template_data_id INTEGER REFERENCES template_data(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)"
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)"
|
||||
)
|
||||
|
||||
# FTS5 full-text search for articles
|
||||
conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
||||
title, meta_description, country, region,
|
||||
content='articles', content_rowid='id'
|
||||
)
|
||||
""")
|
||||
|
||||
# Keep FTS in sync with articles table
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
|
||||
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||
END
|
||||
""")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Rename lemonsqueezy columns to paddle."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(subscriptions)")}
|
||||
if "lemonsqueezy_customer_id" in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE subscriptions"
|
||||
" RENAME COLUMN lemonsqueezy_customer_id TO paddle_customer_id"
|
||||
)
|
||||
conn.execute(
|
||||
"ALTER TABLE subscriptions"
|
||||
" RENAME COLUMN lemonsqueezy_subscription_id"
|
||||
" TO paddle_subscription_id"
|
||||
)
|
||||
# Create index on whichever subscription ID column exists
|
||||
# (paddle_subscription_id before 0011, provider_subscription_id after)
|
||||
conn.execute("DROP INDEX IF EXISTS idx_subscriptions_provider")
|
||||
if "paddle_subscription_id" in cols or "lemonsqueezy_subscription_id" in cols:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_provider"
|
||||
" ON subscriptions(paddle_subscription_id)"
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Expand lead_requests for 3-step quote qualification flow."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(lead_requests)")}
|
||||
new_cols = {
|
||||
"facility_type": "TEXT",
|
||||
"glass_type": "TEXT",
|
||||
"lighting_type": "TEXT",
|
||||
"build_context": "TEXT",
|
||||
"country": "TEXT",
|
||||
"timeline": "TEXT",
|
||||
"location_status": "TEXT",
|
||||
"financing_status": "TEXT",
|
||||
"wants_financing_help": "INTEGER DEFAULT 0",
|
||||
"decision_process": "TEXT",
|
||||
"previous_supplier_contact": "TEXT",
|
||||
"services_needed": "TEXT",
|
||||
"additional_info": "TEXT",
|
||||
"contact_name": "TEXT",
|
||||
"contact_email": "TEXT",
|
||||
"contact_phone": "TEXT",
|
||||
"contact_company": "TEXT",
|
||||
"heat_score": "TEXT DEFAULT 'cool'",
|
||||
}
|
||||
for col, col_type in new_cols.items():
|
||||
if col not in cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE lead_requests ADD COLUMN {col} {col_type}"
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Add stakeholder_type column to lead_requests."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(lead_requests)")}
|
||||
if "stakeholder_type" not in cols:
|
||||
conn.execute("ALTER TABLE lead_requests ADD COLUMN stakeholder_type TEXT")
|
||||
646
web/src/padelnomics/migrations/versions/0004_create_suppliers.py
Normal file
646
web/src/padelnomics/migrations/versions/0004_create_suppliers.py
Normal file
@@ -0,0 +1,646 @@
|
||||
"""Create suppliers table with FTS5 full-text search and seed directory data."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def _slugify(text):
|
||||
text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
|
||||
text = re.sub(r"[^\w\s-]", "", text.lower())
|
||||
return re.sub(r"[-\s]+", "-", text).strip("-")
|
||||
|
||||
|
||||
_REGION = {
|
||||
"DE": "Europe", "ES": "Europe", "IT": "Europe", "FR": "Europe",
|
||||
"PT": "Europe", "GB": "Europe", "NL": "Europe", "BE": "Europe",
|
||||
"SE": "Europe", "DK": "Europe", "FI": "Europe", "NO": "Europe",
|
||||
"AT": "Europe", "SI": "Europe", "IS": "Europe", "CH": "Europe",
|
||||
"US": "North America", "CA": "North America",
|
||||
"MX": "Latin America", "BR": "Latin America", "AR": "Latin America",
|
||||
"AE": "Middle East", "SA": "Middle East", "TR": "Middle East",
|
||||
"CN": "Asia Pacific", "IN": "Asia Pacific", "SG": "Asia Pacific",
|
||||
"ID": "Asia Pacific", "TH": "Asia Pacific", "AU": "Asia Pacific",
|
||||
"ZA": "Africa", "EG": "Africa",
|
||||
}
|
||||
|
||||
# (name, country_code, city, website, description, category, contact)
|
||||
_SUPPLIERS = [
|
||||
# ---- Germany: Court Manufacturers (1.1) ----
|
||||
("artec Sportgeräte GmbH", "DE", "Melle", "artec-sportgeraete.de",
|
||||
"30+ years sports equipment. 12mm laminated safety glass, DIN-standard statics, customizable colors. Indoor & outdoor.", "manufacturer", None),
|
||||
("PADELWERK Court GmbH", "DE", "Dortmund", "padelwerk.de",
|
||||
"Germany's first padel court manufacturer (est. 2021). All components made in Germany. LED lighting, roofing, financing consulting.", "manufacturer", "+49 172 54 60 150"),
|
||||
("Vindico Sport GmbH", "DE", None, "vindico-sport.de",
|
||||
"Serie B and Pano models. Carbon-steel S235JRH, 10-12mm tempered glass, 9-stage corrosion coating, WPT-standard entrances.", "manufacturer", None),
|
||||
("Kübler Sport GmbH", "DE", "Backnang", "kuebler-sport.de",
|
||||
"Major sports supplier. CLASSIC and PANORAMA courts with steel structure, tempered glass, wire mesh, artificial turf, 8 LED elements.", "manufacturer", "info@kuebler-sport.de"),
|
||||
("Padello GmbH", "DE", "Troisdorf", "padello.de",
|
||||
"Full-service builder with own metalworking shop. Steel construction up to 5mm adapted to regional wind loads. DIN-compliant, DEKRA-certified.", "manufacturer", None),
|
||||
("LOB Sport", "DE", "Nuremberg", "lobsport.de",
|
||||
"40+ years in tennis equipment, now padel. Edge and Infinity models. Foundation-free option for converting sand courts. Part of BECO Bermüller group.", "manufacturer", "+49 911 64200-0"),
|
||||
("Brako Padel GmbH", "DE", "Berlin", "brakopadel.com",
|
||||
"Padel specialist since 2013. 15-year corrosion warranty. Indoor, outdoor, panoramic, and portable courts. No-foundation portable options.", "manufacturer", None),
|
||||
("Padel Concept", "DE", None, "padelconcept.de",
|
||||
"Production in Baltic states, serves DACH/Scandinavia/BeNeLux. Own assembly teams. Innovative mobile floor plate solution.", "manufacturer", None),
|
||||
("Padelsportanlagenbau", "DE", None, "padelsportanlagenbau.de",
|
||||
"Manufacturer & turnkey builder, active in 18+ countries.", "manufacturer", None),
|
||||
|
||||
# ---- Germany: Turnkey & Consultants (1.2) ----
|
||||
("The Court Company", "DE", "Cologne", "courtcompany.de",
|
||||
"Germany's most experienced padel construction expert. 40+ courts in 2021. Licensed partner of AFP Courts, RedSport, adidas courts. Full service plus leasing.", "turnkey", "+49 2237 6034685"),
|
||||
("Real Padel GmbH", "DE", "Schönebeck", "realpadel.de",
|
||||
"Premium courts and full-service club consulting. Booking systems, automation, equipment, sponsor connections. DACH broker.", "turnkey", None),
|
||||
("Padel4U", "DE", None, "padel4u.de",
|
||||
"German distributor for Manzasport. Full range including standard, panoramic, and custom courts. ~1,000 courts/year through partnership.", "turnkey", None),
|
||||
("Trendsport Rummenigge", "DE", None, "trendsport-rummenigge.de",
|
||||
"Turnkey solutions from concept to completion. Guidance on German funding programs (Sportstättenförderung).", "turnkey", None),
|
||||
("PadelCity", "DE", None, "padelcity.de",
|
||||
"Germany's largest padel operator (20 facilities, 100+ courts). DTB partner. Proprietary booking app. Franchise model.", "franchise", None),
|
||||
("padelBOX", "DE", None, "padelbox.de",
|
||||
"Major German padel operator and consultant.", "turnkey", "r.stroehl@padelbox.de"),
|
||||
("Padel Solution", "DE", None, "padelsolution.de",
|
||||
"Supplier, planner, autonomous club solutions.", "turnkey", None),
|
||||
("Best World Padel", "DK", None, None,
|
||||
"Official MejorSet distributor for Germany and Denmark.", "turnkey", None),
|
||||
|
||||
# ---- Germany: Hall & Building Constructors (1.3) ----
|
||||
("SMC2 Bau", "DE", None, "smc2-bau.de",
|
||||
"Textile membrane sport halls — glass court walls serve as lower facade with membrane roof above. Meets German building norms. 10-year guarantee.", "hall_builder", None),
|
||||
("Padberg Projektbau", "DE", None, "padberg-projektbau.de",
|
||||
"Turnkey padel hall specialist. Expertise in minimum 6-7m ceiling height, glass wall statics, LED lighting. Assists with permits and grant funding.", "hall_builder", None),
|
||||
("BORGA", "SE", None, "borga.at",
|
||||
"45+ years building steel halls. 3 standardized padel hall solutions plus custom. Sandwich panels, mezzanine floors, court layout optimization.", "hall_builder", None),
|
||||
|
||||
# ---- Germany: International Manufacturers with DE Presence (1.4) — unique entries only ----
|
||||
("Padelcreations", "ES", None, "padelcreations.com",
|
||||
"24+ courts in DACH region. 10 court models. Certified Spanish assembly teams.", "manufacturer", "+34 965 049 221"),
|
||||
("Courtwall", "AT", "Vienna", "padelcourt.biz",
|
||||
"Building courts since 1984 (squash) and padel since 2007. 1,200+ courts worldwide. ISO-certified.", "manufacturer", None),
|
||||
("Unix Padel", "TR", "Istanbul", "unixpadel.com",
|
||||
"7,800+ courts globally. FIP-standard, KIWA-tested. Up to 12 courts/day production. TÜV-tested components.", "manufacturer", None),
|
||||
("The Padel Lab", "NL", None, "thepadellab.com",
|
||||
"HD Vision courts, tournament-grade panoramic, modular steel frame technology. Claims 40% reduction in construction time.", "manufacturer", None),
|
||||
|
||||
# ---- Germany: Turf, Lighting, Software (1.5) — unique entries only ----
|
||||
("BECO Bermüller", "DE", "Nuremberg", "beco-bermueller.de",
|
||||
"German synthetic turf and sports field materials. 40+ years. Parent of LOB Sport.", "turf", "+49 911 64200-0"),
|
||||
("Polytan GmbH", "DE", None, "polytan.com",
|
||||
"Since 1969. Sports surfaces development and production. Active in padel turf across Europe.", "turf", None),
|
||||
("Primaflor", "DE", None, "primaflor.de",
|
||||
"Artificial turf supplier for padel courts.", "turf", None),
|
||||
("bookaball", "DE", None, "bookaball.com",
|
||||
"Booking software for racket sport clubs. Used by German market leaders. €30M+ bookings processed.", "software", None),
|
||||
("AS LED Lighting", "DE", "Upper Bavaria", None,
|
||||
"Upper Bavaria-based LED specialist for padel.", "lighting", None),
|
||||
|
||||
# ---- Spain: Court Manufacturers (2.1) ----
|
||||
("MejorSet", "ES", "Crevillente", "mejorset.com",
|
||||
"Official court of Premier Padel & FIP. 10,000+ courts in 70+ countries. Part of LeDap group. Pioneer of panoramic design.", "manufacturer", "+34 966 374 289"),
|
||||
("Padel Galis", "ES", "Valencia", "padelgalis.com",
|
||||
"10,000+ courts, 75+ countries. Partnership with Wilson and Fernando Belasteguín. Official WPT/Premier Padel supplier.", "manufacturer", None),
|
||||
("Portico Sport", "ES", "Villafranca de los Barros", "porticosport.com",
|
||||
"15+ years, 35+ countries, 4,000+ courts. Only manufacturer also building sports canopies. 110,000+ sqft factory.", "manufacturer", None),
|
||||
("Padel10", "ES", "Rubí", "padel10.com",
|
||||
"Exclusively padel since 2008. 4,500+ courts. Former WPT official supplier. 15-day production. Mondo and ACT turf systems.", "manufacturer", None),
|
||||
("Manzasport", "ES", "Beniparrell", "manzasport.com",
|
||||
"Top-4 global builder since 2003, 4 factories in Valencia. 100% Spanish manufacturing, FIP and NIDE 2021 compliant.", "manufacturer", "+34 963 217 472"),
|
||||
("Maxpeed", "ES", "Viladecans", "maxpeed.com",
|
||||
"Manufacturer, builder, equipment supplier.", "manufacturer", "+34 936 593 961"),
|
||||
("AFP Courts", "ES", None, "afpcourts.com",
|
||||
"Official adidas licensee. 15+ years, 33+ exclusive distribution centers. Official PPL court. 2,150+ courts under RedSport brand.", "manufacturer", None),
|
||||
("Jubo Padel", "ES", None, "jubopadel.com",
|
||||
"Family-owned, 25+ years, 6,000+ courts. FIP official. Comprehensive alliance partner network. Court configurator.", "manufacturer", None),
|
||||
("Padel Courts Deluxe", "ES", "Alicante", "padelcourtsdeluxe.es",
|
||||
"100% Spanish, premium courts. Robotic welding, carbon fibre courts. Partnership with Greenset for surfaces.", "manufacturer", "info@padelcourtsdeluxe.com"),
|
||||
("SportBS", "ES", "Extremadura", "sportbs.es",
|
||||
"100% Spanish materials. 360° service including courts, lighting, turf, software.", "manufacturer", None),
|
||||
("Ingode Padel Courts", "ES", "Crevillente", "ingodepadel.com",
|
||||
"ISO 14001 & 9001. Galvanized steel with epoxy paint. 20+ years.", "manufacturer", None),
|
||||
("SkyPadel", "ES", None, "skypadel.com",
|
||||
"1,500+ courts in 40+ countries. EUROCODE compliant. Featured at WPT events globally. Subsidiaries in India/Brazil/Mexico.", "manufacturer", None),
|
||||
("PadelMagic", "ES", "Valladolid", "padelmagic.es",
|
||||
"400+ courts/year, 80% exported. FIP-certified.", "manufacturer", None),
|
||||
("Padel Hispania", "ES", None, "padelhispania.com",
|
||||
"Federation-approved specialist in Spain and Portugal.", "manufacturer", None),
|
||||
("VerdePadel", "ES", None, "verdepadel.com",
|
||||
"Building since 2004. 2,000+ courts, 40,000+ m² of artificial grass installed.", "manufacturer", None),
|
||||
("J'hayber Padel", "ES", None, "jhayberinstalaciones.com",
|
||||
"Manufacturer & lighting specialist since 1972.", "manufacturer", None),
|
||||
("PadelFan Valencia", "ES", "Alaquàs", "padelfanvalencia.com",
|
||||
"10+ years, 800+ customers. Also provides turf replacement.", "manufacturer", "+34 661 320 398"),
|
||||
("TMPadel", "ES", "Valencia", "tmpadel.com",
|
||||
"Kiwa certified, exports to 6+ EU countries.", "manufacturer", None),
|
||||
("Niberma", "ES", None, "niberma.es",
|
||||
"700+ courts.", "manufacturer", None),
|
||||
("Grupo Pineda", "ES", "Griñón", "grupopineda.eu",
|
||||
"Proprietary turf & pavement.", "manufacturer", None),
|
||||
("MR Instalaciones", "ES", "Madrid", "mrinstalaciones.es",
|
||||
"20+ years, turnkey.", "manufacturer", None),
|
||||
("X-Treme Group", "ES", "Alcalá de Henares", "x-tremegroup.com",
|
||||
"18+ years, 6,000 m² facility.", "manufacturer", None),
|
||||
("MTR Padel", "ES", None, "mtrpadel.com",
|
||||
"Methacrylate glass, turnkey.", "manufacturer", None),
|
||||
("GimPadel", "ES", "León", "gimpadel.com",
|
||||
"800+ installations. Built in Netherlands, Belgium, Italy, Kuwait, Portugal, Kenya.", "manufacturer", None),
|
||||
("EE Padel", "ES", None, "eepadel.com",
|
||||
"2021 merger of Eljoi Padel, EcoNatura, Swedish investors. Aluminum courts. Ships to US/Canada/ME.", "manufacturer", None),
|
||||
("Greencourt", "ES", None, "greencourt.es",
|
||||
"Galvanized/aluminum/precast options.", "manufacturer", None),
|
||||
("ExtremaAdel", "ES", "Extremadura", None,
|
||||
"Aluminum model innovation.", "manufacturer", None),
|
||||
("Padelgest", "ES", None, None,
|
||||
"Urban/sustainability focus.", "manufacturer", None),
|
||||
("Iberopadel", "ES", None, "iberopadel.com",
|
||||
"FEP approved, Joma/Maxpeed partner.", "manufacturer", None),
|
||||
("Padel Alba", "ES", "Granja de Rocamora", "padelalba.com",
|
||||
"25+ years, FIP-compliant.", "manufacturer", None),
|
||||
("Pistas-Padel.es", "ES", None, "pistas-padel.es",
|
||||
"All court types.", "manufacturer", None),
|
||||
|
||||
# ---- Spain: Turf/Surface (2.2) ----
|
||||
("Realturf", "ES", None, "realturf.com",
|
||||
"Official USPA sponsor. Drive Pro, Match Play, fibrillated lines. FEP-compliant.", "turf", None),
|
||||
("Act Sports", "ES", None, "act.sport",
|
||||
"Global padel turf leader, 10,000+ fields.", "turf", None),
|
||||
("Eurocesped", "ES", None, "eurocesped.com",
|
||||
"ITF parameters.", "turf", None),
|
||||
("Allgrass", "ES", None, "allgrass.es",
|
||||
"FEP compliant.", "turf", None),
|
||||
("Albergrass", "ES", "Pilar de la Horadada", "albergrass.com",
|
||||
"Artificial turf for padel courts.", "turf", None),
|
||||
|
||||
# ---- Spain: Lighting (2.3) ----
|
||||
("Led Projects", "ES", None, "ledprojects.es",
|
||||
"World leader in padel lighting, 5,000+ courts, WPT/Premier Padel official.", "lighting", None),
|
||||
("Óptima LED", "ES", None, "optimaled.es",
|
||||
"ProTour padel spotlights, anti-glare.", "lighting", None),
|
||||
("PlazaLED", "ES", "Madrid", "plazaled.es",
|
||||
"Sports LED projectors & scoreboards.", "lighting", None),
|
||||
("Ellite LED Padel", "ES", None, "ledpadel.com",
|
||||
"30+ years R&D. 360° perimeter system. 10-year warranty. Integration with booking apps.", "lighting", None),
|
||||
("Ledkia", "ES", None, "ledkia.com",
|
||||
"LED padel court floodlight solutions, 50W-1,250W.", "lighting", None),
|
||||
|
||||
# ---- Italy (3) ----
|
||||
("Mondo S.p.A.", "IT", "Alba", "mondoworldwide.com",
|
||||
"World's leading padel turf manufacturer. Official FIP/Premier Padel turf partner. 13,000+ courts globally.", "turf", None),
|
||||
("Italgreen", "IT", None, "italgreen.org",
|
||||
"40+ years. Patented fiberglass structure. FIP sponsor. Iron, Full Panoramic, V-PRO courts. Own padel turf lines.", "manufacturer", None),
|
||||
("Limonta Sport", "IT", None, "limontasport.com",
|
||||
"Premium padel turf, FEP approved, CONI partner.", "turf", None),
|
||||
("Padel Factory SRL", "IT", "Near Rome", "padelfactorysrl.com",
|
||||
"1,000+ courts. Innovative hybrid wood-steel design. 10+ European countries.", "manufacturer", None),
|
||||
("Padel Corporation", "IT", None, "padelcorporation.com",
|
||||
"10+ years, 20+ countries. 100% made in Italy, ITF compliant. Full turnkey.", "manufacturer", "+39 339 731 2152"),
|
||||
("Italian Padel", "IT", None, "italianpadel.it",
|
||||
"3,000+ courts in 28 countries. Up to 180 courts/month. CE certified.", "manufacturer", "info@italianpadel.it"),
|
||||
("Italia Team Padel", "IT", "Pesaro-Urbino", "italiateampadel.com",
|
||||
"Supplied first court at Foro Italico. Basic, Vision Pro, Full Vision models.", "manufacturer", "+39 0721 571 588"),
|
||||
("Campidapadel.it", "IT", "Lesmo", "campidapadel.it",
|
||||
"Design, supply, installation, plus financial and marketing consultancy.", "manufacturer", None),
|
||||
("Favaretti Padel", "IT", "Bagnoli di Sopra", "favarettipadel.it",
|
||||
"Turnkey, official Dunlop partner.", "manufacturer", None),
|
||||
("WIP Padel", "IT", None, "wippadel.it",
|
||||
"CE marked, 40+ years in sports.", "manufacturer", None),
|
||||
("Merli Sport", "IT", "Ravenna", "merlisport.com",
|
||||
"500+ courts, 'The Wall' showroom.", "manufacturer", None),
|
||||
("NXPadel", "IT", None, "nxpadel.com",
|
||||
"Patented fiberglass technology.", "manufacturer", None),
|
||||
("Edil Padel S.R.L.", "IT", None, "edilpadel.it",
|
||||
"Construction/installation, wood/steel.", "manufacturer", None),
|
||||
("Durocem Italia", "IT", None, "durocem.it",
|
||||
"Civil works & court installation, Padel Technologies distributor.", "manufacturer", None),
|
||||
("Top Padel Italia", "IT", None, "toppadelitalia.it",
|
||||
"Turnkey, from €15,800.", "manufacturer", None),
|
||||
("Toro Padel", "IT", None, "toro-padel.it",
|
||||
"100+ courts since 2019, patented lighting.", "manufacturer", None),
|
||||
|
||||
# ---- France (4) ----
|
||||
("EPS Concept", "FR", "Moutiers", "eps-concept.com",
|
||||
"French manufacturer, 2,000+ courts, FFT PQP certified.", "manufacturer", "+33 2 99 96 42 61"),
|
||||
("Padel 360", "FR", "Bischheim", "padel360.fr",
|
||||
"FFT PQP certified. Turnkey with 10-year warranty. Automated club solutions and video scoring.", "manufacturer", "+33 7 80 91 69 43"),
|
||||
("France Padel", "FR", "Paris", "france-padel.fr",
|
||||
"Only French company 100% padel-dedicated. Premium, innovation-focused.", "manufacturer", "+33 5 35 45 55 00"),
|
||||
("100% Padel", "FR", None, "centpourcentpadel.fr",
|
||||
"Family company managed by pro player Jérémy Scatena. French-manufactured.", "manufacturer", "+33 6 69 78 18 47"),
|
||||
("Constructeur Padel", "FR", None, "constructeur-padel.fr",
|
||||
"10+ years, 100+ clubs. Turnkey FIP-compliant courts. French/ecological materials.", "manufacturer", "+33 1 59 30 28 24"),
|
||||
("Le Padel Français", "FR", None, "lepadelfrancais.fr",
|
||||
"100% made in France, eco-responsible. Qualisteelcoat C5 protection. Hydro'Way permeable flooring.", "manufacturer", None),
|
||||
("Metal Padel", "FR", "Rousset", "padel.metal-laser.com",
|
||||
"First French padel manufacturer. FFT approved. Installs in France, Sweden, UK, Mauritius.", "manufacturer", None),
|
||||
("SMC2 Construction", "FR", "Mornant", "smc2-construction.com",
|
||||
"Covered halls specialist, wood & textile membrane. Largest athletics hall in Southern Europe.", "hall_builder", "+33 4 78 67 60 56"),
|
||||
("Univers Construction", "FR", "Bouc-Bel-Air", "universconstruction.com",
|
||||
"French manufacturer/installer. Showcase center with 9 courts. 20+ years.", "manufacturer", "+33 6 69 02 08 09"),
|
||||
("3S Sport Systems", "FR", "Montpellier", "sportsystems.fr",
|
||||
"3mm steel, Saint-Gobain Securit glass. 20-year structure warranty.", "manufacturer", None),
|
||||
("WeOui Padel", "FR", "Valence", "terrain-padel.com",
|
||||
"High-end courts, FFT compliant. Custom furniture, decoration, accessories, maintenance.", "manufacturer", None),
|
||||
("KIP Sport", "FR", None, "kipsport.fr",
|
||||
"FFT Qualisport, 30 years sports infra.", "manufacturer", None),
|
||||
("Storkeo", "FR", None, "storkeo.com",
|
||||
"Turnkey including real estate & financing.", "turnkey", None),
|
||||
("VW Sports Padel", "FR", "Noisy-le-Grand", "vwsports.fr",
|
||||
"Historic tennis company, now padel, French manufacturing.", "manufacturer", "+33 1 48 45 04 29"),
|
||||
("Lauralu Industrie", "FR", None, "lauralu.com",
|
||||
"Padel court covers/halls specialist, FFT certified, 20+ years.", "hall_builder", None),
|
||||
("ACS Production", "FR", "Near Nantes", None,
|
||||
"Court construction & coverage, 20+ years, 25-yr membrane warranty.", "manufacturer", "+33 2 40 45 94 94"),
|
||||
("Concasport", "FR", None, None,
|
||||
"French manufacturer, custom designs.", "manufacturer", None),
|
||||
("FieldTurf", "FR", None, "fieldturf.com",
|
||||
"25+ years synthetic turf for tennis/padel. 1,000,000+ m² installed. FIFA Preferred Producer. Part of Tarkett.", "turf", None),
|
||||
("Losberger De Boer", "DE", None, "losbergerdeboer.com",
|
||||
"Semi-permanent modular padel structures. Aluminum/wood frames with canvas roofing.", "hall_builder", None),
|
||||
("Infinite Padel Courts", "FR", None, "infinitepadelcourts.com",
|
||||
"Custom courts, height-adjustable, manufactured in Alicante.", "manufacturer", "infinitepadelcourts@gmail.com"),
|
||||
|
||||
# ---- Portugal (5) ----
|
||||
("inCourts Padel", "PT", "Lisbon", "incourtspadel.com",
|
||||
"Robotic manufacturing, factory in North Portugal.", "manufacturer", None),
|
||||
("Greenpark", "PT", None, "greenpark.com.pt",
|
||||
"First Portuguese-made padel court manufacturer.", "manufacturer", None),
|
||||
("Sports Evolution", "PT", None, "sports-evolution.pt",
|
||||
"Builder/installer, also manufactures covers.", "manufacturer", None),
|
||||
("Sports Partner", "PT", None, "sportspartner.pt",
|
||||
"Equipment supplier, multi-sport.", "manufacturer", None),
|
||||
|
||||
# ---- United Kingdom (6) ----
|
||||
("PRO Padel Courts", "GB", None, "propadelcourts.com",
|
||||
"Times 100 Ones to Watch 2025. Italian-engineered, patented anti-noise system. 50-year lifespan. MejorSet master distributor UK.", "manufacturer", None),
|
||||
("Padel Tech", "GB", None, "padeltech.co.uk",
|
||||
"Leading UK supplier/installer. Exclusive AFP Courts/adidas UK distributor. 150+ courts.", "turnkey", None),
|
||||
("Hexa Padel", "GB", "Woodford Green", "hexapadel.co.uk",
|
||||
"One of UK's largest builders. Courts, canopies, booking software, maintenance, academy.", "manufacturer", None),
|
||||
("SG Padel", "GB", None, "sgpadel.co.uk",
|
||||
"Turnkey, MejorSet distributor, SAPCA approved.", "turnkey", None),
|
||||
("SIS Pitches", "GB", None, "sispitches.com",
|
||||
"25+ years elite sports surfaces. UK-based turf manufacturer. Design, manufacture, install, maintain. 65 years.", "turf", None),
|
||||
("Padel Magic UK", "GB", None, "padelmagic.co.uk",
|
||||
"Proprietary Magic Base for uneven terrain. Custom covers/canopies. Nationwide.", "manufacturer", None),
|
||||
("Padel Build UK", "GB", "North Lincolnshire", "padelbuilduk.com",
|
||||
"UK manufacturer, hot-dip galvanizing.", "manufacturer", None),
|
||||
("Padel Systems", "GB", None, "padelsystems.co.uk",
|
||||
"Bespoke builder, partners with Italian Padel. Sister company: CopriSystems.", "manufacturer", None),
|
||||
("Red Raven Solutions", "GB", None, "redravensolutions.co.uk",
|
||||
"Exclusive PadelCreations UK distributor, 500+ courts.", "turnkey", None),
|
||||
("Padel Works UK", "GB", "Whitchurch", "padelworks.co.uk",
|
||||
"FIP-approved courts. 10-year warranty on court and surface.", "manufacturer", None),
|
||||
("Padel Galis UK", "GB", "Coventry", "padelgalis.uk",
|
||||
"Exclusive UK supplier of Padel Galis.", "turnkey", "info@padelgalis.uk"),
|
||||
("S&C Slatter", "GB", None, "slattersportsconstruction.com",
|
||||
"30+ years sports construction. Partners with FieldTurf. In-house civil engineering.", "turnkey", None),
|
||||
("Fordingbridge", "GB", "West Sussex", "fordingbridge.co.uk",
|
||||
"UK's leading padel canopy specialist, 60+ years, 25-yr guarantee.", "hall_builder", "info@fordingbridge.co.uk"),
|
||||
("Collinson Tensile", "GB", None, "collinsontensile.co.uk",
|
||||
"20+ years tensile buildings. Exclusive UK partner for Best-Hall Finland. ISO 9001/45001.", "hall_builder", None),
|
||||
("Rubb UK", "GB", "Gateshead", "rubbuk.com",
|
||||
"Fabric building specialists. Thermohall insulation. Modular, relocatable.", "hall_builder", None),
|
||||
("J & J Carter", "GB", None, "jjcarter.com",
|
||||
"Tensile sports halls, inflatable halls, frame/fabric structures.", "hall_builder", None),
|
||||
# ---- Netherlands (7) ----
|
||||
("Allesvoorpadel", "NL", "Biddinghuizen", "allesvoorpadel.nl",
|
||||
"Leading Dutch builder, 10+ years, NK Padel official rink builder. AFP Courts partner. Philips lighting.", "manufacturer", "info@allesvoorpadel.nl"),
|
||||
("Padel Nederland B.V.", "NL", "Monster", "padelnederland.nl",
|
||||
"Durable aluminum courts, 15-year warranty. KIWA/KNLTB certified. Solar canopy options, acoustic solutions.", "manufacturer", "info@padelnederland.nl"),
|
||||
("I-Padel", "NL", None, "i-padel.nl",
|
||||
"Dutch manufacturer, in-house production. KIWA ISA Sport / NOC*NSF certified. 15-year warranty. Also offers Ping-Pong Padel.", "manufacturer", None),
|
||||
("SkyPadel NL", "NL", "Zuid-Holland", "skypadel.nl",
|
||||
"1,800+ courts, Babolat official, since 2002.", "turnkey", "info@skypadel.nl"),
|
||||
("Padel.nl", "NL", None, "padel.nl",
|
||||
"Since 2003, KNLTB/KIWA certified, proprietary foundations.", "manufacturer", None),
|
||||
("Orange Padel International", "NL", None, "orangepadel.nl",
|
||||
"Premium Dutch-designed courts. 30 years experience. FEMEPA certification.", "manufacturer", None),
|
||||
("Padel Solution NL", "NL", None, "padelsolution.nl",
|
||||
"3,000+ installed courts in 30+ countries. Full project support.", "turnkey", None),
|
||||
("World Padel NL", "NL", None, "worldpadel.nl",
|
||||
"Court supplier.", "manufacturer", None),
|
||||
("Lumosa", "NL", None, "lumosa.eu",
|
||||
"Dutch LED sports lighting manufacturer. Custom lighting plans, up to 80% energy savings.", "lighting", None),
|
||||
("Frisomat", "BE", None, "frisomat.com",
|
||||
"Nearly 50 years steel construction. Cold-formed galvanized padel canopies/roofs. Modular, demountable.", "hall_builder", None),
|
||||
|
||||
# ---- Belgium (8) ----
|
||||
("Padel Projects", "BE", None, "padelprojects.eu",
|
||||
"Court construction, 10+ years, 3,000+ courts, patented lighting.", "manufacturer", None),
|
||||
("JM Padel", "BE", "Province of Liège", "jmpadel.be",
|
||||
"Installer, consultant, club management, IT/video.", "turnkey", None),
|
||||
("YoPadel SPRL", "BE", None, "yopadel.be",
|
||||
"Belgian manufacturer, Belgian materials, Lano Sports turf partner.", "manufacturer", None),
|
||||
("Domo Sports Grass", "BE", None, None,
|
||||
"Global artificial grass expert. Certified by major sports federations.", "turf", None),
|
||||
|
||||
# ---- Scandinavia (9) ----
|
||||
("Padeltotal", "SE", None, "padeltotal.se",
|
||||
"Largest Nordic supplier. 1,600+ courts since 2013. Galvanized for Nordic conditions, 12mm glass. Duruss partnership.", "manufacturer", None),
|
||||
("Padel Global", "SE", "Jönköping", "padel-global.com",
|
||||
"Manufacturer, own factory.", "manufacturer", None),
|
||||
("Scandinavian Padel AB", "SE", "Malmö", "scandinavianpadel.co",
|
||||
"25+ years in steel/glass for Nordic climate. C3 oil rig grade steel. Own ScanTurf turf.", "manufacturer", "info@scandinavianpadel.co"),
|
||||
("Sweden Padel Master", "SE", None, "swedenpadelmaster.se",
|
||||
"SPM Grass Court Cut technology.", "manufacturer", None),
|
||||
("Acenta Group", "SE", None, "acenta.group",
|
||||
"Major Scandinavian company, courts/service/digital/equipment. Fiberglass courts expanding to AU/NZ.", "manufacturer", None),
|
||||
("Instantpadel", "SE", None, "instantcourts.com",
|
||||
"World-unique mobile court, setup in <4 hours. 150+ courts, 17 countries. Installations at Gleneagles, Soho Club London.", "manufacturer", None),
|
||||
("Hallgruppen", "SE", None, "hallgruppen.com",
|
||||
"Padel hall structures. Self-supporting steel frames (50-year lifespan). Rental, leasing, purchase. CE approved.", "hall_builder", None),
|
||||
("Best-Hall", "FI", None, None,
|
||||
"5,500+ buildings worldwide. 40+ years. Fabric structures for sports halls.", "hall_builder", None),
|
||||
("ViPadel", "DK", None, "vipadel.dk",
|
||||
"Total supplier, official Mondo dealer DK/FI.", "turnkey", None),
|
||||
("A-Sport", "DK", None, "a-sport.dk",
|
||||
"Supplier/installer, 250+ courts.", "manufacturer", None),
|
||||
("Tiebreak International", "DK", "Glostrup", "padeltotal.dk",
|
||||
"PadelTotal concept for Denmark, 200+ courts.", "turnkey", "info@tiebreakinternational.com"),
|
||||
("Unisport", "FI", None, "unisport.com",
|
||||
"Court manufacturer, Saltex Tempo turf.", "manufacturer", None),
|
||||
|
||||
# ---- Other European (10) — unique entries only ----
|
||||
("DUOL", "SI", None, "duol.eu",
|
||||
"Air-supported and fabric sports buildings. Nearly 30 years. Online configurator.", "hall_builder", None),
|
||||
|
||||
# ---- USA: Manufacturers & Builders (11.1) ----
|
||||
("Absolute Padel", "US", "Mohnton, PA", "absolutepadelusa.com",
|
||||
"Only North America-based manufacturer. 100+ projects. 50%+ of US courts. Unique Pickleball & Padel combo court.", "manufacturer", "+1 717 445 5036"),
|
||||
("The Padel Box", "US", None, "thepadelbox.com",
|
||||
"US pioneer since 2012, licensed in 15+ states. Official MejorSet US/Canada distributor. Hurricane-rated to 180 mph.", "manufacturer", "info@padelbox.com"),
|
||||
("Sportsfield Specialties", "US", "Delhi, NY", "sportsfield.com",
|
||||
"USPA-endorsed manufacturer, 100% Made in USA, PaDelhi courts.", "manufacturer", "+1 607 746 8911"),
|
||||
("USA Padel Center", "US", "Houston, TX", "usapadel.com",
|
||||
"Manufacturer/consultant since 2007.", "manufacturer", "+1 713 539 3110"),
|
||||
("Padel One Courts", "US", "Florida", "padelonecourts.com",
|
||||
"Premium American-made courts. C5 anti-rust coating. Trusted by Pro Padel League and SVB Mouratoglou Academy.", "manufacturer", None),
|
||||
("Bounce Padel Courts", "CA", None, "bouncepadelcourts.com",
|
||||
"Premier North American provider. SGCC/ANSI-certified glass. Hurricane-class anchoring. Converts tennis courts and ice rinks.", "manufacturer", None),
|
||||
("Northeast Padel", "US", "Pocasset, MA", "northeastpadel.com",
|
||||
"Division of Cape & Island Tennis & Track, most-awarded US court builder. 50+ facility awards from ASBA.", "manufacturer", "+1 508 759 5636"),
|
||||
("Mondo Padel US", "US", "West Palm Beach, FL", "mondopadel.com",
|
||||
"150+ years combined team experience. FL licensed. Builds from ground up or converts tennis courts.", "manufacturer", "+1 888 423 1120"),
|
||||
("Keystone Sports Construction", "US", None, "keystonesportsconstruction.com",
|
||||
"Full-service turnkey padel from design to installation. Sportsfield Specialties partner.", "turnkey", None),
|
||||
("MTJ Sports", "US", "Chicago", "mtjsports.com",
|
||||
"20+ years sports courts. Padel, pickleball, soccer, tennis. Turnkey for clubs, hotels, municipalities.", "turnkey", None),
|
||||
("Capas Padel", "US", None, "capaspadel.com",
|
||||
"Builder/consultant, GreenSet surfaces, Smart Padel Club.", "turnkey", None),
|
||||
("All Racquet Sports", "US", "Sandy, UT", "allracquetsports.com",
|
||||
"Official adidas/AFP US distributor, 700+ courts network.", "turnkey", "info@allracquetsports.com"),
|
||||
|
||||
# ---- USA: Franchises & Operators (11.3) ----
|
||||
("Conquer Padel Club", "US", "Lehi, UT", "conquerpadel.com",
|
||||
"First US padel franchise, $1.1M-$3M+ investment.", "franchise", None),
|
||||
("Park Padel", "US", "San Francisco", "parkpadel.com",
|
||||
"Franchise, pop-up courts, community-focused.", "franchise", "hello@parkpadel.com"),
|
||||
("Jungle Padel", "US", None, "junglepadel.com",
|
||||
"Franchise, premium Mondo turf, academy.", "franchise", None),
|
||||
|
||||
# ---- USA: Turf (11.4) — unique entries only ----
|
||||
("WinterGreen Synthetic Grass", "US", "Dallas, TX", "wintergreengrass.com",
|
||||
"Pro-grade padel turf in DFW. Padel Pro surface (same as WPT).", "turf", None),
|
||||
("Laykold", "US", None, "laykold.com",
|
||||
"Padel Turf Pro surface. US Open official surface brand. Part of Sport Group.", "turf", None),
|
||||
|
||||
# ---- USA: Lighting (11.5) ----
|
||||
("LED Lighting Supply", "US", None, "ledlightingsupply.com",
|
||||
"15+ years, 25,000+ projects. 150W LED fixtures. Free photometric plans. 5-year warranty.", "lighting", None),
|
||||
("Tweener USA", "US", None, "tweenerusa.com",
|
||||
"Patented LED on existing fencing — no poles needed. Minimal light pollution. Dimmable.", "lighting", None),
|
||||
("Brite Court", "US", None, "britecourt.com",
|
||||
"40+ years racquet sports lighting. 600+ facilities. 18+ fixture designs. Samsung LEDs. 10-year warranty.", "lighting", None),
|
||||
("AEON LED Lighting", "US", None, "aeonledlighting.com",
|
||||
"Patented luminaires. UGR below 19 (glare-free). 100,000-hour lifespan. DLC Premium listed.", "lighting", None),
|
||||
("AGC Lighting", "CN", None, "agcled.com",
|
||||
"SP11 linear sports light for padel. Smart controls (DALI 2, DMX). Supports 4K broadcasting.", "lighting", None),
|
||||
|
||||
# ---- Mexico (12) ----
|
||||
("American Padel", "MX", "Mexico City", "americanpadel.com.mx",
|
||||
"FIP-compliant, 25 yrs metalwork.", "manufacturer", "+52 55 5891 3350"),
|
||||
("Padel Center México", "MX", "Aguascalientes", "padelcenter.mx",
|
||||
"FIP-certified. Clásica, Semipanorámica, Pro models. 20-30 day delivery.", "manufacturer", None),
|
||||
("MG Canchas", "MX", "Monterrey", "mgcanchas.com",
|
||||
"Pioneer manufacturer in Monterrey. Also supplies synthetic turf.", "manufacturer", None),
|
||||
("SicaSport", "MX", None, "sicasport.com",
|
||||
"Manufacturer/builder/installer.", "manufacturer", None),
|
||||
("Gott Padel", "MX", None, "gottpadel.com",
|
||||
"Design, installation, construction. Also sells rackets and balls.", "manufacturer", None),
|
||||
("AFP Courts México", "MX", None, "afpcourts.mx",
|
||||
"Official adidas licensee for Mexico.", "turnkey", None),
|
||||
("CanchasdePadel.com", "MX", None, "canchasdepadel.com",
|
||||
"FIP-certified. WPT-certified curly turf.", "manufacturer", None),
|
||||
("Padel Works MX", "MX", None, "padelworks.com.mx",
|
||||
"High-quality custom courts. Full support from civil works to club growth.", "manufacturer", None),
|
||||
("PadelStore.mx", "MX", None, "padelstore.mx",
|
||||
"Court accessories: fencing, nets, posts, turf, sand, LED, protective pads.", "manufacturer", None),
|
||||
|
||||
# ---- Middle East (13) ----
|
||||
("Padel Factory ME", "AE", "Dubai", "padelfactory.me",
|
||||
"Top manufacturer/supplier. Super Panoramic, Panoramic, Challenger, Portable. 400+ courts across UAE/KSA/Kuwait/Bahrain/Oman.", "manufacturer", "info@padelfactory.me"),
|
||||
("RedLine Padel", "AE", "Dubai", "redlinepadel.com",
|
||||
"Spanish manufacturer based in Dubai. 48-hour delivery across ME. UNE EN 1090.", "manufacturer", None),
|
||||
("Cypex Group", "AE", None, "cypex-group.com",
|
||||
"Represents Padel Factory ME. Exclusive LANO GRASS Belgium distributor for GCC.", "turnkey", None),
|
||||
("APW Pools", "AE", "Dubai", "apw-pools.com",
|
||||
"Padel supplier/installer, smart lighting, advanced materials.", "manufacturer", "+971 50 852 1161"),
|
||||
("Mister Shade ME", "AE", "Dubai", "mistershademe.com",
|
||||
"20+ years in flooring. Artificial turf and acrylic padel courts. All UAE emirates.", "manufacturer", None),
|
||||
("Gebal Group", "AE", "Dubai", "gebalgroup.com",
|
||||
"Turnkey builder across GCC (6 countries). FIP-compliant.", "turnkey", None),
|
||||
("Empower Sport Services", "AE", None, "empowersportservices.com",
|
||||
"2,500+ installations, FEP certified.", "manufacturer", None),
|
||||
("Shades Galaxy", "AE", "Dubai", "shadesgalaxy.com",
|
||||
"Manufacturer/supplier, all UAE emirates.", "manufacturer", None),
|
||||
("Fab Floorings", "AE", "Dubai", "fabfloorings.ae",
|
||||
"Turnkey: flooring/glass/lighting/branding.", "turnkey", None),
|
||||
("Al Mustaqbal Alsarea", "AE", None, "almustaqbalalsarea.com",
|
||||
"Gulf countries leader.", "manufacturer", "+971 50 247 5749"),
|
||||
("PFS Gulf", "SA", None, "pfsgulf.com",
|
||||
"Infrastructure company. Padel courts across KSA (Riyadh, Jeddah, Dammam, Mecca, Medina).", "manufacturer", None),
|
||||
|
||||
# ---- Turkey (14) ----
|
||||
("Mediterra Padel", "TR", "Antalya", "mediterrapadel.com",
|
||||
"Turkey's largest, 35+ countries. Active in Kenya, SA, Sierra Leone, Morocco, Nigeria.", "manufacturer", "info@mediterrapadel.com"),
|
||||
("Integral Grass", "TR", "Istanbul", "integralgrass.com",
|
||||
"11 models, 70+ countries.", "manufacturer", "info@integralgrass.com"),
|
||||
|
||||
# ---- China (15) ----
|
||||
("Legend Sports", "CN", "Yanshan", "legendsports.com",
|
||||
"One of China's largest. 220+ employees, 32 engineers. 5,000+ courts in 60+ countries. 66,000 m² factory.", "manufacturer", None),
|
||||
("Fortune Padel", "CN", None, "fortunepadel.com",
|
||||
"ISO 9001:2015. 20+ models including electric roof. Ships to 50+ countries within 20 days.", "manufacturer", None),
|
||||
("China Youngman Padel", "CN", "Hefei", "youngpadel.com",
|
||||
"China's largest since 2010. SGS, CE, ISO9001. 8 models. Also produces roof covers.", "manufacturer", None),
|
||||
("Wanhe Padel", "CN", "Huaian", "wanhesport.com",
|
||||
"Follows FIP regulations. Courts, 12mm turf, LED lighting. Also padel rackets.", "manufacturer", None),
|
||||
("Shengshi Sports Tech", "CN", "Tianjin", None,
|
||||
"20+ years sports equipment. 10,000+ m² facility near Tianjin port.", "manufacturer", None),
|
||||
("PadelCourt10", "CN", "Hebei", "padelcourt10.com",
|
||||
"1,000 sets/year capacity. 25+ countries. 5-year warranty. DDU door-to-door service.", "manufacturer", None),
|
||||
("Shanghai Super Power", "CN", "Shanghai", "padelcourtfactory.cn",
|
||||
"200+ employees. 350 courts/month capacity. Aluminum frames for coastal regions.", "manufacturer", None),
|
||||
("UNIPADEL", "CN", "Guangzhou", "gzunipadel.com",
|
||||
"5,000+ courts worldwide. Panoramic, classic, portable, roofed. Active in Indonesia, ME, Africa, LATAM.", "manufacturer", None),
|
||||
("ArtPadel", "CN", None, "artpadel.com",
|
||||
"Panoramic/classic courts, patented technology.", "manufacturer", None),
|
||||
("LDK China", "CN", "Shenzhen", "ldkchina.com",
|
||||
"Manufacturer/exporter.", "manufacturer", "info@ldkchina.com"),
|
||||
("SANJING Group", "CN", "Linqu", "sanjingcourt.com",
|
||||
"Glass specialist, 27 years, 300+ employees, 40+ countries.", "manufacturer", None),
|
||||
("Luckin Padel", "CN", None, "luckinpadel.com",
|
||||
"FIP-certified standards, educational focus.", "manufacturer", None),
|
||||
("Saintyol Sports", "CN", None, None,
|
||||
"15+ years. Specializes in padel turf and structures. 10,000+ m² facility.", "manufacturer", None),
|
||||
("Nanjing Padelworker", "CN", "Nanjing", None,
|
||||
"67% client reorder rate. Courts, squash equipment, glass fittings, turf.", "manufacturer", None),
|
||||
("Hebei Aohe Teaching Equipment", "CN", "Hebei", None,
|
||||
"Est. 2012. 80% repeat business. Also aluminum frame sports tents.", "manufacturer", None),
|
||||
("Shandong Century Star", "CN", "Shandong", None,
|
||||
"Large facility. Steel structure courts, panoramic models.", "manufacturer", None),
|
||||
("CCGrass", "CN", None, "ccgrass.com",
|
||||
"Three factories. FEP-compliant. FastPro and YEII products. Also complete court packages.", "turf", None),
|
||||
("JCTurf", "CN", None, "jcturf.com",
|
||||
"In-house fiber extrusion. FIP/FEP compliant. Also complete court solutions.", "turf", None),
|
||||
("MightyGrass", "CN", None, "mightygrass.com",
|
||||
"Professional padel turf, FEP-level. Factory direct pricing.", "turf", None),
|
||||
|
||||
# ---- India (16) ----
|
||||
("Asian Flooring India", "IN", "Mumbai", "afipadel.com",
|
||||
"India's largest padel manufacturer. FIP-standard. Standard, Panoramic, Ultra Panoramic, Kids. 25-day delivery.", "manufacturer", None),
|
||||
("Apex Sport Surfaces", "IN", "Mumbai", "apexsportsurfaces.in",
|
||||
"FIP-compliant manufacturer/exporter.", "manufacturer", None),
|
||||
("Sky Padel India", "IN", "Mumbai", "skypadel.in",
|
||||
"Local manufacturing, subsidiary of Spanish Sky Padel.", "manufacturer", None),
|
||||
("PFS Sport India", "IN", None, "pfs.sport",
|
||||
"Turf & surface manufacturer.", "turf", None),
|
||||
("PadelHaus India", "IN", None, "padelhaus.in",
|
||||
"Courts for every budget.", "manufacturer", None),
|
||||
|
||||
# ---- Asia Other (17) ----
|
||||
("SmartPadel", "SG", None, "smartpadel.asia",
|
||||
"Regional leader in SE Asia. Video recording, automated scoring. Part of SEARA Sports.", "manufacturer", None),
|
||||
("Olympia Courts", "AU", None, "olympiacourts.com",
|
||||
"Premium courts. European know-how, Asian manufacturing. Partners with Asia Pacific Padel Tour.", "manufacturer", None),
|
||||
("Indo Padel", "ID", "Bali", "indopadel.com",
|
||||
"Spanish-Indonesian team. Courts manufactured in Indonesia. Active in Thailand, India.", "manufacturer", None),
|
||||
("Padel Asia", "TH", "Bangkok", "padelasia.org",
|
||||
"Courts meeting international standards. Also sells rackets, clothing. Operates courts in Bangkok.", "manufacturer", None),
|
||||
|
||||
# ---- Brazil (18.1) ----
|
||||
("Padel Master Brasil", "BR", None, "padelmaster.com.br",
|
||||
"1,500+ courts in 10+ countries. Official Pala Tour/WPT Americas court. 10-year warranty.", "manufacturer", None),
|
||||
("Sky Padel Brasil", "BR", None, "skypadel.com.br",
|
||||
"10+ years. FEP/FIP certified. VP PRO 2.0, SP PRO, Full View, rental, mobile. 15-day production.", "manufacturer", None),
|
||||
("Smart Padel BR", "BR", None, "smart-padel.com",
|
||||
"IoT-connected courts. Online monitoring, strategic management software. FIP/FEP certified.", "manufacturer", None),
|
||||
("Flores Pádel", "BR", None, "florespadel.com.br",
|
||||
"Community-based manufacturer. 10+ years. Turf certified by Spanish federation. Founded by national team athletes.", "manufacturer", None),
|
||||
("FC Quadras", "BR", "São Paulo", "fcquadras.com.br",
|
||||
"Distributes Padelgest courts. Turnkey from terrain prep to finishing.", "turnkey", None),
|
||||
("Padel Prime", "BR", None, "padelprime.com.br",
|
||||
"European-standard manufacturing. Co-founded by ex-footballer Edmílson.", "manufacturer", None),
|
||||
("F4 Quadras", "BR", None, None,
|
||||
"Manufacturer/installer.", "manufacturer", None),
|
||||
|
||||
# ---- Argentina (18.2) ----
|
||||
("Padel Courts Master", "AR", None, "padelcourtsmaster.ar",
|
||||
"1,500+ courts delivered worldwide, 8+ countries. Robotic welding. FIP-standard.", "manufacturer", None),
|
||||
("MS Pádel", "AR", None, "metalurgicametalser.com",
|
||||
"Professional panoramic courts. Full package: structure, glass, LED, turf.", "manufacturer", "+54 2314 407746"),
|
||||
("World Padel Court", "AR", None, "worldpadelcourt.com.ar",
|
||||
"Led by Visión Deportiva. Top-quality courts. Portable court rental.", "manufacturer", None),
|
||||
("Blue Court", "AR", None, "bluecourt.com.ar",
|
||||
"15+ years manufacturing. Established Argentine brand.", "manufacturer", None),
|
||||
("Slavon Césped Sintético", "AR", None, "slavoncespedsintetico.com",
|
||||
"Panoramic and full panoramic courts. Also provides synthetic turf.", "manufacturer", None),
|
||||
|
||||
# ---- Africa (19) ----
|
||||
("Padel Nation", "ZA", None, "padelnation.co.za",
|
||||
"SA's leading manufacturer. 150+ courts. Local hot-dip galvanized manufacturing. 4-week lead times. Up to 10-year warranty.", "manufacturer", None),
|
||||
("Padel Build SA", "ZA", None, "padelbuild.co.za",
|
||||
"Premier turnkey builder. Partnered with Spain's Padel Galis. First FlexiPadel Base in Africa.", "turnkey", None),
|
||||
("Techno Padel", "ZA", None, "technopadel.co.za",
|
||||
"Premier supplier/installer. 5-7 day installation.", "manufacturer", None),
|
||||
("Padel Solutions SA", "ZA", None, "padelsolutions.co.za",
|
||||
"Proudly SA. Design, manufacture, install. Turnkey.", "manufacturer", None),
|
||||
("Padel Quip", "ZA", None, "padelquip.co.za",
|
||||
"Local manufacturer. Basic to Premium Plus models. Partnership and financial assistance.", "manufacturer", None),
|
||||
("Padel Projects SA", "ZA", None, "padelprojects.co.za",
|
||||
"Designed for local conditions.", "manufacturer", None),
|
||||
("Trompie Sport", "ZA", None, "trompiesport.co.za",
|
||||
"Builder, imported & local courts.", "manufacturer", None),
|
||||
("Belgotex Sport", "ZA", "Pietermaritzburg", "belgotexsport.co.za",
|
||||
"SA-based turf manufacturer. UNE 147301:2018 compliant. 220+ installations.", "turf", None),
|
||||
("Africa Padel", "ZA", None, "africapadel.com",
|
||||
"Largest club group in Africa. 21+ clubs across SA. Founded 2021. Events, corporate leagues.", "franchise", None),
|
||||
("Technotrade Sports", "EG", None, "technotradesports.com",
|
||||
"Contractor, one of the best in Arab world.", "manufacturer", None),
|
||||
("Turkan Company", "EG", None, "turkan-eg.com",
|
||||
"Manufacturer, one of first in Egypt.", "manufacturer", None),
|
||||
|
||||
# ---- Australia & Oceania (20) ----
|
||||
("APT Asia Pacific", "AU", "Melbourne", "aptasiapacific.com.au",
|
||||
"Asia Pacific's largest sports surfaces company. Australian-made turf. Aluminium 6061-T6 frames. AS/NZS certified. Pop-up courts.", "manufacturer", None),
|
||||
("Synthetic Padel Courts", "AU", None, "syntheticpadelcourts.com.au",
|
||||
"Built the first padel court in Australia. Preferred installer for Indoor Padel Australia, Sydney Racquet Club.", "turnkey", None),
|
||||
("Padel in One Australia", "AU", None, "padelinone.com.au",
|
||||
"Turnkey specialist. 8+ years. Management, marketing, operations consulting.", "turnkey", None),
|
||||
("PadelVolt", "AU", None, "padelvolt.com",
|
||||
"End-to-end premium service. MejorSet distributor across Oceania and Pacific Islands. Extreme weather designs.", "manufacturer", None),
|
||||
("Padel 360 Australia", "AU", None, "padel360.com.au",
|
||||
"Developer/builder/manager, Gimpadel partner.", "turnkey", None),
|
||||
("AS Lodge Tennis Courts", "AU", "Melbourne", "asltenniscourts.com.au",
|
||||
"Builder, $90K-$130K per court.", "manufacturer", None),
|
||||
("All Sport Projects", "AU", None, "allsportprojects.com.au",
|
||||
"Non-rust aluminum courts, 7-15 yr warranties.", "manufacturer", None),
|
||||
# ---- Software & Technology (21) ----
|
||||
("Playtomic", "ES", "Madrid", "playtomic.com",
|
||||
"World's largest racket sports platform. 6,700+ clubs, 4M+ players, 52+ countries. €56M raised.", "software", None),
|
||||
("MATCHi", "SE", None, "tpcmatchpoint.com",
|
||||
"1,600+ venues. Multi-sport booking, memberships, leagues, coaching.", "software", None),
|
||||
("Padel Mates", "NL", None, None,
|
||||
"All-in-one platform. Won Rocket Padel (Europe's largest indoor chain). Gamification features.", "software", None),
|
||||
("SmashClub", "NL", None, "smashclub.cloud",
|
||||
"Padel CRM and club management. Integrates with Playtomic, MATCHi, Padel Mates.", "software", None),
|
||||
("Taykus", "ES", None, None,
|
||||
"Software specifically for padel clubs. Online booking, payment, communication automation.", "software", None),
|
||||
("Playbypoint", "US", None, "playbypoint.com",
|
||||
"Official tech partner of 2025 US Open Padel. Custom branded app per club.", "software", None),
|
||||
("CourtReserve", "US", None, None,
|
||||
"Leading US booking platform alongside Playbypoint and Playtomic.", "software", None),
|
||||
("360Player", "IS", None, "en-us.360player.com",
|
||||
"Club management with website builder, video analysis, player development tools.", "software", None),
|
||||
("Booklux", "EE", None, "booklux.com",
|
||||
"Customizable booking system. Stripe payments. Google Analytics integration.", "software", None),
|
||||
("SetTime", "US", None, "settime.io",
|
||||
"Free padel booking software. Google/Apple Calendar sync. Analytics for utilization.", "software", None),
|
||||
("ProPadelKit", "GB", None, "propadelkit.com",
|
||||
"Turnkey court solutions, Classic & Fusion models.", "manufacturer", None),
|
||||
]
|
||||
|
||||
|
||||
def up(conn):
|
||||
# Table, indexes, FTS, and triggers are created by schema.sql (IF NOT EXISTS).
|
||||
# This migration only needs to seed data.
|
||||
row = conn.execute("SELECT COUNT(*) FROM suppliers").fetchone()
|
||||
if row[0] > 0:
|
||||
return
|
||||
|
||||
# Seed suppliers
|
||||
seen_slugs = set()
|
||||
for name, cc, city, website, desc, cat, contact in _SUPPLIERS:
|
||||
slug = _slugify(name)
|
||||
if slug in seen_slugs:
|
||||
i = 2
|
||||
while f"{slug}-{i}" in seen_slugs:
|
||||
i += 1
|
||||
slug = f"{slug}-{i}"
|
||||
seen_slugs.add(slug)
|
||||
|
||||
region = _REGION.get(cc, "Other")
|
||||
conn.execute(
|
||||
"INSERT INTO suppliers"
|
||||
" (name, slug, country_code, city, region, website, description, category, contact)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(name, slug, cc, city, region, website, desc, cat, contact),
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Add tier, logo, verified, highlight, and sticky columns to suppliers."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
existing = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(suppliers)").fetchall()
|
||||
}
|
||||
|
||||
columns = [
|
||||
("tier", "TEXT NOT NULL DEFAULT 'free'"),
|
||||
("logo_url", "TEXT"),
|
||||
("is_verified", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("highlight", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("sticky_until", "TEXT"),
|
||||
("sticky_country", "TEXT"),
|
||||
]
|
||||
|
||||
for name, definition in columns:
|
||||
if name not in existing:
|
||||
conn.execute(f"ALTER TABLE suppliers ADD COLUMN {name} {definition}")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Add verified_at column to lead_requests for double opt-in tracking."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(lead_requests)").fetchall()}
|
||||
if "verified_at" not in cols:
|
||||
conn.execute("ALTER TABLE lead_requests ADD COLUMN verified_at TEXT")
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Phase 1: credit ledger, lead forwarding, supplier boosts, expanded supplier/lead columns."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
# -- New tables --
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS credit_ledger (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||
delta INTEGER NOT NULL,
|
||||
balance_after INTEGER NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
reference_id INTEGER,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_credit_ledger_supplier ON credit_ledger(supplier_id)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS lead_forwards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lead_id INTEGER NOT NULL REFERENCES lead_requests(id),
|
||||
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||
credit_cost INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'sent',
|
||||
email_sent_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(lead_id, supplier_id)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_lead_forwards_lead ON lead_forwards(lead_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_lead_forwards_supplier ON lead_forwards(supplier_id)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS supplier_boosts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||
boost_type TEXT NOT NULL,
|
||||
paddle_subscription_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
starts_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_supplier_boosts_supplier ON supplier_boosts(supplier_id)")
|
||||
|
||||
# -- Column additions to suppliers --
|
||||
sup_cols = {r[1] for r in conn.execute("PRAGMA table_info(suppliers)").fetchall()}
|
||||
new_sup_cols = {
|
||||
"service_categories": "TEXT",
|
||||
"service_area": "TEXT",
|
||||
"years_in_business": "INTEGER",
|
||||
"project_count": "INTEGER",
|
||||
"short_description": "TEXT",
|
||||
"long_description": "TEXT",
|
||||
"contact_name": "TEXT",
|
||||
"contact_email": "TEXT",
|
||||
"contact_phone": "TEXT",
|
||||
"credit_balance": "INTEGER NOT NULL DEFAULT 0",
|
||||
"monthly_credits": "INTEGER NOT NULL DEFAULT 0",
|
||||
"last_credit_refill": "TEXT",
|
||||
}
|
||||
for col, typedef in new_sup_cols.items():
|
||||
if col not in sup_cols:
|
||||
conn.execute(f"ALTER TABLE suppliers ADD COLUMN {col} {typedef}")
|
||||
|
||||
# -- Column additions to lead_requests --
|
||||
lead_cols = {r[1] for r in conn.execute("PRAGMA table_info(lead_requests)").fetchall()}
|
||||
if "credit_cost" not in lead_cols:
|
||||
conn.execute("ALTER TABLE lead_requests ADD COLUMN credit_cost INTEGER")
|
||||
if "unlock_count" not in lead_cols:
|
||||
conn.execute("ALTER TABLE lead_requests ADD COLUMN unlock_count INTEGER NOT NULL DEFAULT 0")
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Phase 2: paddle_products, business_plan_exports, feedback tables; supplier profile columns."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
# -- paddle_products: store Paddle product/price IDs in DB --
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS paddle_products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
paddle_product_id TEXT NOT NULL,
|
||||
paddle_price_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
price_cents INTEGER NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
billing_type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# -- business_plan_exports: track PDF purchases and generation --
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS business_plan_exports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
scenario_id INTEGER NOT NULL REFERENCES scenarios(id),
|
||||
paddle_transaction_id TEXT,
|
||||
language TEXT NOT NULL DEFAULT 'en',
|
||||
file_path TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_bpe_user ON business_plan_exports(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_bpe_scenario ON business_plan_exports(scenario_id)"
|
||||
)
|
||||
|
||||
# -- feedback: in-app feedback submissions --
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
page_url TEXT,
|
||||
message TEXT NOT NULL,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
|
||||
# -- Column additions to suppliers --
|
||||
sup_cols = {r[1] for r in conn.execute("PRAGMA table_info(suppliers)").fetchall()}
|
||||
new_sup_cols = {
|
||||
"logo_file": "TEXT",
|
||||
"tagline": "TEXT",
|
||||
}
|
||||
for col, typedef in new_sup_cols.items():
|
||||
if col not in sup_cols:
|
||||
conn.execute(f"ALTER TABLE suppliers ADD COLUMN {col} {typedef}")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Add metadata column to supplier_boosts for card_color boost config."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(supplier_boosts)").fetchall()}
|
||||
if "metadata" not in cols:
|
||||
conn.execute("ALTER TABLE supplier_boosts ADD COLUMN metadata TEXT")
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Programmatic SEO: article templates, template data, published scenarios, articles + FTS."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS published_scenarios (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT,
|
||||
location TEXT NOT NULL,
|
||||
country TEXT NOT NULL,
|
||||
venue_type TEXT NOT NULL DEFAULT 'indoor',
|
||||
ownership TEXT NOT NULL DEFAULT 'rent',
|
||||
court_config TEXT NOT NULL,
|
||||
state_json TEXT NOT NULL,
|
||||
calc_json TEXT NOT NULL,
|
||||
template_data_id INTEGER REFERENCES template_data(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pub_scenarios_slug ON published_scenarios(slug)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS article_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'calculator',
|
||||
input_schema TEXT NOT NULL,
|
||||
url_pattern TEXT NOT NULL,
|
||||
title_pattern TEXT NOT NULL,
|
||||
meta_description_pattern TEXT,
|
||||
body_template TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_article_templates_slug ON article_templates(slug)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS template_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
template_id INTEGER NOT NULL REFERENCES article_templates(id),
|
||||
data_json TEXT NOT NULL,
|
||||
scenario_id INTEGER REFERENCES published_scenarios(id),
|
||||
article_id INTEGER REFERENCES articles(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_template_data_template ON template_data(template_id)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url_path TEXT UNIQUE NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
meta_description TEXT,
|
||||
country TEXT,
|
||||
region TEXT,
|
||||
og_image_url TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
published_at TEXT,
|
||||
template_data_id INTEGER REFERENCES template_data(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_url_path ON articles(url_path)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status, published_at)")
|
||||
|
||||
conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
|
||||
title, meta_description, country, region,
|
||||
content='articles', content_rowid='id'
|
||||
)
|
||||
""")
|
||||
|
||||
# FTS sync triggers
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
|
||||
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||
END
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
|
||||
INSERT INTO articles_fts(articles_fts, rowid, title, meta_description, country, region)
|
||||
VALUES ('delete', old.id, old.title, old.meta_description, old.country, old.region);
|
||||
INSERT INTO articles_fts(rowid, title, meta_description, country, region)
|
||||
VALUES (new.id, new.title, new.meta_description, new.country, new.region);
|
||||
END
|
||||
""")
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Add user_roles and billing_customers tables.
|
||||
Migrate paddle_customer_id from subscriptions to billing_customers.
|
||||
Rename paddle_subscription_id -> provider_subscription_id.
|
||||
Drop UNIQUE on subscriptions.user_id (allow multiple subs per user).
|
||||
"""
|
||||
|
||||
|
||||
def _column_names(conn, table):
|
||||
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
|
||||
|
||||
|
||||
def up(conn):
|
||||
# 1. Create new tables (idempotent)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, role)
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_roles_user ON user_roles(user_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_roles_role ON user_roles(role)")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS billing_customers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id),
|
||||
provider_customer_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_billing_customers_user ON billing_customers(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_billing_customers_provider"
|
||||
" ON billing_customers(provider_customer_id)"
|
||||
)
|
||||
|
||||
cols = _column_names(conn, "subscriptions")
|
||||
|
||||
# Already migrated — nothing to do
|
||||
if "provider_subscription_id" in cols and "paddle_customer_id" not in cols:
|
||||
return
|
||||
|
||||
# 2. Migrate paddle_customer_id from subscriptions to billing_customers
|
||||
if "paddle_customer_id" in cols:
|
||||
conn.execute("""
|
||||
INSERT OR IGNORE INTO billing_customers (user_id, provider_customer_id)
|
||||
SELECT user_id, paddle_customer_id
|
||||
FROM subscriptions
|
||||
WHERE paddle_customer_id IS NOT NULL AND paddle_customer_id != ''
|
||||
GROUP BY user_id
|
||||
HAVING MAX(created_at)
|
||||
""")
|
||||
|
||||
# 3. Recreate subscriptions table:
|
||||
# - Drop paddle_customer_id (moved to billing_customers)
|
||||
# - Rename paddle_subscription_id -> provider_subscription_id
|
||||
# - Drop UNIQUE constraint on user_id (allow multiple subs per user)
|
||||
old_sub_col = "paddle_subscription_id" if "paddle_subscription_id" in cols else "provider_subscription_id"
|
||||
|
||||
conn.execute("ALTER TABLE subscriptions RENAME TO _subscriptions_old")
|
||||
conn.execute("""
|
||||
CREATE TABLE subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
status TEXT NOT NULL DEFAULT 'free',
|
||||
provider_subscription_id TEXT,
|
||||
current_period_end TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_subscriptions_provider"
|
||||
" ON subscriptions(provider_subscription_id)"
|
||||
)
|
||||
conn.execute(f"""
|
||||
INSERT INTO subscriptions (id, user_id, plan, status, provider_subscription_id,
|
||||
current_period_end, created_at, updated_at)
|
||||
SELECT id, user_id, plan, status, {old_sub_col},
|
||||
current_period_end, created_at, updated_at
|
||||
FROM _subscriptions_old
|
||||
""")
|
||||
conn.execute("DROP TABLE _subscriptions_old")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Add Basic tier supplier fields and supplier_enquiries table.
|
||||
New columns on suppliers: services_offered, contact_role, linkedin_url,
|
||||
instagram_url, youtube_url.
|
||||
New table: supplier_enquiries.
|
||||
"""
|
||||
|
||||
|
||||
def _column_names(conn, table):
|
||||
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
|
||||
|
||||
|
||||
def up(conn):
|
||||
cols = _column_names(conn, "suppliers")
|
||||
|
||||
for col, defn in [
|
||||
("services_offered", "TEXT"),
|
||||
("contact_role", "TEXT"),
|
||||
("linkedin_url", "TEXT"),
|
||||
("instagram_url", "TEXT"),
|
||||
("youtube_url", "TEXT"),
|
||||
]:
|
||||
if col not in cols:
|
||||
conn.execute(f"ALTER TABLE suppliers ADD COLUMN {col} {defn}")
|
||||
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS supplier_enquiries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
|
||||
contact_name TEXT NOT NULL,
|
||||
contact_email TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'new',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_supplier_enquiries_supplier"
|
||||
" ON supplier_enquiries(supplier_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_supplier_enquiries_email"
|
||||
" ON supplier_enquiries(contact_email, created_at)"
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Add cover_image column to suppliers table."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
cols = [r[1] for r in conn.execute("PRAGMA table_info(suppliers)").fetchall()]
|
||||
if "cover_image" not in cols:
|
||||
conn.execute("ALTER TABLE suppliers ADD COLUMN cover_image TEXT")
|
||||
19
web/src/padelnomics/migrations/versions/0014_add_waitlist.py
Normal file
19
web/src/padelnomics/migrations/versions/0014_add_waitlist.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""Add waitlist table for WAITLIST_MODE smoke test."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS waitlist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
intent TEXT NOT NULL DEFAULT 'signup',
|
||||
source TEXT,
|
||||
plan TEXT,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(email, intent)
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_waitlist_email ON waitlist(email)"
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Cache table for lazily-created Resend audience IDs."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS resend_audiences (
|
||||
name TEXT PRIMARY KEY,
|
||||
audience_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Add umami_id and contact columns to feedback table."""
|
||||
|
||||
|
||||
def up(conn):
|
||||
conn.execute("ALTER TABLE feedback ADD COLUMN umami_id TEXT")
|
||||
conn.execute("ALTER TABLE feedback ADD COLUMN contact TEXT")
|
||||
0
web/src/padelnomics/migrations/versions/__init__.py
Normal file
0
web/src/padelnomics/migrations/versions/__init__.py
Normal file
457
web/src/padelnomics/planner/calculator.py
Normal file
457
web/src/padelnomics/planner/calculator.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
Padel court financial model — server-side calculation engine.
|
||||
|
||||
Ported from planner.js calc()/pmt()/calcIRR() so the full financial
|
||||
model is no longer exposed in client-side JavaScript.
|
||||
"""
|
||||
import math
|
||||
|
||||
from ..i18n import get_calc_item_names
|
||||
|
||||
|
||||
# JS-compatible rounding: half-up (0.5 rounds to 1), not Python's
|
||||
# banker's rounding (round-half-even).
|
||||
def _round(n):
|
||||
return math.floor(n + 0.5)
|
||||
|
||||
|
||||
# -- Default state (mirrors the JS `S` object) --
|
||||
DEFAULTS = {
|
||||
"venue": "indoor",
|
||||
"own": "rent",
|
||||
"dblCourts": 4,
|
||||
"sglCourts": 2,
|
||||
"sqmPerDblHall": 336,
|
||||
"sqmPerSglHall": 240,
|
||||
"sqmPerDblOutdoor": 312,
|
||||
"sqmPerSglOutdoor": 216,
|
||||
"ratePeak": 50,
|
||||
"rateOffPeak": 35,
|
||||
"rateSingle": 30,
|
||||
"peakPct": 40,
|
||||
"hoursPerDay": 16,
|
||||
"daysPerMonthIndoor": 29,
|
||||
"daysPerMonthOutdoor": 25,
|
||||
"bookingFee": 10,
|
||||
"utilTarget": 40,
|
||||
"membershipRevPerCourt": 500,
|
||||
"fbRevPerCourt": 300,
|
||||
"coachingRevPerCourt": 200,
|
||||
"retailRevPerCourt": 80,
|
||||
"racketRentalRate": 15,
|
||||
"racketPrice": 5,
|
||||
"racketQty": 2,
|
||||
"ballRate": 10,
|
||||
"ballPrice": 3,
|
||||
"ballCost": 1.5,
|
||||
"courtCostDbl": 25000,
|
||||
"courtCostSgl": 15000,
|
||||
"shipping": 3000,
|
||||
"hallCostSqm": 500,
|
||||
"foundationSqm": 150,
|
||||
"landPriceSqm": 60,
|
||||
"hvac": 100000,
|
||||
"electrical": 60000,
|
||||
"sanitary": 80000,
|
||||
"parking": 50000,
|
||||
"fitout": 40000,
|
||||
"planning": 100000,
|
||||
"fireProtection": 80000,
|
||||
"floorPrep": 12000,
|
||||
"hvacUpgrade": 20000,
|
||||
"lightingUpgrade": 10000,
|
||||
"outdoorFoundation": 35,
|
||||
"outdoorSiteWork": 8000,
|
||||
"outdoorLighting": 4000,
|
||||
"outdoorFencing": 6000,
|
||||
"equipment": 2000,
|
||||
"workingCapital": 15000,
|
||||
"contingencyPct": 10,
|
||||
"rentSqm": 4,
|
||||
"outdoorRent": 400,
|
||||
"insurance": 300,
|
||||
"electricity": 600,
|
||||
"heating": 400,
|
||||
"maintenance": 300,
|
||||
"cleaning": 300,
|
||||
"marketing": 350,
|
||||
"staff": 0,
|
||||
"propertyTax": 250,
|
||||
"water": 125,
|
||||
"loanPct": 85,
|
||||
"interestRate": 5,
|
||||
"loanTerm": 10,
|
||||
"constructionMonths": 0,
|
||||
"holdYears": 5,
|
||||
"exitMultiple": 6,
|
||||
"annualRevGrowth": 2,
|
||||
"budgetTarget": 0,
|
||||
"country": "DE",
|
||||
"permitsCompliance": 12000,
|
||||
"glassType": "standard",
|
||||
"lightingType": "led_standard",
|
||||
"ramp": [0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.82, 0.88, 0.93, 0.96, 0.98, 1],
|
||||
"season": [0, 0, 0, 0.7, 0.9, 1, 1, 1, 0.8, 0, 0, 0],
|
||||
}
|
||||
|
||||
|
||||
CURRENCY_DEFAULT = {"sym": "\u20ac", "eu_style": True}
|
||||
|
||||
COUNTRY_CURRENCY: dict[str, dict] = {
|
||||
"DE": CURRENCY_DEFAULT,
|
||||
"ES": CURRENCY_DEFAULT,
|
||||
"IT": CURRENCY_DEFAULT,
|
||||
"FR": CURRENCY_DEFAULT,
|
||||
"NL": CURRENCY_DEFAULT,
|
||||
"SE": CURRENCY_DEFAULT,
|
||||
"UK": {"sym": "\u00a3", "eu_style": False},
|
||||
"US": {"sym": "$", "eu_style": False},
|
||||
}
|
||||
|
||||
|
||||
def validate_state(s: dict) -> dict:
|
||||
"""Apply defaults and coerce types. Returns a clean copy."""
|
||||
out = {**DEFAULTS}
|
||||
for k, default in DEFAULTS.items():
|
||||
if k not in s:
|
||||
continue
|
||||
v = s[k]
|
||||
if isinstance(default, list):
|
||||
if isinstance(v, list):
|
||||
out[k] = [float(x) for x in v]
|
||||
elif isinstance(default, float):
|
||||
try:
|
||||
out[k] = float(v)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif isinstance(default, int):
|
||||
try:
|
||||
out[k] = int(float(v))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
elif isinstance(default, str):
|
||||
out[k] = str(v)
|
||||
return out
|
||||
|
||||
|
||||
def pmt(rate: float, nper: int, pv: float) -> float:
|
||||
"""Loan payment calculation (matches JS pmt exactly)."""
|
||||
if rate == 0:
|
||||
return pv / nper
|
||||
return pv * rate * math.pow(1 + rate, nper) / (math.pow(1 + rate, nper) - 1)
|
||||
|
||||
|
||||
def calc_irr(cfs: list[float], guess: float = 0.1) -> float:
|
||||
"""Newton-Raphson IRR solver (matches JS calcIRR exactly)."""
|
||||
r = guess
|
||||
for _ in range(300):
|
||||
npv = 0.0
|
||||
d = 0.0
|
||||
for t, cf in enumerate(cfs):
|
||||
npv += cf / math.pow(1 + r, t)
|
||||
d -= t * cf / math.pow(1 + r, t + 1)
|
||||
if abs(d) < 1e-12:
|
||||
break
|
||||
nr = r - npv / d
|
||||
if abs(nr - r) < 1e-9:
|
||||
return nr
|
||||
r = nr
|
||||
if r < -0.99:
|
||||
r = -0.99
|
||||
if r > 10:
|
||||
r = 10
|
||||
return r
|
||||
|
||||
|
||||
def calc(s: dict, lang: str = "en") -> dict:
|
||||
"""
|
||||
Main financial model. Takes validated state dict, returns full
|
||||
derived-data dict (the `d` object from the JS version).
|
||||
"""
|
||||
names = get_calc_item_names(lang)
|
||||
sym = COUNTRY_CURRENCY.get(s.get("country", "DE"), CURRENCY_DEFAULT)["sym"]
|
||||
d: dict = {}
|
||||
is_in = s["venue"] == "indoor"
|
||||
is_buy = s["own"] == "buy"
|
||||
|
||||
d["totalCourts"] = s["dblCourts"] + s["sglCourts"]
|
||||
total_courts = d["totalCourts"]
|
||||
|
||||
d["hallSqm"] = (
|
||||
(s["dblCourts"] * s["sqmPerDblHall"] + s["sglCourts"] * s["sqmPerSglHall"] + 200 + total_courts * 20)
|
||||
if total_courts
|
||||
else 0
|
||||
)
|
||||
d["outdoorLandSqm"] = (
|
||||
(s["dblCourts"] * s["sqmPerDblOutdoor"] + s["sglCourts"] * s["sqmPerSglOutdoor"] + 100)
|
||||
if total_courts
|
||||
else 0
|
||||
)
|
||||
d["sqm"] = d["hallSqm"] if is_in else d["outdoorLandSqm"]
|
||||
|
||||
# -- Multipliers for glass and lighting --
|
||||
glass_mult = 1.4 if s["glassType"] == "panoramic" else 1.0
|
||||
light_mult = 1.5 if s["lightingType"] == "led_competition" else 1.0
|
||||
# Natural light zeroes out lighting costs (outdoor only)
|
||||
if s["lightingType"] == "natural" and not is_in:
|
||||
light_mult = 0
|
||||
|
||||
# -- CAPEX --
|
||||
capex_items: list[dict] = []
|
||||
|
||||
def ci(name: str, amount: float, info: str = ""):
|
||||
capex_items.append({"name": name, "amount": _round(amount), "info": info})
|
||||
|
||||
ci(
|
||||
names["padel_courts"],
|
||||
(s["dblCourts"] * s["courtCostDbl"] + s["sglCourts"] * s["courtCostSgl"]) * glass_mult,
|
||||
f"{s['dblCourts']}\u00d7dbl + {s['sglCourts']}\u00d7sgl"
|
||||
+ (" (panoramic)" if s["glassType"] == "panoramic" else ""),
|
||||
)
|
||||
ci(names["shipping"], math.ceil(total_courts / 2) * s["shipping"] if total_courts else 0)
|
||||
|
||||
if is_in:
|
||||
if is_buy:
|
||||
ci(names["hall_construction"], d["hallSqm"] * s["hallCostSqm"],
|
||||
f"{d['hallSqm']}m\u00b2 \u00d7 {sym}{s['hallCostSqm']}/m\u00b2")
|
||||
ci(names["foundation"], d["hallSqm"] * s["foundationSqm"],
|
||||
f"{d['hallSqm']}m\u00b2 \u00d7 {sym}{s['foundationSqm']}/m\u00b2")
|
||||
land_sqm = _round(d["hallSqm"] * 1.25)
|
||||
ci(names["land_purchase"], land_sqm * s["landPriceSqm"],
|
||||
f"{land_sqm}m\u00b2 \u00d7 {sym}{s['landPriceSqm']}/m\u00b2")
|
||||
ci(names["transaction_costs"], _round(land_sqm * s["landPriceSqm"] * 0.1), "~10% of land")
|
||||
ci(names["hvac_system"], s["hvac"])
|
||||
ci(names["electrical_lighting"], s["electrical"] * light_mult)
|
||||
ci(names["sanitary_changing"], s["sanitary"])
|
||||
ci(names["parking_exterior"], s["parking"])
|
||||
ci(names["planning_permits"], s["planning"])
|
||||
ci(names["fire_protection"], s["fireProtection"])
|
||||
else:
|
||||
ci(names["floor_preparation"], s["floorPrep"])
|
||||
ci(names["hvac_upgrade"], s["hvacUpgrade"])
|
||||
ci(names["lighting_upgrade"], s["lightingUpgrade"] * light_mult)
|
||||
ci(names["fitout_reception"], s["fitout"])
|
||||
ci(names["permits_compliance"], s["permitsCompliance"])
|
||||
else:
|
||||
ci(names["concrete_foundation"], (s["dblCourts"] * 250 + s["sglCourts"] * 150) * s["outdoorFoundation"])
|
||||
ci(names["site_work"], s["outdoorSiteWork"])
|
||||
ci(names["outdoor_lighting"], total_courts * s["outdoorLighting"] * light_mult)
|
||||
ci(names["fencing"], s["outdoorFencing"])
|
||||
ci(names["permits_compliance"], s["permitsCompliance"])
|
||||
if is_buy:
|
||||
ci(names["land_purchase"], d["outdoorLandSqm"] * s["landPriceSqm"],
|
||||
f"{d['outdoorLandSqm']}m\u00b2 \u00d7 {sym}{s['landPriceSqm']}/m\u00b2")
|
||||
ci(names["transaction_costs"], _round(d["outdoorLandSqm"] * s["landPriceSqm"] * 0.1))
|
||||
|
||||
ci(names["equipment"], s["equipment"] + total_courts * 300)
|
||||
ci(names["working_capital"], s["workingCapital"])
|
||||
ci(names["miscellaneous"], 8000 if is_buy else 6000)
|
||||
|
||||
sub = sum(i["amount"] for i in capex_items)
|
||||
cont = _round(sub * s["contingencyPct"] / 100)
|
||||
if s["contingencyPct"] > 0:
|
||||
ci(f"{names['contingency']} ({s['contingencyPct']}%)", cont)
|
||||
|
||||
d["capexItems"] = capex_items
|
||||
d["capex"] = sub + cont
|
||||
d["capexPerCourt"] = d["capex"] / total_courts if total_courts > 0 else 0
|
||||
d["capexPerSqm"] = d["capex"] / d["sqm"] if d["sqm"] > 0 else 0
|
||||
|
||||
# -- OPEX --
|
||||
opex_items: list[dict] = []
|
||||
|
||||
def oi(name: str, amount: float, info: str = ""):
|
||||
opex_items.append({"name": name, "amount": _round(amount), "info": info})
|
||||
|
||||
rent_amount = 0
|
||||
if not is_buy:
|
||||
if is_in:
|
||||
rent_amount = _round(d["hallSqm"] * s["rentSqm"])
|
||||
oi(names["rent"], d["hallSqm"] * s["rentSqm"],
|
||||
f"{d['hallSqm']}m\u00b2 \u00d7 {sym}{s['rentSqm']}/m\u00b2")
|
||||
else:
|
||||
rent_amount = s["outdoorRent"]
|
||||
oi(names["rent"], s["outdoorRent"])
|
||||
else:
|
||||
oi(names["property_tax"], s["propertyTax"])
|
||||
|
||||
oi(names["insurance"], s["insurance"])
|
||||
oi(names["electricity"], s["electricity"])
|
||||
if is_in:
|
||||
oi(names["heating"], s["heating"])
|
||||
oi(names["water"], s["water"])
|
||||
oi(names["maintenance"], s["maintenance"])
|
||||
if is_in:
|
||||
oi(names["cleaning"], s["cleaning"])
|
||||
oi(names["marketing_misc"], s["marketing"])
|
||||
if s["staff"] > 0:
|
||||
oi(names["staff"], s["staff"])
|
||||
|
||||
d["opexItems"] = opex_items
|
||||
d["opex"] = sum(i["amount"] for i in opex_items)
|
||||
d["annualOpex"] = d["opex"] * 12
|
||||
|
||||
# -- Financing --
|
||||
d["equity"] = _round(d["capex"] * (1 - s["loanPct"] / 100))
|
||||
d["loanAmount"] = d["capex"] - d["equity"]
|
||||
d["monthlyPayment"] = (
|
||||
pmt(s["interestRate"] / 100 / 12, max(s["loanTerm"], 1) * 12, d["loanAmount"])
|
||||
if d["loanAmount"] > 0
|
||||
else 0
|
||||
)
|
||||
d["annualDebtService"] = d["monthlyPayment"] * 12
|
||||
d["ltv"] = d["loanAmount"] / d["capex"] if d["capex"] > 0 else 0
|
||||
|
||||
# -- Revenue model --
|
||||
dpm = s["daysPerMonthIndoor"] if is_in else s["daysPerMonthOutdoor"]
|
||||
d["daysPerMonth"] = dpm
|
||||
|
||||
if total_courts > 0:
|
||||
w_rate = (
|
||||
s["dblCourts"] * (s["ratePeak"] * s["peakPct"] / 100 + s["rateOffPeak"] * (1 - s["peakPct"] / 100))
|
||||
+ s["sglCourts"] * s["rateSingle"]
|
||||
) / total_courts
|
||||
else:
|
||||
w_rate = s["ratePeak"]
|
||||
d["weightedRate"] = w_rate
|
||||
|
||||
d["availHoursMonth"] = s["hoursPerDay"] * dpm * total_courts
|
||||
d["bookedHoursMonth"] = d["availHoursMonth"] * (s["utilTarget"] / 100)
|
||||
|
||||
d["courtRevMonth"] = d["bookedHoursMonth"] * w_rate
|
||||
d["feeDeduction"] = d["courtRevMonth"] * (s["bookingFee"] / 100)
|
||||
d["racketRev"] = d["bookedHoursMonth"] * (s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
|
||||
d["ballMargin"] = d["bookedHoursMonth"] * (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"])
|
||||
d["membershipRev"] = total_courts * s["membershipRevPerCourt"]
|
||||
d["fbRev"] = total_courts * s["fbRevPerCourt"]
|
||||
d["coachingRev"] = total_courts * s["coachingRevPerCourt"]
|
||||
d["retailRev"] = total_courts * s["retailRevPerCourt"]
|
||||
|
||||
d["grossRevMonth"] = (
|
||||
d["courtRevMonth"] + d["racketRev"] + d["ballMargin"]
|
||||
+ d["membershipRev"] + d["fbRev"] + d["coachingRev"] + d["retailRev"]
|
||||
)
|
||||
d["netRevMonth"] = d["grossRevMonth"] - d["feeDeduction"]
|
||||
d["ebitdaMonth"] = d["netRevMonth"] - d["opex"]
|
||||
d["netCFMonth"] = d["ebitdaMonth"] - d["monthlyPayment"]
|
||||
|
||||
# -- 60-month cash flow projection --
|
||||
months: list[dict] = []
|
||||
for m in range(1, 61):
|
||||
cm = (m - 1) % 12
|
||||
yr = math.ceil(m / 12)
|
||||
ramp = s["ramp"][m - 1] if m <= 12 else 1
|
||||
seas = 1 if is_in else s["season"][cm]
|
||||
eff_util = (s["utilTarget"] / 100) * ramp * seas
|
||||
avail = s["hoursPerDay"] * dpm * total_courts if seas > 0 else 0
|
||||
booked = avail * eff_util
|
||||
court_rev = booked * w_rate
|
||||
fees = -court_rev * (s["bookingFee"] / 100)
|
||||
ancillary = booked * (
|
||||
(s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
|
||||
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"])
|
||||
)
|
||||
membership = total_courts * s["membershipRevPerCourt"] * (ramp if seas > 0 else 0)
|
||||
fb = total_courts * s["fbRevPerCourt"] * (ramp if seas > 0 else 0)
|
||||
coaching = total_courts * s["coachingRevPerCourt"] * (ramp if seas > 0 else 0)
|
||||
retail = total_courts * s["retailRevPerCourt"] * (ramp if seas > 0 else 0)
|
||||
total_rev = court_rev + fees + ancillary + membership + fb + coaching + retail
|
||||
opex_val = -d["opex"]
|
||||
loan = -d["monthlyPayment"]
|
||||
ebitda = total_rev + opex_val
|
||||
ncf = ebitda + loan
|
||||
prev = months[-1] if months else None
|
||||
cum = (prev["cum"] if prev else -d["capex"]) + ncf
|
||||
months.append({
|
||||
"m": m, "cm": cm + 1, "yr": yr, "ramp": ramp, "seas": seas,
|
||||
"effUtil": eff_util, "avail": avail, "booked": booked,
|
||||
"courtRev": court_rev, "fees": fees, "ancillary": ancillary,
|
||||
"membership": membership, "totalRev": total_rev, "opex": opex_val,
|
||||
"loan": loan, "ebitda": ebitda, "ncf": ncf, "cum": cum,
|
||||
})
|
||||
d["months"] = months
|
||||
|
||||
# -- Annual summaries --
|
||||
annuals: list[dict] = []
|
||||
for y in range(1, 6):
|
||||
ym = [m for m in months if m["yr"] == y]
|
||||
annuals.append({
|
||||
"year": y,
|
||||
"revenue": sum(m["totalRev"] for m in ym),
|
||||
"ebitda": sum(m["ebitda"] for m in ym),
|
||||
"ncf": sum(m["ncf"] for m in ym),
|
||||
"ds": sum(abs(m["loan"]) for m in ym),
|
||||
"booked": sum(m["booked"] for m in ym),
|
||||
"avail": sum(m["avail"] for m in ym),
|
||||
})
|
||||
d["annuals"] = annuals
|
||||
|
||||
# -- Returns & exit --
|
||||
y3_ebitda = annuals[2]["ebitda"] if len(annuals) >= 3 else 0
|
||||
d["stabEbitda"] = y3_ebitda
|
||||
d["exitValue"] = y3_ebitda * s["exitMultiple"]
|
||||
d["remainingLoan"] = d["loanAmount"] * max(0, 1 - s["holdYears"] / (max(s["loanTerm"], 1) * 1.5))
|
||||
d["netExit"] = d["exitValue"] - d["remainingLoan"]
|
||||
|
||||
irr_cfs = [-d["capex"]]
|
||||
for y in range(s["holdYears"]):
|
||||
ycf = annuals[y]["ncf"] if y < len(annuals) else (annuals[-1]["ncf"] if annuals else 0)
|
||||
if y == s["holdYears"] - 1:
|
||||
irr_cfs.append(ycf + d["netExit"])
|
||||
else:
|
||||
irr_cfs.append(ycf)
|
||||
|
||||
d["irr"] = calc_irr(irr_cfs)
|
||||
d["totalReturned"] = sum(irr_cfs[1:])
|
||||
d["moic"] = d["totalReturned"] / d["capex"] if d["capex"] > 0 else 0
|
||||
|
||||
d["dscr"] = [
|
||||
{"year": a["year"], "dscr": a["ebitda"] / a["ds"] if a["ds"] > 0 else 999}
|
||||
for a in annuals
|
||||
]
|
||||
|
||||
payback_idx = -1
|
||||
for i, m in enumerate(months):
|
||||
if m["cum"] >= 0:
|
||||
payback_idx = i
|
||||
break
|
||||
d["paybackIdx"] = payback_idx
|
||||
|
||||
# -- Efficiency metrics --
|
||||
rev_per_hr = (
|
||||
w_rate * (1 - s["bookingFee"] / 100)
|
||||
+ (s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
|
||||
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"])
|
||||
)
|
||||
fixed_month = d["opex"] + d["monthlyPayment"]
|
||||
d["breakEvenHrs"] = fixed_month / max(rev_per_hr, 0.01)
|
||||
d["breakEvenUtil"] = d["breakEvenHrs"] / d["availHoursMonth"] if d["availHoursMonth"] > 0 else 1
|
||||
d["breakEvenHrsPerCourt"] = d["breakEvenHrs"] / total_courts / dpm if total_courts > 0 else 0
|
||||
d["revPAH"] = d["netRevMonth"] / d["availHoursMonth"] if d["availHoursMonth"] > 0 else 0
|
||||
d["revPerSqm"] = (d["netRevMonth"] * 12) / d["sqm"] if d["sqm"] > 0 else 0
|
||||
d["ebitdaMargin"] = d["ebitdaMonth"] / d["netRevMonth"] if d["netRevMonth"] > 0 else 0
|
||||
d["opexRatio"] = d["opex"] / d["netRevMonth"] if d["netRevMonth"] > 0 else 0
|
||||
|
||||
# Rent ratio — use the tracked rent_amount (set when oi() for rent was called)
|
||||
d["rentRatio"] = rent_amount / d["netRevMonth"] if d["netRevMonth"] > 0 else 0
|
||||
|
||||
d["cashOnCash"] = (annuals[2]["ncf"] if len(annuals) >= 3 else 0) / d["equity"] if d["equity"] > 0 else 0
|
||||
d["yieldOnCost"] = d["stabEbitda"] / d["capex"] if d["capex"] > 0 else 0
|
||||
d["debtYield"] = d["stabEbitda"] / d["loanAmount"] if d["loanAmount"] > 0 else 0
|
||||
d["costPerBookedHr"] = (
|
||||
(d["opex"] + d["monthlyPayment"]) / d["bookedHoursMonth"]
|
||||
if d["bookedHoursMonth"] > 0
|
||||
else 0
|
||||
)
|
||||
d["avgUtil"] = (
|
||||
annuals[2]["booked"] / annuals[2]["avail"]
|
||||
if len(annuals) >= 3 and annuals[2]["avail"] > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
# -- Budget comparison --
|
||||
d["budgetTarget"] = s["budgetTarget"]
|
||||
d["budgetVariance"] = d["capex"] - s["budgetTarget"] if s["budgetTarget"] > 0 else 0
|
||||
d["budgetPct"] = d["capex"] / s["budgetTarget"] * 100 if s["budgetTarget"] > 0 else 0
|
||||
|
||||
return d
|
||||
624
web/src/padelnomics/planner/routes.py
Normal file
624
web/src/padelnomics/planner/routes.py
Normal file
@@ -0,0 +1,624 @@
|
||||
"""
|
||||
Planner domain: padel court financial planner + scenario management.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Blueprint, Response, g, jsonify, render_template, request
|
||||
|
||||
from ..auth.routes import login_required
|
||||
from ..core import (
|
||||
config,
|
||||
csrf_protect,
|
||||
execute,
|
||||
fetch_all,
|
||||
fetch_one,
|
||||
get_paddle_price,
|
||||
waitlist_gate,
|
||||
)
|
||||
from ..i18n import get_translations
|
||||
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, DEFAULTS, calc, validate_state
|
||||
|
||||
bp = Blueprint(
|
||||
"planner",
|
||||
__name__,
|
||||
template_folder=str(Path(__file__).parent / "templates"),
|
||||
url_prefix="/planner",
|
||||
)
|
||||
|
||||
# Country presets (mirrors JS COUNTRY_PRESETS)
|
||||
COUNTRY_PRESETS = {
|
||||
"DE": {"permitsCompliance": 12000},
|
||||
"ES": {"permitsCompliance": 25000},
|
||||
"IT": {"permitsCompliance": 18000},
|
||||
"FR": {"permitsCompliance": 15000},
|
||||
"NL": {"permitsCompliance": 10000},
|
||||
"SE": {"permitsCompliance": 8000},
|
||||
"UK": {"permitsCompliance": 10000},
|
||||
"US": {"permitsCompliance": 15000},
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# SQL Queries
|
||||
# =============================================================================
|
||||
|
||||
async def count_scenarios(user_id: int) -> int:
|
||||
row = await fetch_one(
|
||||
"SELECT COUNT(*) as cnt FROM scenarios WHERE user_id = ? AND deleted_at IS NULL",
|
||||
(user_id,),
|
||||
)
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
|
||||
async def get_default_scenario(user_id: int) -> dict | None:
|
||||
return await fetch_one(
|
||||
"SELECT * FROM scenarios WHERE user_id = ? AND is_default = 1 AND deleted_at IS NULL",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
async def get_scenarios(user_id: int) -> list[dict]:
|
||||
return await fetch_all(
|
||||
"SELECT id, name, location, is_default, created_at, updated_at FROM scenarios WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC",
|
||||
(user_id,),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helpers
|
||||
# =============================================================================
|
||||
|
||||
def form_to_state(form) -> dict:
|
||||
"""Convert Quart ImmutableMultiDict form data to state dict."""
|
||||
data: dict = {}
|
||||
ramp = form.getlist("ramp")
|
||||
if ramp:
|
||||
data["ramp"] = [float(v) for v in ramp]
|
||||
season = form.getlist("season")
|
||||
if season:
|
||||
data["season"] = [float(v) for v in season]
|
||||
for key in form.keys():
|
||||
if key not in ("ramp", "season", "activeTab"):
|
||||
data[key] = form.get(key)
|
||||
return data
|
||||
|
||||
|
||||
def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"""Add display-only derived fields to calc result dict (mutates d in-place)."""
|
||||
t = get_translations(lang)
|
||||
month_keys = ["jan", "feb", "mar", "apr", "may", "jun",
|
||||
"jul", "aug", "sep", "oct", "nov", "dec"]
|
||||
|
||||
d["irr_ok"] = math.isfinite(d.get("irr", 0))
|
||||
|
||||
# Chart data — full Chart.js 4.x config objects, embedded as JSON in partials
|
||||
_PALETTE = [
|
||||
"#1D4ED8", "#16A34A", "#D97706", "#EF4444", "#8B5CF6",
|
||||
"#EC4899", "#06B6D4", "#84CC16", "#F97316", "#475569",
|
||||
"#0EA5E9", "#A78BFA",
|
||||
]
|
||||
_cap_items = [i for i in d["capexItems"] if i["amount"] > 0]
|
||||
d["capex_chart"] = {
|
||||
"type": "doughnut",
|
||||
"data": {
|
||||
"labels": [i["name"] for i in _cap_items],
|
||||
"datasets": [{
|
||||
"data": [i["amount"] for i in _cap_items],
|
||||
"backgroundColor": [_PALETTE[i % len(_PALETTE)] for i in range(len(_cap_items))],
|
||||
"borderWidth": 0,
|
||||
}],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"cutout": "60%",
|
||||
"plugins": {"legend": {"position": "right", "labels": {"boxWidth": 10, "font": {"size": 10}}}},
|
||||
},
|
||||
}
|
||||
|
||||
ramp_data = d["months"][:24]
|
||||
d["ramp_chart"] = {
|
||||
"type": "line",
|
||||
"data": {
|
||||
"labels": [f"M{m['m']}" for m in ramp_data],
|
||||
"datasets": [
|
||||
{
|
||||
"label": t["chart_revenue"],
|
||||
"data": [round(m["totalRev"]) for m in ramp_data],
|
||||
"borderColor": "#16A34A",
|
||||
"backgroundColor": "rgba(22,163,74,0.08)",
|
||||
"fill": True,
|
||||
"tension": 0.35,
|
||||
"pointRadius": 0,
|
||||
"borderWidth": 2,
|
||||
},
|
||||
{
|
||||
"label": t["chart_opex_debt"],
|
||||
"data": [round(abs(m["opex"]) + abs(m["loan"])) for m in ramp_data],
|
||||
"borderColor": "#EF4444",
|
||||
"backgroundColor": "rgba(239,68,68,0.06)",
|
||||
"fill": True,
|
||||
"tension": 0.35,
|
||||
"pointRadius": 0,
|
||||
"borderWidth": 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": True, "labels": {"boxWidth": 12, "font": {"size": 10}}}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
|
||||
},
|
||||
}
|
||||
|
||||
_pl_values = [
|
||||
round(d["courtRevMonth"]),
|
||||
-round(d["feeDeduction"]),
|
||||
round(d["racketRev"] + d["ballMargin"] + d["membershipRev"]
|
||||
+ d["fbRev"] + d["coachingRev"] + d["retailRev"]),
|
||||
-round(d["opex"]),
|
||||
-round(d["monthlyPayment"]),
|
||||
]
|
||||
d["pl_chart"] = {
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [t["chart_court_rev"], t["chart_fees"], t["chart_ancillary"], t["chart_opex"], t["chart_debt"]],
|
||||
"datasets": [{
|
||||
"data": _pl_values,
|
||||
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)" for v in _pl_values],
|
||||
"borderRadius": 4,
|
||||
}],
|
||||
},
|
||||
"options": {
|
||||
"indexAxis": "y",
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"x": {"ticks": {"font": {"size": 9}}}, "y": {"ticks": {"font": {"size": 10}}}},
|
||||
},
|
||||
}
|
||||
|
||||
_cf_values = [round(m["ncf"]) for m in d["months"]]
|
||||
d["cf_chart"] = {
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [f"Y{m['yr']}" if m["m"] % 12 == 1 else "" for m in d["months"]],
|
||||
"datasets": [{
|
||||
"data": _cf_values,
|
||||
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 0 else "rgba(239,68,68,0.7)" for v in _cf_values],
|
||||
"borderRadius": 2,
|
||||
}],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
|
||||
},
|
||||
}
|
||||
|
||||
d["cum_chart"] = {
|
||||
"type": "line",
|
||||
"data": {
|
||||
"labels": [f"M{m['m']}" if m["m"] % 6 == 1 else "" for m in d["months"]],
|
||||
"datasets": [{
|
||||
"data": [round(m["cum"]) for m in d["months"]],
|
||||
"borderColor": "#1D4ED8",
|
||||
"backgroundColor": "rgba(29,78,216,0.08)",
|
||||
"fill": True,
|
||||
"tension": 0.3,
|
||||
"pointRadius": 0,
|
||||
"borderWidth": 2,
|
||||
}],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}}, "x": {"ticks": {"font": {"size": 9}}}},
|
||||
},
|
||||
}
|
||||
|
||||
_dscr_values = [min(x["dscr"], 10) for x in d["dscr"]]
|
||||
d["dscr_chart"] = {
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [f"Y{x['year']}" for x in d["dscr"]],
|
||||
"datasets": [{
|
||||
"data": _dscr_values,
|
||||
"backgroundColor": ["rgba(22,163,74,0.7)" if v >= 1.2 else "rgba(239,68,68,0.7)" for v in _dscr_values],
|
||||
"borderRadius": 4,
|
||||
}],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}, "min": 0}, "x": {"ticks": {"font": {"size": 10}}}},
|
||||
},
|
||||
}
|
||||
|
||||
d["season_chart"] = {
|
||||
"type": "bar",
|
||||
"data": {
|
||||
"labels": [t[f"month_{k}"] for k in month_keys],
|
||||
"datasets": [{
|
||||
"data": [v * 100 for v in s["season"]],
|
||||
"backgroundColor": "rgba(29,78,216,0.6)",
|
||||
"borderRadius": 3,
|
||||
}],
|
||||
},
|
||||
"options": {
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"plugins": {"legend": {"display": False}},
|
||||
"scales": {"y": {"ticks": {"font": {"size": 10}}, "min": 0}, "x": {"ticks": {"font": {"size": 10}}}},
|
||||
},
|
||||
}
|
||||
|
||||
# Sensitivity tables (pre-computed for returns tab)
|
||||
is_in = s["venue"] == "indoor"
|
||||
w_rate = d["weightedRate"]
|
||||
rev_per_hr = (
|
||||
w_rate * (1 - s["bookingFee"] / 100)
|
||||
+ (s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
|
||||
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"])
|
||||
)
|
||||
utils = [15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70]
|
||||
ancillary_per_court = (
|
||||
s["membershipRevPerCourt"] + s["fbRevPerCourt"]
|
||||
+ s["coachingRevPerCourt"] + s["retailRevPerCourt"]
|
||||
)
|
||||
sens_rows = []
|
||||
for u in utils:
|
||||
booked = d["availHoursMonth"] * (u / 100)
|
||||
rev = booked * rev_per_hr + d["totalCourts"] * ancillary_per_court * (u / max(s["utilTarget"], 1))
|
||||
ncf = rev - d["opex"] - d["monthlyPayment"]
|
||||
annual = ncf * (12 if is_in else 6)
|
||||
ebitda = rev - d["opex"]
|
||||
dscr = (ebitda * (12 if is_in else 6)) / d["annualDebtService"] if d["annualDebtService"] > 0 else 999
|
||||
sens_rows.append({
|
||||
"util": u,
|
||||
"rev": round(rev),
|
||||
"ncf": round(ncf),
|
||||
"annual": round(annual),
|
||||
"dscr": min(dscr, 99),
|
||||
"is_target": u == s["utilTarget"],
|
||||
})
|
||||
d["sens_rows"] = sens_rows
|
||||
|
||||
prices = [-20, -10, -5, 0, 5, 10, 15, 20]
|
||||
price_rows = []
|
||||
for delta in prices:
|
||||
adj_rate = w_rate * (1 + delta / 100)
|
||||
booked = d["bookedHoursMonth"]
|
||||
rev = (
|
||||
booked * adj_rate * (1 - s["bookingFee"] / 100)
|
||||
+ booked * ((s["racketRentalRate"] / 100) * s["racketQty"] * s["racketPrice"]
|
||||
+ (s["ballRate"] / 100) * (s["ballPrice"] - s["ballCost"]))
|
||||
+ d["totalCourts"] * ancillary_per_court
|
||||
)
|
||||
ncf = rev - d["opex"] - d["monthlyPayment"]
|
||||
price_rows.append({
|
||||
"delta": delta,
|
||||
"adj_rate": round(adj_rate),
|
||||
"rev": round(rev),
|
||||
"ncf": round(ncf),
|
||||
"is_base": delta == 0,
|
||||
})
|
||||
d["price_rows"] = price_rows
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/")
|
||||
async def index():
|
||||
scenario_count = 0
|
||||
default = None
|
||||
if g.user:
|
||||
scenario_count = await count_scenarios(g.user["id"])
|
||||
default = await get_default_scenario(g.user["id"])
|
||||
initial_state = json.loads(default["state_json"]) if default else {}
|
||||
s = validate_state(initial_state)
|
||||
lang = g.get("lang", "en")
|
||||
d = calc(s, lang=lang)
|
||||
augment_d(d, s, lang)
|
||||
cur = COUNTRY_CURRENCY.get(s["country"], CURRENCY_DEFAULT)
|
||||
g.currency_sym = cur["sym"]
|
||||
g.currency_eu_style = cur["eu_style"]
|
||||
return await render_template(
|
||||
"planner.html",
|
||||
s=s,
|
||||
d=d,
|
||||
scenario_count=scenario_count,
|
||||
lang=lang,
|
||||
active_tab="capex",
|
||||
country_presets=COUNTRY_PRESETS,
|
||||
defaults=DEFAULTS,
|
||||
currency_sym=cur["sym"],
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/calculate", methods=["POST"])
|
||||
async def calculate():
|
||||
"""HTMX endpoint: form data → HTML tab partial + OOB swaps."""
|
||||
form = await request.form
|
||||
s = validate_state(form_to_state(form))
|
||||
lang = g.get("lang", "en")
|
||||
d = calc(s, lang=lang)
|
||||
augment_d(d, s, lang)
|
||||
active_tab = form.get("activeTab", "capex")
|
||||
if active_tab not in {"capex", "operating", "cashflow", "returns", "metrics"}:
|
||||
active_tab = "capex"
|
||||
cur = COUNTRY_CURRENCY.get(s["country"], CURRENCY_DEFAULT)
|
||||
g.currency_sym = cur["sym"]
|
||||
g.currency_eu_style = cur["eu_style"]
|
||||
return await render_template(
|
||||
"partials/calculate_response.html",
|
||||
s=s,
|
||||
d=d,
|
||||
active_tab=active_tab,
|
||||
lang=lang,
|
||||
currency_sym=cur["sym"],
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/scenarios", methods=["GET"])
|
||||
@login_required
|
||||
async def scenario_list():
|
||||
scenarios = await get_scenarios(g.user["id"])
|
||||
return await render_template("partials/scenario_list.html", scenarios=scenarios)
|
||||
|
||||
|
||||
@bp.route("/scenarios/save", methods=["POST"])
|
||||
@login_required
|
||||
@csrf_protect
|
||||
async def save_scenario():
|
||||
data = await request.get_json()
|
||||
name = data.get("name", "Untitled Scenario")
|
||||
state_json = data.get("state_json", "{}")
|
||||
location = data.get("location", "")
|
||||
scenario_id = data.get("id")
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
is_first_save = not scenario_id and (await count_scenarios(g.user["id"])) == 0
|
||||
|
||||
if scenario_id:
|
||||
# Update existing
|
||||
await execute(
|
||||
"UPDATE scenarios SET name = ?, state_json = ?, location = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||
(name, state_json, location, now, scenario_id, g.user["id"]),
|
||||
)
|
||||
else:
|
||||
# Create new
|
||||
scenario_id = await execute(
|
||||
"INSERT INTO scenarios (user_id, name, state_json, location, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(g.user["id"], name, state_json, location, now, now),
|
||||
)
|
||||
|
||||
# Add to Resend nurture audience on first scenario save
|
||||
if is_first_save:
|
||||
from ..core import config as _config
|
||||
if _config.RESEND_AUDIENCE_PLANNER and _config.RESEND_API_KEY:
|
||||
try:
|
||||
import resend
|
||||
resend.api_key = _config.RESEND_API_KEY
|
||||
resend.Contacts.create({
|
||||
"audience_id": _config.RESEND_AUDIENCE_PLANNER,
|
||||
"email": g.user["email"],
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[NURTURE] Failed to add {g.user['email']} to audience: {e}")
|
||||
|
||||
count = await count_scenarios(g.user["id"])
|
||||
return jsonify({"ok": True, "id": scenario_id, "count": count})
|
||||
|
||||
|
||||
@bp.route("/scenarios/<int:scenario_id>", methods=["GET"])
|
||||
@login_required
|
||||
async def get_scenario(scenario_id: int):
|
||||
row = await fetch_one(
|
||||
"SELECT * FROM scenarios WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||
(scenario_id, g.user["id"]),
|
||||
)
|
||||
if not row:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
return jsonify({"id": row["id"], "name": row["name"], "state_json": row["state_json"], "location": row["location"]})
|
||||
|
||||
|
||||
@bp.route("/scenarios/<int:scenario_id>", methods=["DELETE"])
|
||||
@login_required
|
||||
@csrf_protect
|
||||
async def delete_scenario(scenario_id: int):
|
||||
now = datetime.utcnow().isoformat()
|
||||
await execute(
|
||||
"UPDATE scenarios SET deleted_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||
(now, scenario_id, g.user["id"]),
|
||||
)
|
||||
scenarios = await get_scenarios(g.user["id"])
|
||||
return await render_template("partials/scenario_list.html", scenarios=scenarios)
|
||||
|
||||
|
||||
@bp.route("/scenarios/<int:scenario_id>/default", methods=["POST"])
|
||||
@login_required
|
||||
@csrf_protect
|
||||
async def set_default(scenario_id: int):
|
||||
# Clear existing default
|
||||
await execute(
|
||||
"UPDATE scenarios SET is_default = 0 WHERE user_id = ?",
|
||||
(g.user["id"],),
|
||||
)
|
||||
# Set new default
|
||||
await execute(
|
||||
"UPDATE scenarios SET is_default = 1 WHERE id = ? AND user_id = ?",
|
||||
(scenario_id, g.user["id"]),
|
||||
)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Business Plan PDF Export
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/export")
|
||||
@login_required
|
||||
@waitlist_gate("export_waitlist.html")
|
||||
async def export():
|
||||
"""Export options page — language, scenario picker, pricing."""
|
||||
scenarios = await get_scenarios(g.user["id"])
|
||||
|
||||
# Check for existing purchases
|
||||
exports = await fetch_all(
|
||||
"SELECT * FROM business_plan_exports WHERE user_id = ? ORDER BY created_at DESC",
|
||||
(g.user["id"],),
|
||||
)
|
||||
|
||||
return await render_template(
|
||||
"export.html",
|
||||
scenarios=scenarios,
|
||||
exports=exports,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/export/checkout", methods=["POST"])
|
||||
@login_required
|
||||
@csrf_protect
|
||||
async def export_checkout():
|
||||
"""Return JSON for Paddle.js overlay checkout for business plan PDF."""
|
||||
form = await request.form
|
||||
scenario_id = form.get("scenario_id")
|
||||
language = form.get("language", "en")
|
||||
|
||||
if not scenario_id:
|
||||
return jsonify({"error": "Select a scenario."}), 400
|
||||
|
||||
# Verify ownership
|
||||
scenario = await fetch_one(
|
||||
"SELECT id FROM scenarios WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||
(int(scenario_id), g.user["id"]),
|
||||
)
|
||||
if not scenario:
|
||||
return jsonify({"error": "Scenario not found."}), 404
|
||||
|
||||
price_id = await get_paddle_price("business_plan")
|
||||
if not price_id:
|
||||
return jsonify({"error": "Product not configured. Contact support."}), 500
|
||||
|
||||
return jsonify({
|
||||
"items": [{"priceId": price_id, "quantity": 1}],
|
||||
"customData": {
|
||||
"user_id": str(g.user["id"]),
|
||||
"scenario_id": str(scenario_id),
|
||||
"language": language,
|
||||
},
|
||||
"settings": {
|
||||
"successUrl": f"{config.BASE_URL}/planner/export/success",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@bp.route("/export/success")
|
||||
@login_required
|
||||
async def export_success():
|
||||
"""Post-checkout landing — shows download link when ready."""
|
||||
exports = await fetch_all(
|
||||
"SELECT * FROM business_plan_exports WHERE user_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
(g.user["id"],),
|
||||
)
|
||||
return await render_template("export_success.html", exports=exports)
|
||||
|
||||
|
||||
@bp.route("/export/<int:export_id>")
|
||||
@login_required
|
||||
async def export_download(export_id: int):
|
||||
"""Download a generated PDF."""
|
||||
export = await fetch_one(
|
||||
"SELECT * FROM business_plan_exports WHERE id = ? AND user_id = ?",
|
||||
(export_id, g.user["id"]),
|
||||
)
|
||||
if not export:
|
||||
return jsonify({"error": "Export not found."}), 404
|
||||
|
||||
if export["status"] == "pending" or export["status"] == "generating":
|
||||
return await render_template("export_generating.html", export=export)
|
||||
|
||||
if export["status"] == "failed":
|
||||
return jsonify({"error": "PDF generation failed. Please contact support."}), 500
|
||||
|
||||
# Serve the PDF file
|
||||
from pathlib import Path
|
||||
file_path = Path(export["file_path"])
|
||||
if not file_path.exists():
|
||||
return jsonify({"error": "PDF file not found."}), 404
|
||||
|
||||
pdf_bytes = file_path.read_bytes()
|
||||
return Response(
|
||||
pdf_bytes,
|
||||
mimetype="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="padel-business-plan-{export_id}.pdf"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DuckDB analytics integration — market data for planner pre-fill
|
||||
# =============================================================================
|
||||
|
||||
@bp.route("/api/market-data")
|
||||
async def market_data():
|
||||
"""Return per-city planner defaults from DuckDB serving layer.
|
||||
|
||||
GET /planner/api/market-data?city_slug=berlin
|
||||
|
||||
Returns a partial DEFAULTS override dict (camelCase keys).
|
||||
Returns {} when the analytics DB has no data yet — caller merges with DEFAULTS.
|
||||
"""
|
||||
city_slug = request.args.get("city_slug", "").strip()
|
||||
if not city_slug:
|
||||
return jsonify({}), 200
|
||||
|
||||
from ..analytics import fetch_analytics
|
||||
|
||||
rows = await fetch_analytics(
|
||||
"SELECT * FROM padelnomics.planner_defaults WHERE city_slug = ? LIMIT 1",
|
||||
[city_slug],
|
||||
)
|
||||
if not rows:
|
||||
return jsonify({}), 200
|
||||
|
||||
row = rows[0]
|
||||
|
||||
# Map DuckDB snake_case columns → DEFAULTS camelCase keys.
|
||||
# Only include fields that exist in the row and have non-null values.
|
||||
col_map: dict[str, str] = {
|
||||
"rate_peak": "ratePeak",
|
||||
"rate_off_peak": "rateOffPeak",
|
||||
"court_cost_dbl": "courtCostDbl",
|
||||
"court_cost_sgl": "courtCostSgl",
|
||||
"rent_sqm": "rentSqm",
|
||||
"insurance": "insurance",
|
||||
"electricity": "electricity",
|
||||
"maintenance": "maintenance",
|
||||
"marketing": "marketing",
|
||||
}
|
||||
|
||||
overrides: dict = {}
|
||||
for col, key in col_map.items():
|
||||
val = row.get(col)
|
||||
if val is not None:
|
||||
overrides[key] = round(float(val))
|
||||
|
||||
# Include data quality metadata so frontend can show confidence indicator
|
||||
if row.get("data_confidence") is not None:
|
||||
overrides["_dataConfidence"] = round(float(row["data_confidence"]), 2)
|
||||
if row.get("country_code"):
|
||||
overrides["_countryCode"] = row["country_code"]
|
||||
|
||||
return jsonify(overrides), 200
|
||||
142
web/src/padelnomics/planner/templates/export.html
Normal file
142
web/src/padelnomics/planner/templates/export.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ t.export_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.exp-wrap { max-width: 640px; margin: 0 auto; padding: 3rem 0; }
|
||||
.exp-hero { text-align: center; margin-bottom: 2rem; }
|
||||
.exp-hero h1 { font-size: 1.75rem; margin-bottom: 0.5rem; }
|
||||
.exp-hero p { color: #64748B; }
|
||||
.exp-price { font-size: 2rem; font-weight: 800; color: #1E293B; text-align: center; margin: 1rem 0; }
|
||||
.exp-price span { font-size: 0.875rem; font-weight: 400; color: #64748B; }
|
||||
.exp-features {
|
||||
list-style: none; padding: 0; margin: 1.5rem 0; display: grid;
|
||||
grid-template-columns: 1fr 1fr; gap: 8px;
|
||||
}
|
||||
.exp-features li {
|
||||
font-size: 0.8125rem; color: #475569; display: flex; align-items: flex-start; gap: 6px;
|
||||
}
|
||||
.exp-features li::before { content: "✓"; color: #16A34A; font-weight: 700; flex-shrink: 0; }
|
||||
.exp-form { background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem; margin: 1.5rem 0; }
|
||||
.exp-form label { display: block; font-size: 0.8125rem; font-weight: 600; color: #475569; margin-bottom: 4px; }
|
||||
.exp-form select, .exp-form input { width: 100%; margin-bottom: 1rem; }
|
||||
.exp-existing { background: white; border: 1px solid #E2E8F0; border-radius: 14px; padding: 1.5rem; margin-top: 2rem; }
|
||||
.exp-existing h3 { font-size: 0.9375rem; margin-bottom: 0.75rem; }
|
||||
.exp-dl-link {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 0; border-bottom: 1px solid #F1F5F9; font-size: 0.8125rem;
|
||||
}
|
||||
.exp-dl-link:last-child { border-bottom: none; }
|
||||
.exp-status { font-size: 0.6875rem; font-weight: 700; padding: 2px 8px; border-radius: 999px; }
|
||||
.exp-status--ready { background: #DCFCE7; color: #16A34A; }
|
||||
.exp-status--pending { background: #FEF3C7; color: #D97706; }
|
||||
.exp-status--failed { background: #FEE2E2; color: #DC2626; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page">
|
||||
<div class="exp-wrap">
|
||||
<div class="exp-hero">
|
||||
<h1>{{ t.export_title }}</h1>
|
||||
<p>{{ t.export_subtitle }}</p>
|
||||
</div>
|
||||
|
||||
<div class="exp-price">€99 <span>one-time</span></div>
|
||||
|
||||
<ul class="exp-features">
|
||||
{% for key in ['planner_export_f1','planner_export_f2','planner_export_f3','planner_export_f4','planner_export_f5','planner_export_f6','planner_export_f7','planner_export_f8'] %}
|
||||
<li>{{ t[key] }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="exp-form">
|
||||
<form id="export-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>{{ t.export_scenario_label }}</label>
|
||||
<select name="scenario_id" class="form-input" required>
|
||||
<option value="">{{ t.export_scenario_default }}</option>
|
||||
{% for s in scenarios %}
|
||||
<option value="{{ s.id }}">{{ s.name }}{% if s.location %} ({{ s.location }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<label>{{ t.export_language_label }}</label>
|
||||
<select name="language" class="form-input">
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
</select>
|
||||
|
||||
<button type="submit" class="btn" style="width:100%;margin-top:0.5rem" id="export-buy-btn">
|
||||
{{ t.export_btn }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if exports %}
|
||||
<div class="exp-existing">
|
||||
<h3>{{ t.export_your_exports }}</h3>
|
||||
{% for e in exports %}
|
||||
<div class="exp-dl-link">
|
||||
<div>
|
||||
<span>Export #{{ e.id }}</span>
|
||||
<span style="color:#94A3B8;font-size:0.75rem;margin-left:8px">{{ e.created_at[:10] }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{% if e.status == 'ready' %}
|
||||
<a href="{{ url_for('planner.export_download', export_id=e.id) }}" class="btn-outline" style="font-size:0.75rem;padding:4px 12px">{{ t.export_download }}</a>
|
||||
{% elif e.status == 'pending' or e.status == 'generating' %}
|
||||
<span class="exp-status exp-status--pending">{{ t.export_generating }}</span>
|
||||
{% else %}
|
||||
<span class="exp-status exp-status--failed">{{ t.export_failed }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p style="text-align:center;margin-top:1.5rem">
|
||||
<a href="{{ url_for('planner.index') }}" style="color:#1D4ED8;font-size:0.875rem">{{ t.export_back }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('export-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('export-buy-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '{{ t.export_generating }}';
|
||||
|
||||
const formData = new FormData(this);
|
||||
try {
|
||||
const resp = await fetch("{{ url_for('planner.export_checkout') }}", {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
btn.disabled = false;
|
||||
btn.textContent = '{{ t.export_btn }}';
|
||||
return;
|
||||
}
|
||||
Paddle.Checkout.open({
|
||||
items: data.items,
|
||||
customData: data.customData,
|
||||
settings: data.settings,
|
||||
});
|
||||
btn.disabled = false;
|
||||
btn.textContent = '{{ t.export_btn }}';
|
||||
} catch (err) {
|
||||
alert('{{ t.export_failed }}');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '{{ t.export_btn }}';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user