feat: complete German translation of all public-facing content
Translate the entire public-facing surface of the app to German,
using a hybrid approach: {{ t.key }} for short UI strings and
{% if lang == 'de' %} conditionals for prose blocks/FAQs.
Coverage:
- i18n.py: +300 UI keys, +200 planner JS locale strings, +35
CAPEX/OPEX item name translations; new get_planner_translations()
and get_calc_item_names() helpers
- base.html / _cookie_banner.html: nav, footer, cookie banner,
feedback placeholder; JS toggle text injected via tojson
- public/: landing.html (hero, ROI calc, FAQ, SEO, JSON-LD),
features.html, about.html — all with German meta tags
- planner/: planner.html (wizard, tabs, chart labels, CTAs),
all export templates, scenario_list.html; window.__PADELNOMICS_LOCALE__
injected server-side; planner.js all ~200 strings via tr()
- calculator.py: add lang param, translated CAPEX/OPEX item names,
replace rent name-lookup with local rent_amount variable
- directory/: directory.html, supplier_detail.html, results.html,
enquiry_result.html
- leads/: quote_step_1–9.html, quote_request.html, quote_submitted.html,
quote_verify_sent.html; routes.py flash messages + _get_quote_steps(lang)
- suppliers/: signup flow (step 1–4), signup_success.html,
waitlist.html, waitlist_confirmed.html
- content/: markets.html, article_detail.html, market_results.html,
all scenario partials (summary, capex, cashflow, operating, returns)
- 629 tests pass, ruff clean
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
23
CHANGELOG.md
23
CHANGELOG.md
@@ -6,6 +6,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- i18n: translate `base.html`, `_cookie_banner.html` — "Manage Cookies", "About" footer links, feedback placeholder via `{{ t.key }}`; cookie banner heading/categories/descriptions/buttons; JS toggle text injected via `tojson` so "Manage"/"Close" states are also translated; `public/routes.py` feedback flash messages use `get_translations(g.lang)` keys
|
||||
- i18n: expand `i18n.py` with ~300 UI template keys, ~200 planner JS locale strings (`_PLANNER_TRANSLATIONS`), ~35 CAPEX/OPEX item name translations (`_CALC_ITEM_NAMES`), plus `get_planner_translations()` and `get_calc_item_names()` functions
|
||||
|
||||
### Fixed
|
||||
- i18n: `planner.html` used `{% if lang %}...{% block %}` nesting which Jinja2 forbids — restructured to `{% block title %}{% if lang == 'de' %}...{% endif %}{% endblock %}`
|
||||
- ruff: unsorted import in `planner/routes.py` (new `get_planner_translations` import) — auto-fixed with `ruff --fix`
|
||||
|
||||
### Added
|
||||
- i18n: localize planner — inject `window.__PADELNOMICS_LOCALE__` from server via `get_planner_translations(lang)`, add `const L / tr()` helpers in `planner.js`, replace all hardcoded English strings in TABS, WIZ_STEPS, all `buildInputs()`/`rebuildCapexInputs()`/`rebuildOpexInputs()` slider labels, `renderWith()`, `renderCapex()`, `renderOperating()`, `renderCashflow()`, `renderReturns()`, `renderMetrics()`, `renderSeasonChart()`, `resetToDefaults()`, `saveScenario()`, `renderWizNav()`, and `renderWizPreview()` with `tr('key', 'fallback')` calls
|
||||
- i18n: localize `planner.html` — add `window.__PADELNOMICS_LOCALE__` script injection, translate wizard step titles/subtitles, toggle labels, chart/section headers, CTA sidebar and inline CTA, signup bar, scenario controls, metrics section headers, and page title/meta via `{% if lang == 'de' %}` and `{{ t.key }}` / `{{ planner_t.key }}`
|
||||
- i18n: localize all export templates — `export.html`, `export_success.html`, `export_generating.html`, `export_waitlist.html` — all strings via `{{ t.key }}`, feature lists via `{% if lang == 'de' %}` conditionals
|
||||
- i18n: localize `partials/scenario_list.html` — drawer title, default badge, Load/Delete buttons, updated label, empty state message via `{{ t.scenario_* }}`
|
||||
- calculator: add `lang: str = "en"` parameter to `calc()`, import `get_calc_item_names`, replace all `ci()`/`oi()` hardcoded English names with `names["key"]` lookups, track `rent_amount` as local variable to replace name-based loop lookup for rentRatio
|
||||
- routes: pass `lang` and `planner_t` to `planner.html` render context; pass `lang=lang` to `calc()` in both index and `/calculate` endpoints
|
||||
|
||||
- i18n: translate directory and leads templates — `directory.html`, `supplier_detail.html`, `partials/results.html`, `partials/enquiry_result.html`, `quote_request.html`, `quote_step_1–9.html`, `quote_submitted.html`, `quote_verify_sent.html` — short strings via `{{ t.key }}`, long paragraphs and context-sensitive text via `{% if lang == 'de' %}` conditionals, title/meta tags conditional per language
|
||||
- i18n: translate supplier signup flow (`signup.html`, `signup_step_1–4.html`, `signup_success.html`), waitlist pages (`waitlist.html`, `waitlist_confirmed.html`), content templates (`markets.html`, `article_detail.html`, `market_results.html`), and all scenario partials (`scenario_summary`, `scenario_capex`, `scenario_cashflow`, `scenario_operating`, `scenario_returns`) — step labels via `{{ t.key }}`, all other strings via `{% if lang == 'de' %}` conditionals
|
||||
- i18n: translate `landing.html`, `features.html`, and `about.html` to German — all short strings via `{{ t.key }}`, long paragraphs/FAQ answers via `{% if lang == 'de' %}` conditionals, JSON-LD structured data wrapped per language, title/meta blocks conditional
|
||||
|
||||
### Changed
|
||||
- leads/routes.py: replace hardcoded `QUOTE_STEPS` list with `_get_quote_steps(lang)` function — step titles now use i18n keys so the progress bar shows translated step names; all public-facing `flash()` calls now use `get_translations(g.lang)` keys instead of hardcoded English strings
|
||||
|
||||
### Fixed
|
||||
- i18n: improve German nav labels — "Verzeichnis" → "Anbieterverzeichnis", "Planer" → "Kostenrechner"
|
||||
- CI: add missing env vars to `.env` heredoc — `WAITLIST_MODE`, `LEADS_EMAIL`, `UMAMI_API_URL`; make Paddle vars optional (`:-`) so they don't break deploys when unset
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl mb-2">{{ article.title }}</h1>
|
||||
<p class="text-sm text-slate">
|
||||
{% if article.published_at %}{{ article.published_at[:10] }} · {% endif %}Padelnomics Research
|
||||
{% if article.published_at %}{% if lang == 'de' %}Veröffentlicht{% else %}Published{% endif %} {{ article.published_at[:10] }} · {% endif %}{% if lang == 'de' %}Padelnomics Forschung{% else %}Padelnomics Research{% endif %}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -50,9 +50,9 @@
|
||||
|
||||
<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">Run Your Own Numbers</h3>
|
||||
<p class="text-slate text-sm mb-4">Use our free financial planner to model a padel center with your own assumptions.</p>
|
||||
<a href="{{ url_for('planner.index') }}" class="btn">Open the Planner</a>
|
||||
<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>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Padel Markets - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Padel-Märkte - {{ config.APP_NAME }}{% else %}Padel Markets - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="Padel court cost analysis and market data for cities worldwide. Real financial scenarios with local data.">
|
||||
<meta property="og:title" content="Padel Markets - {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="Explore padel court costs, revenue projections, and investment returns by city.">
|
||||
<meta name="description" content="{% if lang == 'de' %}Padel-Platz-Kostenanalyse und Marktdaten für Städte weltweit. Echte Finanzszenarien mit lokalen Daten.{% else %}Padel court cost analysis and market data for cities worldwide. Real financial scenarios with local data.{% endif %}">
|
||||
<meta property="og:title" content="{% if lang == 'de' %}Padel-Märkte - {{ config.APP_NAME }}{% else %}Padel Markets - {{ config.APP_NAME }}{% endif %}">
|
||||
<meta property="og:description" content="{% if lang == 'de' %}Erkunden Sie Padel-Platz-Kosten, Umsatzprojektionen und Investitionsrenditen nach Stadt.{% else %}Explore padel court costs, revenue projections, and investment returns by city.{% endif %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl mb-2">Padel Markets</h1>
|
||||
<p class="text-slate">Cost analysis and financial projections for padel centers worldwide.</p>
|
||||
<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">Search</label>
|
||||
<input type="text" id="market-q" name="q" value="{{ current_q }}" placeholder="Search articles..."
|
||||
<label class="form-label" for="market-q">{% if lang == 'de' %}Suche{% else %}Search{% endif %}</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"
|
||||
@@ -28,13 +28,13 @@
|
||||
hx-include="#market-country, #market-region">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="market-country">Country</label>
|
||||
<label class="form-label" for="market-country">{% if lang == 'de' %}Land{% else %}Country{% endif %}</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="">All Countries</option>
|
||||
<option value="">{{ t.mkt_all_countries }}</option>
|
||||
{% for c in countries %}
|
||||
<option value="{{ c }}" {% if c == current_country %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
@@ -47,7 +47,7 @@
|
||||
hx-target="#market-results"
|
||||
hx-trigger="change"
|
||||
hx-include="#market-q, #market-country">
|
||||
<option value="">All Regions</option>
|
||||
<option value="">{{ t.mkt_all_regions }}</option>
|
||||
{% for r in regions %}
|
||||
<option value="{{ r }}" {% if r == current_region %}selected{% endif %}>{{ r }}</option>
|
||||
{% endfor %}
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center">
|
||||
<p class="text-slate text-sm">No articles found. Try adjusting your filters.</p>
|
||||
<p class="text-slate text-sm">{{ t.mkt_no_results }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<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">Investment Breakdown</span>
|
||||
<span class="scenario-widget__config">{% if lang == 'de' %}Investitionsaufschlüsselung{% else %}Investment Breakdown{% endif %}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__body">
|
||||
<div class="scenario-widget__table-wrap">
|
||||
<table class="scenario-widget__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th class="text-right">Amount</th>
|
||||
<th>{% if lang == 'de' %}Position{% else %}Item{% endif %}</th>
|
||||
<th class="text-right">{% if lang == 'de' %}Betrag{% else %}Amount{% endif %}</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -24,7 +24,7 @@
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total CAPEX</strong></td>
|
||||
<td><strong>{% if lang == 'de' %}Gesamt-CAPEX{% else %}Total CAPEX{% endif %}</strong></td>
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.capex) }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
<div class="scenario-widget__metrics" style="margin-top: 1rem;">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Per Court</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Pro Platz{% else %}Per Court{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capexPerCourt) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
@@ -47,20 +47,20 @@
|
||||
</div>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Equity</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Eigenkapital{% else %}Equity{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.equity) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Loan</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Darlehen{% else %}Loan{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.loanAmount) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Total</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Gesamt{% else %}Total{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capex) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="/planner/">Try with your own numbers →</a>
|
||||
<a href="/planner/">{% if lang == 'de' %}Mit eigenen Zahlen testen →{% else %}Try with your own numbers →{% endif %}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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">{{ s.holdYears }}-Year Projection</span>
|
||||
<span class="scenario-widget__config">{% if lang == 'de' %}{{ s.holdYears }}-Jahres-Projektion{% else %}{{ s.holdYears }}-Year Projection{% endif %}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__body">
|
||||
<div class="scenario-widget__table-wrap">
|
||||
@@ -10,13 +10,13 @@
|
||||
<tr>
|
||||
<th></th>
|
||||
{% for a in d.annuals %}
|
||||
<th class="text-right">Year {{ a.year }}</th>
|
||||
<th class="text-right">{% if lang == 'de' %}Jahr{% else %}Year{% endif %} {{ a.year }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Revenue</td>
|
||||
<td>{% if lang == 'de' %}Umsatz{% else %}Revenue{% endif %}</td>
|
||||
{% for a in d.annuals %}
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(a.revenue) }}</td>
|
||||
{% endfor %}
|
||||
@@ -28,19 +28,19 @@
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Debt Service</td>
|
||||
<td>{% if lang == 'de' %}Schuldendienst{% else %}Debt Service{% endif %}</td>
|
||||
{% for a in d.annuals %}
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(a.ds) }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Net Cash Flow</strong></td>
|
||||
<td><strong>{% if lang == 'de' %}Netto-Cashflow{% else %}Net Cash Flow{% endif %}</strong></td>
|
||||
{% for a in d.annuals %}
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(a.ncf) }}</strong></td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cumulative NCF</td>
|
||||
<td>{% if lang == 'de' %}Kumulativer NCF{% else %}Cumulative NCF{% endif %}</td>
|
||||
{% set cum = namespace(total=-d.capex) %}
|
||||
{% for a in d.annuals %}
|
||||
{% set cum.total = cum.total + a.ncf %}
|
||||
@@ -60,6 +60,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="/planner/">Try with your own numbers →</a>
|
||||
<a href="/planner/">{% if lang == 'de' %}Mit eigenen Zahlen testen →{% else %}Try with your own numbers →{% endif %}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
<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">Revenue & Operating Costs</span>
|
||||
<span class="scenario-widget__config">{% if lang == 'de' %}Umsatz & Betriebskosten{% else %}Revenue & Operating Costs{% endif %}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__body">
|
||||
<h4 class="scenario-widget__section-title">Revenue Model</h4>
|
||||
<h4 class="scenario-widget__section-title">{% if lang == 'de' %}Umsatzmodell{% else %}Revenue Model{% endif %}</h4>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Weighted Rate</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Gewichteter Satz{% else %}Weighted Rate{% endif %}</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">Utilization Target</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Auslastungsziel{% else %}Utilization Target{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">{{ s.utilTarget }}%</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Booked Hours/mo</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Gebuchte Std./Monat{% else %}Booked Hours/mo{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:,.0f}".format(d.bookedHoursMonth) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="scenario-widget__section-title">Monthly OPEX</h4>
|
||||
<h4 class="scenario-widget__section-title">{% if lang == 'de' %}Monatliche OPEX{% else %}Monthly OPEX{% endif %}</h4>
|
||||
<div class="scenario-widget__table-wrap">
|
||||
<table class="scenario-widget__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th class="text-right">Monthly</th>
|
||||
<th>{% if lang == 'de' %}Position{% else %}Item{% endif %}</th>
|
||||
<th class="text-right">{% if lang == 'de' %}Monatlich{% else %}Monthly{% endif %}</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -41,7 +41,7 @@
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td><strong>Total Monthly OPEX</strong></td>
|
||||
<td><strong>{% if lang == 'de' %}Monatliche OPEX gesamt{% else %}Total Monthly OPEX{% endif %}</strong></td>
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.opex) }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
@@ -49,24 +49,24 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 class="scenario-widget__section-title">Monthly Summary</h4>
|
||||
<h4 class="scenario-widget__section-title">{% if lang == 'de' %}Monatliche Übersicht{% else %}Monthly Summary{% endif %}</h4>
|
||||
<div class="scenario-widget__table-wrap">
|
||||
<table class="scenario-widget__table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Gross Revenue</td>
|
||||
<td>{% if lang == 'de' %}Bruttoumsatz{% else %}Gross Revenue{% endif %}</td>
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(d.grossRevMonth) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Booking Fees</td>
|
||||
<td>{% if lang == 'de' %}Buchungsgebühren{% else %}Booking Fees{% endif %}</td>
|
||||
<td class="text-right mono">-€{{ "{:,.0f}".format(d.feeDeduction) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Net Revenue</td>
|
||||
<td>{% if lang == 'de' %}Nettoumsatz{% else %}Net Revenue{% endif %}</td>
|
||||
<td class="text-right mono">€{{ "{:,.0f}".format(d.netRevMonth) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Operating Costs</td>
|
||||
<td>{% if lang == 'de' %}Betriebskosten{% else %}Operating Costs{% endif %}</td>
|
||||
<td class="text-right mono">-€{{ "{:,.0f}".format(d.opex) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -74,11 +74,11 @@
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.ebitdaMonth) }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Debt Service</td>
|
||||
<td>{% if lang == 'de' %}Schuldendienst{% else %}Debt Service{% endif %}</td>
|
||||
<td class="text-right mono">-€{{ "{:,.0f}".format(d.monthlyPayment) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Net Cash Flow</strong></td>
|
||||
<td><strong>{% if lang == 'de' %}Netto-Cashflow{% else %}Net Cash Flow{% endif %}</strong></td>
|
||||
<td class="text-right mono"><strong>€{{ "{:,.0f}".format(d.netCFMonth) }}</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -86,6 +86,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="/planner/">Try with your own numbers →</a>
|
||||
<a href="/planner/">{% if lang == 'de' %}Mit eigenen Zahlen testen →{% else %}Try with your own numbers →{% endif %}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<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">Returns & Financing</span>
|
||||
<span class="scenario-widget__config">{% if lang == 'de' %}Renditen & Finanzierung{% else %}Returns & Financing{% endif %}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__body">
|
||||
<h4 class="scenario-widget__section-title">Return Metrics</h4>
|
||||
<h4 class="scenario-widget__section-title">{% if lang == 'de' %}Renditekennzahlen{% else %}Return Metrics{% endif %}</h4>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">IRR ({{ s.holdYears }}yr)</span>
|
||||
@@ -15,8 +15,8 @@
|
||||
<span class="scenario-widget__metric-value">{{ "{:.2f}".format(d.moic) }}x</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Payback</span>
|
||||
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} months{% else %}N/A{% endif %}</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Amortisation{% else %}Payback{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} {% if lang == 'de' %}Monate{% else %}months{% endif %}{% else %}N/A{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__metrics">
|
||||
@@ -25,48 +25,48 @@
|
||||
<span class="scenario-widget__metric-value">{{ "{:.1f}".format(d.cashOnCash * 100) }}%</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Yield on Cost</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Rendite auf Kosten{% else %}Yield on Cost{% endif %}</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">EBITDA Margin</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}EBITDA-Marge{% else %}EBITDA Margin{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ebitdaMargin * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="scenario-widget__section-title">Exit Analysis</h4>
|
||||
<h4 class="scenario-widget__section-title">{% if lang == 'de' %}Exit-Analyse{% else %}Exit Analysis{% endif %}</h4>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Exit Value ({{ s.exitMultiple }}x EBITDA)</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Exit-Wert ({{ s.exitMultiple }}x EBITDA){% else %}Exit Value ({{ s.exitMultiple }}x EBITDA){% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.exitValue) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Remaining Loan</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Restschuld{% else %}Remaining Loan{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.remainingLoan) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Net Exit</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Netto-Exit{% else %}Net Exit{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.netExit) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="scenario-widget__section-title">Financing</h4>
|
||||
<h4 class="scenario-widget__section-title">{% if lang == 'de' %}Finanzierung{% else %}Financing{% endif %}</h4>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Loan Amount</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Darlehensbetrag{% else %}Loan Amount{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.loanAmount) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Rate / Term</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Zinssatz / Laufzeit{% else %}Rate / Term{% endif %}</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">Monthly Payment</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Monatliche Rate{% else %}Monthly Payment{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.monthlyPayment) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="/planner/">Try with your own numbers →</a>
|
||||
<a href="/planner/">{% if lang == 'de' %}Mit eigenen Zahlen testen →{% else %}Try with your own numbers →{% endif %}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<div class="scenario-widget__body">
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Total CAPEX</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Gesamt-CAPEX{% else %}Total CAPEX{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.capex) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Monthly EBITDA</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Monatliches EBITDA{% else %}Monthly EBITDA{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">€{{ "{:,.0f}".format(d.ebitdaMonth) }}</span>
|
||||
</div>
|
||||
<div class="scenario-widget__metric">
|
||||
@@ -20,20 +20,20 @@
|
||||
</div>
|
||||
<div class="scenario-widget__metrics">
|
||||
<div class="scenario-widget__metric">
|
||||
<span class="scenario-widget__metric-label">Payback</span>
|
||||
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} months{% else %}N/A{% endif %}</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}Amortisation{% else %}Payback{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">{% if d.paybackIdx >= 0 %}{{ d.paybackIdx + 1 }} {% if lang == 'de' %}Monate{% else %}months{% endif %}{% 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">EBITDA Margin</span>
|
||||
<span class="scenario-widget__metric-label">{% if lang == 'de' %}EBITDA-Marge{% else %}EBITDA Margin{% endif %}</span>
|
||||
<span class="scenario-widget__metric-value">{{ "{:.0f}".format(d.ebitdaMargin * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="scenario-widget__cta">
|
||||
<a href="/planner/">Try with your own numbers →</a>
|
||||
<a href="/planner/">{% if lang == 'de' %}Mit eigenen Zahlen testen →{% else %}Try with your own numbers →{% endif %}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Padel Court Supplier Directory - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Padel-Platz Anbieterverzeichnis - {{ config.APP_NAME }}{% else %}Padel Court Supplier Directory - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% if lang == 'de' %}
|
||||
<meta name="description" content="Über {{ total_suppliers }}+ Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software. Den richtigen Partner für Ihr Projekt finden.">
|
||||
<meta property="og:title" content="Padel-Platz Anbieterverzeichnis - {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="Über {{ total_suppliers }}+ Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen, Kunstrasenproduzenten, Beleuchtung und Software.">
|
||||
{% else %}
|
||||
<meta name="description" content="Browse {{ total_suppliers }}+ padel court suppliers across {{ total_countries }} countries. Manufacturers, builders, turf, lighting, and software. Find the right partner for your project.">
|
||||
<meta property="og:title" content="Padel Court Supplier Directory - {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="Browse {{ total_suppliers }}+ padel court suppliers across {{ total_countries }} countries. Manufacturers, builders, turf, lighting, and software.">
|
||||
{% endif %}
|
||||
<style>
|
||||
:root {
|
||||
--dir-green: #15803D;
|
||||
@@ -298,18 +304,18 @@
|
||||
{% block content %}
|
||||
<main class="container-page">
|
||||
<div class="dir-hero">
|
||||
<h1>Padel Court Supplier Directory</h1>
|
||||
<p>Browse {{ total_suppliers }}+ suppliers across {{ total_countries }} countries. Find manufacturers, builders, and specialists for your project.</p>
|
||||
<h1>{{ t.dir_heading }}</h1>
|
||||
<p>{% if lang == 'de' %}Über {{ total_suppliers }}+ Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen und Spezialisten für Ihr Projekt.{% else %}Browse {{ total_suppliers }}+ suppliers across {{ total_countries }} countries. Find manufacturers, builders, and specialists for your project.{% endif %}</p>
|
||||
<div class="dir-stats">
|
||||
<span><strong>{{ total_suppliers }}</strong> suppliers</span>
|
||||
<span><strong>{{ total_countries }}</strong> countries</span>
|
||||
<span><strong>{{ category_counts | length }}</strong> categories</span>
|
||||
<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="Search suppliers, countries, products..."
|
||||
<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"
|
||||
@@ -319,7 +325,7 @@
|
||||
hx-trigger="change"
|
||||
hx-target="#dir-results"
|
||||
hx-include="#dir-search-form">
|
||||
<option value="">All Countries</option>
|
||||
<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 %}
|
||||
@@ -329,13 +335,13 @@
|
||||
hx-trigger="change"
|
||||
hx-target="#dir-results"
|
||||
hx-include="#dir-search-form">
|
||||
<option value="">All Categories</option>
|
||||
<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">Search</button>
|
||||
<button type="submit">{{ t.dir_search_btn }}</button>
|
||||
</form>
|
||||
|
||||
<!-- Active filters -->
|
||||
@@ -344,7 +350,7 @@
|
||||
{% 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">Clear all</a>
|
||||
<a href="{{ request.path }}" style="font-size:0.75rem;color:#64748B;text-decoration:none;padding:4px 8px">{{ t.dir_filter_clear }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -355,9 +361,9 @@
|
||||
|
||||
<!-- 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">Are you a padel court supplier?</h2>
|
||||
<p style="color:#64748B;font-size:0.875rem;margin-bottom:1rem">Get listed and connect with entrepreneurs planning padel projects.</p>
|
||||
<a href="{{ url_for('public.suppliers') }}" class="btn">Get Listed</a>
|
||||
<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 %}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{% 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">Enquiry sent!</p>
|
||||
<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 %}
|
||||
We've forwarded your message to {{ supplier.name }}. They'll be in touch directly.
|
||||
{% if lang == 'de' %}Ihre Nachricht wurde an {{ supplier.name }} weitergeleitet. Sie werden sich direkt bei Ihnen melden.{% else %}We've forwarded your message to {{ supplier.name }}. They'll be in touch directly.{% endif %}
|
||||
{% else %}
|
||||
Your message has been received. The team will be in touch shortly.
|
||||
{% if lang == 'de' %}Ihre Nachricht wurde empfangen. Das Team wird sich in Kürze bei Ihnen melden.{% else %}Your message has been received. The team will be in touch shortly.{% endif %}
|
||||
{% 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">Please fix the following:</p>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p style="font-size:0.8125rem;color:#64748B;margin-bottom:1rem">
|
||||
Showing {{ suppliers | length }} of {{ total }} supplier{{ 's' if total != 1 }}
|
||||
{% if lang == 'de' %}{{ suppliers|length }} von {{ total }} Anbieter{% if total != 1 %}n{% endif %}{% else %}Showing {{ suppliers|length }} of {{ total }} supplier{{ 's' if total != 1 }}{% endif %}
|
||||
{% if page > 1 %} (page {{ page }}){% endif %}
|
||||
</p>
|
||||
|
||||
@@ -38,31 +38,31 @@
|
||||
<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>Your project photo</p>
|
||||
<p>{% if lang == 'de' %}Ihr Projektfoto{% else %}Your project photo{% endif %}</p>
|
||||
</div>
|
||||
<div class="dir-card__featured" style="background:#3B82F6">Example</div>
|
||||
<div class="dir-card__cat dir-card__cat--example">Your Category</div>
|
||||
<div class="dir-card__featured" style="background:#3B82F6">{% if lang == 'de' %}Beispiel{% else %}Example{% endif %}</div>
|
||||
<div class="dir-card__cat dir-card__cat--example">{% if lang == 'de' %}Ihre Kategorie{% else %}Your Category{% endif %}</div>
|
||||
</div>
|
||||
<div class="dir-card__body">
|
||||
<div class="dir-card__logo-wrap">
|
||||
<div class="dir-card__logo-ph">?</div>
|
||||
</div>
|
||||
<h3 class="dir-card__name">Your Company</h3>
|
||||
<h3 class="dir-card__name">{% if lang == 'de' %}Ihr Unternehmen{% else %}Your Company{% endif %}</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>
|
||||
Your City, Country
|
||||
{% if lang == 'de' %}Ihre Stadt, Land{% else %}Your City, Country{% 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>
|
||||
Verified
|
||||
{{ t.dir_card_verified }}
|
||||
</span>
|
||||
<span class="dir-card__stat">12 projects · 8 yrs</span>
|
||||
</div>
|
||||
<p class="dir-card__desc">Verified listings include cover photo, project stats, and a direct quote button — placed above unverified suppliers in search results.</p>
|
||||
<p class="dir-card__desc">{% if lang == 'de' %}Verifizierte Einträge enthalten Titelfoto, Projektstatistiken und einen direkten Anfragebutton — sie erscheinen über nicht verifizierten Anbietern in den Suchergebnissen.{% else %}Verified listings include cover photo, project stats, and a direct quote button — placed above unverified suppliers in search results.{% endif %}</p>
|
||||
<div class="dir-card__foot">
|
||||
<span></span>
|
||||
<span class="dir-card__action dir-card__action--example">Get listed →</span>
|
||||
<span class="dir-card__action dir-card__action--example">{% if lang == 'de' %}Eintrag erstellen →{% else %}Get listed →{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -79,13 +79,13 @@
|
||||
{% 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">Featured</div>{% endif %}
|
||||
{% 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">Featured</div>{% endif %}
|
||||
{% 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 %}
|
||||
@@ -105,7 +105,7 @@
|
||||
<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>
|
||||
Verified
|
||||
{{ t.dir_card_verified }}
|
||||
</span>
|
||||
{% if s.project_count %}
|
||||
<span class="dir-card__stat">
|
||||
@@ -131,7 +131,7 @@
|
||||
{% endif %}
|
||||
<div class="dir-card__foot">
|
||||
<span class="dir-card__web">{{ s.website or '' }}</span>
|
||||
<span class="dir-card__action dir-card__action--quote">Request Quote →</span>
|
||||
<span class="dir-card__action dir-card__action--quote">{{ t.dir_card_quote_btn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -143,14 +143,14 @@
|
||||
{% 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">Featured</div>{% endif %}
|
||||
{% 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">Featured</div>{% endif %}
|
||||
{% 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 %}
|
||||
@@ -168,7 +168,7 @@
|
||||
{{ 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">Growth</span>
|
||||
<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>
|
||||
@@ -186,7 +186,7 @@
|
||||
{% endif %}
|
||||
<div class="dir-card__foot">
|
||||
<span class="dir-card__web">{{ s.website or '' }}</span>
|
||||
<span class="dir-card__action dir-card__action--quote">Request Quote →</span>
|
||||
<span class="dir-card__action dir-card__action--quote">{{ t.dir_card_quote_btn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -198,14 +198,14 @@
|
||||
{% 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">Featured</div>{% endif %}
|
||||
{% 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">Featured</div>{% endif %}
|
||||
{% 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 %}
|
||||
@@ -225,7 +225,7 @@
|
||||
<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>
|
||||
Verified
|
||||
{{ t.dir_card_verified }}
|
||||
</span>
|
||||
</div>
|
||||
{% if s.short_description or s.description %}
|
||||
@@ -233,7 +233,7 @@
|
||||
{% endif %}
|
||||
<div class="dir-card__foot">
|
||||
<span class="dir-card__web">{{ s.website or '' }}</span>
|
||||
<span class="dir-card__action dir-card__action--claim">View Listing →</span>
|
||||
<span class="dir-card__action dir-card__action--claim">{{ t.dir_card_view_btn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -257,11 +257,11 @@
|
||||
{{ 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">Unverified</span>
|
||||
<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">Is this yours? →</span>
|
||||
<span class="dir-card__action dir-card__action--claim">{{ t.dir_card_claim_btn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -293,8 +293,8 @@
|
||||
|
||||
{% else %}
|
||||
<div class="dir-empty">
|
||||
<h3>No suppliers found</h3>
|
||||
<p>Try adjusting your search or filters.</p>
|
||||
<a href="/directory/" style="color:#1D4ED8">Clear all filters</a>
|
||||
<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 %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ supplier.name }} - Supplier Directory - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}{{ supplier.name }} - Anbieterverzeichnis - {{ config.APP_NAME }}{% else %}{{ supplier.name }} - Supplier Directory - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% set _sup_country = country_labels.get(supplier.country_code, supplier.country_code) %}
|
||||
@@ -247,7 +247,7 @@
|
||||
<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>
|
||||
Back to Directory
|
||||
{{ t.sp_back }}
|
||||
</a>
|
||||
|
||||
<div class="sp-hero-row">
|
||||
@@ -263,7 +263,7 @@
|
||||
<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">Verified ✓</span>
|
||||
<span class="sp-hero-badge sp-hero-badge--verified">{{ t.sp_verified }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if supplier.tagline %}
|
||||
@@ -275,13 +275,13 @@
|
||||
<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">
|
||||
Request Quote →
|
||||
{{ 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>
|
||||
Visit Website
|
||||
{{ t.sp_visit_website }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@
|
||||
{% set desc = supplier.long_description or supplier.short_description or supplier.description %}
|
||||
{% if desc or supplier.service_categories %}
|
||||
<div class="sp-card">
|
||||
<h2>About</h2>
|
||||
<h2>{{ t.sp_about }}</h2>
|
||||
{% if desc %}
|
||||
<p class="sp-desc">{{ desc }}</p>
|
||||
{% endif %}
|
||||
@@ -319,7 +319,7 @@
|
||||
{# Services offered #}
|
||||
{% if services_list %}
|
||||
<div class="sp-card">
|
||||
<h2>Services Offered</h2>
|
||||
<h2>{{ t.sp_services }}</h2>
|
||||
<ul class="sp-services">
|
||||
{% for s in services_list %}
|
||||
<li>{{ s }}</li>
|
||||
@@ -331,7 +331,7 @@
|
||||
{# Service area #}
|
||||
{% if supplier.service_area %}
|
||||
<div class="sp-card">
|
||||
<h2>Service Area</h2>
|
||||
<h2>{{ t.sp_service_area }}</h2>
|
||||
<div class="sp-area-pills">
|
||||
{% for area in (supplier.service_area or '').split(',') %}
|
||||
{% if area.strip() %}
|
||||
@@ -344,26 +344,26 @@
|
||||
|
||||
{# Enquiry form for Basic+ #}
|
||||
<div class="sp-card sp-enquiry">
|
||||
<h2>Send an Enquiry</h2>
|
||||
<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">Your Name <span style="color:#EF4444">*</span></label>
|
||||
<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">Email <span style="color:#EF4444">*</span></label>
|
||||
<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">Message <span style="color:#EF4444">*</span></label>
|
||||
<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="Tell {{ supplier.name }} about your project…"></textarea>
|
||||
placeholder="{% if lang == 'de' %}Erzählen Sie {{ supplier.name }} von Ihrem Projekt…{% else %}Tell {{ supplier.name }} about your project…{% endif %}"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="sp-enquiry-submit">Send Enquiry</button>
|
||||
<button type="submit" class="sp-enquiry-submit">{{ t.sp_enquiry_submit }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,7 +372,7 @@
|
||||
<aside class="sp-sidebar">
|
||||
{# Contact card #}
|
||||
<div class="sp-card">
|
||||
<h2>Contact</h2>
|
||||
<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>
|
||||
@@ -433,13 +433,13 @@
|
||||
{% if supplier.years_in_business %}
|
||||
<div class="sp-stat">
|
||||
<div class="sp-stat__value">{{ supplier.years_in_business }}</div>
|
||||
<div class="sp-stat__label">Years Active</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">Projects</div>
|
||||
<div class="sp-stat__label">{{ t.sp_projects }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -449,7 +449,7 @@
|
||||
{% 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>
|
||||
Verified listing — identity and ownership confirmed
|
||||
{{ t.sp_trust }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -460,11 +460,11 @@
|
||||
{% if supplier.tier == 'basic' %}
|
||||
<div class="sp-cta-strip">
|
||||
<div class="sp-cta-strip__text">
|
||||
<h3>Looking for direct quote matching?</h3>
|
||||
<p>Upgrade to Growth to appear in our supplier matching and receive qualified project leads.</p>
|
||||
<h3>{{ t.sp_cta_basic_h3 }}</h3>
|
||||
<p>{% if lang == 'de' %}Upgraden Sie auf Growth, um in unserer Anbieter-Vermittlung zu erscheinen und qualifizierte Projekt-Leads zu erhalten.{% else %}Upgrade to Growth to appear in our supplier matching and receive qualified project leads.{% endif %}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('suppliers.signup') }}" class="sp-cta-strip__btn">
|
||||
Upgrade to Growth →
|
||||
{% if lang == 'de' %}Auf Growth upgraden →{% else %}Upgrade to Growth →{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% elif supplier.tier == 'growth' %}
|
||||
@@ -494,18 +494,17 @@
|
||||
<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">
|
||||
Request Quote
|
||||
{{ t.sp_request_quote }}
|
||||
</button>
|
||||
<p class="sp-locked-hint">Listing not yet verified</p>
|
||||
<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">Direct quotes unavailable</p>
|
||||
<p class="sp-locked-popover__title">{{ t.sp_locked_popover_title }}</p>
|
||||
<p class="sp-locked-popover__body">
|
||||
This supplier hasn't verified their listing yet. Use our quote wizard and
|
||||
we'll match you with verified suppliers in your region.
|
||||
{% if lang == 'de' %}Dieser Anbieter hat seinen Eintrag noch nicht verifiziert. Nutzen Sie unseren Angebotsassistenten und wir vermitteln Sie mit verifizierten Anbietern in Ihrer Region.{% else %}This supplier hasn't verified their listing yet. Use our quote wizard and we'll match you with verified suppliers in your region.{% endif %}
|
||||
</p>
|
||||
<a href="{{ url_for('leads.quote_request', country=supplier.country_code) }}"
|
||||
class="sp-locked-popover__link">Use Quote Wizard →</a>
|
||||
<button type="button" class="sp-locked-popover__dismiss" id="dismiss-popover">Dismiss</button>
|
||||
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>
|
||||
@@ -514,11 +513,11 @@
|
||||
{% if not supplier.claimed_by %}
|
||||
<div class="sp-cta-strip">
|
||||
<div class="sp-cta-strip__text">
|
||||
<h3>Is this your company?</h3>
|
||||
<p>Claim and verify this listing to start receiving project enquiries from padel developers.</p>
|
||||
<h3>{{ t.sp_cta_claim_h3 }}</h3>
|
||||
<p>{% if lang == 'de' %}Beanspruchen und verifizieren Sie diesen Eintrag, um Projektanfragen von Padel-Entwicklern zu erhalten.{% else %}Claim and verify this listing to start receiving project enquiries from padel developers.{% endif %}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('suppliers.claim', slug=supplier.slug) }}" class="sp-cta-strip__btn sp-cta-strip__btn--green">
|
||||
Claim This Listing →
|
||||
{{ t.sp_cta_claim_btn }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ from ..auth.routes import (
|
||||
update_user,
|
||||
)
|
||||
from ..core import config, csrf_protect, execute, fetch_one, send_email
|
||||
from ..i18n import get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
"leads",
|
||||
@@ -99,7 +100,8 @@ async def suppliers():
|
||||
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>",
|
||||
)
|
||||
await flash("Thanks! We'll connect you with verified court suppliers.", "success")
|
||||
_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
|
||||
@@ -142,7 +144,8 @@ async def financing():
|
||||
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>",
|
||||
)
|
||||
await flash("Thanks! We'll connect you with financing partners.", "success")
|
||||
_t = get_translations(g.get("lang", "en"))
|
||||
await flash(_t["flash_financing_success"], "success")
|
||||
return redirect(url_for("leads.financing"))
|
||||
|
||||
scenario = await fetch_one(
|
||||
@@ -160,16 +163,19 @@ async def financing():
|
||||
return await render_template("financing.html", prefill=prefill)
|
||||
|
||||
|
||||
QUOTE_STEPS = [
|
||||
{"n": 1, "title": "Your Project", "required": ["facility_type"]},
|
||||
{"n": 2, "title": "Location", "required": ["country"]},
|
||||
{"n": 3, "title": "Build Context", "required": []},
|
||||
{"n": 4, "title": "Project Phase", "required": []},
|
||||
{"n": 5, "title": "Timeline", "required": ["timeline"]},
|
||||
{"n": 6, "title": "Financing", "required": []},
|
||||
{"n": 7, "title": "About You", "required": ["stakeholder_type"]},
|
||||
{"n": 8, "title": "Services Needed", "required": []},
|
||||
{"n": 9, "title": "Contact Details", "required": ["contact_name", "contact_email", "contact_phone"]},
|
||||
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": []},
|
||||
{"n": 7, "title": t["q7_heading"], "required": ["stakeholder_type"]},
|
||||
{"n": 8, "title": t["q8_heading"], "required": []},
|
||||
{"n": 9, "title": t["q9_heading"], "required": ["contact_name", "contact_email", "contact_phone"]},
|
||||
]
|
||||
|
||||
|
||||
@@ -186,7 +192,9 @@ def _parse_accumulated(form_or_args):
|
||||
@csrf_protect
|
||||
async def quote_step(step):
|
||||
"""HTMX endpoint — validate current step and return next step partial."""
|
||||
if step < 1 or step > len(QUOTE_STEPS):
|
||||
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":
|
||||
@@ -208,7 +216,7 @@ async def quote_step(step):
|
||||
accumulated["services_needed"] = services
|
||||
|
||||
# Validate required fields for current step
|
||||
step_def = QUOTE_STEPS[step - 1]
|
||||
step_def = steps[step - 1]
|
||||
errors = []
|
||||
for field in step_def["required"]:
|
||||
val = accumulated.get(field, "")
|
||||
@@ -219,16 +227,16 @@ async def quote_step(step):
|
||||
if errors:
|
||||
return await render_template(
|
||||
f"partials/quote_step_{step}.html",
|
||||
data=accumulated, step=step, steps=QUOTE_STEPS,
|
||||
data=accumulated, step=step, steps=steps,
|
||||
errors=errors,
|
||||
)
|
||||
# Return next step
|
||||
next_step = step + 1
|
||||
if next_step > len(QUOTE_STEPS):
|
||||
next_step = len(QUOTE_STEPS)
|
||||
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=QUOTE_STEPS,
|
||||
data=accumulated, step=next_step, steps=steps,
|
||||
errors=[],
|
||||
)
|
||||
|
||||
@@ -236,7 +244,7 @@ async def quote_step(step):
|
||||
accumulated = _parse_accumulated(request.args)
|
||||
return await render_template(
|
||||
f"partials/quote_step_{step}.html",
|
||||
data=accumulated, step=step, steps=QUOTE_STEPS,
|
||||
data=accumulated, step=step, steps=steps,
|
||||
errors=[],
|
||||
)
|
||||
|
||||
@@ -428,7 +436,7 @@ async def quote_request():
|
||||
start_step = 2 # skip project step, already filled
|
||||
return await render_template(
|
||||
"quote_request.html",
|
||||
data=data, step=start_step, steps=QUOTE_STEPS,
|
||||
data=data, step=start_step, steps=_get_quote_steps(g.get("lang", "en")),
|
||||
)
|
||||
|
||||
|
||||
@@ -438,14 +446,15 @@ async def verify_quote():
|
||||
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("Invalid verification link.", "error")
|
||||
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("This link has expired or already been used. Please submit a new quote request.", "error")
|
||||
await flash(_t["flash_verify_expired"], "error")
|
||||
return redirect(url_for("leads.quote_request"))
|
||||
|
||||
# Validate lead exists and is pending
|
||||
@@ -454,7 +463,7 @@ async def verify_quote():
|
||||
(lead_id,),
|
||||
)
|
||||
if not lead:
|
||||
await flash("This quote has already been verified or does not exist.", "error")
|
||||
await flash(_t["flash_verify_invalid_lead"], "error")
|
||||
return redirect(url_for("leads.quote_request"))
|
||||
|
||||
# Mark token used
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{# Step 1: Your Project #}
|
||||
{% if data.get('facility_type') %}
|
||||
{# Pre-filled from planner — show read-only summary #}
|
||||
<h2 class="q-step-title">Your Project</h2>
|
||||
<p class="q-step-sub">Pre-filled from the planner. You can edit these in the planner.</p>
|
||||
<h2 class="q-step-title">{{ t.q1_heading }}</h2>
|
||||
<p class="q-step-sub">{% if lang == 'de' %}Aus dem Planer vorausgefüllt. Diese Angaben können Sie im Planer bearbeiten.{% else %}Pre-filled from the planner. You can edit these in the planner.{% endif %}</p>
|
||||
|
||||
<div class="q-prefill-card">
|
||||
<dl style="display:grid;grid-template-columns:1fr 1fr;gap:2px 1rem;margin:0">
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<div class="q-nav">
|
||||
<div></div>
|
||||
<button type="submit" class="q-btn-next">Next →</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -33,37 +33,37 @@
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">Your Project</h2>
|
||||
<p class="q-step-sub">What type of padel facility are you planning?</p>
|
||||
<h2 class="q-step-title">{{ t.q1_heading }}</h2>
|
||||
<p class="q-step-sub">{{ t.q1_subheading }}</p>
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">Facility Type <span class="required">*</span></span>
|
||||
{% if 'facility_type' in errors %}<p class="q-error-hint">Please select a facility type</p>{% endif %}
|
||||
<span class="q-label">{{ t.q1_facility_label }} <span class="required">*</span></span>
|
||||
{% if 'facility_type' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wählen Sie einen Anlagentyp{% else %}Please select a facility type{% endif %}</p>{% endif %}
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('indoor', 'Indoor'), ('outdoor', 'Outdoor'), ('both', 'Indoor + Outdoor')] %}
|
||||
{% 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">Number of Courts</label>
|
||||
<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">Glass Type</span>
|
||||
<span class="q-label">{{ t.q1_glass_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('standard', 'Standard Glass'), ('panoramic', 'Panoramic Glass'), ('no_preference', 'No Preference')] %}
|
||||
{% 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">Lighting</span>
|
||||
<span class="q-label">{{ t.q1_lighting_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('led_standard', 'LED Standard'), ('led_competition', 'LED Competition'), ('natural', 'Natural Light'), ('not_sure', 'Not Sure')] %}
|
||||
{% 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>
|
||||
@@ -71,7 +71,7 @@
|
||||
|
||||
<div class="q-nav">
|
||||
<div></div>
|
||||
<button type="submit" class="q-btn-next">Next →</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -79,7 +79,7 @@
|
||||
<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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">Location</h2>
|
||||
<p class="q-step-sub">Where are you planning to build?</p>
|
||||
<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">City / Region</label>
|
||||
<input type="text" id="city" name="city" class="q-input" placeholder="e.g. Munich, Bavaria" value="{{ data.get('city', '') }}">
|
||||
<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">Country <span class="required">*</span></label>
|
||||
{% if 'country' in errors %}<p class="q-error-hint">Please select a country</p>{% endif %}
|
||||
<label class="q-label" for="country">{{ t.q2_country_label }} <span class="required">*</span></label>
|
||||
{% if 'country' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wählen Sie ein Land{% else %}Please select a country{% endif %}</p>{% endif %}
|
||||
<select id="country" name="country" class="q-input {% if 'country' in errors %}q-input--error{% endif %}">
|
||||
<option value="">Select country...</option>
|
||||
<option value="">{{ t.q2_country_default }}</option>
|
||||
{% for code, name in [('DE', 'Germany'), ('ES', 'Spain'), ('IT', 'Italy'), ('FR', 'France'), ('NL', 'Netherlands'), ('SE', 'Sweden'), ('UK', 'United Kingdom'), ('PT', 'Portugal'), ('BE', 'Belgium'), ('AT', 'Austria'), ('CH', 'Switzerland'), ('DK', 'Denmark'), ('FI', 'Finland'), ('NO', 'Norway'), ('PL', 'Poland'), ('CZ', 'Czech Republic'), ('AE', 'UAE'), ('SA', 'Saudi Arabia'), ('US', 'United States'), ('OTHER', 'Other')] %}
|
||||
<option value="{{ code }}" {{ 'selected' if data.get('country') == code }}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
@@ -26,15 +26,15 @@
|
||||
<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">← Back</button>
|
||||
<button type="submit" class="q-btn-next">Next →</button>
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">Build Context</h2>
|
||||
<p class="q-step-sub">What best describes your project?</p>
|
||||
<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">Build Context</span>
|
||||
<span class="q-label">{{ t.q3_context_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('new_standalone', 'New Standalone Venue'), ('adding_to_club', 'Adding to Existing Club'), ('converting_building', 'Converting a Building'), ('venue_search', 'Need Help Finding a Venue')] %}
|
||||
{% 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>
|
||||
@@ -19,15 +19,15 @@
|
||||
<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">← Back</button>
|
||||
<button type="submit" class="q-btn-next">Next →</button>
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">Project Phase</h2>
|
||||
<p class="q-step-sub">Where are you in the process?</p>
|
||||
<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">Project Phase</span>
|
||||
<span class="q-label">{{ t.q4_phase_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('still_searching', 'Still searching for a location'), ('location_found', 'Location identified'), ('converting_existing', 'Converting existing facility'), ('lease_signed', 'Lease / purchase signed'), ('permit_not_filed', 'Permit not yet filed'), ('permit_pending', 'Permit in progress'), ('permit_granted', 'Permit approved')] %}
|
||||
{% 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>
|
||||
@@ -19,15 +19,15 @@
|
||||
<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">← Back</button>
|
||||
<button type="submit" class="q-btn-next">Next →</button>
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -4,36 +4,36 @@
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">Timeline</h2>
|
||||
<p class="q-step-sub">When do you want to get started?</p>
|
||||
<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">Timeline <span class="required">*</span></span>
|
||||
{% if 'timeline' in errors %}<p class="q-error-hint">Please select a timeline</p>{% endif %}
|
||||
<span class="q-label">{{ t.q5_timeline_label }} <span class="required">*</span></span>
|
||||
{% if 'timeline' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wählen Sie einen Zeitplan{% else %}Please select a timeline{% endif %}</p>{% endif %}
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('asap', 'ASAP'), ('3-6mo', '3-6 Months'), ('6-12mo', '6-12 Months'), ('12+mo', '12+ Months')] %}
|
||||
{% 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">Budget Estimate (€)</label>
|
||||
<label class="q-label" for="budget_estimate">{{ t.q5_budget_label }}</label>
|
||||
<input type="number" id="budget_estimate" name="budget_estimate" class="q-input" placeholder="e.g. 500000" value="{{ data.get('budget_estimate', '') }}">
|
||||
</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">← Back</button>
|
||||
<button type="submit" class="q-btn-next">Next →</button>
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">Financing</h2>
|
||||
<p class="q-step-sub">How are you funding the project?</p>
|
||||
<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">Financing Status</span>
|
||||
<span class="q-label">{{ t.q6_status_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('self_funded', 'Self-Funded'), ('loan_approved', 'Loan Approved'), ('seeking', 'Seeking Financing'), ('not_started', 'Not Started')] %}
|
||||
{% for val, label in [('self_funded', t.q6_status_self), ('loan_approved', t.q6_status_loan), ('seeking', t.q6_status_seeking), ('not_started', t.q6_status_not_started)] %}
|
||||
<label><input type="radio" name="financing_status" value="{{ val }}" {{ 'checked' if data.get('financing_status') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -19,14 +19,14 @@
|
||||
<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>I'd like help finding financing options</span>
|
||||
<span>{{ t.q6_help_checkbox }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<span class="q-label">Decision Process</span>
|
||||
<span class="q-label">{{ t.q6_decision_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('solo', 'Solo Decision'), ('partners', 'With Partners'), ('committee', 'Committee / Board')] %}
|
||||
{% for val, label in [('solo', t.q6_decision_solo), ('partners', t.q6_decision_partners), ('committee', t.q6_decision_committee)] %}
|
||||
<label><input type="radio" name="decision_process" value="{{ val }}" {{ 'checked' if data.get('decision_process') == val }}><span class="q-pill">{{ label }}</span></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -35,15 +35,15 @@
|
||||
<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">← Back</button>
|
||||
<button type="submit" class="q-btn-next">Next →</button>
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -4,23 +4,23 @@
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">About You</h2>
|
||||
<p class="q-step-sub">This helps us match you with the right suppliers.</p>
|
||||
<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">You are... <span class="required">*</span></span>
|
||||
{% if 'stakeholder_type' in errors %}<p class="q-error-hint">Please select your role</p>{% endif %}
|
||||
<span class="q-label">{{ t.q7_role_label }} <span class="required">*</span></span>
|
||||
{% if 'stakeholder_type' in errors %}<p class="q-error-hint">{% if lang == 'de' %}Bitte wählen Sie Ihre Rolle{% else %}Please select your role{% endif %}</p>{% endif %}
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('entrepreneur', 'Entrepreneur / Investor'), ('tennis_club', 'Tennis / Sports Club'), ('municipality', 'Municipality / Public Body'), ('developer', 'Real Estate Developer'), ('operator', 'Existing Padel Operator'), ('architect', 'Architect / Engineer')] %}
|
||||
{% 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">Have you contacted suppliers before?</span>
|
||||
<span class="q-label">{{ t.q7_contact_label }}</span>
|
||||
<div class="q-pills">
|
||||
{% for val, label in [('first_time', 'First time'), ('researching', 'Researching options'), ('received_quotes', 'Already received quotes')] %}
|
||||
{% 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>
|
||||
@@ -29,15 +29,15 @@
|
||||
<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">← Back</button>
|
||||
<button type="submit" class="q-btn-next">Next →</button>
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -4,36 +4,36 @@
|
||||
<input type="hidden" name="_accumulated" value='{{ data | tojson }}'>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<h2 class="q-step-title">Services Needed</h2>
|
||||
<p class="q-step-sub">Select all that apply. This helps suppliers prepare relevant proposals.</p>
|
||||
<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">Services <span style="color:#94A3B8;font-weight:400">(select all that apply)</span></span>
|
||||
<span class="q-label">{{ t.q8_services_label }} <span style="color:#94A3B8;font-weight:400">{{ t.q8_services_note }}</span></span>
|
||||
<div class="q-pills">
|
||||
{% set selected_services = data.get('services_needed', []) %}
|
||||
{% for val, label in [('court_supply', 'Court Supply'), ('installation', 'Installation'), ('construction', 'Hall Construction'), ('design', 'Facility Design'), ('lighting', 'Lighting'), ('flooring', 'Flooring'), ('turnkey', 'Full Turnkey')] %}
|
||||
{% for val, label in [('court_supply', t.q8_court_supply), ('installation', t.q8_installation), ('construction', t.q8_construction), ('design', t.q8_design), ('lighting', t.q8_lighting), ('flooring', t.q8_flooring), ('turnkey', t.q8_turnkey)] %}
|
||||
<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">Anything else?</label>
|
||||
<textarea id="additional_info" name="additional_info" class="q-input" rows="3" placeholder="Any specific requirements, questions, or context...">{{ data.get('additional_info', '') }}</textarea>
|
||||
<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">← Back</button>
|
||||
<button type="submit" class="q-btn-next">Next →</button>
|
||||
hx-target="#quote-step" hx-swap="innerHTML">{{ t.q_btn_back }}</button>
|
||||
<button type="submit" class="q-btn-next">{{ t.q_btn_next }}</button>
|
||||
</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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -11,58 +11,58 @@
|
||||
<input type="hidden" name="services_needed" value="{{ svc }}">
|
||||
{% endfor %}
|
||||
|
||||
<h2 class="q-step-title">Contact Details</h2>
|
||||
<p class="q-step-sub">How should matched suppliers reach you?</p>
|
||||
<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>Your contact details are shared only with pre-vetted suppliers that match your project specs.</span>
|
||||
<span>{{ t.q9_privacy_msg }}</span>
|
||||
</div>
|
||||
|
||||
<div class="q-field-group">
|
||||
<label class="q-label" for="contact_name">Full Name <span class="required">*</span></label>
|
||||
{% if 'contact_name' in errors %}<p class="q-error-hint">Full name is required</p>{% endif %}
|
||||
<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">{% if lang == 'de' %}Vollständiger Name ist erforderlich{% else %}Full name is required{% endif %}</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">Email <span class="required">*</span></label>
|
||||
{% if 'contact_email' in errors %}<p class="q-error-hint">Email is required</p>{% endif %}
|
||||
<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">{% if lang == 'de' %}E-Mail ist erforderlich{% else %}Email is required{% endif %}</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">Phone <span class="required">*</span></label>
|
||||
{% if 'contact_phone' in errors %}<p class="q-error-hint">Phone number is required</p>{% endif %}
|
||||
<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">{% if lang == 'de' %}Telefonnummer ist erforderlich{% else %}Phone number is required{% endif %}</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">Company <span style="color:#94A3B8;font-weight:400">(optional)</span></label>
|
||||
<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>I agree that my project details and contact information may be shared with verified padel court suppliers matched to my project. <a href="{{ url_for('public.privacy') }}">Privacy Policy</a> · <a href="{{ url_for('public.terms') }}">Terms</a></span>
|
||||
<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">← Back</button>
|
||||
<button type="submit" class="q-btn-submit">Submit & Get Quotes →</button>
|
||||
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;">No obligation.</p>
|
||||
<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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Get Builder Quotes - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Angebote von Bauunternehmen erhalten - {{ config.APP_NAME }}{% else %}Get Builder Quotes - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
@@ -105,7 +105,7 @@
|
||||
<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">{{ step }} of {{ steps | length }}</span>
|
||||
<span class="q-progress__count">{% if lang == 'de' %}Schritt {{ step }} von {{ steps|length }}{% else %}{{ step }} of {{ steps|length }}{% endif %}</span>
|
||||
</div>
|
||||
<div class="q-progress__track">
|
||||
<div class="q-progress__fill" style="width: {{ (step / steps|length * 100) | round }}%"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}You're Matched! - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{{ t.qs_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
@@ -55,38 +55,42 @@
|
||||
<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;">You're matched!</h1>
|
||||
<h1 class="text-2xl" style="margin-bottom: 0.5rem;">{{ t.qs_title }}</h1>
|
||||
<p style="color: #64748B; font-size: 0.9375rem;">
|
||||
{% if lang == 'de' %}
|
||||
Wir haben Ihr{% if court_count %} {{ court_count }}-Platz-{% endif %}{% if facility_type %} {{ facility_type }}-{% endif %}Projekt{% if country %} in {{ country }}{% endif %} mit verifizierten Anbietern abgestimmt, die sich mit maßgeschneiderten Angeboten bei Ihnen melden.
|
||||
{% else %}
|
||||
We've matched your
|
||||
{% if court_count %}{{ court_count }}-court{% endif %}
|
||||
{% if facility_type %}{{ facility_type }}{% endif %}
|
||||
project
|
||||
{% if country %}in {{ country }}{% endif %}
|
||||
with verified suppliers who'll reach out with tailored proposals.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="next-steps">
|
||||
<h3>What happens next</h3>
|
||||
<h3>{{ t.qs_next_h2 }}</h3>
|
||||
<ol>
|
||||
<li>
|
||||
<span class="step-num">1</span>
|
||||
<span>Suppliers review your project brief and prepare proposals</span>
|
||||
<span class="step-time">Now</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>Matched suppliers contact you with tailored quotes</span>
|
||||
<span class="step-time">1-2 days</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>Compare proposals and ask follow-up questions</span>
|
||||
<span class="step-time">1-2 weeks</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>Choose the supplier that fits your project best</span>
|
||||
<span class="step-time">At your pace</span>
|
||||
<span>{{ t.qs_step_4 }}</span>
|
||||
<span class="step-time">{{ t.qs_step_4_time }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
@@ -99,13 +103,13 @@
|
||||
|
||||
{% if not user %}
|
||||
<div class="signup-box">
|
||||
<h3>Create an account</h3>
|
||||
<p>Save scenarios, track your project, and get notified when suppliers respond.</p>
|
||||
<a href="{{ url_for('auth.signup') }}?next={{ url_for('planner.index') }}" class="btn">Create Account</a>
|
||||
<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">Back to Planner</a>
|
||||
<a href="{{ url_for('planner.index') }}" class="btn">{{ t.qs_back_planner }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Check Your Email - {{ config.APP_NAME }}{% endblock %}
|
||||
{% 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">Check your email</h1>
|
||||
<h1 class="text-2xl mb-4">{{ t.qv_heading }}</h1>
|
||||
|
||||
<p class="text-slate-dark">We've sent a verification link to:</p>
|
||||
<p class="text-slate-dark">{% if lang == 'de' %}Wir haben einen Verifizierungslink an folgende Adresse gesendet:{% else %}We've sent a verification link to:{% endif %}</p>
|
||||
<p class="font-semibold text-navy my-2">{{ contact_email }}</p>
|
||||
|
||||
<p class="text-slate text-sm" style="margin-top: 1rem;">
|
||||
Click the link in the email to verify your address and activate your quote request.
|
||||
This will also create your {{ config.APP_NAME }} account and log you in automatically.
|
||||
{% if lang == 'de' %}Klicken Sie auf den Link in der E-Mail, um Ihre Adresse zu bestätigen und Ihre Angebotsanfrage zu aktivieren. Dadurch wird auch Ihr {{ config.APP_NAME }}-Konto erstellt und Sie werden automatisch angemeldet.{% else %}Click the link in the email to verify your address and activate your quote request. This will also create your {{ config.APP_NAME }} account and log you in automatically.{% endif %}
|
||||
</p>
|
||||
|
||||
<p class="text-slate text-sm" style="margin-top: 0.5rem;">
|
||||
The link expires in 60 minutes.
|
||||
{{ t.qv_link_expiry }}
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<details class="text-left">
|
||||
<summary class="cursor-pointer text-sm font-medium text-navy">Didn't receive the email?</summary>
|
||||
<summary class="cursor-pointer text-sm font-medium text-navy">{% if lang == 'de' %}E-Mail nicht erhalten?{% else %}Didn't receive the email?{% endif %}</summary>
|
||||
<ul class="list-disc pl-6 mt-2 space-y-1 text-sm text-slate-dark">
|
||||
<li>Check your spam folder</li>
|
||||
<li>Make sure <strong>{{ contact_email }}</strong> is correct</li>
|
||||
<li>Wait a minute — delivery can take a moment</li>
|
||||
<li>{{ t.qv_spam }}</li>
|
||||
<li>{% if lang == 'de' %}Stellen Sie sicher, dass <strong>{{ contact_email }}</strong> korrekt ist{% else %}Make sure <strong>{{ contact_email }}</strong> is correct{% endif %}</li>
|
||||
<li>{{ t.qv_wait }}</li>
|
||||
</ul>
|
||||
<p class="text-sm text-slate mt-3">
|
||||
Wrong email? <a href="{{ url_for('leads.quote_request') }}">Submit a new request</a>.
|
||||
{{ t.qv_wrong_email }} <a href="{{ url_for('leads.quote_request') }}">{{ t.qv_wrong_email_link }}</a>.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@ 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).
|
||||
@@ -147,11 +149,12 @@ def calc_irr(cfs: list[float], guess: float = 0.1) -> float:
|
||||
return r
|
||||
|
||||
|
||||
def calc(s: dict) -> dict:
|
||||
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)
|
||||
d: dict = {}
|
||||
is_in = s["venue"] == "indoor"
|
||||
is_buy = s["own"] == "buy"
|
||||
@@ -185,54 +188,54 @@ def calc(s: dict) -> dict:
|
||||
capex_items.append({"name": name, "amount": _round(amount), "info": info})
|
||||
|
||||
ci(
|
||||
"Padel Courts",
|
||||
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("Shipping", math.ceil(total_courts / 2) * s["shipping"] if total_courts else 0)
|
||||
ci(names["shipping"], math.ceil(total_courts / 2) * s["shipping"] if total_courts else 0)
|
||||
|
||||
if is_in:
|
||||
if is_buy:
|
||||
ci("Hall Construction", d["hallSqm"] * s["hallCostSqm"],
|
||||
ci(names["hall_construction"], d["hallSqm"] * s["hallCostSqm"],
|
||||
f"{d['hallSqm']}m\u00b2 \u00d7 \u20ac{s['hallCostSqm']}/m\u00b2")
|
||||
ci("Foundation", d["hallSqm"] * s["foundationSqm"],
|
||||
ci(names["foundation"], d["hallSqm"] * s["foundationSqm"],
|
||||
f"{d['hallSqm']}m\u00b2 \u00d7 \u20ac{s['foundationSqm']}/m\u00b2")
|
||||
land_sqm = _round(d["hallSqm"] * 1.25)
|
||||
ci("Land Purchase", land_sqm * s["landPriceSqm"],
|
||||
ci(names["land_purchase"], land_sqm * s["landPriceSqm"],
|
||||
f"{land_sqm}m\u00b2 \u00d7 \u20ac{s['landPriceSqm']}/m\u00b2")
|
||||
ci("Transaction Costs", _round(land_sqm * s["landPriceSqm"] * 0.1), "~10% of land")
|
||||
ci("HVAC System", s["hvac"])
|
||||
ci("Electrical + Lighting", s["electrical"] * light_mult)
|
||||
ci("Sanitary / Changing", s["sanitary"])
|
||||
ci("Parking + Exterior", s["parking"])
|
||||
ci("Planning + Permits", s["planning"])
|
||||
ci("Fire Protection", s["fireProtection"])
|
||||
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("Floor Preparation", s["floorPrep"])
|
||||
ci("HVAC Upgrade", s["hvacUpgrade"])
|
||||
ci("Lighting Upgrade", s["lightingUpgrade"] * light_mult)
|
||||
ci("Fit-Out & Reception", s["fitout"])
|
||||
ci("Permits & Compliance", s["permitsCompliance"])
|
||||
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("Concrete Foundation", (s["dblCourts"] * 250 + s["sglCourts"] * 150) * s["outdoorFoundation"])
|
||||
ci("Site Work", s["outdoorSiteWork"])
|
||||
ci("Lighting", total_courts * s["outdoorLighting"] * light_mult)
|
||||
ci("Fencing", s["outdoorFencing"])
|
||||
ci("Permits & Compliance", s["permitsCompliance"])
|
||||
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("Land Purchase", d["outdoorLandSqm"] * s["landPriceSqm"],
|
||||
ci(names["land_purchase"], d["outdoorLandSqm"] * s["landPriceSqm"],
|
||||
f"{d['outdoorLandSqm']}m\u00b2 \u00d7 \u20ac{s['landPriceSqm']}/m\u00b2")
|
||||
ci("Transaction Costs", _round(d["outdoorLandSqm"] * s["landPriceSqm"] * 0.1))
|
||||
ci(names["transaction_costs"], _round(d["outdoorLandSqm"] * s["landPriceSqm"] * 0.1))
|
||||
|
||||
ci("Equipment", s["equipment"] + total_courts * 300)
|
||||
ci("Working Capital", s["workingCapital"])
|
||||
ci("Miscellaneous", 8000 if is_buy else 6000)
|
||||
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"Contingency ({s['contingencyPct']}%)", cont)
|
||||
ci(f"{names['contingency']} ({s['contingencyPct']}%)", cont)
|
||||
|
||||
d["capexItems"] = capex_items
|
||||
d["capex"] = sub + cont
|
||||
@@ -245,26 +248,29 @@ def calc(s: dict) -> 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:
|
||||
oi("Rent", d["hallSqm"] * s["rentSqm"],
|
||||
rent_amount = _round(d["hallSqm"] * s["rentSqm"])
|
||||
oi(names["rent"], d["hallSqm"] * s["rentSqm"],
|
||||
f"{d['hallSqm']}m\u00b2 \u00d7 \u20ac{s['rentSqm']}/m\u00b2")
|
||||
else:
|
||||
oi("Rent", s["outdoorRent"])
|
||||
rent_amount = s["outdoorRent"]
|
||||
oi(names["rent"], s["outdoorRent"])
|
||||
else:
|
||||
oi("Property Tax", s["propertyTax"])
|
||||
oi(names["property_tax"], s["propertyTax"])
|
||||
|
||||
oi("Insurance", s["insurance"])
|
||||
oi("Electricity", s["electricity"])
|
||||
oi(names["insurance"], s["insurance"])
|
||||
oi(names["electricity"], s["electricity"])
|
||||
if is_in:
|
||||
oi("Heating", s["heating"])
|
||||
oi("Water", s["water"])
|
||||
oi("Maintenance", s["maintenance"])
|
||||
oi(names["heating"], s["heating"])
|
||||
oi(names["water"], s["water"])
|
||||
oi(names["maintenance"], s["maintenance"])
|
||||
if is_in:
|
||||
oi("Cleaning", s["cleaning"])
|
||||
oi("Marketing / Software / Misc", s["marketing"])
|
||||
oi(names["cleaning"], s["cleaning"])
|
||||
oi(names["marketing_misc"], s["marketing"])
|
||||
if s["staff"] > 0:
|
||||
oi("Staff", s["staff"])
|
||||
oi(names["staff"], s["staff"])
|
||||
|
||||
d["opexItems"] = opex_items
|
||||
d["opex"] = sum(i["amount"] for i in opex_items)
|
||||
@@ -411,12 +417,7 @@ def calc(s: dict) -> dict:
|
||||
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 — find the "Rent" item in opex
|
||||
rent_amount = 0
|
||||
for item in opex_items:
|
||||
if item["name"] == "Rent":
|
||||
rent_amount = item["amount"]
|
||||
break
|
||||
# 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
|
||||
|
||||
@@ -17,6 +17,7 @@ from ..core import (
|
||||
get_paddle_price,
|
||||
waitlist_gate,
|
||||
)
|
||||
from ..i18n import get_planner_translations
|
||||
from .calculator import calc, validate_state
|
||||
|
||||
bp = Blueprint(
|
||||
@@ -66,12 +67,15 @@ async def index():
|
||||
default = await get_default_scenario(g.user["id"])
|
||||
initial_state = json.loads(default["state_json"]) if default else {}
|
||||
state = validate_state(initial_state)
|
||||
initial_d = calc(state)
|
||||
lang = g.get("lang", "en")
|
||||
initial_d = calc(state, lang=lang)
|
||||
return await render_template(
|
||||
"planner.html",
|
||||
initial_state=default["state_json"] if default else None,
|
||||
initial_d=json.dumps(initial_d),
|
||||
scenario_count=scenario_count,
|
||||
planner_t=get_planner_translations(lang),
|
||||
lang=lang,
|
||||
)
|
||||
|
||||
|
||||
@@ -79,7 +83,8 @@ async def index():
|
||||
async def calculate():
|
||||
data = await request.get_json()
|
||||
state = validate_state(data.get("state", {}))
|
||||
d = calc(state)
|
||||
lang = g.get("lang", "en")
|
||||
d = calc(state, lang=lang)
|
||||
return jsonify(d)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Export Business Plan - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{{ t.export_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
@@ -39,13 +39,23 @@
|
||||
<main class="container-page">
|
||||
<div class="exp-wrap">
|
||||
<div class="exp-hero">
|
||||
<h1>Export Business Plan (PDF)</h1>
|
||||
<p>Bank-ready financial projections from your planner scenario.</p>
|
||||
<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">
|
||||
{% if lang == 'de' %}
|
||||
<li>Zusammenfassung</li>
|
||||
<li>CAPEX-Aufschlüsselung</li>
|
||||
<li>5-Jahres GuV-Projektion</li>
|
||||
<li>12-Monats-Cashflow</li>
|
||||
<li>Finanzierungsstruktur</li>
|
||||
<li>Kennzahlen (IRR, MOIC, DSCR)</li>
|
||||
<li>Sensitivitätsanalyse</li>
|
||||
<li>Englisch oder Deutsch</li>
|
||||
{% else %}
|
||||
<li>Executive summary</li>
|
||||
<li>CAPEX breakdown</li>
|
||||
<li>5-year P&L projection</li>
|
||||
@@ -54,34 +64,35 @@
|
||||
<li>Key metrics (IRR, MOIC, DSCR)</li>
|
||||
<li>Sensitivity analysis</li>
|
||||
<li>English or German</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<div class="exp-form">
|
||||
<form id="export-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label>Scenario</label>
|
||||
<label>{{ t.export_scenario_label }}</label>
|
||||
<select name="scenario_id" class="form-input" required>
|
||||
<option value="">Select a scenario...</option>
|
||||
<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>Language</label>
|
||||
<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">
|
||||
Purchase & Generate PDF — €99
|
||||
{{ t.export_btn }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if exports %}
|
||||
<div class="exp-existing">
|
||||
<h3>Your Exports</h3>
|
||||
<h3>{{ t.export_your_exports }}</h3>
|
||||
{% for e in exports %}
|
||||
<div class="exp-dl-link">
|
||||
<div>
|
||||
@@ -90,11 +101,11 @@
|
||||
</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">Download PDF</a>
|
||||
<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">Generating...</span>
|
||||
<span class="exp-status exp-status--pending">{{ t.export_generating }}</span>
|
||||
{% else %}
|
||||
<span class="exp-status exp-status--failed">Failed</span>
|
||||
<span class="exp-status exp-status--failed">{{ t.export_failed }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +114,7 @@
|
||||
{% endif %}
|
||||
|
||||
<p style="text-align:center;margin-top:1.5rem">
|
||||
<a href="{{ url_for('planner.index') }}" style="color:#1D4ED8;font-size:0.875rem">← Back to Planner</a>
|
||||
<a href="{{ url_for('planner.index') }}" style="color:#1D4ED8;font-size:0.875rem">{{ t.export_back }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
@@ -115,7 +126,7 @@ document.getElementById('export-form').addEventListener('submit', async function
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById('export-buy-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Processing...';
|
||||
btn.textContent = '{{ t.export_generating }}';
|
||||
|
||||
const formData = new FormData(this);
|
||||
try {
|
||||
@@ -127,7 +138,7 @@ document.getElementById('export-form').addEventListener('submit', async function
|
||||
if (data.error) {
|
||||
alert(data.error);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Purchase & Generate PDF — €99';
|
||||
btn.textContent = '{{ t.export_btn }}';
|
||||
return;
|
||||
}
|
||||
Paddle.Checkout.open({
|
||||
@@ -136,11 +147,11 @@ document.getElementById('export-form').addEventListener('submit', async function
|
||||
settings: data.settings,
|
||||
});
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Purchase & Generate PDF — €99';
|
||||
btn.textContent = '{{ t.export_btn }}';
|
||||
} catch (err) {
|
||||
alert('Something went wrong. Please try again.');
|
||||
alert('{{ t.export_failed }}');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Purchase & Generate PDF — €99';
|
||||
btn.textContent = '{{ t.export_btn }}';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Generating PDF - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{{ t.export_gen_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center">
|
||||
<div style="font-size:2rem;margin-bottom:1rem;animation:spin 1s linear infinite;display:inline-block">◠</div>
|
||||
<h1 style="font-size:1.5rem;margin-bottom:0.5rem">Generating Your Business Plan</h1>
|
||||
<p style="color:#64748B;margin-bottom:2rem">This usually takes less than a minute. This page will auto-refresh.</p>
|
||||
<h1 style="font-size:1.5rem;margin-bottom:0.5rem">{{ t.export_gen_title }}</h1>
|
||||
<p style="color:#64748B;margin-bottom:2rem">{{ t.export_gen_subtitle }}</p>
|
||||
|
||||
<button onclick="window.location.reload()" class="btn-outline">Refresh Now</button>
|
||||
<button onclick="window.location.reload()" class="btn-outline">{{ t.export_gen_refresh }}</button>
|
||||
|
||||
<p style="margin-top:2rem">
|
||||
<a href="{{ url_for('planner.export') }}" style="color:#1D4ED8;font-size:0.875rem">View All Exports</a>
|
||||
<a href="{{ url_for('planner.export') }}" style="color:#1D4ED8;font-size:0.875rem">{{ t.export_gen_all }}</a>
|
||||
</p>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Export Ready - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{{ t.export_success_title }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page" style="max-width:500px;margin:0 auto;padding:4rem 1rem;text-align:center">
|
||||
<div style="font-size:3rem;margin-bottom:1rem">✓</div>
|
||||
<h1 style="font-size:1.5rem;margin-bottom:0.5rem">Payment Received</h1>
|
||||
<p style="color:#64748B;margin-bottom:2rem">Your business plan PDF is being generated. This usually takes less than a minute.</p>
|
||||
<h1 style="font-size:1.5rem;margin-bottom:0.5rem">{{ t.export_success_title }}</h1>
|
||||
<p style="color:#64748B;margin-bottom:2rem">{{ t.export_success_subtitle }}</p>
|
||||
|
||||
{% if exports %}
|
||||
{% for e in exports %}
|
||||
{% if e.status == 'ready' %}
|
||||
<a href="{{ url_for('planner.export_download', export_id=e.id) }}" class="btn" style="display:inline-block;padding:12px 32px">Download PDF</a>
|
||||
<a href="{{ url_for('planner.export_download', export_id=e.id) }}" class="btn" style="display:inline-block;padding:12px 32px">{{ t.export_download }}</a>
|
||||
{% else %}
|
||||
<div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:10px;padding:1rem;margin-bottom:1rem;font-size:0.875rem;color:#92400E">
|
||||
Your PDF is being generated. Refresh this page in a moment, or check your email — we'll send you a download link when it's ready.
|
||||
{{ t.export_success_status }}
|
||||
</div>
|
||||
<button onclick="window.location.reload()" class="btn-outline">Refresh Status</button>
|
||||
<button onclick="window.location.reload()" class="btn-outline">{{ t.export_success_refresh }}</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-top:2rem">
|
||||
<a href="{{ url_for('planner.export') }}" style="color:#1D4ED8;font-size:0.875rem">View All Exports</a>
|
||||
<a href="{{ url_for('planner.export') }}" style="color:#1D4ED8;font-size:0.875rem">{{ t.export_success_all }}</a>
|
||||
·
|
||||
<a href="{{ url_for('planner.index') }}" style="color:#1D4ED8;font-size:0.875rem">Back to Planner</a>
|
||||
<a href="{{ url_for('planner.index') }}" style="color:#1D4ED8;font-size:0.875rem">{{ t.export_success_planner }}</a>
|
||||
</p>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Business Plan Export - Coming Soon - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{{ t.export_waitlist_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">Business Plan PDF Export Coming Soon</h1>
|
||||
<h1 class="text-2xl mb-4">{{ t.export_waitlist_title }}</h1>
|
||||
|
||||
{% if lang == 'de' %}
|
||||
<p class="text-slate-dark mb-6">Wir bereiten den Start unseres professionellen Geschäftsplan-PDF-Exports vor. Sie stehen bereits auf der Warteliste und werden benachrichtigt, sobald es verfügbar ist.</p>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-6 text-left">
|
||||
<h3 class="font-semibold text-navy text-sm mb-2">Was enthalten ist</h3>
|
||||
<ul class="text-sm text-slate-dark space-y-1">
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>Professioneller Geschäftsplan (20+ Seiten als PDF)</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>Finanzprojektionen mit Diagrammen</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>Marktanalyse und Strategie</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-blue-600 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span>Mehrsprachige Optionen</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-slate mb-6">
|
||||
<p>Sie erhalten bei unserem Launch eine E-Mail mit:</p>
|
||||
<ul class="mt-2 text-slate-dark">
|
||||
<li>• Frühem Zugang mit Sonderpreis</li>
|
||||
<li>• Launch-Rabatt</li>
|
||||
<li>• Vorrangiger Generierungswarteschlange</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-slate-dark mb-6">We're preparing to launch our professional business plan PDF export feature. You're already on the waitlist and will be notified as soon as it's ready.</p>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-6 text-left">
|
||||
@@ -47,8 +89,9 @@
|
||||
<li>• Priority generation queue</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('planner.index') }}" class="btn w-full">Back to Planner</a>
|
||||
<a href="{{ url_for('planner.index') }}" class="btn w-full">{{ t.export_waitlist_btn }}</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--head)">My Scenarios</h3>
|
||||
<h3 style="margin:0;font-size:16px;color:var(--head)">{{ t.scenario_drawer_title }}</h3>
|
||||
<button onclick="document.getElementById('scenario-drawer').classList.remove('open')"
|
||||
style="background:none;border:none;color:var(--txt-3);cursor:pointer;font-size:18px">×</button>
|
||||
</div>
|
||||
@@ -9,18 +9,18 @@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div class="scenario-item__name">{{ s.name }}</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
{% if s.is_default %}<span style="font-size:10px;color:var(--gn,#10B981)">default</span>{% endif %}
|
||||
<button onclick="loadScenario({{ s.id }})" style="background:none;border:none;color:var(--bl,#3B82F6);cursor:pointer;font-size:11px;padding:0">Load</button>
|
||||
{% if s.is_default %}<span style="font-size:10px;color:var(--gn,#10B981)">{{ t.scenario_badge_default }}</span>{% endif %}
|
||||
<button onclick="loadScenario({{ s.id }})" style="background:none;border:none;color:var(--bl,#3B82F6);cursor:pointer;font-size:11px;padding:0">{{ t.scenario_btn_load }}</button>
|
||||
<button hx-delete="{{ url_for('planner.delete_scenario', scenario_id=s.id) }}"
|
||||
hx-target="#scenario-drawer" hx-swap="innerHTML"
|
||||
hx-confirm="Delete this scenario?"
|
||||
style="background:none;border:none;color:#EF4444;cursor:pointer;font-size:11px;padding:0">Del</button>
|
||||
style="background:none;border:none;color:#EF4444;cursor:pointer;font-size:11px;padding:0">{{ t.scenario_btn_delete }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if s.location %}<div class="scenario-item__meta">{{ s.location }}</div>{% endif %}
|
||||
<div class="scenario-item__meta">Updated {{ s.updated_at[:10] if s.updated_at else s.created_at[:10] }}</div>
|
||||
<div class="scenario-item__meta">{{ t.scenario_updated }} {{ s.updated_at[:10] if s.updated_at else s.created_at[:10] }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color:var(--txt-3,#64748B);font-size:13px">No saved scenarios yet. Use the Save button to store your current plan.</p>
|
||||
<p style="color:var(--txt-3,#64748B);font-size:13px">{{ t.scenario_empty }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Padel Court Financial Planner - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Padel-Platz Finanzrechner - {{ config.APP_NAME }}{% else %}Padel Court Financial Planner - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{% if lang == 'de' %}
|
||||
<meta name="description" content="Plane deine Padel-Platz-Investition mit unserem Finanzrechner mit 60+ Variablen. CAPEX, Cashflow und ROI berechnen.">
|
||||
<meta property="og:title" content="Padel-Platz Finanzrechner - {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="Plane deine Padel-Platz-Investition mit unserem Finanzrechner mit 60+ Variablen. CAPEX, Cashflow und ROI berechnen.">
|
||||
{% else %}
|
||||
<meta name="description" content="Plan your padel court investment with our 60+ variable financial planner. Calculate ROI, CAPEX, cash flow, and more.">
|
||||
<meta property="og:title" content="Padel Court Financial Planner - {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="Plan your padel court investment with our 60+ variable financial planner. Calculate ROI, CAPEX, cash flow, and more.">
|
||||
{% endif %}
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/planner.css') }}">
|
||||
@@ -14,7 +20,7 @@
|
||||
{% block content %}
|
||||
<div class="planner-app">
|
||||
<header class="planner-header">
|
||||
<h1>Padel Court Financial Planner</h1>
|
||||
<h1>{% if lang == 'de' %}Padel-Platz Finanzrechner{% else %}Padel Court Financial Planner{% endif %}</h1>
|
||||
<span id="headerTag" class="planner-summary"></span>
|
||||
|
||||
{% if user %}
|
||||
@@ -23,9 +29,9 @@
|
||||
hx-get="{{ url_for('planner.scenario_list') }}"
|
||||
hx-target="#scenario-drawer"
|
||||
hx-swap="innerHTML">
|
||||
My Scenarios ({{ scenario_count }})
|
||||
{{ planner_t.btn_my_scenarios }} ({{ scenario_count }})
|
||||
</button>
|
||||
<button id="saveScenarioBtn">Save</button>
|
||||
<button id="saveScenarioBtn">{{ planner_t.btn_save }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
@@ -37,11 +43,21 @@
|
||||
<div class="tab" id="tab-assumptions">
|
||||
<div class="wizard-header" id="wizardHeader">
|
||||
<div class="wizard-dots" id="wizardDots"></div>
|
||||
<button id="resetDefaultsBtn" class="btn-reset" title="Reset all assumptions to defaults">Reset to Defaults</button>
|
||||
<button id="resetDefaultsBtn" class="btn-reset" title="Reset all assumptions to defaults">{{ planner_t.btn_reset }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Your Venue -->
|
||||
<div class="wizard-step active" data-wiz="1">
|
||||
{% if lang == 'de' %}
|
||||
<h2 class="wizard-step__title">Ihre Anlage</h2>
|
||||
<p class="wizard-step__sub">Definieren Sie den Typ der Anlage, die Sie planen.</p>
|
||||
<div class="mb-section">
|
||||
<label class="slider-group__label">Umgebung</label>
|
||||
<div class="toggle-group" id="tog-venue"></div>
|
||||
<label class="slider-group__label">Eigentumsmodell</label>
|
||||
<div class="toggle-group" id="tog-own"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="wizard-step__title">Your Venue</h2>
|
||||
<p class="wizard-step__sub">Define the type of facility you're planning to build.</p>
|
||||
<div class="mb-section">
|
||||
@@ -50,13 +66,22 @@
|
||||
<label class="slider-group__label">Ownership Model</label>
|
||||
<div class="toggle-group" id="tog-own"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-section">
|
||||
<div id="inp-country"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
{% if lang == 'de' %}
|
||||
<div class="section-header"><h3>Platzkonfiguration</h3></div>
|
||||
{% else %}
|
||||
<div class="section-header"><h3>Court Configuration</h3></div>
|
||||
{% endif %}
|
||||
<div id="inp-courts"></div>
|
||||
{% if lang == 'de' %}
|
||||
<div class="section-header" style="margin-top:1rem"><h3>Platzbedarf</h3></div>
|
||||
{% else %}
|
||||
<div class="section-header" style="margin-top:1rem"><h3>Space Requirements</h3></div>
|
||||
{% endif %}
|
||||
<div id="inp-space"></div>
|
||||
<div class="court-summary" id="courtSummary"></div>
|
||||
</div>
|
||||
@@ -64,6 +89,18 @@
|
||||
|
||||
<!-- Step 2: Pricing & Utilization -->
|
||||
<div class="wizard-step" data-wiz="2">
|
||||
{% if lang == 'de' %}
|
||||
<h2 class="wizard-step__title">Preise & Auslastung</h2>
|
||||
<p class="wizard-step__sub">Legen Sie Ihre Platztarife, Betriebszeiten und Nebeneinnahmen fest.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Preise</h3><span class="hint">Pro Platz und Stunde</span></div>
|
||||
<div id="inp-pricing"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Auslastung & Betrieb</h3></div>
|
||||
<div id="inp-util"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="wizard-step__title">Pricing & Utilization</h2>
|
||||
<p class="wizard-step__sub">Set your court rates, operating schedule, and ancillary revenue streams.</p>
|
||||
<div class="mb-section">
|
||||
@@ -74,20 +111,46 @@
|
||||
<div class="section-header"><h3>Utilization & Operations</h3></div>
|
||||
<div id="inp-util"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Investment & Build Costs -->
|
||||
<div class="wizard-step" data-wiz="3">
|
||||
{% if lang == 'de' %}
|
||||
<h2 class="wizard-step__title">Investition & Baukosten</h2>
|
||||
<p class="wizard-step__sub">Konfigurieren Sie Baukosten, Glas- und Beleuchtungsoptionen sowie Ihr Budgetziel.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Bau & CAPEX</h3><span class="hint">Nach Szenario anpassen</span></div>
|
||||
<div id="inp-capex"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="wizard-step__title">Investment & Build Costs</h2>
|
||||
<p class="wizard-step__sub">Configure construction costs, glass and lighting options, and your budget target.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Construction & CAPEX</h3><span class="hint">Adjust per scenario</span></div>
|
||||
<div id="inp-capex"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Operations & Financing -->
|
||||
<div class="wizard-step" data-wiz="4">
|
||||
{% if lang == 'de' %}
|
||||
<h2 class="wizard-step__title">Betrieb & Finanzierung</h2>
|
||||
<p class="wizard-step__sub">Monatliche Betriebskosten, Kreditkonditionen und Exit-Annahmen.</p>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Monatliche Betriebskosten</h3></div>
|
||||
<div id="inp-opex"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Finanzierung</h3></div>
|
||||
<div id="inp-finance"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Exit-Annahmen</h3></div>
|
||||
<div id="inp-exit"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<h2 class="wizard-step__title">Operations & Financing</h2>
|
||||
<p class="wizard-step__sub">Monthly operating costs, loan terms, and exit assumptions.</p>
|
||||
<div class="mb-section">
|
||||
@@ -102,6 +165,7 @@
|
||||
<div class="section-header"><h3>Exit Assumptions</h3></div>
|
||||
<div id="inp-exit"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Preview bar + navigation (sticky) -->
|
||||
@@ -116,7 +180,7 @@
|
||||
<div class="grid-3 mb-4" id="capexCards"></div>
|
||||
<div id="capexTable"></div>
|
||||
<div class="chart-container mt-4">
|
||||
<div class="chart-container__label">CAPEX Breakdown</div>
|
||||
<div class="chart-container__label">{% if lang == 'de' %}CAPEX-Aufschlüsselung{% else %}CAPEX Breakdown{% endif %}</div>
|
||||
<div class="chart-h-56 chart-container__canvas"><canvas id="chartCapex"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,24 +190,24 @@
|
||||
<div class="grid-4 mb-4" id="opCards"></div>
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label">Monthly Revenue Build-Up (Ramp Period)</div>
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Monatlicher Umsatzaufbau (Anlaufphase){% else %}Monthly Revenue Build-Up (Ramp Period){% endif %}</div>
|
||||
<div class="chart-h-48 chart-container__canvas"><canvas id="chartRevRamp"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label">Stabilized Monthly P&L</div>
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Stabilisierte monatliche GuV{% else %}Stabilized Monthly P&L{% endif %}</div>
|
||||
<div class="chart-h-48 chart-container__canvas"><canvas id="chartPL"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Revenue Streams (Stabilized Month)</h3></div>
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Einnahmequellen (stabilisierter Monat){% else %}Revenue Streams (Stabilized Month){% endif %}</h3></div>
|
||||
<div id="revenueTable"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Monthly OpEx Breakdown</h3></div>
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Monatliche OPEX-Aufschlüsselung{% else %}Monthly OpEx Breakdown{% endif %}</h3></div>
|
||||
<div id="opexDetailTable"></div>
|
||||
</div>
|
||||
<div class="mb-section season-section" id="seasonSection">
|
||||
<div class="section-header"><h3>Outdoor Seasonality</h3></div>
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Outdoor-Saisonalität{% else %}Outdoor Seasonality{% endif %}</h3></div>
|
||||
<div class="chart-container"><div class="chart-h-40 chart-container__canvas"><canvas id="chartSeason"></canvas></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,15 +216,15 @@
|
||||
<div class="tab" id="tab-cashflow">
|
||||
<div class="grid-4 mb-4" id="cfCards"></div>
|
||||
<div class="chart-container mb-4">
|
||||
<div class="chart-container__label">Monthly Net Cash Flow (60 Months)</div>
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Monatlicher Netto-Cashflow (60 Monate){% else %}Monthly Net Cash Flow (60 Months){% endif %}</div>
|
||||
<div class="chart-h-56 chart-container__canvas"><canvas id="chartCF"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-container mb-4">
|
||||
<div class="chart-container__label">Cumulative Cash Flow</div>
|
||||
<div class="chart-container__label">{% if lang == 'de' %}Kumulierter Cashflow{% else %}Cumulative Cash Flow{% endif %}</div>
|
||||
<div class="chart-h-48 chart-container__canvas"><canvas id="chartCum"></canvas></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Annual Summary</h3></div>
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Jahresübersicht{% else %}Annual Summary{% endif %}</h3></div>
|
||||
<div id="annualTable"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,72 +234,72 @@
|
||||
<div class="grid-4 mb-4" id="retCards"></div>
|
||||
<div class="grid-2 mb-4">
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label" style="font-size:10px">Exit Valuation Waterfall</div>
|
||||
<div class="chart-container__label" style="font-size:10px">{% if lang == 'de' %}Exit-Bewertungs-Wasserfall{% else %}Exit Valuation Waterfall{% endif %}</div>
|
||||
<div id="exitWaterfall" style="margin-top:10px"></div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-container__label">DSCR by Year</div>
|
||||
<div class="chart-container__label">{% if lang == 'de' %}DSCR nach Jahr{% else %}DSCR by Year{% endif %}</div>
|
||||
<div class="chart-h-44 chart-container__canvas"><canvas id="chartDSCR"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Utilization Sensitivity</h3></div>
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Auslastungs-Sensitivität{% else %}Utilization Sensitivity{% endif %}</h3></div>
|
||||
<div id="sensTable"></div>
|
||||
</div>
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>Pricing Sensitivity (at target utilization)</h3></div>
|
||||
<div class="section-header"><h3>{% if lang == 'de' %}Preis-Sensitivität (bei Ziel-Auslastung){% else %}Pricing Sensitivity (at target utilization){% endif %}</h3></div>
|
||||
<div id="priceSensTable"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- METRICS -->
|
||||
<div class="tab" id="tab-metrics">
|
||||
<div class="mb-section"><div class="section-header"><h3>Return Metrics</h3></div><div class="grid-4" id="mReturn"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>Revenue Efficiency</h3></div><div class="grid-4" id="mRevenue"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>Cost & Margin</h3></div><div class="grid-4" id="mCost"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>Debt & Coverage</h3></div><div class="grid-4" id="mDebt"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>Investment Efficiency</h3></div><div class="grid-4" id="mInvest"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>Operational</h3></div><div class="grid-4" id="mOps"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_return }}</h3></div><div class="grid-4" id="mReturn"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_revenue }}</h3></div><div class="grid-4" id="mRevenue"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_cost }}</h3></div><div class="grid-4" id="mCost"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_debt }}</h3></div><div class="grid-4" id="mDebt"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_invest }}</h3></div><div class="grid-4" id="mInvest"></div></div>
|
||||
<div class="mb-section"><div class="section-header"><h3>{{ planner_t.metrics_ops }}</h3></div><div class="grid-4" id="mOps"></div></div>
|
||||
</div>
|
||||
<!-- Inline quote CTA (mobile / narrow screens) -->
|
||||
<div class="quote-inline-cta" id="quoteInlineCta">
|
||||
<div class="quote-inline-cta__label">Next Step</div>
|
||||
<h3 class="quote-inline-cta__title">Get quotes from verified court suppliers</h3>
|
||||
<p class="quote-inline-cta__desc">Share your project specs and we'll connect you with matched suppliers.</p>
|
||||
<div class="quote-inline-cta__label">{{ t.planner_quote_cta_label }}</div>
|
||||
<h3 class="quote-inline-cta__title">{{ t.planner_quote_cta_title }}</h3>
|
||||
<p class="quote-inline-cta__desc">{{ t.planner_quote_cta_desc }}</p>
|
||||
<ul class="quote-inline-cta__checks">
|
||||
<li><span class="quote-inline-cta__check">✓</span> Matched suppliers</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> Direct contact, no middleman</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> No commitment</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> Your data stays private</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> {{ t.planner_quote_cta_check_1 }}</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> {{ t.planner_quote_cta_check_2 }}</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> {{ t.planner_quote_cta_check_3 }}</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> {{ t.planner_quote_cta_check_4 }}</li>
|
||||
</ul>
|
||||
<button class="quote-inline-cta__btn" onclick="goToQuoteForm()">Get Supplier Quotes →</button>
|
||||
<span class="quote-inline-cta__hint">Takes ~2 minutes</span>
|
||||
<button class="quote-inline-cta__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }}</button>
|
||||
<span class="quote-inline-cta__hint">{{ t.planner_quote_cta_hint }}</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Sidebar CTA (desktop wide screens) -->
|
||||
<aside class="quote-sidebar" id="quoteSidebar">
|
||||
<div class="quote-sidebar__label">Next Step</div>
|
||||
<h3 class="quote-sidebar__title">Get quotes from verified court suppliers</h3>
|
||||
<p class="quote-sidebar__desc">Share your project specs and we'll connect you with matched suppliers.</p>
|
||||
<div class="quote-sidebar__label">{{ t.planner_quote_cta_label }}</div>
|
||||
<h3 class="quote-sidebar__title">{{ t.planner_quote_cta_title }}</h3>
|
||||
<p class="quote-sidebar__desc">{{ t.planner_quote_cta_desc }}</p>
|
||||
<ul class="quote-sidebar__checks">
|
||||
<li><span class="quote-sidebar__check">✓</span> Matched suppliers</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> Direct contact, no middleman</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> No commitment</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> Your data stays private</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> {{ t.planner_quote_cta_check_1 }}</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> {{ t.planner_quote_cta_check_2 }}</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> {{ t.planner_quote_cta_check_3 }}</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> {{ t.planner_quote_cta_check_4 }}</li>
|
||||
</ul>
|
||||
<button class="quote-sidebar__btn" onclick="goToQuoteForm()">Get Supplier Quotes →</button>
|
||||
<span class="quote-sidebar__hint">Takes ~2 minutes</span>
|
||||
<button class="quote-sidebar__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }}</button>
|
||||
<span class="quote-sidebar__hint">{{ t.planner_quote_cta_hint }}</span>
|
||||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1)">
|
||||
<a href="{{ url_for('planner.export') }}" class="quote-sidebar__btn" style="background:#16A34A;text-decoration:none;display:block;text-align:center">Export Business Plan (PDF) →</a>
|
||||
<span class="quote-sidebar__hint">€99 one-time · Bank-ready</span>
|
||||
<a href="{{ url_for('planner.export') }}" class="quote-sidebar__btn" style="background:#16A34A;text-decoration:none;display:block;text-align:center">{{ t.planner_export_btn }}</a>
|
||||
<span class="quote-sidebar__hint">{{ t.planner_export_hint }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{% if not user %}
|
||||
<div class="signup-bar" id="signupBar">
|
||||
<span>Create an account to <b>save scenarios</b> and <b>compare plans</b>.</span>
|
||||
<a href="{{ url_for('auth.signup') }}" class="lead-cta__btn">Sign Up Free</a>
|
||||
<span>{{ t.planner_signup_bar_msg }}</span>
|
||||
<a href="{{ url_for('auth.signup') }}" class="lead-cta__btn">{{ t.planner_signup_bar_btn }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -251,6 +315,7 @@
|
||||
window.__PADELNOMICS_INITIAL_STATE__ = {{ initial_state | safe }};
|
||||
{% endif %}
|
||||
window.__PADELNOMICS_INITIAL_D__ = {{ initial_d | safe }};
|
||||
window.__PADELNOMICS_LOCALE__ = {{ planner_t | tojson | safe }};
|
||||
window.__PADELNOMICS_CALC_URL__ = "{{ url_for('planner.calculate') }}";
|
||||
window.__PADELNOMICS_SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
|
||||
window.__PADELNOMICS_SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from quart import Blueprint, g, render_template, request, session
|
||||
|
||||
from ..core import check_rate_limit, csrf_protect, execute, fetch_all, fetch_one
|
||||
from ..i18n import get_translations
|
||||
|
||||
bp = Blueprint(
|
||||
"public",
|
||||
@@ -110,16 +111,18 @@ async def suppliers():
|
||||
@csrf_protect
|
||||
async def feedback():
|
||||
"""Handle feedback submission. Returns HTMX partial."""
|
||||
t = get_translations(g.get("lang", "en"))
|
||||
|
||||
# Rate limit: 5 per hour per IP
|
||||
key = f"feedback:{request.remote_addr}"
|
||||
allowed, _info = await check_rate_limit(key, limit=5, window=3600)
|
||||
if not allowed:
|
||||
return '<p style="font-size:0.8125rem;color:#D97706;padding:8px">Too many submissions. Try again later.</p>'
|
||||
return f'<p style="font-size:0.8125rem;color:#D97706;padding:8px">{t["flash_feedback_rate_limit"]}</p>'
|
||||
|
||||
form = await request.form
|
||||
message = form.get("message", "").strip()
|
||||
if not message:
|
||||
return '<p style="font-size:0.8125rem;color:#DC2626;padding:8px">Please enter a message.</p>'
|
||||
return f'<p style="font-size:0.8125rem;color:#DC2626;padding:8px">{t["flash_feedback_empty"]}</p>'
|
||||
|
||||
page_url = form.get("page_url", "")
|
||||
user_id = session.get("user_id")
|
||||
@@ -129,4 +132,4 @@ async def feedback():
|
||||
(user_id, page_url, message),
|
||||
)
|
||||
|
||||
return '<p style="font-size:0.8125rem;color:#16A34A;padding:12px;text-align:center;font-weight:600">Thank you for your feedback!</p>'
|
||||
return f'<p style="font-size:0.8125rem;color:#16A34A;padding:12px;text-align:center;font-weight:600">{t["flash_feedback_success"]}</p>'
|
||||
|
||||
@@ -1,37 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}About Padelnomics — Padel Court Investment Platform{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Über Padelnomics — Planungsplattform für Padelplatz-Investitionen{% else %}About Padelnomics — Padel Court Investment Platform{% endif %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="Padelnomics is a free financial planning platform for padel entrepreneurs. Model your investment, find suppliers, and plan your padel court business with professional-grade tools.">
|
||||
<meta property="og:title" content="About Padelnomics — Padel Court Investment Platform">
|
||||
<meta property="og:description" content="Built for padel entrepreneurs who need professional financial tools without consulting fees. Free planner, 60+ variables, supplier directory, and more.">
|
||||
<meta name="description" content="{% if lang == 'de' %}Padelnomics ist eine kostenlose Finanzplanungsplattform für Padel-Unternehmer. Modellieren Sie Ihre Investition, finden Sie Anbieter und planen Sie Ihr Padel-Business mit professionellen Tools.{% else %}Padelnomics is a free financial planning platform for padel entrepreneurs. Model your investment, find suppliers, and plan your padel court business with professional-grade tools.{% endif %}">
|
||||
<meta property="og:title" content="{% if lang == 'de' %}Über Padelnomics — Planungsplattform für Padelplatz-Investitionen{% else %}About Padelnomics — Padel Court Investment Platform{% endif %}">
|
||||
<meta property="og:description" content="{% if lang == 'de' %}Entwickelt für Padel-Unternehmer, die professionelle Finanztools ohne Beratungskosten benötigen. Kostenloser Planer, 60+ Variablen, Anbieterverzeichnis und mehr.{% else %}Built for padel entrepreneurs who need professional financial tools without consulting fees. Free planner, 60+ variables, supplier directory, and more.{% endif %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-3xl mx-auto">
|
||||
<h1 class="text-2xl mb-6 text-center">About {{ config.APP_NAME }}</h1>
|
||||
<h1 class="text-2xl mb-6 text-center">
|
||||
{% if lang == 'de' %}Über {% endif %}{{ config.APP_NAME }}
|
||||
</h1>
|
||||
|
||||
<div class="space-y-4 text-slate-dark leading-relaxed">
|
||||
{% if lang == 'de' %}
|
||||
<p>Padel ist der am schnellsten wachsende Sport in Europa, doch für die meisten Unternehmer ist die Eröffnung einer Paddelhalle immer noch ein Sprung ins Ungewisse. Die Finanzen sind komplex: Der CAPEX variiert stark je nach Anlagentyp, der Standort bestimmt die Auslastung, und der Unterschied zwischen 60 % und 75 % Belegung kann über Erfolg oder Misserfolg einer Investition entscheiden.</p>
|
||||
|
||||
<p>Wir haben Padelnomics gebaut, weil wir kein ausreichend gutes Finanzplanungstool gefunden haben. Vorhandene Rechner sind entweder zu simpel (5 Eingaben, ein Ergebnis) oder hinter teuren Beratungsmandaten verborgen. Wir wollten etwas mit der Tiefe eines professionellen Finanzmodells, aber der Zugänglichkeit einer Web-App.</p>
|
||||
|
||||
<p>Das Ergebnis ist ein kostenloser Finanzplaner mit 60+ anpassbaren Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und den professionellen Kennzahlen, die Banken und Investoren sehen müssen. Jede Annahme ist transparent und anpassbar. Keine Blackboxen.</p>
|
||||
|
||||
<h3 class="text-lg mt-6">{{ t.about_why_h3 }}</h3>
|
||||
<p>Der Planer ist kostenlos, weil wir glauben, dass bessere Planung zu besseren Padelanlagen führt — und das ist gut für die gesamte Branche. Wir verdienen Geld, indem wir Unternehmer mit Platz-Anbietern und Finanzierungspartnern verbinden, wenn sie bereit sind, von der Planung zum Bau überzugehen.</p>
|
||||
|
||||
<h3 class="text-lg mt-6">{{ t.about_next_h3 }}</h3>
|
||||
<p>Padelnomics baut die Infrastruktur für Padel-Unternehmertum auf. Nach der Planung kommen Finanzierung, Bau und Betrieb. Wir arbeiten an Marktintelligenz auf Basis realer Buchungsdaten, einem Anbietermarktplatz für Platzausstattung und Analyse-Tools für Betreiber.</p>
|
||||
{% else %}
|
||||
<p>Padel is the fastest-growing sport in Europe, but opening a padel hall is still a leap of faith for most entrepreneurs. The financials are complex: CAPEX varies wildly depending on venue type, location drives utilization, and the difference between a 60% and 75% occupancy rate can mean the difference between a great investment and a money pit.</p>
|
||||
|
||||
<p>We built Padelnomics because we couldn't find a financial planning tool that was good enough. Existing calculators are either too simplistic (5 inputs, one output) or locked behind expensive consulting engagements. We wanted something with the depth of a professional financial model but the accessibility of a web app.</p>
|
||||
|
||||
<p>The result is a free financial planner with 60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and the professional metrics that banks and investors need to see. Every assumption is transparent and adjustable. No black boxes.</p>
|
||||
|
||||
<h3 class="text-lg mt-6">Why free?</h3>
|
||||
<h3 class="text-lg mt-6">{{ t.about_why_h3 }}</h3>
|
||||
<p>The planner is free because we believe better planning leads to better padel venues, and that's good for the entire industry. We make money by connecting entrepreneurs with court suppliers and financing partners when they're ready to move from planning to building.</p>
|
||||
|
||||
<h3 class="text-lg mt-6">What's next</h3>
|
||||
<h3 class="text-lg mt-6">{{ t.about_next_h3 }}</h3>
|
||||
<p>Padelnomics is building the infrastructure for padel entrepreneurship. After planning comes financing, building, and operating. We're working on market intelligence powered by real booking data, a supplier marketplace for court equipment, and analytics tools for venue operators.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-10">
|
||||
{% if user %}
|
||||
<a href="{{ url_for('planner.index') }}" class="btn">Open Planner</a>
|
||||
<a href="{{ url_for('planner.index') }}" class="btn">{{ t.about_cta_open }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.signup') }}" class="btn">Create Free Account</a>
|
||||
<a href="{{ url_for('auth.signup') }}" class="btn">{{ t.about_cta_signup }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,78 +1,138 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Features - Padel Court Financial Planner | {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Funktionen - Padel-Kostenrechner & Finanzplaner | {{ config.APP_NAME }}{% else %}Features - Padel Court Financial Planner | {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.">
|
||||
<meta property="og:title" content="Features - Padel Court Financial Planner | {{ config.APP_NAME }}">
|
||||
<meta property="og:description" content="60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.">
|
||||
<meta name="description" content="{% if lang == 'de' %}60+ anpassbare Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und professionelle Finanzprojektionen für Ihre Padelplatz-Investition.{% else %}60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.{% endif %}">
|
||||
<meta property="og:title" content="{% if lang == 'de' %}Funktionen - Padel-Kostenrechner & Finanzplaner | {{ config.APP_NAME }}{% else %}Features - Padel Court Financial Planner | {{ config.APP_NAME }}{% endif %}">
|
||||
<meta property="og:description" content="{% if lang == 'de' %}60+ anpassbare Variablen, 6 Analyse-Tabs, Sensitivitätsanalyse und professionelle Finanzprojektionen für Ihre Padelplatz-Investition.{% else %}60+ adjustable variables, 6 analysis tabs, sensitivity analysis, and professional-grade financial projections for your padel court investment.{% endif %}">
|
||||
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<header class="text-center mb-12">
|
||||
<h1 class="text-3xl mb-2">Everything You Need to Plan Your Padel Business</h1>
|
||||
<p class="text-lg text-slate">Professional-grade financial modeling, completely free.</p>
|
||||
<h1 class="text-3xl mb-2">{{ t.features_h1 }}</h1>
|
||||
<p class="text-lg text-slate">{{ t.features_subtitle }}</p>
|
||||
</header>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">60+ Variables</h2>
|
||||
<p class="text-slate-dark">Every assumption is adjustable. Court costs, rent, hourly pricing, utilization curves, financing terms, exit multiples. Nothing is hard-coded — your model reflects your reality.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_card_1_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Jede Annahme ist anpassbar: Platzbaukosten, Miete, Stundensätze, Auslastungskurven, Finanzierungskonditionen, Exit-Multiplikatoren. Nichts ist fest vorgegeben — Ihr Modell spiegelt Ihre Realität wider.
|
||||
{% else %}
|
||||
Every assumption is adjustable. Court costs, rent, hourly pricing, utilization curves, financing terms, exit multiples. Nothing is hard-coded — your model reflects your reality.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">6 Analysis Tabs</h2>
|
||||
<p class="text-slate-dark">Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each tab with interactive charts that update in real time as you adjust inputs.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_card_2_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Annahmen, Investition (CAPEX), Betriebsmodell, Cashflow, Renditen & Exit sowie Kennzahlen. Jeder Tab mit interaktiven Diagrammen, die sich in Echtzeit aktualisieren, wenn Sie Eingaben anpassen.
|
||||
{% else %}
|
||||
Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each tab with interactive charts that update in real time as you adjust inputs.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">Indoor & Outdoor</h2>
|
||||
<p class="text-slate-dark">Model indoor halls (rent an existing building or build new) and outdoor courts with seasonality adjustments. Compare scenarios side by side to find the best approach for your market.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_card_3_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Innenhallenmodelle (Anmietung eines Bestandsgebäudes oder Neubau) und Außenanlagen mit Saisonalitätsanpassungen. Szenarien direkt nebeneinander vergleichen, um den besten Ansatz für Ihren Markt zu finden.
|
||||
{% else %}
|
||||
Model indoor halls (rent an existing building or build new) and outdoor courts with seasonality adjustments. Compare scenarios side by side to find the best approach for your market.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">Sensitivity Analysis</h2>
|
||||
<p class="text-slate-dark">See how your IRR and cash yield change across different utilization rates and pricing levels. Find your break-even point instantly with the built-in sensitivity matrix.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_card_4_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Sehen Sie, wie sich Ihre IRR und Cash-Rendite bei unterschiedlichen Auslastungsraten und Preisen verändern. Ermitteln Sie Ihren Break-even-Punkt sofort mit der integrierten Sensitivitätsmatrix.
|
||||
{% else %}
|
||||
See how your IRR and cash yield change across different utilization rates and pricing levels. Find your break-even point instantly with the built-in sensitivity matrix.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">Professional Metrics</h2>
|
||||
<p class="text-slate-dark">IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors expect to see in a padel court business plan.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_card_5_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
IRR, MOIC, DSCR, Cash-on-Cash-Rendite, Break-even-Auslastung, RevPAH, Schuldenrendite — die Kennzahlen, die Banken und Investoren in einem Padelplatz-Businessplan sehen möchten.
|
||||
{% else %}
|
||||
IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors expect to see in a padel court business plan.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">Save & Compare</h2>
|
||||
<p class="text-slate-dark">Save unlimited scenarios. Test different locations, court counts, financing structures, and pricing strategies. Load and compare to find the optimal plan for your investment.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_card_6_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Unbegrenzte Szenarien speichern. Verschiedene Standorte, Platzzahlen, Finanzierungsstrukturen und Preisstrategien testen. Laden und vergleichen, um den optimalen Plan für Ihre Investition zu finden.
|
||||
{% else %}
|
||||
Save unlimited scenarios. Test different locations, court counts, financing structures, and pricing strategies. Load and compare to find the optimal plan for your investment.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 max-w-3xl mx-auto mt-12">
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">Detailed CAPEX Breakdown</h2>
|
||||
<p class="text-slate-dark">Model every cost line individually: court installation, flooring, lighting, climate control, changing rooms, reception, parking, landscaping. Toggle between renting a building and constructing new. Adjust land costs, construction costs per sqm, and fit-out budgets independently.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_capex_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Jede Kostenstelle einzeln modellieren: Platzmontage, Bodenbelag, Beleuchtung, Klimatisierung, Umkleideräume, Rezeption, Parkplatz, Außenanlagen. Zwischen Gebäudenanmietung und Neubau umschalten. Grundstückskosten, Baukosten pro m² und Ausstattungsbudgets unabhängig voneinander anpassen.
|
||||
{% else %}
|
||||
Model every cost line individually: court installation, flooring, lighting, climate control, changing rooms, reception, parking, landscaping. Toggle between renting a building and constructing new. Adjust land costs, construction costs per sqm, and fit-out budgets independently.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">Operating Model</h2>
|
||||
<p class="text-slate-dark">Peak and off-peak pricing with configurable hour splits. Monthly utilization ramp-up curves. Staff costs, maintenance, insurance, marketing, and utilities — all adjustable with sliders. Revenue from court rentals, coaching, equipment, and F&B.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_opex_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Peak- und Off-Peak-Preise mit konfigurierbaren Stundenaufteilungen. Monatliche Anlaufkurven für die Auslastung. Personalkosten, Wartung, Versicherung, Marketing und Betriebskosten — alle mit Schiebereglern anpassbar. Einnahmen aus Platzvermietung, Coaching, Ausrüstung und F&B.
|
||||
{% else %}
|
||||
Peak and off-peak pricing with configurable hour splits. Monthly utilization ramp-up curves. Staff costs, maintenance, insurance, marketing, and utilities — all adjustable with sliders. Revenue from court rentals, coaching, equipment, and F&B.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">Cash Flow & Financing</h2>
|
||||
<p class="text-slate-dark">10-year monthly cash flow projections. Model your equity/debt split, interest rates, and loan terms. See debt service coverage ratios and free cash flow month by month. Waterfall charts show exactly where your money goes.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_cf_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Monatliche Cashflow-Projektionen über 10 Jahre. Eigen-/Fremdkapitalaufteilung, Zinssätze und Kreditlaufzeiten modellieren. Schuldendienstdeckungsgrade und freien Cashflow Monat für Monat einsehen. Wasserfalldiagramme zeigen genau, wohin Ihr Geld fließt.
|
||||
{% else %}
|
||||
10-year monthly cash flow projections. Model your equity/debt split, interest rates, and loan terms. See debt service coverage ratios and free cash flow month by month. Waterfall charts show exactly where your money goes.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="text-xl mb-2">Returns & Exit</h2>
|
||||
<p class="text-slate-dark">Calculate your equity IRR and MOIC under different exit scenarios. Model cap rate exits with configurable holding periods. See your equity waterfall from initial investment through to exit proceeds.</p>
|
||||
<h2 class="text-xl mb-2">{{ t.features_returns_h2 }}</h2>
|
||||
<p class="text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Eigenkapital-IRR und MOIC unter verschiedenen Exit-Szenarien berechnen. Cap-Rate-Exits mit konfigurierbaren Haltedauern modellieren. Die Eigenkapitalentwicklung vom Ersteinsatz bis zum Exit-Erlös nachvollziehen.
|
||||
{% else %}
|
||||
Calculate your equity IRR and MOIC under different exit scenarios. Model cap rate exits with configurable holding periods. See your equity waterfall from initial investment through to exit proceeds.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-12">
|
||||
{% if user %}
|
||||
<a href="{{ url_for('planner.index') }}" class="btn">Open Planner</a>
|
||||
<a href="{{ url_for('planner.index') }}" class="btn">{{ t.features_cta_open }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.signup') }}" class="btn">Create Free Account</a>
|
||||
<a href="{{ url_for('auth.signup') }}" class="btn">{{ t.features_cta_signup }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Padelnomics - Padel Court Business Plan & ROI Calculator{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Padelnomics - Padel-Kostenrechner & Finanzplaner{% else %}Padelnomics - Padel Court Business Plan & ROI Calculator{% endif %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<meta name="description" content="Plan your padel court investment in minutes. 60+ variables, sensitivity analysis, and professional-grade projections. Indoor/outdoor, rent/buy models.">
|
||||
<meta name="description" content="{% if lang == 'de' %}Modellieren Sie Ihre Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Innen-/Außenanlage, Miet- oder Eigentumsmodell.{% else %}Plan your padel court investment in minutes. 60+ variables, sensitivity analysis, and professional-grade projections. Indoor/outdoor, rent/buy models.{% endif %}">
|
||||
<meta property="og:title" content="Padelnomics - Padel Court Financial Planner">
|
||||
<meta property="og:description" content="The most sophisticated padel court business plan calculator. 60+ variables, 6 analysis tabs, charts, sensitivity analysis, and supplier connections.">
|
||||
<meta property="og:description" content="{% if lang == 'de' %}Der professionellste Padel-Finanzplaner. 60+ Variablen, 6 Analyse-Tabs, Diagramme, Sensitivitätsanalyse und Anbieter-Vermittlung.{% else %}The most sophisticated padel court business plan calculator. 60+ variables, 6 analysis tabs, charts, sensitivity analysis, and supplier connections.{% endif %}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:image" content="{{ url_for('static', filename='images/planner-screenshot.png', _external=True) }}">
|
||||
<style>
|
||||
@@ -244,64 +244,68 @@
|
||||
<div class="hero-inner">
|
||||
<div class="hero-grid">
|
||||
<div>
|
||||
<div class="hero-badge">🎾 Padel court financial planner</div>
|
||||
<div class="hero-badge">🎾 {{ t.landing_hero_badge }}</div>
|
||||
<h1 class="hero-title">
|
||||
Plan Your Padel<br>
|
||||
Business in Minutes,<br>
|
||||
<span class="accent">Not Months</span>
|
||||
{{ t.landing_hero_h1_1 }}<br>
|
||||
{{ t.landing_hero_h1_2 }}<br>
|
||||
<span class="accent">{{ t.landing_hero_h1_3 }}</span>
|
||||
</h1>
|
||||
<p class="hero-desc">
|
||||
{% if lang == 'de' %}
|
||||
Modellieren Sie Ihre Padelplatz-Investition mit 60+ Variablen, Sensitivitätsanalyse und professionellen Projektionen. Dann werden Sie mit verifizierten Anbietern zusammengebracht.
|
||||
{% else %}
|
||||
Model your padel court investment with 60+ variables, sensitivity analysis,
|
||||
and professional-grade projections. Then get matched with verified suppliers.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="{{ url_for('planner.index') }}" class="btn-hero">Plan Your Padel Business →</a>
|
||||
<a href="{{ url_for('directory.index') }}" class="btn-hero-outline">Browse Suppliers</a>
|
||||
<a href="{{ url_for('planner.index') }}" class="btn-hero">{{ t.landing_hero_btn_primary }}</a>
|
||||
<a href="{{ url_for('directory.index') }}" class="btn-hero-outline">{{ t.landing_hero_btn_secondary }}</a>
|
||||
</div>
|
||||
<div class="hero-bullets">
|
||||
<span><span class="hero-check">✓</span> No signup required</span>
|
||||
<span><span class="hero-check">✓</span> 60+ variables</span>
|
||||
<span><span class="hero-check">✓</span> Unlimited scenarios</span>
|
||||
<span><span class="hero-check">✓</span> {{ t.landing_hero_bullet_1 }}</span>
|
||||
<span><span class="hero-check">✓</span> {{ t.landing_hero_bullet_2 }}</span>
|
||||
<span><span class="hero-check">✓</span> {{ t.landing_hero_bullet_3 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roi-calc">
|
||||
<div class="roi-calc__title">Quick ROI Estimate</div>
|
||||
<div class="roi-calc__sub">Drag the sliders to see your projection</div>
|
||||
<div class="roi-calc__title">{{ t.landing_roi_title }}</div>
|
||||
<div class="roi-calc__sub">{{ t.landing_roi_subtitle }}</div>
|
||||
<div class="roi-calc__sliders">
|
||||
<div class="roi-calc__row">
|
||||
<label>Courts <span id="roiCourtsVal">6</span></label>
|
||||
<label>{{ t.landing_roi_courts }} <span id="roiCourtsVal">6</span></label>
|
||||
<input type="range" id="roiCourts" min="2" max="20" value="6" step="1">
|
||||
</div>
|
||||
<div class="roi-calc__row">
|
||||
<label>Avg. Hourly Rate <span id="roiRateVal">€40</span></label>
|
||||
<label>{{ t.landing_roi_rate }} <span id="roiRateVal">€40</span></label>
|
||||
<input type="range" id="roiRate" min="20" max="120" value="40" step="5">
|
||||
</div>
|
||||
<div class="roi-calc__row">
|
||||
<label>Target Utilization <span id="roiUtilVal">35%</span></label>
|
||||
<label>{{ t.landing_roi_util }} <span id="roiUtilVal">35%</span></label>
|
||||
<input type="range" id="roiUtil" min="20" max="80" value="35" step="5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="roi-metrics">
|
||||
<div>
|
||||
<div class="roi-metric__label">Investment</div>
|
||||
<div class="roi-metric__label">{{ t.landing_roi_investment }}</div>
|
||||
<div class="roi-metric__val" id="roiCapex">€330K</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="roi-metric__label">Monthly Cash Flow</div>
|
||||
<div class="roi-metric__label">{{ t.landing_roi_monthly_cf }}</div>
|
||||
<div class="roi-metric__val" id="roiCf">€7K</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="roi-metric__label">Payback Period</div>
|
||||
<div class="roi-metric__label">{{ t.landing_roi_payback }}</div>
|
||||
<div class="roi-metric__val" id="roiPayback">3.9 yr</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="roi-metric__label">Annual ROI</div>
|
||||
<div class="roi-metric__label">{{ t.landing_roi_annual_roi }}</div>
|
||||
<div class="roi-metric__val" id="roiReturn">26%</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="roi-calc__note">Assumes indoor rent model, €8/m² rent, staff costs, 5% interest, 10-yr loan. Payback and ROI based on total investment.</p>
|
||||
<a href="{{ url_for('planner.index') }}" class="roi-calc__cta">Plan Your Padel Business →</a>
|
||||
<p class="roi-calc__note">{{ t.landing_roi_note }}</p>
|
||||
<a href="{{ url_for('planner.index') }}" class="roi-calc__cta">{{ t.landing_roi_cta }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,141 +314,276 @@
|
||||
<main class="container-page">
|
||||
<!-- Journey Timeline -->
|
||||
<section class="journey-section">
|
||||
<h2>Your Journey</h2>
|
||||
<h2>{{ t.landing_journey_title }}</h2>
|
||||
<div class="journey-track">
|
||||
<div class="journey-step journey-step--upcoming">
|
||||
<div class="journey-step__num">01</div>
|
||||
<h3 class="journey-step__title">Explore <span class="badge-soon">Soon</span></h3>
|
||||
<p class="journey-step__desc">Market demand analysis, whitespace mapping, location scoring.</p>
|
||||
<h3 class="journey-step__title">{{ t.landing_journey_01 }} <span class="badge-soon">{{ t.landing_journey_01_badge }}</span></h3>
|
||||
<p class="journey-step__desc">
|
||||
{% if lang == 'de' %}
|
||||
Marktbedarfsanalyse, Standortbewertung und Identifikation von Nachfragepotenzialen.
|
||||
{% else %}
|
||||
Market demand analysis, whitespace mapping, location scoring.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="journey-step journey-step--active">
|
||||
<div class="journey-step__num">02</div>
|
||||
<h3 class="journey-step__title">Plan</h3>
|
||||
<p class="journey-step__desc">Model your investment with 60+ variables, charts, and sensitivity analysis.</p>
|
||||
<h3 class="journey-step__title">{{ t.landing_journey_02 }}</h3>
|
||||
<p class="journey-step__desc">
|
||||
{% if lang == 'de' %}
|
||||
Modellieren Sie Ihre Investition mit 60+ Variablen, Diagrammen und Sensitivitätsanalyse.
|
||||
{% else %}
|
||||
Model your investment with 60+ variables, charts, and sensitivity analysis.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="journey-step journey-step--upcoming">
|
||||
<div class="journey-step__num">03</div>
|
||||
<h3 class="journey-step__title">Finance <span class="badge-soon">Soon</span></h3>
|
||||
<p class="journey-step__desc">Connect with banks and investors. Your planner becomes your business case.</p>
|
||||
<h3 class="journey-step__title">{{ t.landing_journey_03 }} <span class="badge-soon">{{ t.landing_journey_03_badge }}</span></h3>
|
||||
<p class="journey-step__desc">
|
||||
{% if lang == 'de' %}
|
||||
Kontakte zu Banken und Investoren herstellen. Ihr Finanzplan wird zum Businesscase.
|
||||
{% else %}
|
||||
Connect with banks and investors. Your planner becomes your business case.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="journey-step journey-step--active">
|
||||
<div class="journey-step__num">04</div>
|
||||
<h3 class="journey-step__title">Build</h3>
|
||||
<p class="journey-step__desc">Browse {{ total_suppliers }}+ court suppliers across {{ total_countries }} countries. Get matched to your specs.</p>
|
||||
<h3 class="journey-step__title">{{ t.landing_journey_04 }}</h3>
|
||||
<p class="journey-step__desc">
|
||||
{% if lang == 'de' %}
|
||||
Über {{ total_suppliers }}+ Platz-Anbieter aus {{ total_countries }} Ländern durchsuchen. Passend zu Ihren Anforderungen vermittelt.
|
||||
{% else %}
|
||||
Browse {{ total_suppliers }}+ court suppliers across {{ total_countries }} countries. Get matched to your specs.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="journey-step journey-step--upcoming">
|
||||
<div class="journey-step__num">05</div>
|
||||
<h3 class="journey-step__title">Grow <span class="badge-soon">Soon</span></h3>
|
||||
<p class="journey-step__desc">Launch playbook, performance benchmarks, and expansion analytics.</p>
|
||||
<h3 class="journey-step__title">{{ t.landing_journey_05 }} <span class="badge-soon">{{ t.landing_journey_05_badge }}</span></h3>
|
||||
<p class="journey-step__desc">
|
||||
{% if lang == 'de' %}
|
||||
Launch-Playbook, Performance-Benchmarks und Wachstumsanalysen für Ihren Betrieb.
|
||||
{% else %}
|
||||
Launch playbook, performance benchmarks, and expansion analytics.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature Highlights -->
|
||||
<section class="py-12">
|
||||
<h2 class="text-2xl text-center mb-8">Built for Serious Padel Entrepreneurs</h2>
|
||||
<h2 class="text-2xl text-center mb-8">{{ t.landing_features_title }}</h2>
|
||||
<div class="grid-3">
|
||||
<div class="card border-l-4 border-l-electric">
|
||||
<h3 class="text-lg mb-2">🔧 60+ Variables</h3>
|
||||
<p class="text-sm text-slate-dark">Every assumption is adjustable. Court costs, rent, pricing, utilization, financing terms, exit scenarios. Nothing is hard-coded.</p>
|
||||
<h3 class="text-lg mb-2">🔧 {{ t.landing_feature_1_h3 }}</h3>
|
||||
<p class="text-sm text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Jede Annahme ist anpassbar: Platzbaukosten, Miete, Preisgestaltung, Auslastung, Finanzierungskonditionen, Exit-Szenarien. Nichts ist fest vorgegeben.
|
||||
{% else %}
|
||||
Every assumption is adjustable. Court costs, rent, pricing, utilization, financing terms, exit scenarios. Nothing is hard-coded.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card border-l-4 border-l-accent">
|
||||
<h3 class="text-lg mb-2">📋 6 Analysis Tabs</h3>
|
||||
<p class="text-sm text-slate-dark">Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each with interactive charts.</p>
|
||||
<h3 class="text-lg mb-2">📋 {{ t.landing_feature_2_h3 }}</h3>
|
||||
<p class="text-sm text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Annahmen, Investition (CAPEX), Betriebsmodell, Cashflow, Renditen & Exit sowie Kennzahlen — jeder Tab mit interaktiven Diagrammen.
|
||||
{% else %}
|
||||
Assumptions, Investment (CAPEX), Operating Model, Cash Flow, Returns & Exit, and Key Metrics. Each with interactive charts.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card border-l-4 border-l-warning">
|
||||
<h3 class="text-lg mb-2">☀️ Indoor & Outdoor</h3>
|
||||
<p class="text-sm text-slate-dark">Model indoor halls (rent or build) and outdoor courts with seasonality. Compare scenarios side by side.</p>
|
||||
<h3 class="text-lg mb-2">☀️ {{ t.landing_feature_3_h3 }}</h3>
|
||||
<p class="text-sm text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Innenhallenmodelle (Miete oder Neubau) und Außenanlagen mit Saisonalität. Szenarien direkt nebeneinander vergleichen.
|
||||
{% else %}
|
||||
Model indoor halls (rent or build) and outdoor courts with seasonality. Compare scenarios side by side.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-3 mt-0">
|
||||
<div class="card border-l-4 border-l-danger">
|
||||
<h3 class="text-lg mb-2">📉 Sensitivity Analysis</h3>
|
||||
<p class="text-sm text-slate-dark">See how your returns change with different utilization rates and pricing. Find your break-even point instantly.</p>
|
||||
<h3 class="text-lg mb-2">📉 {{ t.landing_feature_4_h3 }}</h3>
|
||||
<p class="text-sm text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Sehen Sie, wie sich Ihre Renditen bei unterschiedlichen Auslastungsraten und Preisen verändern. Break-even-Punkt sofort ermitteln.
|
||||
{% else %}
|
||||
See how your returns change with different utilization rates and pricing. Find your break-even point instantly.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card border-l-4 border-l-electric">
|
||||
<h3 class="text-lg mb-2">🎯 Professional Metrics</h3>
|
||||
<p class="text-sm text-slate-dark">IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors want to see.</p>
|
||||
<h3 class="text-lg mb-2">🎯 {{ t.landing_feature_5_h3 }}</h3>
|
||||
<p class="text-sm text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
IRR, MOIC, DSCR, Cash-on-Cash-Rendite, Break-even-Auslastung, RevPAH, Schuldenrendite — die Kennzahlen, die Banken und Investoren sehen möchten.
|
||||
{% else %}
|
||||
IRR, MOIC, DSCR, cash-on-cash yield, break-even utilization, RevPAH, debt yield. The metrics banks and investors want to see.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card border-l-4 border-l-accent">
|
||||
<h3 class="text-lg mb-2">💾 Save & Compare</h3>
|
||||
<p class="text-sm text-slate-dark">Save unlimited scenarios. Test different locations, court counts, financing structures. Find the optimal plan.</p>
|
||||
<h3 class="text-lg mb-2">💾 {{ t.landing_feature_6_h3 }}</h3>
|
||||
<p class="text-sm text-slate-dark">
|
||||
{% if lang == 'de' %}
|
||||
Unbegrenzte Szenarien speichern. Verschiedene Standorte, Platzzahlen und Finanzierungsstrukturen testen. Den optimalen Plan finden.
|
||||
{% else %}
|
||||
Save unlimited scenarios. Test different locations, court counts, financing structures. Find the optimal plan.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Supplier Matching -->
|
||||
<section class="py-12">
|
||||
<h2 class="text-2xl text-center mb-2">Find the Right Suppliers for Your Project</h2>
|
||||
<p class="text-center text-slate mb-8">{{ total_suppliers }}+ verified suppliers across {{ total_countries }} countries. Manufacturers, builders, turf, lighting, and more.</p>
|
||||
<h2 class="text-2xl text-center mb-2">{{ t.landing_supplier_title }}</h2>
|
||||
<p class="text-center text-slate mb-8">
|
||||
{% if lang == 'de' %}
|
||||
{{ total_suppliers }}+ verifizierte Anbieter aus {{ total_countries }} Ländern. Hersteller, Baufirmen, Belaghersteller, Beleuchtung und mehr.
|
||||
{% else %}
|
||||
{{ total_suppliers }}+ verified suppliers across {{ total_countries }} countries. Manufacturers, builders, turf, lighting, and more.
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="match-grid">
|
||||
<div class="match-step">
|
||||
<div class="match-step__num">1</div>
|
||||
<h3>Plan Your Venue</h3>
|
||||
<p>Use the financial planner to model your courts, budget, and timeline.</p>
|
||||
<h3>{{ t.landing_supplier_step_1_title }}</h3>
|
||||
<p>
|
||||
{% if lang == 'de' %}
|
||||
Nutzen Sie den Finanzplaner, um Ihre Platzzahl, Ihr Budget und Ihren Zeitplan zu modellieren.
|
||||
{% else %}
|
||||
Use the financial planner to model your courts, budget, and timeline.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="match-step">
|
||||
<div class="match-step__num">2</div>
|
||||
<h3>Get Quotes</h3>
|
||||
<p>Request quotes and we match you with suppliers based on your project specs.</p>
|
||||
<h3>{{ t.landing_supplier_step_2_title }}</h3>
|
||||
<p>
|
||||
{% if lang == 'de' %}
|
||||
Angebote anfordern — wir vermitteln Sie anhand Ihrer Projektspezifikationen an passende Anbieter.
|
||||
{% else %}
|
||||
Request quotes and we match you with suppliers based on your project specs.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="match-step">
|
||||
<div class="match-step__num">3</div>
|
||||
<h3>Compare & Build</h3>
|
||||
<p>Receive proposals from matched suppliers. No cold outreach needed.</p>
|
||||
<h3>{{ t.landing_supplier_step_3_title }}</h3>
|
||||
<p>
|
||||
{% if lang == 'de' %}
|
||||
Angebote von vermittelten Anbietern erhalten. Keine Kaltakquise erforderlich.
|
||||
{% else %}
|
||||
Receive proposals from matched suppliers. No cold outreach needed.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-8">
|
||||
<a href="{{ url_for('directory.index') }}" class="btn-outline">Browse Supplier Directory</a>
|
||||
<a href="{{ url_for('directory.index') }}" class="btn-outline">{{ t.landing_supplier_browse_btn }}</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
<section id="faq" class="py-12">
|
||||
<h2 class="text-2xl text-center mb-6">Frequently Asked Questions</h2>
|
||||
<h2 class="text-2xl text-center mb-6">{{ t.landing_faq_title }}</h2>
|
||||
<div class="faq">
|
||||
<details>
|
||||
<summary>What does the planner calculate?</summary>
|
||||
<p>The planner produces a complete financial model: CAPEX breakdown, monthly operating costs, cash flow projections, debt service, IRR, MOIC, DSCR, payback period, break-even utilization, and sensitivity analysis. It covers indoor/outdoor, rent/buy, and all major cost and revenue variables.</p>
|
||||
<summary>{{ t.landing_faq_q1 }}</summary>
|
||||
<p>
|
||||
{% if lang == 'de' %}
|
||||
Der Planer erstellt ein vollständiges Finanzmodell: CAPEX-Aufschlüsselung, monatliche Betriebskosten, Cashflow-Projektionen, Schuldendienst, IRR, MOIC, DSCR, Amortisationszeit, Break-even-Auslastung und Sensitivitätsanalyse. Es werden Indoor-/Outdoor-Anlagen, Miet- und Eigentumsmodelle sowie alle wesentlichen Kosten- und Erlösvariablen abgedeckt.
|
||||
{% else %}
|
||||
The planner produces a complete financial model: CAPEX breakdown, monthly operating costs, cash flow projections, debt service, IRR, MOIC, DSCR, payback period, break-even utilization, and sensitivity analysis. It covers indoor/outdoor, rent/buy, and all major cost and revenue variables.
|
||||
{% endif %}
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Do I need to sign up?</summary>
|
||||
<p>No. The planner works instantly with no signup. Create an account to save scenarios, compare configurations, and export PDF reports.</p>
|
||||
<summary>{{ t.landing_faq_q2 }}</summary>
|
||||
<p>
|
||||
{% if lang == 'de' %}
|
||||
Nein. Der Planer funktioniert sofort ohne Registrierung. Erstellen Sie ein Konto, um Szenarien zu speichern, Konfigurationen zu vergleichen und PDF-Berichte zu exportieren.
|
||||
{% else %}
|
||||
No. The planner works instantly with no signup. Create an account to save scenarios, compare configurations, and export PDF reports.
|
||||
{% endif %}
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>How does supplier matching work?</summary>
|
||||
<p>When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with relevant suppliers from our directory. They contact you directly with proposals.</p>
|
||||
<summary>{{ t.landing_faq_q3 }}</summary>
|
||||
<p>
|
||||
{% if lang == 'de' %}
|
||||
Wenn Sie über den Planer Angebote anfordern, teilen wir Ihre Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren Sie direkt mit ihren Angeboten.
|
||||
{% else %}
|
||||
When you request quotes through the planner, we share your project details (venue type, court count, glass, lighting, country, budget, timeline) with relevant suppliers from our directory. They contact you directly with proposals.
|
||||
{% endif %}
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Is the supplier directory free?</summary>
|
||||
<p>Browsing the directory is free for everyone. Suppliers have a basic listing by default. Paid plans (Basic at €39/mo, Growth at €199/mo, Pro at €499/mo) unlock enquiry forms, full descriptions, logos, verified badges, and priority placement.</p>
|
||||
<summary>{{ t.landing_faq_q4 }}</summary>
|
||||
<p>
|
||||
{% if lang == 'de' %}
|
||||
Das Durchsuchen des Verzeichnisses ist für alle kostenlos. Anbieter erhalten standardmäßig einen Basiseintrag. Kostenpflichtige Pläne (Basic ab 39 €/Monat, Growth ab 199 €/Monat, Pro ab 499 €/Monat) schalten Anfrageformulare, vollständige Beschreibungen, Logos, verifizierte Badges und Prioritätsplatzierung frei.
|
||||
{% else %}
|
||||
Browsing the directory is free for everyone. Suppliers have a basic listing by default. Paid plans (Basic at €39/mo, Growth at €199/mo, Pro at €499/mo) unlock enquiry forms, full descriptions, logos, verified badges, and priority placement.
|
||||
{% endif %}
|
||||
</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>How accurate are the financial projections?</summary>
|
||||
<p>The model uses real-world defaults based on European market data. Every assumption is adjustable so you can match your local conditions. The sensitivity analysis shows how results change across different scenarios, helping you understand the range of outcomes.</p>
|
||||
<summary>{{ t.landing_faq_q5 }}</summary>
|
||||
<p>
|
||||
{% if lang == 'de' %}
|
||||
Das Modell verwendet reale Standardwerte auf Basis europäischer Marktdaten. Jede Annahme ist anpassbar, sodass Sie Ihre lokalen Gegebenheiten abbilden können. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern, und hilft Ihnen, die Bandbreite möglicher Ergebnisse zu verstehen.
|
||||
{% else %}
|
||||
The model uses real-world defaults based on European market data. Every assumption is adjustable so you can match your local conditions. The sensitivity analysis shows how results change across different scenarios, helping you understand the range of outcomes.
|
||||
{% endif %}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SEO Content -->
|
||||
<section class="py-12 max-w-3xl mx-auto">
|
||||
<h2 class="text-2xl mb-4">Padel Court Investment Planning</h2>
|
||||
<h2 class="text-2xl mb-4">{{ t.landing_seo_title }}</h2>
|
||||
<div class="space-y-4 text-slate-dark leading-relaxed">
|
||||
{% if lang == 'de' %}
|
||||
<p>
|
||||
Padel ist der am schnellsten wachsende Sport in Europa — die Nachfrage nach Plätzen übersteigt das Angebot in Deutschland, Österreich, der Schweiz und darüber hinaus bei weitem. Eine Paddelhalle zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Innenhalle mit 6–8 Plätzen erfordert zwischen 300.000 € (Anmietung eines Bestandsgebäudes) und 2–3 Mio. € (Neubau), mit Amortisationszeiten von 3–5 Jahren für gut gelegene Anlagen.
|
||||
</p>
|
||||
<p>
|
||||
Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es Ihnen, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob Sie als Unternehmer Ihre erste Anlage prüfen, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrieren oder als Investor eine bestehende Paddelhalle bewerten — Padelnomics gibt Ihnen die finanzielle Klarheit für fundierte Entscheidungen.
|
||||
</p>
|
||||
{% else %}
|
||||
<p>
|
||||
Padel is the fastest-growing sport in Europe, with demand for courts far outstripping supply in Germany, the UK, Scandinavia, and beyond. Opening a padel hall can be a lucrative investment, but the numbers need to work. A typical indoor padel venue with 6-8 courts requires between €300K (renting an existing building) and €2-3M (building new), with payback periods of 3-5 years for well-located venues.
|
||||
</p>
|
||||
<p>
|
||||
The key variables that determine success are location (driving utilization), construction costs (CAPEX), rent or land costs, and pricing strategy. Our financial planner lets you model all of these variables interactively, seeing the impact on your IRR, MOIC, cash flow, and debt service coverage ratio in real time. Whether you're an entrepreneur exploring your first venue, a real estate developer adding padel to a mixed-use project, or an investor evaluating a padel hall acquisition, Padelnomics gives you the financial clarity to make informed decisions.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Final CTA — dark card -->
|
||||
<section style="padding: 2rem 0 4rem">
|
||||
<div class="cta-card">
|
||||
<h2>Start Planning Today</h2>
|
||||
<p>Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.</p>
|
||||
<a href="{{ url_for('planner.index') }}" class="cta-card__btn">Plan Your Padel Business →</a>
|
||||
<h2>{{ t.landing_final_cta_h2 }}</h2>
|
||||
<p>
|
||||
{% if lang == 'de' %}
|
||||
Modellieren Sie Ihre Investition und lassen Sie sich mit verifizierten Platz-Anbietern aus {{ total_countries }} Ländern zusammenbringen.
|
||||
{% else %}
|
||||
Model your investment, then get matched with verified court suppliers across {{ total_countries }} countries.
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="{{ url_for('planner.index') }}" class="cta-card__btn">{{ t.landing_final_cta_btn }}</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -517,6 +656,66 @@
|
||||
update();
|
||||
})();
|
||||
</script>
|
||||
{% if lang == 'de' %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "Padelnomics",
|
||||
"url": "{{ config.BASE_URL }}",
|
||||
"logo": "{{ url_for('static', filename='images/logo.png', _external=True) }}",
|
||||
"description": "Professionelle Planungsplattform für Padelplatz-Investitionen. Finanzplaner, Anbieterverzeichnis und Marktinformationen für Padel-Unternehmer."
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Was berechnet der Planer?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Der Planer erstellt ein vollständiges Finanzmodell: CAPEX-Aufschlüsselung, monatliche Betriebskosten, Cashflow-Projektionen, Schuldendienst, IRR, MOIC, DSCR, Amortisationszeit, Break-even-Auslastung und Sensitivitätsanalyse. Es werden Indoor-/Outdoor-Anlagen, Miet- und Eigentumsmodelle sowie alle wesentlichen Kosten- und Erlösvariablen abgedeckt."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Muss ich mich registrieren?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Nein. Der Planer funktioniert sofort ohne Registrierung. Erstellen Sie ein Konto, um Szenarien zu speichern, Konfigurationen zu vergleichen und PDF-Berichte zu exportieren."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Wie funktioniert die Anbieter-Vermittlung?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Wenn Sie über den Planer Angebote anfordern, teilen wir Ihre Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren Sie direkt mit ihren Angeboten."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Ist das Anbieterverzeichnis kostenlos?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Das Durchsuchen des Verzeichnisses ist für alle kostenlos. Anbieter erhalten standardmäßig einen Basiseintrag. Kostenpflichtige Pläne schalten Anfrageformulare, vollständige Beschreibungen, Logos, verifizierte Badges und Prioritätsplatzierung frei."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Wie genau sind die Finanzprojektionen?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "Das Modell verwendet reale Standardwerte auf Basis europäischer Marktdaten. Jede Annahme ist anpassbar, sodass Sie Ihre lokalen Gegebenheiten abbilden können. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
@@ -575,4 +774,5 @@
|
||||
]
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -28,18 +28,22 @@ const S = {
|
||||
// Freeze a copy of defaults before any overrides
|
||||
const DEFAULTS = Object.freeze(JSON.parse(JSON.stringify(S)));
|
||||
|
||||
// Locale helpers — injected by server as window.__PADELNOMICS_LOCALE__
|
||||
const L = window.__PADELNOMICS_LOCALE__ || {};
|
||||
const tr = (k, fb) => L[k] !== undefined ? L[k] : fb;
|
||||
|
||||
// Restore saved scenario if available
|
||||
if (window.__PADELNOMICS_INITIAL_STATE__) {
|
||||
Object.assign(S, window.__PADELNOMICS_INITIAL_STATE__);
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{id:'assumptions',label:'Assumptions'},
|
||||
{id:'capex',label:'Investment'},
|
||||
{id:'operating',label:'Operating Model'},
|
||||
{id:'cashflow',label:'Cash Flow'},
|
||||
{id:'returns',label:'Returns & Exit'},
|
||||
{id:'metrics',label:'Key Metrics'},
|
||||
{id:'assumptions',label:tr('tab_assumptions','Assumptions')},
|
||||
{id:'capex',label:tr('tab_capex','Investment')},
|
||||
{id:'operating',label:tr('tab_operating','Operating Model')},
|
||||
{id:'cashflow',label:tr('tab_cashflow','Cash Flow')},
|
||||
{id:'returns',label:tr('tab_returns','Returns & Exit')},
|
||||
{id:'metrics',label:tr('tab_metrics','Key Metrics')},
|
||||
];
|
||||
let activeTab = 'assumptions';
|
||||
const charts = {};
|
||||
@@ -47,10 +51,10 @@ const charts = {};
|
||||
// ── Wizard state ──────────────────────────────────────────
|
||||
let wizStep = 1;
|
||||
const WIZ_STEPS = [
|
||||
{n:1, label:'Venue'},
|
||||
{n:2, label:'Pricing'},
|
||||
{n:3, label:'Costs'},
|
||||
{n:4, label:'Finance'},
|
||||
{n:1, label:tr('wiz_venue','Venue')},
|
||||
{n:2, label:tr('wiz_pricing','Pricing')},
|
||||
{n:3, label:tr('wiz_costs','Costs')},
|
||||
{n:4, label:tr('wiz_finance','Finance')},
|
||||
];
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
@@ -189,110 +193,110 @@ function slider(key,label,min,max,step,fmtFn,tip){
|
||||
}
|
||||
|
||||
function buildInputs(){
|
||||
buildToggle('tog-venue',[{v:'indoor',l:'Indoor'},{v:'outdoor',l:'Outdoor'}],'venue');
|
||||
buildToggle('tog-own',[{v:'rent',l:'Rent / Lease'},{v:'buy',l:'Buy / Build'}],'own');
|
||||
buildToggle('tog-venue',[{v:'indoor',l:tr('toggle_indoor','Indoor')},{v:'outdoor',l:tr('toggle_outdoor','Outdoor')}],'venue');
|
||||
buildToggle('tog-own',[{v:'rent',l:tr('toggle_rent','Rent / Lease')},{v:'buy',l:tr('toggle_buy','Buy / Build')}],'own');
|
||||
buildCountryPill();
|
||||
|
||||
$('#inp-courts').innerHTML =
|
||||
slider('dblCourts','Double Courts (20\u00D710m)',0,30,1,fN,'Standard padel court for 4 players. Most common format with highest recreational demand.')+
|
||||
slider('sglCourts','Single Courts (20\u00D76m)',0,30,1,fN,'Narrow court for 2 players. Popular for coaching, training, and competitive play.');
|
||||
slider('dblCourts',tr('sl_dbl_courts','Double Courts (20\u00D710m)'),0,30,1,fN,'Standard padel court for 4 players. Most common format with highest recreational demand.')+
|
||||
slider('sglCourts',tr('sl_sgl_courts','Single Courts (20\u00D76m)'),0,30,1,fN,'Narrow court for 2 players. Popular for coaching, training, and competitive play.');
|
||||
|
||||
rebuildSpaceInputs();
|
||||
|
||||
$('#inp-pricing').innerHTML =
|
||||
slider('ratePeak','Peak Hour Rate (\u20AC)',0,150,1,fD,'Price per court per hour during peak times (evenings 17:00\u201322:00 and weekends). Highest demand period.')+
|
||||
slider('rateOffPeak','Off-Peak Hour Rate (\u20AC)',0,150,1,fD,'Price per court per hour during off-peak (weekday mornings/afternoons). Typically 30\u201340% lower than peak.')+
|
||||
slider('rateSingle','Single Court Rate (\u20AC)',0,150,1,fD,'Hourly rate for single-width courts. Usually lower than doubles since fewer players share the cost.')+
|
||||
slider('peakPct','Peak Hours Share',0,100,1,fP,'Percentage of total booked hours at peak rate. Higher means more revenue but harder to fill off-peak slots.')+
|
||||
slider('bookingFee','Platform Fee',0,30,1,fP,'Commission taken by booking platforms like Playtomic or Matchi. Typically 5\u201315% of court revenue.');
|
||||
slider('ratePeak',tr('sl_rate_peak','Peak Hour Rate (\u20AC)'),0,150,1,fD,'Price per court per hour during peak times (evenings 17:00\u201322:00 and weekends). Highest demand period.')+
|
||||
slider('rateOffPeak',tr('sl_rate_offpeak','Off-Peak Hour Rate (\u20AC)'),0,150,1,fD,'Price per court per hour during off-peak (weekday mornings/afternoons). Typically 30\u201340% lower than peak.')+
|
||||
slider('rateSingle',tr('sl_rate_single','Single Court Rate (\u20AC)'),0,150,1,fD,'Hourly rate for single-width courts. Usually lower than doubles since fewer players share the cost.')+
|
||||
slider('peakPct',tr('sl_peak_pct','Peak Hours Share'),0,100,1,fP,'Percentage of total booked hours at peak rate. Higher means more revenue but harder to fill off-peak slots.')+
|
||||
slider('bookingFee',tr('sl_booking_fee','Platform Fee'),0,30,1,fP,'Commission taken by booking platforms like Playtomic or Matchi. Typically 5\u201315% of court revenue.');
|
||||
|
||||
$('#inp-util').innerHTML =
|
||||
slider('utilTarget','Target Utilization',0,100,1,fP,'Percentage of available court-hours that are actually booked. 35\u201345% is realistic for new venues, 50%+ is strong.')+
|
||||
slider('hoursPerDay','Operating Hours / Day',0,24,1,fH,'Total operating hours per day. Typical padel venues run 7:00\u201323:00 (16h). Some extend to 6:00\u201324:00.')+
|
||||
slider('daysPerMonthIndoor','Indoor Days / Month',0,31,1,fN,'Average operating days per month for indoor venue. ~29 accounts for holidays and maintenance closures.')+
|
||||
slider('daysPerMonthOutdoor','Outdoor Days / Month',0,31,1,fN,'Average playable days per month outdoors. Reduced by rain, extreme heat, or cold weather.')+
|
||||
'<div style="font-size:11px;color:var(--txt-3);margin:4px 0 8px"><b>Ancillary Revenue (per court/month):</b></div>'+
|
||||
slider('membershipRevPerCourt','Membership Revenue / Court',0,2000,50,fE,'Monthly membership/subscription income per court. From loyalty programs, monthly plans, or club memberships.')+
|
||||
slider('fbRevPerCourt','F&B Revenue / Court',0,2000,25,fE,'Food & Beverage revenue per court per month. Income from bar, caf\u00E9, restaurant, or vending machines at the venue.')+
|
||||
slider('coachingRevPerCourt','Coaching & Events / Court',0,2000,25,fE,'Revenue from coaching sessions, clinics, tournaments, and events allocated per court per month.')+
|
||||
slider('retailRevPerCourt','Retail / Court',0,1000,10,fE,'Revenue from pro shop sales: grip tape, overgrips, accessories, and branded merchandise per court per month.');
|
||||
slider('utilTarget',tr('sl_util_target','Target Utilization'),0,100,1,fP,'Percentage of available court-hours that are actually booked. 35\u201345% is realistic for new venues, 50%+ is strong.')+
|
||||
slider('hoursPerDay',tr('sl_hours_per_day','Operating Hours / Day'),0,24,1,fH,'Total operating hours per day. Typical padel venues run 7:00\u201323:00 (16h). Some extend to 6:00\u201324:00.')+
|
||||
slider('daysPerMonthIndoor',tr('sl_days_indoor','Indoor Days / Month'),0,31,1,fN,'Average operating days per month for indoor venue. ~29 accounts for holidays and maintenance closures.')+
|
||||
slider('daysPerMonthOutdoor',tr('sl_days_outdoor','Outdoor Days / Month'),0,31,1,fN,'Average playable days per month outdoors. Reduced by rain, extreme heat, or cold weather.')+
|
||||
'<div style="font-size:11px;color:var(--txt-3);margin:4px 0 8px"><b>'+tr('sl_ancillary_header','Ancillary Revenue (per court/month):')+'</b></div>'+
|
||||
slider('membershipRevPerCourt',tr('sl_membership_rev','Membership Revenue / Court'),0,2000,50,fE,'Monthly membership/subscription income per court. From loyalty programs, monthly plans, or club memberships.')+
|
||||
slider('fbRevPerCourt',tr('sl_fb_rev','F&B Revenue / Court'),0,2000,25,fE,'Food & Beverage revenue per court per month. Income from bar, caf\u00E9, restaurant, or vending machines at the venue.')+
|
||||
slider('coachingRevPerCourt',tr('sl_coaching_rev','Coaching & Events / Court'),0,2000,25,fE,'Revenue from coaching sessions, clinics, tournaments, and events allocated per court per month.')+
|
||||
slider('retailRevPerCourt',tr('sl_retail_rev','Retail / Court'),0,1000,10,fE,'Revenue from pro shop sales: grip tape, overgrips, accessories, and branded merchandise per court per month.');
|
||||
|
||||
rebuildCapexInputs();
|
||||
rebuildOpexInputs();
|
||||
|
||||
$('#inp-finance').innerHTML =
|
||||
slider('loanPct','Loan-to-Cost (LTC)',0,100,1,fP,'Percentage of total CAPEX financed by debt. Banks typically offer 70\u201385%. Higher with personal guarantees or subsidies.')+
|
||||
slider('interestRate','Interest Rate',0,15,0.1,fP,'Annual interest rate on the loan. Depends on creditworthiness, collateral, market conditions, and bank relationship.')+
|
||||
slider('loanTerm','Loan Term',0,30,1,fY,'Loan repayment period in years. Longer terms mean lower monthly payments but more total interest paid.')+
|
||||
slider('constructionMonths','Construction Period',0,24,1,fM,'Months of construction/setup before opening. Costs accrue (loan interest, rent) but no revenue is generated.');
|
||||
slider('loanPct',tr('sl_loan_pct','Loan-to-Cost (LTC)'),0,100,1,fP,'Percentage of total CAPEX financed by debt. Banks typically offer 70\u201385%. Higher with personal guarantees or subsidies.')+
|
||||
slider('interestRate',tr('sl_interest_rate','Interest Rate'),0,15,0.1,fP,'Annual interest rate on the loan. Depends on creditworthiness, collateral, market conditions, and bank relationship.')+
|
||||
slider('loanTerm',tr('sl_loan_term','Loan Term'),0,30,1,fY,'Loan repayment period in years. Longer terms mean lower monthly payments but more total interest paid.')+
|
||||
slider('constructionMonths',tr('sl_construction_months','Construction Period'),0,24,1,fM,'Months of construction/setup before opening. Costs accrue (loan interest, rent) but no revenue is generated.');
|
||||
|
||||
$('#inp-exit').innerHTML =
|
||||
slider('holdYears','Holding Period',1,20,1,fY,'Investment holding period before exit/sale. Typical for PE/investors: 5\u20137 years. Owner-operators may hold indefinitely.')+
|
||||
slider('exitMultiple','Exit EBITDA Multiple',0,20,0.5,fR,'EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 4\u20136x, strong brand: 6\u20138x.')+
|
||||
slider('annualRevGrowth','Annual Revenue Growth',0,15,0.5,fP,'Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.');
|
||||
slider('holdYears',tr('sl_hold_years','Holding Period'),1,20,1,fY,'Investment holding period before exit/sale. Typical for PE/investors: 5\u20137 years. Owner-operators may hold indefinitely.')+
|
||||
slider('exitMultiple',tr('sl_exit_multiple','Exit EBITDA Multiple'),0,20,0.5,fR,'EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 4\u20136x, strong brand: 6\u20138x.')+
|
||||
slider('annualRevGrowth',tr('sl_annual_rev_growth','Annual Revenue Growth'),0,15,0.5,fP,'Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.');
|
||||
}
|
||||
|
||||
function rebuildSpaceInputs(){
|
||||
const isIn = S.venue==='indoor';
|
||||
let h = '';
|
||||
if(isIn){
|
||||
h += slider('sqmPerDblHall','Hall m\u00B2 per Double Court',200,600,10,fN,'Total hall space needed per double court. Includes court (200m\u00B2), safety zones, circulation, and minimum clearances. Standard: 300\u2013350m\u00B2.')+
|
||||
slider('sqmPerSglHall','Hall m\u00B2 per Single Court',120,400,10,fN,'Total hall space needed per single court. Includes court (120m\u00B2), safety zones, and access. Standard: 200\u2013250m\u00B2.');
|
||||
h += slider('sqmPerDblHall',tr('sl_sqm_dbl_hall','Hall m\u00B2 per Double Court'),200,600,10,fN,'Total hall space needed per double court. Includes court (200m\u00B2), safety zones, circulation, and minimum clearances. Standard: 300\u2013350m\u00B2.')+
|
||||
slider('sqmPerSglHall',tr('sl_sqm_sgl_hall','Hall m\u00B2 per Single Court'),120,400,10,fN,'Total hall space needed per single court. Includes court (120m\u00B2), safety zones, and access. Standard: 200\u2013250m\u00B2.');
|
||||
} else {
|
||||
h += slider('sqmPerDblOutdoor','Land m\u00B2 per Double Court',200,500,10,fN,'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280\u2013320m\u00B2.')+
|
||||
slider('sqmPerSglOutdoor','Land m\u00B2 per Single Court',120,350,10,fN,'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180\u2013220m\u00B2.');
|
||||
h += slider('sqmPerDblOutdoor',tr('sl_sqm_dbl_outdoor','Land m\u00B2 per Double Court'),200,500,10,fN,'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280\u2013320m\u00B2.')+
|
||||
slider('sqmPerSglOutdoor',tr('sl_sqm_sgl_outdoor','Land m\u00B2 per Single Court'),120,350,10,fN,'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180\u2013220m\u00B2.');
|
||||
}
|
||||
$('#inp-space').innerHTML = h;
|
||||
}
|
||||
|
||||
function buildCountryPill(){
|
||||
$('#inp-country').innerHTML = pillSelect('country','Country',[
|
||||
{v:'DE',l:'Germany'},{v:'ES',l:'Spain'},{v:'IT',l:'Italy'},
|
||||
{v:'FR',l:'France'},{v:'NL',l:'Netherlands'},{v:'SE',l:'Sweden'},
|
||||
{v:'UK',l:'UK'},{v:'US',l:'USA'},
|
||||
]) + slider('permitsCompliance','Permits & Compliance',0,50000,1000,fE,
|
||||
$('#inp-country').innerHTML = pillSelect('country',tr('pill_country','Country'),[
|
||||
{v:'DE',l:tr('country_de','Germany')},{v:'ES',l:tr('country_es','Spain')},{v:'IT',l:tr('country_it','Italy')},
|
||||
{v:'FR',l:tr('country_fr','France')},{v:'NL',l:tr('country_nl','Netherlands')},{v:'SE',l:tr('country_se','Sweden')},
|
||||
{v:'UK',l:tr('country_uk','UK')},{v:'US',l:tr('country_us','USA')},
|
||||
]) + slider('permitsCompliance',tr('sl_permits','Permits & Compliance'),0,50000,1000,fE,
|
||||
'Building permits, noise studies, change-of-use, fire safety, and regulatory compliance. Adjusts automatically when you pick a country — feel free to override.');
|
||||
}
|
||||
|
||||
function rebuildCapexInputs(){
|
||||
const isIn=S.venue==='indoor', isBuy=S.own==='buy';
|
||||
let glassOpts=[{v:'standard',l:'Standard Glass'},{v:'panoramic',l:'Panoramic Glass'}];
|
||||
let lightOpts=[{v:'led_standard',l:'LED Standard'},{v:'led_competition',l:'LED Competition'}];
|
||||
if(!isIn) lightOpts.push({v:'natural',l:'Natural Light'});
|
||||
let glassOpts=[{v:'standard',l:tr('pill_glass_standard','Standard Glass')},{v:'panoramic',l:tr('pill_glass_panoramic','Panoramic Glass')}];
|
||||
let lightOpts=[{v:'led_standard',l:tr('pill_light_led_standard','LED Standard')},{v:'led_competition',l:tr('pill_light_led_competition','LED Competition')}];
|
||||
if(!isIn) lightOpts.push({v:'natural',l:tr('pill_light_natural','Natural Light')});
|
||||
// Reset lightingType to led_standard if natural was selected but switched to indoor
|
||||
if(isIn && S.lightingType==='natural') S.lightingType='led_standard';
|
||||
|
||||
const lightTip = isIn
|
||||
? 'LED Standard: meets club play requirements. LED Competition: 50% more cost, meets tournament/broadcast standards.'
|
||||
: 'LED Standard: meets club play requirements. LED Competition: 50% more cost, meets tournament standards. Natural: no lighting cost, daylight only.';
|
||||
let h = pillSelect('glassType','Glass Type',glassOpts,'Standard glass: \u20AC25\u201330K per court. Panoramic glass: \u20AC30\u201345K. Panoramic offers full visibility and premium feel.')+
|
||||
pillSelect('lightingType','Lighting Type',lightOpts,lightTip)+
|
||||
slider('courtCostDbl','Court Cost \u2014 Double',0,80000,1000,fE,'Base price of one double padel court. The glass type multiplier is applied automatically.')+
|
||||
slider('courtCostSgl','Court Cost \u2014 Single',0,60000,1000,fE,'Base price of one single padel court. Generally 60\u201370% of a double court cost.');
|
||||
let h = pillSelect('glassType',tr('pill_glass_type','Glass Type'),glassOpts,'Standard glass: \u20AC25\u201330K per court. Panoramic glass: \u20AC30\u201345K. Panoramic offers full visibility and premium feel.')+
|
||||
pillSelect('lightingType',tr('pill_lighting_type','Lighting Type'),lightOpts,lightTip)+
|
||||
slider('courtCostDbl',tr('sl_court_cost_dbl','Court Cost \u2014 Double'),0,80000,1000,fE,'Base price of one double padel court. The glass type multiplier is applied automatically.')+
|
||||
slider('courtCostSgl',tr('sl_court_cost_sgl','Court Cost \u2014 Single'),0,60000,1000,fE,'Base price of one single padel court. Generally 60\u201370% of a double court cost.');
|
||||
if(isIn&&isBuy){
|
||||
h+=slider('hallCostSqm','Hall Construction (\u20AC/m\u00B2)',0,2000,10,fE,'Construction cost per m\u00B2 for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 10\u201312m clear height.')+
|
||||
slider('foundationSqm','Foundation (\u20AC/m\u00B2)',0,400,5,fE,'Foundation cost per m\u00B2. Depends on soil conditions, load-bearing requirements, and local ground water levels.')+
|
||||
slider('landPriceSqm','Land Price (\u20AC/m\u00B2)',0,500,5,fE,'Land purchase price per m\u00B2. Rural: \u20AC20\u201360. Suburban: \u20AC60\u2013150. Urban: \u20AC150\u2013300+. Varies hugely by location.')+
|
||||
slider('hvac','HVAC System',0,500000,5000,fE,'Heating, ventilation, and air conditioning. Essential for indoor comfort and humidity control. Cost scales with hall volume.')+
|
||||
slider('electrical','Electrical + Lighting',0,400000,5000,fE,'Complete electrical installation: court lighting (LED, 500+ lux), power distribution, panels, and outlets.')+
|
||||
slider('sanitary','Sanitary / Changing',0,400000,5000,fE,'Changing rooms, showers, toilets, and plumbing. Includes fixtures, tiling, waterproofing, and ventilation.')+
|
||||
slider('fireProtection','Fire Protection',0,500000,5000,fE,'Fire detection, sprinkler suppression, emergency exits, and smoke ventilation. Often the biggest surprise cost for large halls.')+
|
||||
slider('planning','Planning + Permits',0,500000,5000,fE,'Architectural planning, structural engineering, building permits, zoning applications, and regulatory compliance costs.');
|
||||
h+=slider('hallCostSqm',tr('sl_hall_cost_sqm','Hall Construction (\u20AC/m\u00B2)'),0,2000,10,fE,'Construction cost per m\u00B2 for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 10\u201312m clear height.')+
|
||||
slider('foundationSqm',tr('sl_foundation_sqm','Foundation (\u20AC/m\u00B2)'),0,400,5,fE,'Foundation cost per m\u00B2. Depends on soil conditions, load-bearing requirements, and local ground water levels.')+
|
||||
slider('landPriceSqm',tr('sl_land_price_sqm','Land Price (\u20AC/m\u00B2)'),0,500,5,fE,'Land purchase price per m\u00B2. Rural: \u20AC20\u201360. Suburban: \u20AC60\u2013150. Urban: \u20AC150\u2013300+. Varies hugely by location.')+
|
||||
slider('hvac',tr('sl_hvac','HVAC System'),0,500000,5000,fE,'Heating, ventilation, and air conditioning. Essential for indoor comfort and humidity control. Cost scales with hall volume.')+
|
||||
slider('electrical',tr('sl_electrical','Electrical + Lighting'),0,400000,5000,fE,'Complete electrical installation: court lighting (LED, 500+ lux), power distribution, panels, and outlets.')+
|
||||
slider('sanitary',tr('sl_sanitary','Sanitary / Changing'),0,400000,5000,fE,'Changing rooms, showers, toilets, and plumbing. Includes fixtures, tiling, waterproofing, and ventilation.')+
|
||||
slider('fireProtection',tr('sl_fire','Fire Protection'),0,500000,5000,fE,'Fire detection, sprinkler suppression, emergency exits, and smoke ventilation. Often the biggest surprise cost for large halls.')+
|
||||
slider('planning',tr('sl_planning','Planning + Permits'),0,500000,5000,fE,'Architectural planning, structural engineering, building permits, zoning applications, and regulatory compliance costs.');
|
||||
} else if(isIn&&!isBuy){
|
||||
h+=slider('floorPrep','Floor Preparation',0,100000,1000,fE,'Floor leveling, sealing, and preparation for court installation in an existing rented building.')+
|
||||
slider('hvacUpgrade','HVAC Upgrade',0,200000,1000,fE,'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.')+
|
||||
slider('lightingUpgrade','Lighting Upgrade',0,100000,1000,fE,'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.')+
|
||||
slider('fitout','Fit-Out & Reception',0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.');
|
||||
h+=slider('floorPrep',tr('sl_floor_prep','Floor Preparation'),0,100000,1000,fE,'Floor leveling, sealing, and preparation for court installation in an existing rented building.')+
|
||||
slider('hvacUpgrade',tr('sl_hvac_upgrade','HVAC Upgrade'),0,200000,1000,fE,'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.')+
|
||||
slider('lightingUpgrade',tr('sl_lighting_upgrade','Lighting Upgrade'),0,100000,1000,fE,'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.')+
|
||||
slider('fitout',tr('sl_fitout','Fit-Out & Reception'),0,300000,1000,fE,'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.');
|
||||
} else if(!isIn){
|
||||
h+=slider('outdoorFoundation','Concrete (\u20AC/m\u00B2)',0,150,1,fE,'Concrete pad per m\u00B2 for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.')+
|
||||
slider('outdoorSiteWork','Site Work',0,60000,500,fE,'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.')+
|
||||
slider('outdoorLighting','Lighting per Court',0,20000,500,fE,'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.')+
|
||||
slider('outdoorFencing','Fencing',0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.');
|
||||
if(isBuy) h+=slider('landPriceSqm','Land Price (\u20AC/m\u00B2)',0,500,5,fE,'Land purchase price per m\u00B2. Varies by location, zoning, and accessibility.');
|
||||
h+=slider('outdoorFoundation',tr('sl_outdoor_foundation','Concrete (\u20AC/m\u00B2)'),0,150,1,fE,'Concrete pad per m\u00B2 for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.')+
|
||||
slider('outdoorSiteWork',tr('sl_outdoor_site_work','Site Work'),0,60000,500,fE,'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.')+
|
||||
slider('outdoorLighting',tr('sl_outdoor_lighting','Lighting per Court'),0,20000,500,fE,'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.')+
|
||||
slider('outdoorFencing',tr('sl_outdoor_fencing','Fencing'),0,40000,500,fE,'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.');
|
||||
if(isBuy) h+=slider('landPriceSqm',tr('sl_land_price_sqm','Land Price (\u20AC/m\u00B2)'),0,500,5,fE,'Land purchase price per m\u00B2. Varies by location, zoning, and accessibility.');
|
||||
}
|
||||
h+=slider('workingCapital','Working Capital',0,200000,1000,fE,'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer \u2014 underfunding is a common startup failure.')+
|
||||
slider('contingencyPct','Contingency',0,30,1,fP,'Percentage buffer on total CAPEX for unexpected costs. 10\u201315% is standard for construction, 15\u201320% for complex projects.')+
|
||||
slider('budgetTarget','Your Budget Target',0,5000000,10000,fE,'Set your total budget to see how your planned CAPEX compares. Leave at 0 to hide the budget indicator.');
|
||||
h+=slider('workingCapital',tr('sl_working_capital','Working Capital'),0,200000,1000,fE,'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer \u2014 underfunding is a common startup failure.')+
|
||||
slider('contingencyPct',tr('sl_contingency','Contingency'),0,30,1,fP,'Percentage buffer on total CAPEX for unexpected costs. 10\u201315% is standard for construction, 15\u201320% for complex projects.')+
|
||||
slider('budgetTarget',tr('sl_budget_target','Your Budget Target'),0,5000000,10000,fE,'Set your total budget to see how your planned CAPEX compares. Leave at 0 to hide the budget indicator.');
|
||||
$('#inp-capex').innerHTML = h;
|
||||
}
|
||||
|
||||
@@ -300,19 +304,19 @@ function rebuildOpexInputs(){
|
||||
const isIn=S.venue==='indoor', isBuy=S.own==='buy';
|
||||
let h='';
|
||||
if(!isBuy){
|
||||
if(isIn) h+=slider('rentSqm','Rent (\u20AC/m\u00B2/month)',0,25,0.5,fD,'Monthly rent per square meter for indoor hall space. Varies by location, building quality, and lease terms.');
|
||||
else h+=slider('outdoorRent','Monthly Land Rent',0,5000,50,fE,'Monthly land rent for outdoor court area. Much cheaper than indoor space but weather-dependent.');
|
||||
if(isIn) h+=slider('rentSqm',tr('sl_rent_sqm','Rent (\u20AC/m\u00B2/month)'),0,25,0.5,fD,'Monthly rent per square meter for indoor hall space. Varies by location, building quality, and lease terms.');
|
||||
else h+=slider('outdoorRent',tr('sl_outdoor_rent','Monthly Land Rent'),0,5000,50,fE,'Monthly land rent for outdoor court area. Much cheaper than indoor space but weather-dependent.');
|
||||
} else {
|
||||
h+=slider('propertyTax','Property Tax / month',0,2000,25,fE,'Monthly property tax when owning the building/land. Grundsteuer in Germany, varies by municipality and property value.');
|
||||
h+=slider('propertyTax',tr('sl_property_tax','Property Tax / month'),0,2000,25,fE,'Monthly property tax when owning the building/land. Grundsteuer in Germany, varies by municipality and property value.');
|
||||
}
|
||||
h+=slider('insurance','Insurance (\u20AC/mo)',0,2000,25,fE,'Monthly insurance premium covering liability, property damage, business interruption, and equipment.')+
|
||||
slider('electricity','Electricity (\u20AC/mo)',0,5000,25,fE,'Monthly electricity cost. Major driver for indoor venues due to court lighting, HVAC, and equipment.');
|
||||
if(isIn) h+=slider('heating','Heating (\u20AC/mo)',0,3000,25,fE,'Monthly heating cost for indoor venue. Significant in northern European climates during winter months.')+
|
||||
slider('water','Water (\u20AC/mo)',0,1000,25,fE,'Monthly water cost for showers, toilets, cleaning, and potentially outdoor court irrigation.');
|
||||
h+=slider('maintenance','Maintenance (\u20AC/mo)',0,2000,25,fE,'Monthly court and facility maintenance: glass cleaning, surface repair, net replacement, and equipment upkeep.')+
|
||||
(isIn?slider('cleaning','Cleaning (\u20AC/mo)',0,2000,25,fE,'Monthly professional cleaning of courts, changing rooms, common areas, and reception.'):'')+
|
||||
slider('marketing','Marketing / Misc (\u20AC/mo)',0,5000,25,fE,'Monthly spend on marketing, booking platform subscriptions, website, social media, and customer acquisition.')+
|
||||
slider('staff','Staff (\u20AC/mo)',0,20000,100,fE,'Monthly staff costs including wages, social contributions, and benefits. Many venues run lean using automated booking and access systems.');
|
||||
h+=slider('insurance',tr('sl_insurance','Insurance (\u20AC/mo)'),0,2000,25,fE,'Monthly insurance premium covering liability, property damage, business interruption, and equipment.')+
|
||||
slider('electricity',tr('sl_electricity','Electricity (\u20AC/mo)'),0,5000,25,fE,'Monthly electricity cost. Major driver for indoor venues due to court lighting, HVAC, and equipment.');
|
||||
if(isIn) h+=slider('heating',tr('sl_heating','Heating (\u20AC/mo)'),0,3000,25,fE,'Monthly heating cost for indoor venue. Significant in northern European climates during winter months.')+
|
||||
slider('water',tr('sl_water','Water (\u20AC/mo)'),0,1000,25,fE,'Monthly water cost for showers, toilets, cleaning, and potentially outdoor court irrigation.');
|
||||
h+=slider('maintenance',tr('sl_maintenance','Maintenance (\u20AC/mo)'),0,2000,25,fE,'Monthly court and facility maintenance: glass cleaning, surface repair, net replacement, and equipment upkeep.')+
|
||||
(isIn?slider('cleaning',tr('sl_cleaning','Cleaning (\u20AC/mo)'),0,2000,25,fE,'Monthly professional cleaning of courts, changing rooms, common areas, and reception.'):'')+
|
||||
slider('marketing',tr('sl_marketing','Marketing / Misc (\u20AC/mo)'),0,5000,25,fE,'Monthly spend on marketing, booking platform subscriptions, website, social media, and customer acquisition.')+
|
||||
slider('staff',tr('sl_staff','Staff (\u20AC/mo)'),0,20000,100,fE,'Monthly staff costs including wages, social contributions, and benefits. Many venues run lean using automated booking and access systems.');
|
||||
$('#inp-opex').innerHTML = h;
|
||||
}
|
||||
|
||||
@@ -383,14 +387,14 @@ function render(){
|
||||
|
||||
function renderWith(d){
|
||||
const isIn=S.venue==='indoor';
|
||||
const label = `${isIn?'Indoor':'Outdoor'} \u00B7 ${S.own==='buy'?'Build/Buy':'Rent'}`;
|
||||
$('#headerTag').textContent = `${label} \u00B7 ${d.totalCourts} courts \u00B7 ${fmtK(d.capex)}`;
|
||||
const label = `${isIn?tr('label_indoor','Indoor'):tr('label_outdoor','Outdoor')} \u00B7 ${S.own==='buy'?tr('label_build_buy','Build/Buy'):tr('label_rent','Rent')}`;
|
||||
$('#headerTag').textContent = `${label} \u00B7 ${d.totalCourts} ${tr('label_courts','courts')} \u00B7 ${fmtK(d.capex)}`;
|
||||
|
||||
const courtPlaySqm = S.dblCourts*200+S.sglCourts*120;
|
||||
$('#courtSummary').innerHTML =
|
||||
cardSmHTML('Total Courts',d.totalCourts)+
|
||||
cardSmHTML('Floor Area',`${fmtN(d.sqm)} m\u00B2`,isIn?'Indoor hall':'Outdoor land')+
|
||||
cardSmHTML('Court Area',`${fmtN(courtPlaySqm)} m\u00B2`,'Playing surface');
|
||||
cardSmHTML(tr('card_total_courts','Total Courts'),d.totalCourts)+
|
||||
cardSmHTML(tr('card_floor_area','Floor Area'),`${fmtN(d.sqm)} m\u00B2`,isIn?tr('label_indoor_hall','Indoor hall'):tr('label_outdoor_land','Outdoor land'))+
|
||||
cardSmHTML(tr('card_court_area','Court Area'),`${fmtN(courtPlaySqm)} m\u00B2`,tr('label_playing_surface','Playing surface'));
|
||||
|
||||
if(activeTab==='capex') renderCapex(d);
|
||||
if(activeTab==='operating') renderOperating(d);
|
||||
@@ -413,9 +417,9 @@ const THR = t => `<th class="right">${t}</th>`;
|
||||
|
||||
function renderCapex(d){
|
||||
$('#capexCards').innerHTML =
|
||||
cardHTML('Total CAPEX',fmt(d.capex),'','red','Capital Expenditure: total upfront investment required to build and equip the venue before opening.')+
|
||||
cardHTML('Per Court',fmt(Math.round(d.capexPerCourt)),d.totalCourts+' courts','','Total investment divided by number of courts. Useful for comparing scenarios and benchmarking.')+
|
||||
cardHTML('Per m\u00B2',fmt(Math.round(d.capexPerSqm)),fmtN(d.sqm)+' m\u00B2','','Total investment per square meter of venue space. Benchmarks construction efficiency.');
|
||||
cardHTML(tr('card_total_capex','Total CAPEX'),fmt(d.capex),'','red','Capital Expenditure: total upfront investment required to build and equip the venue before opening.')+
|
||||
cardHTML(tr('card_per_court','Per Court'),fmt(Math.round(d.capexPerCourt)),d.totalCourts+' '+tr('label_courts','courts'),'','Total investment divided by number of courts. Useful for comparing scenarios and benchmarking.')+
|
||||
cardHTML(tr('card_per_sqm','Per m\u00B2'),fmt(Math.round(d.capexPerSqm)),fmtN(d.sqm)+' m\u00B2','','Total investment per square meter of venue space. Benchmarks construction efficiency.');
|
||||
|
||||
// Budget indicator
|
||||
if(d.budgetTarget>0){
|
||||
@@ -423,15 +427,15 @@ function renderCapex(d){
|
||||
const cls=over?'red':'green';
|
||||
const sign=over?'+':'';
|
||||
$('#capexCards').innerHTML+=`<div class="budget-indicator budget-indicator--${over?'over':'under'}">
|
||||
<div class="metric-card__label">BUDGET ${over?'OVER':'UNDER'}</div>
|
||||
<div class="metric-card__label">${over?tr('budget_over','BUDGET OVER'):tr('budget_under','BUDGET UNDER')}</div>
|
||||
<div class="metric-card__value ${over?'c-red':'c-green'}">${sign}${fmt(Math.round(d.budgetVariance))}</div>
|
||||
<div class="metric-card__sub">${d.budgetPct.toFixed(0)}% of ${fmtK(d.budgetTarget)} budget</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let rows = d.capexItems.map(i=>`<tr><td>${i.name}${i.info?` <span style="color:var(--txt-3);font-size:10px">(${i.info})</span>`:''}</td><td class="mono">${fmt(i.amount)}</td></tr>`).join('');
|
||||
rows += `<tr class="total-row"><td>TOTAL CAPEX</td><td class="mono">${fmt(d.capex)}</td></tr>`;
|
||||
$('#capexTable').innerHTML = `<table class="data-table"><thead><tr>${TH('Item')}${THR('Amount')}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
rows += `<tr class="total-row"><td>${tr('table_total_capex','TOTAL CAPEX')}</td><td class="mono">${fmt(d.capex)}</td></tr>`;
|
||||
$('#capexTable').innerHTML = `<table class="data-table"><thead><tr>${TH(tr('th_item','Item'))}${THR(tr('th_amount','Amount'))}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
|
||||
renderChart('chartCapex','doughnut',{
|
||||
labels:d.capexItems.filter(i=>i.amount>0).map(i=>i.name),
|
||||
@@ -444,46 +448,46 @@ function renderCapex(d){
|
||||
function renderOperating(d){
|
||||
const margin = d.netRevMonth>0?(d.ebitdaMonth/d.netRevMonth*100).toFixed(1):0;
|
||||
$('#opCards').innerHTML =
|
||||
cardHTML('Net Revenue/mo',fmt(Math.round(d.netRevMonth)),'Stabilized','green','Monthly revenue after deducting platform booking fees but before operating expenses.')+
|
||||
cardHTML('EBITDA/mo',fmt(Math.round(d.ebitdaMonth)),margin+'% margin',d.ebitdaMonth>=0?'green':'red','Earnings Before Interest, Taxes, Depreciation & Amortization. Core monthly operating profit of the business.')+
|
||||
cardHTML('Annual Revenue',fmt(Math.round(d.annuals.length>=3?d.annuals[2].revenue:0)),'Year 3','','Projected total annual revenue in Year 3 when the business has reached stabilized utilization.')+
|
||||
cardHTML('RevPAH',fmt(d.revPAH),'Revenue per available hour','blue','Revenue Per Available Hour. Net revenue divided by total available court-hours. Measures how well you monetize capacity.');
|
||||
cardHTML(tr('card_net_rev_mo','Net Revenue/mo'),fmt(Math.round(d.netRevMonth)),tr('sub_stabilized','Stabilized'),'green','Monthly revenue after deducting platform booking fees but before operating expenses.')+
|
||||
cardHTML(tr('card_ebitda_mo','EBITDA/mo'),fmt(Math.round(d.ebitdaMonth)),margin+'% margin',d.ebitdaMonth>=0?'green':'red','Earnings Before Interest, Taxes, Depreciation & Amortization. Core monthly operating profit of the business.')+
|
||||
cardHTML(tr('card_annual_rev','Annual Revenue'),fmt(Math.round(d.annuals.length>=3?d.annuals[2].revenue:0)),tr('sub_year3','Year 3'),'','Projected total annual revenue in Year 3 when the business has reached stabilized utilization.')+
|
||||
cardHTML(tr('card_rev_pah','RevPAH'),fmt(d.revPAH),'Revenue per available hour','blue','Revenue Per Available Hour. Net revenue divided by total available court-hours. Measures how well you monetize capacity.');
|
||||
|
||||
const streams=[
|
||||
['Court Rental (net of fees)',d.courtRevMonth-d.feeDeduction],
|
||||
['Equipment Rental (rackets/balls)',d.racketRev+d.ballMargin],
|
||||
['Memberships',d.membershipRev],
|
||||
['F&B',d.fbRev],
|
||||
['Coaching & Events',d.coachingRev],
|
||||
['Retail',d.retailRev],
|
||||
[tr('stream_court_rental','Court Rental (net of fees)'),d.courtRevMonth-d.feeDeduction],
|
||||
[tr('stream_equipment','Equipment Rental (rackets/balls)'),d.racketRev+d.ballMargin],
|
||||
[tr('stream_memberships','Memberships'),d.membershipRev],
|
||||
[tr('stream_fb','F&B'),d.fbRev],
|
||||
[tr('stream_coaching','Coaching & Events'),d.coachingRev],
|
||||
[tr('stream_retail','Retail'),d.retailRev],
|
||||
];
|
||||
const totalStream = streams.reduce((s,r)=>s+r[1],0);
|
||||
let sRows = streams.map(([n,v])=>{
|
||||
const pct=totalStream>0?(v/totalStream*100).toFixed(0):0;
|
||||
return `<tr><td>${n}</td><td class="mono">${fmt(Math.round(v))}</td><td class="mono">${pct}%</td></tr>`;
|
||||
}).join('');
|
||||
sRows+=`<tr class="total-row"><td>Total Net Revenue</td><td class="mono">${fmt(Math.round(totalStream))}</td><td class="mono">100%</td></tr>`;
|
||||
$('#revenueTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Stream')}${THR('Monthly')}${THR('Share')}</tr></thead><tbody>${sRows}</tbody></table>`;
|
||||
sRows+=`<tr class="total-row"><td>${tr('table_total_net_rev','Total Net Revenue')}</td><td class="mono">${fmt(Math.round(totalStream))}</td><td class="mono">100%</td></tr>`;
|
||||
$('#revenueTable').innerHTML=`<table class="data-table"><thead><tr>${TH(tr('th_stream','Stream'))}${THR(tr('th_monthly','Monthly'))}${THR(tr('th_share','Share'))}</tr></thead><tbody>${sRows}</tbody></table>`;
|
||||
|
||||
let oRows=d.opexItems.map(i=>`<tr><td>${i.name}${i.info?` <span style="color:var(--txt-3);font-size:10px">(${i.info})</span>`:''}</td><td class="mono">${fmt(i.amount)}</td></tr>`).join('');
|
||||
oRows+=`<tr class="total-row"><td>Total Monthly OpEx</td><td class="mono">${fmt(d.opex)}</td></tr>`;
|
||||
$('#opexDetailTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Item')}${THR('Monthly')}</tr></thead><tbody>${oRows}</tbody></table>`;
|
||||
oRows+=`<tr class="total-row"><td>${tr('table_total_opex','Total Monthly OpEx')}</td><td class="mono">${fmt(d.opex)}</td></tr>`;
|
||||
$('#opexDetailTable').innerHTML=`<table class="data-table"><thead><tr>${TH(tr('th_item','Item'))}${THR(tr('th_monthly','Monthly'))}</tr></thead><tbody>${oRows}</tbody></table>`;
|
||||
|
||||
const rampData = d.months.slice(0,24);
|
||||
renderChart('chartRevRamp','bar',{
|
||||
labels:rampData.map(m=>'M'+m.m),
|
||||
datasets:[
|
||||
{label:'Revenue',data:rampData.map(m=>Math.round(m.totalRev)),backgroundColor:'rgba(16,185,129,0.5)',borderRadius:3},
|
||||
{label:'OpEx+Debt',data:rampData.map(m=>Math.round(Math.abs(m.opex)+Math.abs(m.loan))),backgroundColor:'rgba(239,68,68,0.4)',borderRadius:3},
|
||||
{label:tr('chart_revenue','Revenue'),data:rampData.map(m=>Math.round(m.totalRev)),backgroundColor:'rgba(16,185,129,0.5)',borderRadius:3},
|
||||
{label:tr('chart_opex_debt','OpEx+Debt'),data:rampData.map(m=>Math.round(Math.abs(m.opex)+Math.abs(m.loan))),backgroundColor:'rgba(239,68,68,0.4)',borderRadius:3},
|
||||
]
|
||||
},{scales:{x:{ticks:{maxTicksLimit:12,color:'#94A3B8',font:{size:9}}},y:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{labels:{color:'#64748B',font:{size:10}}}}});
|
||||
|
||||
const plData = [
|
||||
{label:'Court Rev',val:Math.round(d.courtRevMonth)},
|
||||
{label:'Fees',val:-Math.round(d.feeDeduction)},
|
||||
{label:'Ancillary',val:Math.round(d.racketRev+d.ballMargin+d.membershipRev+d.fbRev+d.coachingRev+d.retailRev)},
|
||||
{label:'OpEx',val:-Math.round(d.opex)},
|
||||
{label:'Debt',val:-Math.round(d.monthlyPayment)},
|
||||
{label:tr('chart_court_rev','Court Rev'),val:Math.round(d.courtRevMonth)},
|
||||
{label:tr('chart_fees','Fees'),val:-Math.round(d.feeDeduction)},
|
||||
{label:tr('chart_ancillary','Ancillary'),val:Math.round(d.racketRev+d.ballMargin+d.membershipRev+d.fbRev+d.coachingRev+d.retailRev)},
|
||||
{label:tr('chart_opex','OpEx'),val:-Math.round(d.opex)},
|
||||
{label:tr('chart_debt','Debt'),val:-Math.round(d.monthlyPayment)},
|
||||
];
|
||||
renderChart('chartPL','bar',{
|
||||
labels:plData.map(p=>p.label),
|
||||
@@ -492,14 +496,14 @@ function renderOperating(d){
|
||||
}
|
||||
|
||||
function renderCashflow(d){
|
||||
const payback = d.paybackIdx>=0?`Month ${d.paybackIdx+1}`:'Not reached';
|
||||
const payback = d.paybackIdx>=0?`Month ${d.paybackIdx+1}`:tr('payback_not_reached','Not reached');
|
||||
const y1ncf = d.annuals[0]?.ncf||0;
|
||||
const y3ncf = d.annuals.length>=3?d.annuals[2].ncf:0;
|
||||
$('#cfCards').innerHTML =
|
||||
cardHTML('Year 1 Net CF',fmt(Math.round(y1ncf)),'',y1ncf>=0?'green':'red','Net Cash Flow in Year 1. Typically negative due to ramp-up. Includes all revenue minus OpEx and debt service.')+
|
||||
cardHTML('Year 3 Net CF',fmt(Math.round(y3ncf)),'Stabilized',y3ncf>=0?'green':'red','Net Cash Flow in Year 3 when utilization has reached target levels. This is the stabilized annual performance.')+
|
||||
cardHTML('Payback',payback,d.paybackIdx>=0?`~${((d.paybackIdx+1)/12).toFixed(1)} years`:'','','Number of months until cumulative cash flows recover the full initial CAPEX investment.')+
|
||||
cardHTML('Initial Investment',fmt(d.capex),'','red','Total upfront capital required including construction, equipment, permits, and working capital buffer.');
|
||||
cardHTML(tr('card_y1_ncf','Year 1 Net CF'),fmt(Math.round(y1ncf)),'',y1ncf>=0?'green':'red','Net Cash Flow in Year 1. Typically negative due to ramp-up. Includes all revenue minus OpEx and debt service.')+
|
||||
cardHTML(tr('card_y3_ncf','Year 3 Net CF'),fmt(Math.round(y3ncf)),tr('sub_stabilized','Stabilized'),y3ncf>=0?'green':'red','Net Cash Flow in Year 3 when utilization has reached target levels. This is the stabilized annual performance.')+
|
||||
cardHTML(tr('card_payback','Payback'),payback,d.paybackIdx>=0?`~${((d.paybackIdx+1)/12).toFixed(1)} years`:'','','Number of months until cumulative cash flows recover the full initial CAPEX investment.')+
|
||||
cardHTML(tr('card_initial_inv','Initial Investment'),fmt(d.capex),'','red','Total upfront capital required including construction, equipment, permits, and working capital buffer.');
|
||||
|
||||
renderChart('chartCF','bar',{
|
||||
labels:d.months.map(m=>m.m%12===1?'Y'+m.yr:''),
|
||||
@@ -525,27 +529,27 @@ function renderCashflow(d){
|
||||
<td class="mono">${util}%</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
$('#annualTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Year')}${THR('Revenue')}${THR('EBITDA')}${THR('Debt Service')}${THR('Net CF')}${THR('DSCR')}${THR('Util.')}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
$('#annualTable').innerHTML=`<table class="data-table"><thead><tr>${TH(tr('th_year','Year'))}${THR(tr('th_revenue','Revenue'))}${THR(tr('th_ebitda','EBITDA'))}${THR(tr('th_debt_service','Debt Service'))}${THR(tr('th_net_cf','Net CF'))}${THR(tr('th_dscr','DSCR'))}${THR(tr('th_util','Util.'))}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
}
|
||||
|
||||
function renderReturns(d){
|
||||
const irrOk=isFinite(d.irr)&&!isNaN(d.irr);
|
||||
$('#retCards').innerHTML =
|
||||
cardHTML('IRR',irrOk?fmtP(d.irr):'N/A',irrOk&&d.irr>0.2?'\u2713 Above 20%':'\u2717 Below target',irrOk&&d.irr>0.2?'green':'red','Internal Rate of Return. The annualized rate of return that makes the NPV of all cash flows equal zero. Accounts for timing of cash flows. Target: >20% for small business risk.')+
|
||||
cardHTML('MOIC',fmtX(d.moic),d.moic>2?'\u2713 Above 2.0x':'\u2717 Below 2.0x',d.moic>2?'green':'red','Multiple on Invested Capital. Total money returned (cash flows + exit proceeds) divided by total money invested. 2.0x = you doubled your money.')+
|
||||
cardHTML('Break-Even Util.',fmtP(d.breakEvenUtil),`${d.breakEvenHrsPerCourt.toFixed(1)} hrs/court/day`,d.breakEvenUtil<0.35?'green':'amber','Minimum court utilization needed to cover all monthly costs (OpEx + debt service). Below this level, the venue loses money each month.')+
|
||||
cardHTML('Cash-on-Cash',fmtP(d.cashOnCash),'Year 3 NCF \u00F7 Equity',d.cashOnCash>0.15?'green':'amber','Annual cash flow (Year 3, stabilized) divided by your equity investment. Measures the cash yield on your own money, ignoring appreciation.');
|
||||
cardHTML(tr('card_irr','IRR'),irrOk?fmtP(d.irr):'N/A',irrOk&&d.irr>0.2?'\u2713 Above 20%':'\u2717 Below target',irrOk&&d.irr>0.2?'green':'red','Internal Rate of Return. The annualized rate of return that makes the NPV of all cash flows equal zero. Accounts for timing of cash flows. Target: >20% for small business risk.')+
|
||||
cardHTML(tr('card_moic','MOIC'),fmtX(d.moic),d.moic>2?'\u2713 Above 2.0x':'\u2717 Below 2.0x',d.moic>2?'green':'red','Multiple on Invested Capital. Total money returned (cash flows + exit proceeds) divided by total money invested. 2.0x = you doubled your money.')+
|
||||
cardHTML(tr('card_break_even','Break-Even Util.'),fmtP(d.breakEvenUtil),`${d.breakEvenHrsPerCourt.toFixed(1)} hrs/court/day`,d.breakEvenUtil<0.35?'green':'amber','Minimum court utilization needed to cover all monthly costs (OpEx + debt service). Below this level, the venue loses money each month.')+
|
||||
cardHTML(tr('card_cash_on_cash','Cash-on-Cash'),fmtP(d.cashOnCash),'Year 3 NCF \u00F7 Equity',d.cashOnCash>0.15?'green':'amber','Annual cash flow (Year 3, stabilized) divided by your equity investment. Measures the cash yield on your own money, ignoring appreciation.');
|
||||
|
||||
const wf = [
|
||||
['Stabilized EBITDA (Y3)',fmt(Math.round(d.stabEbitda)),'c-head'],
|
||||
['\u00D7 Exit Multiple',S.exitMultiple+'x','c-head'],
|
||||
['= Enterprise Value',fmt(Math.round(d.exitValue)),'c-blue'],
|
||||
['\u2013 Remaining Loan',fmt(Math.round(d.remainingLoan)),'c-red'],
|
||||
['= Net Exit Proceeds',fmt(Math.round(d.netExit)),d.netExit>0?'c-green':'c-red'],
|
||||
['+ Cumulative Cash Flow',fmt(Math.round(d.totalReturned-d.netExit)),'c-head'],
|
||||
['= Total Returns',fmt(Math.round(d.totalReturned)),d.totalReturned>0?'c-green':'c-red'],
|
||||
['\u00F7 Investment',fmt(d.capex),'c-head'],
|
||||
['= MOIC',fmtX(d.moic),d.moic>2?'c-green':'c-red'],
|
||||
[tr('wf_stab_ebitda','Stabilized EBITDA (Y3)'),fmt(Math.round(d.stabEbitda)),'c-head'],
|
||||
[tr('wf_exit_multiple','\u00D7 Exit Multiple'),S.exitMultiple+'x','c-head'],
|
||||
[tr('wf_enterprise_value','= Enterprise Value'),fmt(Math.round(d.exitValue)),'c-blue'],
|
||||
[tr('wf_remaining_loan','\u2013 Remaining Loan'),fmt(Math.round(d.remainingLoan)),'c-red'],
|
||||
[tr('wf_net_exit','= Net Exit Proceeds'),fmt(Math.round(d.netExit)),d.netExit>0?'c-green':'c-red'],
|
||||
[tr('wf_cum_cf','+ Cumulative Cash Flow'),fmt(Math.round(d.totalReturned-d.netExit)),'c-head'],
|
||||
[tr('wf_total_returns','= Total Returns'),fmt(Math.round(d.totalReturned)),d.totalReturned>0?'c-green':'c-red'],
|
||||
[tr('wf_investment','\u00F7 Investment'),fmt(d.capex),'c-head'],
|
||||
[tr('wf_moic','= MOIC'),fmtX(d.moic),d.moic>2?'c-green':'c-red'],
|
||||
];
|
||||
$('#exitWaterfall').innerHTML = wf.map(([l,v,c])=>`<div class="waterfall-row"><span class="waterfall-row__label">${l}</span><span class="waterfall-row__value ${c}">${v}</span></div>`).join('');
|
||||
|
||||
@@ -568,7 +572,7 @@ function renderReturns(d){
|
||||
const isTarget = u===S.utilTarget;
|
||||
return `<tr${isTarget?' style="background:var(--rd-bg)"':''}><td>${isTarget?'<b>\u2192 ':''} ${u}%${isTarget?' \u2190</b>':''}</td><td class="mono">${fmt(Math.round(rev))}</td><td class="mono ${ncf>=0?'c-green':'c-red'}">${fmt(Math.round(ncf))}</td><td class="mono ${annual>=0?'c-green':'c-red'}">${fmt(Math.round(annual))}</td><td class="mono">${dscr>99?'\u221E':fmtX(dscr)}</td></tr>`;
|
||||
}).join('');
|
||||
$('#sensTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Utilization')}${THR('Monthly Rev')}${THR('Monthly NCF')}${THR('Annual NCF')}${THR('DSCR')}</tr></thead><tbody>${sRows}</tbody></table>`;
|
||||
$('#sensTable').innerHTML=`<table class="data-table"><thead><tr>${TH(tr('th_utilization','Utilization'))}${THR(tr('th_monthly_rev','Monthly Rev'))}${THR(tr('th_monthly_ncf','Monthly NCF'))}${THR(tr('th_annual_ncf','Annual NCF'))}${THR(tr('th_dscr','DSCR'))}</tr></thead><tbody>${sRows}</tbody></table>`;
|
||||
|
||||
const prices = [-20,-10,-5,0,5,10,15,20];
|
||||
let pRows = prices.map(delta=>{
|
||||
@@ -579,7 +583,7 @@ function renderReturns(d){
|
||||
const isBase = delta===0;
|
||||
return `<tr${isBase?' style="background:var(--rd-bg)"':''}><td>${isBase?'<b>\u2192 ':''}${delta>=0?'+':''}${delta}%${isBase?' (base)</b>':''}</td><td class="mono">${fmt(Math.round(adjRate))}</td><td class="mono">${fmt(Math.round(rev))}</td><td class="mono ${ncf>=0?'c-green':'c-red'}">${fmt(Math.round(ncf))}</td></tr>`;
|
||||
}).join('');
|
||||
$('#priceSensTable').innerHTML=`<table class="data-table"><thead><tr>${TH('Price Change')}${THR('Avg Rate')}${THR('Monthly Rev')}${THR('Monthly NCF')}</tr></thead><tbody>${pRows}</tbody></table>`;
|
||||
$('#priceSensTable').innerHTML=`<table class="data-table"><thead><tr>${TH(tr('th_price_change','Price Change'))}${THR(tr('th_avg_rate','Avg Rate'))}${THR(tr('th_monthly_rev','Monthly Rev'))}${THR(tr('th_monthly_ncf','Monthly NCF'))}</tr></thead><tbody>${pRows}</tbody></table>`;
|
||||
}
|
||||
|
||||
function renderMetrics(d){
|
||||
@@ -626,7 +630,7 @@ function renderMetrics(d){
|
||||
}
|
||||
|
||||
function renderSeasonChart(){
|
||||
const months=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
const months=[tr('month_jan','Jan'),tr('month_feb','Feb'),tr('month_mar','Mar'),tr('month_apr','Apr'),tr('month_may','May'),tr('month_jun','Jun'),tr('month_jul','Jul'),tr('month_aug','Aug'),tr('month_sep','Sep'),tr('month_oct','Oct'),tr('month_nov','Nov'),tr('month_dec','Dec')];
|
||||
renderChart('chartSeason','bar',{
|
||||
labels:months,
|
||||
datasets:[{data:S.season.map(s=>s*100),backgroundColor:S.season.map(s=>s>0?'rgba(16,185,129,0.5)':'rgba(239,68,68,0.2)'),borderRadius:4}]
|
||||
@@ -666,7 +670,7 @@ function deepMerge(t,s){
|
||||
|
||||
// ── Scenario Save/Load ────────────────────────────────────
|
||||
function saveScenario(){
|
||||
const name = prompt('Scenario name:', 'My Padel Plan');
|
||||
const name = prompt(tr('prompt_scenario_name','Scenario name:'), tr('prompt_scenario_default','My Padel Plan'));
|
||||
if(!name) return;
|
||||
const csrf = document.querySelector('input[name="csrf_token"]')?.value;
|
||||
fetch(window.__PADELNOMICS_SAVE_URL__, {
|
||||
@@ -678,9 +682,9 @@ function saveScenario(){
|
||||
.then(data=>{
|
||||
if(data.ok){
|
||||
const fb = document.getElementById('save-feedback');
|
||||
fb.innerHTML = '<div class="save-toast">Scenario saved!</div>';
|
||||
fb.innerHTML = `<div class="save-toast">${tr('toast_saved','Scenario saved!')}</div>`;
|
||||
const countBtn = document.getElementById('scenarioListBtn');
|
||||
if(countBtn) countBtn.textContent = `My Scenarios (${data.count})`;
|
||||
if(countBtn) countBtn.textContent = `${tr('btn_my_scenarios','My Scenarios')} (${data.count})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -707,18 +711,18 @@ function resetToDefaults(){
|
||||
const btn = document.getElementById('resetDefaultsBtn');
|
||||
if(!_resetPending){
|
||||
_resetPending = true;
|
||||
btn.textContent = 'Sure? Reset';
|
||||
btn.textContent = tr('btn_reset_confirm','Sure? Reset');
|
||||
btn.classList.add('btn-reset--confirm');
|
||||
_resetTimer = setTimeout(()=>{
|
||||
_resetPending = false;
|
||||
btn.textContent = 'Reset to Defaults';
|
||||
btn.textContent = tr('btn_reset','Reset to Defaults');
|
||||
btn.classList.remove('btn-reset--confirm');
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
clearTimeout(_resetTimer);
|
||||
_resetPending = false;
|
||||
btn.textContent = 'Reset to Defaults';
|
||||
btn.textContent = tr('btn_reset','Reset to Defaults');
|
||||
btn.classList.remove('btn-reset--confirm');
|
||||
Object.assign(S, JSON.parse(JSON.stringify(DEFAULTS)));
|
||||
_userAdjusted.clear();
|
||||
@@ -776,15 +780,15 @@ function renderWizPreview(){
|
||||
const irrOk = isFinite(d.irr)&&!isNaN(d.irr);
|
||||
el.innerHTML=`
|
||||
<div class="wiz-preview__item">
|
||||
<div class="wiz-preview__label">CAPEX</div>
|
||||
<div class="wiz-preview__label">${tr('wiz_capex','CAPEX')}</div>
|
||||
<div class="wiz-preview__value">${fmtK(d.capex)}</div>
|
||||
</div>
|
||||
<div class="wiz-preview__item">
|
||||
<div class="wiz-preview__label">Monthly CF</div>
|
||||
<div class="wiz-preview__value ${cfCls}">${fmtK(cf)}/mo</div>
|
||||
<div class="wiz-preview__label">${tr('wiz_monthly_cf','Monthly CF')}</div>
|
||||
<div class="wiz-preview__value ${cfCls}">${fmtK(cf)}${tr('wiz_mo','/mo')}</div>
|
||||
</div>
|
||||
<div class="wiz-preview__item">
|
||||
<div class="wiz-preview__label">IRR (${S.holdYears}yr)</div>
|
||||
<div class="wiz-preview__label">${tr('wiz_irr','IRR')} (${S.holdYears}yr)</div>
|
||||
<div class="wiz-preview__value">${irrOk?fmtP(d.irr):'N/A'}</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -795,15 +799,15 @@ function renderWizNav(){
|
||||
let left='', right='';
|
||||
|
||||
if(wizStep>1){
|
||||
left=`<button class="wiz-btn--back" onclick="wizStep--;showWizStep()">← Back</button>`;
|
||||
left=`<button class="wiz-btn--back" onclick="wizStep--;showWizStep()">${tr('btn_back','\u2190 Back')}</button>`;
|
||||
} else {
|
||||
left='<div></div>';
|
||||
}
|
||||
|
||||
if(wizStep<4){
|
||||
right=`<button class="wiz-btn--next" onclick="wizStep++;showWizStep()">Next →</button>`;
|
||||
right=`<button class="wiz-btn--next" onclick="wizStep++;showWizStep()">${tr('btn_next','Next \u2192')}</button>`;
|
||||
} else if(wizStep===4){
|
||||
right=`<button class="wiz-btn--next" onclick="activeTab='capex';render()">Show Results →</button>`;
|
||||
right=`<button class="wiz-btn--next" onclick="activeTab='capex';render()">${tr('btn_show_results','Show Results \u2192')}</button>`;
|
||||
}
|
||||
|
||||
el.innerHTML=left+right;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div data-step="1">
|
||||
<h2 class="s-step-title">Choose Your Plan</h2>
|
||||
<p class="s-step-sub">Select the plan that fits your growth goals.</p>
|
||||
<h2 class="s-step-title">{% if lang == 'de' %}Plan auswählen{% else %}Choose Your Plan{% endif %}</h2>
|
||||
<p class="s-step-sub">{% if lang == 'de' %}Wählen Sie den Plan, der zu Ihren Wachstumszielen passt.{% else %}Select the plan that fits your growth goals.{% endif %}</p>
|
||||
|
||||
<!-- Billing period toggle (CSS sibling selector trick).
|
||||
Radios must be direct siblings of .s-billing-toggle AND <form> so that
|
||||
@@ -11,9 +11,9 @@
|
||||
{% if data.get('billing_period', 'yearly') != 'monthly' %}checked{% endif %}>
|
||||
<div class="s-billing-toggle">
|
||||
<div class="s-billing-toggle__pill">
|
||||
<label for="bp-monthly" class="s-billing-toggle__opt">Monthly</label>
|
||||
<label for="bp-monthly" class="s-billing-toggle__opt">{% if lang == 'de' %}Monatlich{% else %}Monthly{% endif %}</label>
|
||||
<label for="bp-yearly" class="s-billing-toggle__opt">
|
||||
Yearly <span class="s-save-badge">Save up to 26%</span>
|
||||
{% if lang == 'de' %}Jährlich{% else %}Yearly{% endif %} <span class="s-save-badge">{% if lang == 'de' %}Bis zu 26 % sparen{% else %}Save up to 26%{% endif %}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,15 +61,15 @@
|
||||
<label class="s-plan-card {% if data.get('plan', 'supplier_growth') == key %}s-plan-card--selected{% endif %}"
|
||||
onclick="this.parentNode.querySelectorAll('.s-plan-card').forEach(c=>c.classList.remove('s-plan-card--selected')); this.classList.add('s-plan-card--selected')">
|
||||
<input type="radio" name="plan" value="{{ key }}" {% if data.get('plan', 'supplier_growth') == key %}checked{% endif %}>
|
||||
{% if key == 'supplier_growth' %}<div class="s-plan-card__popular">Most Popular</div>{% endif %}
|
||||
{% if key == 'supplier_growth' %}<div class="s-plan-card__popular">{% if lang == 'de' %}Beliebtester{% else %}Most Popular{% endif %}</div>{% endif %}
|
||||
<h3>{{ plan.name }}</h3>
|
||||
<div class="price-yearly">
|
||||
<div class="price">€{{ plan.yearly_monthly_equivalent }} <span>/mo</span></div>
|
||||
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">billed at €{{ plan.yearly_price }}/yr</div>
|
||||
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">{% if lang == 'de' %}jährl. €{{ plan.yearly_price }}{% else %}billed at €{{ plan.yearly_price }}/yr{% endif %}</div>
|
||||
</div>
|
||||
<div class="price-monthly">
|
||||
<div class="price">€{{ plan.monthly_price }} <span>/mo</span></div>
|
||||
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">billed monthly</div>
|
||||
<div style="font-size:0.6875rem;color:#94A3B8;margin-top:2px">{% if lang == 'de' %}monatliche Abrechnung{% else %}billed monthly{% endif %}</div>
|
||||
</div>
|
||||
<ul>
|
||||
{% for f in plan.features %}
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
<div class="s-nav">
|
||||
<span></span>
|
||||
<button type="submit" class="s-btn-next">Next: Add-Ons</button>
|
||||
<button type="submit" class="s-btn-next">{% if lang == 'de' %}Weiter: Add-ons{% else %}Next: Add-Ons{% endif %}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div data-step="2">
|
||||
<h2 class="s-step-title">Boost Add-Ons</h2>
|
||||
<p class="s-step-sub">Increase your visibility with optional boosts. {% if included_boosts %}Some are included in your plan.{% endif %}</p>
|
||||
<h2 class="s-step-title">{% if lang == 'de' %}Boost-Add-ons{% else %}Boost Add-Ons{% endif %}</h2>
|
||||
<p class="s-step-sub">{% if lang == 'de' %}Erhöhen Sie Ihre Sichtbarkeit mit optionalen Boosts. {% if included_boosts %}Einige sind in Ihrem Plan enthalten.{% endif %}{% else %}Increase your visibility with optional boosts. {% if included_boosts %}Some are included in your plan.{% endif %}{% endif %}</p>
|
||||
|
||||
<form hx-post="{{ url_for('suppliers.signup_step', step=2) }}"
|
||||
hx-target="#signup-step" hx-swap="innerHTML">
|
||||
@@ -21,7 +21,7 @@
|
||||
{% endif %}
|
||||
<strong>{{ b.name }}</strong>
|
||||
{% if is_included %}
|
||||
<span class="boost-included">Included in plan</span>
|
||||
<span class="boost-included">{% if lang == 'de' %}Im Plan enthalten{% else %}Included in plan{% endif %}</span>
|
||||
{% else %}
|
||||
<span class="boost-price">€{{ b.price }}/mo</span>
|
||||
{% endif %}
|
||||
@@ -33,8 +33,8 @@
|
||||
<div class="s-nav">
|
||||
<button type="button" class="s-btn-back"
|
||||
hx-get="{{ url_for('suppliers.signup_step', step=1) }}?_accumulated={{ data | tojson | urlencode }}"
|
||||
hx-target="#signup-step" hx-swap="innerHTML">Back</button>
|
||||
<button type="submit" class="s-btn-next">Next: Credit Packs</button>
|
||||
hx-target="#signup-step" hx-swap="innerHTML">{% if lang == 'de' %}Zurück{% else %}Back{% endif %}</button>
|
||||
<button type="submit" class="s-btn-next">{% if lang == 'de' %}Weiter: Credit-Pakete{% else %}Next: Credit Packs{% endif %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div data-step="3">
|
||||
<h2 class="s-step-title">Credit Packs</h2>
|
||||
<p class="s-step-sub">Optionally top up your lead credits. Your plan includes monthly credits — packs give you extra.</p>
|
||||
<h2 class="s-step-title">{% if lang == 'de' %}Credit-Pakete{% else %}Credit Packs{% endif %}</h2>
|
||||
<p class="s-step-sub">{% if lang == 'de' %}Optional Ihre Lead-Credits aufstocken. Ihr Plan enthält monatliche Credits — Pakete geben Ihnen zusätzliche.{% else %}Optionally top up your lead credits. Your plan includes monthly credits — packs give you extra.{% endif %}</p>
|
||||
|
||||
<form hx-post="{{ url_for('suppliers.signup_step', step=3) }}"
|
||||
hx-target="#signup-step" hx-swap="innerHTML">
|
||||
@@ -12,8 +12,8 @@
|
||||
onclick="this.parentNode.querySelectorAll('.s-credit-card').forEach(c=>c.classList.remove('s-credit-card--selected')); this.classList.add('s-credit-card--selected')">
|
||||
<input type="radio" name="credit_pack" value="" {% if not data.get('credit_pack') %}checked{% endif %}>
|
||||
<div class="amount">0</div>
|
||||
<div class="price">Free</div>
|
||||
<div class="per">Plan credits only</div>
|
||||
<div class="price">{% if lang == 'de' %}Kostenlos{% else %}Free{% endif %}</div>
|
||||
<div class="per">{% if lang == 'de' %}Nur Plan-Credits{% else %}Plan credits only{% endif %}</div>
|
||||
</label>
|
||||
{% for cp in credit_packs %}
|
||||
<label class="s-credit-card {% if data.get('credit_pack') == cp.key %}s-credit-card--selected{% endif %}"
|
||||
@@ -30,8 +30,8 @@
|
||||
<button type="button" class="s-btn-back"
|
||||
hx-post="{{ url_for('suppliers.signup_step', step=1) }}"
|
||||
hx-target="#signup-step" hx-swap="innerHTML"
|
||||
hx-include="[name='_accumulated']">Back</button>
|
||||
<button type="submit" class="s-btn-next">Next: Your Details</button>
|
||||
hx-include="[name='_accumulated']">{% if lang == 'de' %}Zurück{% else %}Back{% endif %}</button>
|
||||
<button type="submit" class="s-btn-next">{% if lang == 'de' %}Weiter: Ihre Daten{% else %}Next: Your Details{% endif %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div data-step="4">
|
||||
<h2 class="s-step-title">Account Details</h2>
|
||||
<p class="s-step-sub">Tell us about your company and how to reach you.</p>
|
||||
<h2 class="s-step-title">{% if lang == 'de' %}Kontodaten{% else %}Account Details{% endif %}</h2>
|
||||
<p class="s-step-sub">{% if lang == 'de' %}Erzählen Sie uns von Ihrem Unternehmen und wie wir Sie erreichen können.{% else %}Tell us about your company and how to reach you.{% endif %}</p>
|
||||
|
||||
<form method="post" action="{{ url_for('suppliers.signup_checkout') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
@@ -8,39 +8,39 @@
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 1rem">
|
||||
<div class="s-field">
|
||||
<label class="s-label">Contact Name <span style="color:#EF4444">*</span></label>
|
||||
<label class="s-label">{% if lang == 'de' %}Ansprechpartner{% else %}Contact Name{% endif %} <span style="color:#EF4444">*</span></label>
|
||||
<input type="text" name="contact_name" class="s-input" value="{{ data.get('contact_name', '') }}" required>
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label class="s-label">Email <span style="color:#EF4444">*</span></label>
|
||||
<label class="s-label">{% if lang == 'de' %}E-Mail{% else %}Email{% endif %} <span style="color:#EF4444">*</span></label>
|
||||
<input type="email" name="contact_email" class="s-input" value="{{ data.get('contact_email', '') }}" required>
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label class="s-label">Phone</label>
|
||||
<label class="s-label">{% if lang == 'de' %}Telefon{% else %}Phone{% endif %}</label>
|
||||
<input type="tel" name="contact_phone" class="s-input" value="{{ data.get('contact_phone', '') }}">
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label class="s-label">Short Description</label>
|
||||
<label class="s-label">{% if lang == 'de' %}Kurzbeschreibung{% else %}Short Description{% endif %}</label>
|
||||
<input type="text" name="short_description" class="s-input" maxlength="160"
|
||||
value="{{ data.get('short_description', '') }}" placeholder="160 chars max">
|
||||
value="{{ data.get('short_description', '') }}" placeholder="{% if lang == 'de' %}max. 160 Zeichen{% else %}160 chars max{% endif %}">
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label class="s-label">Service Categories</label>
|
||||
<label class="s-label">{% if lang == 'de' %}Leistungskategorien{% else %}Service Categories{% endif %}</label>
|
||||
<input type="text" name="service_categories" class="s-input"
|
||||
value="{{ data.get('service_categories', '') }}" placeholder="e.g. turnkey, surfaces, lighting">
|
||||
value="{{ data.get('service_categories', '') }}" placeholder="{% if lang == 'de' %}z.B. schlüsselfertig, Beläge, Beleuchtung{% else %}e.g. turnkey, surfaces, lighting{% endif %}">
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label class="s-label">Service Area (countries)</label>
|
||||
<label class="s-label">{% if lang == 'de' %}Servicegebiet (Länder){% else %}Service Area (countries){% endif %}</label>
|
||||
<input type="text" name="service_area" class="s-input"
|
||||
value="{{ data.get('service_area', '') }}" placeholder="e.g. DE, ES, FR">
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label class="s-label">Years in Business</label>
|
||||
<label class="s-label">{% if lang == 'de' %}Jahre im Geschäft{% else %}Years in Business{% endif %}</label>
|
||||
<input type="number" name="years_in_business" class="s-input" min="0"
|
||||
value="{{ data.get('years_in_business', '') }}">
|
||||
</div>
|
||||
<div class="s-field">
|
||||
<label class="s-label">Project Count</label>
|
||||
<label class="s-label">{% if lang == 'de' %}Anzahl Projekte{% else %}Project Count{% endif %}</label>
|
||||
<input type="number" name="project_count" class="s-input" min="0"
|
||||
value="{{ data.get('project_count', '') }}">
|
||||
</div>
|
||||
@@ -48,13 +48,13 @@
|
||||
|
||||
{% if data.get('supplier_name') %}
|
||||
<p style="font-size:12px;color:#64748B;margin-top:0.5rem">
|
||||
Claiming listing: <strong>{{ data.get('supplier_name') }}</strong>
|
||||
{% if lang == 'de' %}Eintrag beanspruchen:{% else %}Claiming listing:{% endif %} <strong>{{ data.get('supplier_name') }}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Order Summary -->
|
||||
<div class="s-summary">
|
||||
<h3>Order Summary</h3>
|
||||
<h3>{% if lang == 'de' %}Bestellübersicht{% else %}Order Summary{% endif %}</h3>
|
||||
<div class="s-summary-row">
|
||||
<span>{{ order.plan_name }} Plan</span>
|
||||
<span>
|
||||
@@ -71,17 +71,17 @@
|
||||
</div>
|
||||
{% if order.boost_monthly > 0 %}
|
||||
<div class="s-summary-row">
|
||||
<span>Boost add-ons</span>
|
||||
<span>{% if lang == 'de' %}Boost-Add-ons{% else %}Boost add-ons{% endif %}</span>
|
||||
<span>+€{{ order.boost_monthly }}/mo</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="s-summary-row s-summary-total">
|
||||
<span>{% if order.billing_period == 'yearly' %}Yearly total{% else %}Monthly total{% endif %}</span>
|
||||
<span>€{{ order.monthly_total }}{% if order.billing_period == 'yearly' %}/mo equiv.{% else %}/mo{% endif %}</span>
|
||||
<span>{% if order.billing_period == 'yearly' %}{% if lang == 'de' %}Jahresgesamt{% else %}Yearly total{% endif %}{% else %}{% if lang == 'de' %}Monatsgesamt{% else %}Monthly total{% endif %}{% endif %}</span>
|
||||
<span>€{{ order.monthly_total }}{% if order.billing_period == 'yearly' %}{% if lang == 'de' %}/Mon. äquiv.{% else %}/mo equiv.{% endif %}{% else %}/mo{% endif %}</span>
|
||||
</div>
|
||||
{% if order.one_time_total > 0 %}
|
||||
<div class="s-summary-row" style="margin-top:8px">
|
||||
<span>Credit pack (one-time)</span>
|
||||
<span>{% if lang == 'de' %}Credit-Paket (einmalig){% else %}Credit pack (one-time){% endif %}</span>
|
||||
<span>€{{ order.one_time_total }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -91,8 +91,8 @@
|
||||
<button type="button" class="s-btn-back"
|
||||
hx-post="{{ url_for('suppliers.signup_step', step=2) }}"
|
||||
hx-target="#signup-step" hx-swap="innerHTML"
|
||||
hx-include="[name='_accumulated']">Back</button>
|
||||
<button type="submit" class="s-btn-next" id="checkout-btn">Proceed to Checkout</button>
|
||||
hx-include="[name='_accumulated']">{% if lang == 'de' %}Zurück{% else %}Back{% endif %}</button>
|
||||
<button type="submit" class="s-btn-next" id="checkout-btn">{% if lang == 'de' %}Zur Kasse{% else %}Proceed to Checkout{% endif %}</button>
|
||||
</div>
|
||||
|
||||
<div id="checkout-error" hidden
|
||||
@@ -107,7 +107,7 @@
|
||||
var btn = document.getElementById('checkout-btn');
|
||||
var errBox = document.getElementById('checkout-error');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Loading\u2026';
|
||||
btn.textContent = '{% if lang == 'de' %}Wird geladen\u2026{% else %}Loading\u2026{% endif %}';
|
||||
errBox.hidden = true;
|
||||
|
||||
fetch(form.action, {
|
||||
@@ -118,10 +118,10 @@
|
||||
.then(function(res) { return res.json().then(function(d) { return { ok: res.ok, data: d }; }); })
|
||||
.then(function(result) {
|
||||
if (!result.ok || result.data.error) {
|
||||
errBox.textContent = result.data.error || 'Something went wrong. Please try again.';
|
||||
errBox.textContent = result.data.error || '{% if lang == 'de' %}Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.{% else %}Something went wrong. Please try again.{% endif %}';
|
||||
errBox.hidden = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Proceed to Checkout';
|
||||
btn.textContent = '{% if lang == 'de' %}Zur Kasse{% else %}Proceed to Checkout{% endif %}';
|
||||
return;
|
||||
}
|
||||
Paddle.Checkout.open({
|
||||
@@ -130,13 +130,13 @@
|
||||
settings: result.data.settings
|
||||
});
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Proceed to Checkout';
|
||||
btn.textContent = '{% if lang == 'de' %}Zur Kasse{% else %}Proceed to Checkout{% endif %}';
|
||||
})
|
||||
.catch(function() {
|
||||
errBox.textContent = 'Network error. Please check your connection and try again.';
|
||||
errBox.textContent = '{% if lang == 'de' %}Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.{% else %}Network error. Please check your connection and try again.{% endif %}';
|
||||
errBox.hidden = false;
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Proceed to Checkout';
|
||||
btn.textContent = '{% if lang == 'de' %}Zur Kasse{% else %}Proceed to Checkout{% endif %}';
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Supplier Signup - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Anbieter-Registrierung - {{ config.APP_NAME }}{% else %}Supplier Signup - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
{% block paddle %}{% include "_paddle.html" %}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
@@ -118,8 +118,8 @@
|
||||
<div class="s-wizard">
|
||||
<div class="s-progress" id="s-progress">
|
||||
<div class="s-progress__meta">
|
||||
<span class="s-progress__label" id="s-progress-label">Choose Your Plan</span>
|
||||
<span class="s-progress__count" id="s-progress-count">1 of 4</span>
|
||||
<span class="s-progress__label" id="s-progress-label">{{ t.sup_signup_step1 }}</span>
|
||||
<span class="s-progress__count" id="s-progress-count">1 {% if lang == 'de' %}von 4{% else %}of 4{% endif %}</span>
|
||||
</div>
|
||||
<div class="s-progress__track">
|
||||
<div class="s-progress__fill" id="s-progress-fill" style="width: 25%"></div>
|
||||
@@ -142,9 +142,9 @@ document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||
var step = e.detail.target.querySelector('[data-step]');
|
||||
if (step) {
|
||||
var n = parseInt(step.dataset.step);
|
||||
var labels = ['Choose Your Plan', 'Boost Add-Ons', 'Credit Packs', 'Account Details'];
|
||||
var labels = [{{ t.sup_signup_step1 | tojson }}, {{ t.sup_signup_step2 | tojson }}, {{ t.sup_signup_step3 | tojson }}, {{ t.sup_signup_step4 | tojson }}];
|
||||
document.getElementById('s-progress-label').textContent = labels[n-1] || '';
|
||||
document.getElementById('s-progress-count').textContent = n + ' of 4';
|
||||
document.getElementById('s-progress-count').textContent = n + ' {% if lang == 'de' %}von 4{% else %}of 4{% endif %}';
|
||||
document.getElementById('s-progress-fill').style.width = (n * 25) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Welcome! - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Willkommen! - {{ config.APP_NAME }}{% else %}Welcome! - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main style="background: linear-gradient(180deg, #F1F5F9, #F8FAFC); min-height: 80vh;">
|
||||
@@ -8,22 +8,22 @@
|
||||
<div style="width:64px;height:64px;border-radius:50%;background:#DCFCE7;margin:0 auto 1.5rem;display:flex;align-items:center;justify-content:center">
|
||||
<span style="font-size:28px;color:#16A34A">✓</span>
|
||||
</div>
|
||||
<h1 style="font-size:1.5rem;font-weight:800;margin:0 0 0.5rem">You're All Set!</h1>
|
||||
<h1 style="font-size:1.5rem;font-weight:800;margin:0 0 0.5rem">{{ t.sup_success_h2 }}</h1>
|
||||
<p style="color:#64748B;font-size:0.9375rem;margin:0 0 1.5rem">
|
||||
Your supplier account is being activated. You'll start receiving qualified leads matching your services.
|
||||
{{ t.sup_success_text }}
|
||||
</p>
|
||||
|
||||
<div style="background:#F8FAFC;border-radius:12px;padding:1rem;margin-bottom:1.5rem;text-align:left">
|
||||
<h3 style="font-size:0.8125rem;font-weight:700;margin:0 0 0.5rem">What happens next:</h3>
|
||||
<h3 style="font-size:0.8125rem;font-weight:700;margin:0 0 0.5rem">{{ t.sup_success_next_h3 }}</h3>
|
||||
<ul style="list-style:none;padding:0;margin:0;font-size:0.8125rem;color:#475569">
|
||||
<li style="padding:3px 0">✓ Your listing will be upgraded within minutes</li>
|
||||
<li style="padding:3px 0">✓ Lead credits have been added to your account</li>
|
||||
<li style="padding:3px 0">✓ Check your email for a sign-in link</li>
|
||||
<li style="padding:3px 0">✓ Browse and unlock leads in your feed</li>
|
||||
<li style="padding:3px 0">✓ {% if lang == 'de' %}Ihr Eintrag wird in wenigen Minuten aktualisiert{% else %}Your listing will be upgraded within minutes{% endif %}</li>
|
||||
<li style="padding:3px 0">✓ {% if lang == 'de' %}Lead-Credits wurden Ihrem Konto hinzugefügt{% else %}Lead credits have been added to your account{% endif %}</li>
|
||||
<li style="padding:3px 0">✓ {% if lang == 'de' %}Prüfen Sie Ihre E-Mail auf einen Anmelde-Link{% else %}Check your email for a sign-in link{% endif %}</li>
|
||||
<li style="padding:3px 0">✓ {% if lang == 'de' %}Durchsuchen und entsperren Sie Leads in Ihrem Feed{% else %}Browse and unlock leads in your feed{% endif %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('suppliers.lead_feed') }}" class="btn" style="display:inline-block;padding:12px 28px">Go to Lead Feed</a>
|
||||
<a href="{{ url_for('suppliers.lead_feed') }}" class="btn" style="display:inline-block;padding:12px 28px">{{ t.sup_success_btn }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Supplier Waitlist - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Anbieter-Warteliste - {{ config.APP_NAME }}{% else %}Supplier Waitlist - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-md mx-auto mt-8">
|
||||
{% set plan_info = plans.get(plan, plans['supplier_growth']) %}
|
||||
|
||||
<h1 class="text-2xl mb-1">Join the Supplier Platform Waitlist</h1>
|
||||
<p class="text-slate mb-6">We're building the ultimate platform to connect verified padel suppliers with entrepreneurs. Be first in line for {{ plan_info.name }} tier access.</p>
|
||||
<h1 class="text-2xl mb-1">{{ t.sup_waitlist_h1 }}</h1>
|
||||
<p class="text-slate mb-6">{% if lang == 'de' %}Wir bauen die ultimative Plattform, um verifizierte Padel-Anbieter mit Unternehmern zu verbinden. Seien Sie Erster in der Schlange für den {{ plan_info.name }}-Tier-Zugang.{% else %}We're building the ultimate platform to connect verified padel suppliers with entrepreneurs. Be first in line for {{ plan_info.name }} tier access.{% endif %}</p>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-6">
|
||||
<h3 class="font-semibold text-navy text-sm mb-2">{{ plan_info.name }} Plan Highlights</h3>
|
||||
<h3 class="font-semibold text-navy text-sm mb-2">{% if lang == 'de' %}{{ plan_info.name }} Plan-Highlights{% else %}{{ plan_info.name }} Plan Highlights{% endif %}</h3>
|
||||
<ul class="text-sm text-slate-dark space-y-1">
|
||||
{% for feature in plan_info.features[:4] %}
|
||||
<li class="flex items-start gap-2">
|
||||
@@ -29,7 +29,7 @@
|
||||
<input type="hidden" name="plan" value="{{ plan }}">
|
||||
|
||||
<div>
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<label for="email" class="form-label">{{ t.sup_waitlist_email_label }}</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
@@ -39,15 +39,15 @@
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
<p class="form-hint">Get early access, exclusive launch pricing, and priority onboarding.</p>
|
||||
<p class="form-hint">{% if lang == 'de' %}Frühzeitiger Zugang, exklusiver Launch-Preis und bevorzugtes Onboarding.{% else %}Get early access, exclusive launch pricing, and priority onboarding.{% endif %}</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn w-full">Join Waitlist</button>
|
||||
<button type="submit" class="btn w-full">{{ t.sup_waitlist_submit }}</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm text-slate mt-6">
|
||||
Already have an account?
|
||||
<a href="{{ url_for('auth.login') }}">Sign in</a>
|
||||
{{ t.sup_waitlist_signin_text }}
|
||||
<a href="{{ url_for('auth.login') }}">{{ t.sup_waitlist_signin_link }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}You're on the Supplier Waitlist - {{ config.APP_NAME }}{% endblock %}
|
||||
{% block title %}{% if lang == 'de' %}Sie stehen auf der Anbieter-Warteliste - {{ config.APP_NAME }}{% else %}You're on the Supplier Waitlist - {{ config.APP_NAME }}{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="container-page py-12">
|
||||
<div class="card max-w-sm mx-auto mt-8 text-center">
|
||||
{% set plan_info = plans.get(plan, plans['supplier_growth']) %}
|
||||
|
||||
<h1 class="text-2xl mb-4">You're on the Supplier Waitlist!</h1>
|
||||
<h1 class="text-2xl mb-4">{% if lang == 'de' %}Sie stehen auf der Anbieter-Warteliste!{% else %}You're on the Supplier Waitlist!{% endif %}</h1>
|
||||
|
||||
<p class="text-slate-dark">We've sent a confirmation to:</p>
|
||||
<p class="text-slate-dark">{% if lang == 'de' %}Wir haben eine Bestätigung gesendet an:{% else %}We've sent a confirmation to:{% endif %}</p>
|
||||
<p class="font-semibold text-navy my-2">{{ email }}</p>
|
||||
|
||||
<p class="text-slate text-sm mb-2">You'll be among the first suppliers with access to the <strong>{{ plan_info.name }}</strong> tier when we launch.</p>
|
||||
<p class="text-slate text-sm mb-2">{% if lang == 'de' %}Sie gehören zu den ersten Anbietern mit Zugang zum <strong>{{ plan_info.name }}</strong>-Tier bei unserem Launch.{% else %}You'll be among the first suppliers with access to the <strong>{{ plan_info.name }}</strong> tier when we launch.{% endif %}</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="text-left mt-6">
|
||||
<h3 class="text-sm font-semibold text-navy mb-3">What you'll get as an early member:</h3>
|
||||
<h3 class="text-sm font-semibold text-navy mb-3">{% if lang == 'de' %}Was Sie als Frühmitglied erhalten:{% else %}What you'll get as an early member:{% endif %}</h3>
|
||||
<ul class="list-disc pl-6 space-y-1 text-sm text-slate-dark">
|
||||
<li>First access to qualified leads from padel entrepreneurs</li>
|
||||
<li>Exclusive launch pricing (locked in for 12 months)</li>
|
||||
<li>Priority onboarding and listing optimization support</li>
|
||||
<li>Featured placement in the directory at launch</li>
|
||||
<li>{% if lang == 'de' %}Erster Zugang zu qualifizierten Leads von Padel-Unternehmern{% else %}First access to qualified leads from padel entrepreneurs{% endif %}</li>
|
||||
<li>{% if lang == 'de' %}Exklusiver Launch-Preis (für 12 Monate festgeschrieben){% else %}Exclusive launch pricing (locked in for 12 months){% endif %}</li>
|
||||
<li>{% if lang == 'de' %}Vorrangiges Onboarding und Support bei der Eintragsoptimierung{% else %}Priority onboarding and listing optimization support{% endif %}</li>
|
||||
<li>{% if lang == 'de' %}Hervorgehobene Platzierung im Verzeichnis beim Launch{% else %}Featured placement in the directory at launch{% endif %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('directory.index') }}" class="btn-outline w-full mt-6">Browse Supplier Directory</a>
|
||||
<a href="{{ url_for('directory.index') }}" class="btn-outline w-full mt-6">{% if lang == 'de' %}Anbieterverzeichnis durchsuchen{% else %}Browse Supplier Directory{% endif %}</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</style>
|
||||
|
||||
<div id="cookie-banner"
|
||||
role="dialog" aria-label="Cookie consent"
|
||||
role="dialog" aria-label="{{ t.cookie_title }}"
|
||||
style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:200;
|
||||
background:#fff;border-top:1px solid #E2E8F0;
|
||||
box-shadow:0 -4px 32px rgba(15,23,42,0.09)">
|
||||
@@ -22,9 +22,9 @@
|
||||
<!-- Left: copy + collapsible prefs -->
|
||||
<div style="flex:1;min-width:240px">
|
||||
<p style="font-size:0.8125rem;color:#475569;margin:0;line-height:1.55">
|
||||
We use cookies to keep you signed in and improve the site.
|
||||
{{ t.cookie_message }}
|
||||
<a href="{{ url_for('public.privacy') }}#cookies"
|
||||
style="color:#1D4ED8;text-decoration:underline;text-underline-offset:2px">Cookie policy</a>
|
||||
style="color:#1D4ED8;text-decoration:underline;text-underline-offset:2px">{{ t.cookie_policy }}</a>
|
||||
</p>
|
||||
|
||||
<!-- Preferences panel (collapsed by default) -->
|
||||
@@ -36,19 +36,19 @@
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;
|
||||
padding-bottom:0.75rem;margin-bottom:0.75rem;border-bottom:1px solid #F1F5F9">
|
||||
<div>
|
||||
<div style="font-size:0.8125rem;font-weight:600;color:#0F172A;margin-bottom:2px">Essential</div>
|
||||
<div style="font-size:0.75rem;color:#64748B;line-height:1.4">Session management. Always required.</div>
|
||||
<div style="font-size:0.8125rem;font-weight:600;color:#0F172A;margin-bottom:2px">{{ t.cookie_essential_label }}</div>
|
||||
<div style="font-size:0.75rem;color:#64748B;line-height:1.4">{{ t.cookie_essential_desc }}</div>
|
||||
</div>
|
||||
<span style="font-size:0.625rem;font-weight:700;letter-spacing:0.06em;color:#16A34A;
|
||||
background:#F0FDF4;border:1px solid #BBF7D0;border-radius:6px;
|
||||
padding:3px 8px;flex-shrink:0;margin-top:2px">ON</span>
|
||||
padding:3px 8px;flex-shrink:0;margin-top:2px">{{ t.cookie_essential_always }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Functional row -->
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.75rem">
|
||||
<div style="flex:1">
|
||||
<div style="font-size:0.8125rem;font-weight:600;color:#0F172A;margin-bottom:2px">Functional</div>
|
||||
<div style="font-size:0.75rem;color:#64748B;line-height:1.4">A/B testing to improve the experience.</div>
|
||||
<div style="font-size:0.8125rem;font-weight:600;color:#0F172A;margin-bottom:2px">{{ t.cookie_functional_label }}</div>
|
||||
<div style="font-size:0.75rem;color:#64748B;line-height:1.4">{{ t.cookie_functional_desc }}</div>
|
||||
</div>
|
||||
<!-- Toggle switch -->
|
||||
<label id="cookie-functional-label"
|
||||
@@ -75,7 +75,7 @@
|
||||
padding:6px 16px;border:1px solid #E2E8F0;border-radius:8px;
|
||||
background:white;color:#475569;
|
||||
transition:border-color 0.15s ease,color 0.15s ease">
|
||||
Save choices
|
||||
{{ t.cookie_save }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,11 +86,11 @@
|
||||
<button type="button" id="cookie-manage-btn" class="btn-outline btn-sm"
|
||||
style="font-size:0.8125rem;white-space:nowrap;padding:8px 16px;
|
||||
border-radius:10px;transition:background 0.15s,border-color 0.15s">
|
||||
Manage
|
||||
{{ t.cookie_manage }}
|
||||
</button>
|
||||
<button type="button" id="cookie-accept-btn" class="btn btn-sm"
|
||||
style="font-size:0.8125rem;white-space:nowrap;padding:8px 18px;border-radius:10px">
|
||||
Accept all
|
||||
{{ t.cookie_accept_all }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -129,10 +129,12 @@
|
||||
}
|
||||
checkbox.addEventListener('change', updateToggle);
|
||||
|
||||
var TEXT_MANAGE = {{ t.cookie_manage | tojson }};
|
||||
var TEXT_CLOSE = {{ t.cookie_close | tojson }};
|
||||
manageBtn.addEventListener('click', function () {
|
||||
var opening = prefs.hidden;
|
||||
prefs.hidden = !opening;
|
||||
manageBtn.textContent = opening ? 'Close' : 'Manage';
|
||||
manageBtn.textContent = opening ? TEXT_CLOSE : TEXT_MANAGE;
|
||||
if (opening) prefs.querySelector('input').focus();
|
||||
});
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="page_url" id="feedback-page-url">
|
||||
<p style="font-size:0.8125rem;font-weight:600;color:#1E293B;margin:0 0 8px">{{ t.nav_feedback }}</p>
|
||||
<textarea name="message" rows="3" required placeholder="Ideas to improve this page..."
|
||||
<textarea name="message" rows="3" required placeholder="{{ t.base_feedback_placeholder }}"
|
||||
style="width:100%;border:1px solid #E2E8F0;border-radius:6px;padding:8px;font-size:0.8125rem;font-family:inherit;resize:vertical"></textarea>
|
||||
<button type="submit" class="btn" style="width:100%;margin-top:8px;font-size:0.8125rem;padding:8px">{{ t.nav_send }}</button>
|
||||
</form>
|
||||
@@ -131,13 +131,13 @@
|
||||
<li><a href="{{ url_for('public.terms') }}">{{ t.link_terms }}</a></li>
|
||||
<li><a href="{{ url_for('public.privacy') }}">{{ t.link_privacy }}</a></li>
|
||||
<li><a href="{{ url_for('public.imprint') }}">{{ t.link_imprint }}</a></li>
|
||||
<li><a href="#" onclick="document.cookie='cookie_consent=;path=/;max-age=0';location.reload();return false">Manage Cookies</a></li>
|
||||
<li><a href="#" onclick="document.cookie='cookie_consent=;path=/;max-age=0';location.reload();return false">{{ t.base_manage_cookies }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-navy text-sm mb-2">{{ t.footer_company }}</p>
|
||||
<ul class="space-y-1 text-sm">
|
||||
<li><a href="{{ url_for('public.about') }}">About</a></li>
|
||||
<li><a href="{{ url_for('public.about') }}">{{ t.base_about }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user