Merge branch 'worktree-planner-mobile-redesign'
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Padelnomics - Application factory and entry point.
|
||||
"""
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Quart, Response, abort, g, redirect, request, session, url_for
|
||||
@@ -9,6 +10,8 @@ from .analytics import close_analytics_db, open_analytics_db
|
||||
from .core import close_db, config, get_csrf_token, init_db, setup_request_id
|
||||
from .i18n import LANG_BLUEPRINTS, SUPPORTED_LANGS, get_translations
|
||||
|
||||
_ASSET_VERSION = str(int(time.time()))
|
||||
|
||||
|
||||
def _detect_lang() -> str:
|
||||
"""Detect preferred language from cookie then Accept-Language header."""
|
||||
@@ -220,6 +223,7 @@ def create_app() -> Quart:
|
||||
"ab_tag": getattr(g, "ab_tag", None),
|
||||
"lang": effective_lang,
|
||||
"t": get_translations(effective_lang),
|
||||
"v": _ASSET_VERSION,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
"planner_quote_cta_check_4": "Nur passende Anbieter sehen deine Anfrage",
|
||||
"planner_quote_cta_btn": "Angebote einholen →",
|
||||
"planner_quote_cta_hint": "Dauert ca. 2 Minuten",
|
||||
"planner_export_inline": "Teile diese Analyse mit Partnern oder Investoren",
|
||||
"planner_export_btn": "Geschäftsplan exportieren (PDF) →",
|
||||
"planner_export_hint": "99 € einmalig · Bankfertig",
|
||||
"planner_signup_bar_msg": "Erstelle ein Konto, um Szenarien zu speichern und Pläne zu vergleichen.",
|
||||
@@ -802,12 +803,12 @@
|
||||
"card_total_courts": "Plätze gesamt",
|
||||
"card_floor_area": "Grundfläche",
|
||||
"card_court_area": "Platzfläche",
|
||||
"card_total_capex": "Gesamt-CAPEX",
|
||||
"card_total_capex": "Gesamtinvestition",
|
||||
"card_per_court": "Pro Platz",
|
||||
"card_per_sqm": "Pro m²",
|
||||
"budget_over": "BUDGET ÜBERSCHRITTEN",
|
||||
"budget_under": "IM BUDGET",
|
||||
"table_total_capex": "GESAMT-CAPEX",
|
||||
"table_total_capex": "GESAMTINVESTITION",
|
||||
"th_item": "Position",
|
||||
"th_amount": "Betrag",
|
||||
"card_net_rev_mo": "Nettoumsatz/Monat",
|
||||
@@ -1109,14 +1110,14 @@
|
||||
"planner_section_util": "Auslastung & Betrieb",
|
||||
"planner_step3_title": "Investition & Baukosten",
|
||||
"planner_step3_sub": "Konfiguriere Baukosten, Glas- und Beleuchtungsoptionen sowie Dein Budgetziel.",
|
||||
"planner_section_capex": "Bau & CAPEX",
|
||||
"planner_section_capex": "Investitionskosten (CAPEX)",
|
||||
"planner_hint_adjust": "Nach Szenario anpassen",
|
||||
"planner_step4_title": "Betrieb & Finanzierung",
|
||||
"planner_step4_sub": "Monatliche Betriebskosten, Kreditkonditionen und Exit-Annahmen.",
|
||||
"planner_section_opex": "Monatliche Betriebskosten",
|
||||
"planner_section_financing": "Finanzierung",
|
||||
"planner_section_exit": "Exit-Annahmen",
|
||||
"planner_chart_capex": "CAPEX-Aufschlüsselung",
|
||||
"planner_chart_capex": "Kostenaufschlüsselung",
|
||||
"planner_chart_cf": "Monatlicher Netto-Cashflow (60 Monate)",
|
||||
"planner_chart_cum": "Kumulierter Cashflow",
|
||||
"planner_section_annual": "Jahresübersicht",
|
||||
@@ -1151,7 +1152,7 @@
|
||||
"about_meta_desc": "Padelnomics ist eine kostenlose Finanzplanungsplattform für Padel-Unternehmer. Modelliere deine Investition, finde Anbieter und plane dein Padel-Business mit professionellen Tools.",
|
||||
"about_og_desc": "Entwickelt für Padel-Unternehmer, die professionelle Finanztools ohne Beratungskosten benötigen. Kostenloser Planer, 60+ Variablen, Anbieterverzeichnis und mehr.",
|
||||
"about_h1": "Über Padelnomics",
|
||||
"about_body_p1": "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.",
|
||||
"about_body_p1": "Padel ist eine der am schnellsten wachsenden Sportarten weltweit, 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.",
|
||||
"about_body_p2": "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.",
|
||||
"about_body_p3": "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.",
|
||||
"about_why_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.",
|
||||
@@ -1191,8 +1192,8 @@
|
||||
"landing_faq_a2": "Nein. Der Planer funktioniert sofort ohne Registrierung. Erstelle ein Konto, um Szenarien zu speichern, Konfigurationen zu vergleichen und PDF-Berichte zu exportieren.",
|
||||
"landing_faq_a3": "Wenn du über den Planer Angebote anforderst, teilen wir deine Projektdetails (Anlagentyp, Platzzahl, Glas, Beleuchtung, Land, Budget, Zeitplan) mit passenden Anbietern aus unserem Verzeichnis. Diese kontaktieren dich direkt mit ihren Angeboten.",
|
||||
"landing_faq_a4": "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.",
|
||||
"landing_faq_a5": "Das Modell verwendet reale Standardwerte auf Basis europäischer Marktdaten. Jede Annahme ist anpassbar, sodass du deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern, und hilft dir, die Bandbreite möglicher Ergebnisse zu verstehen.",
|
||||
"landing_seo_p1": "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 Indoorhalle 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.",
|
||||
"landing_faq_a5": "Das Modell verwendet reale Standardwerte auf Basis globaler Marktdaten. Jede Annahme ist anpassbar, sodass du deine lokalen Gegebenheiten abbilden kannst. Die Sensitivitätsanalyse zeigt, wie sich die Ergebnisse in verschiedenen Szenarien verändern, und hilft dir, die Bandbreite möglicher Ergebnisse zu verstehen.",
|
||||
"landing_seo_p1": "Padel ist eine der am schnellsten wachsenden Racketsportarten weltweit — die Nachfrage nach Plätzen übersteigt das Angebot von Deutschland, Spanien und Schweden bis in die USA und den Nahen Osten. Eine Paddelhalle zu eröffnen kann eine attraktive Investition sein, aber die Zahlen müssen stimmen. Eine typische Indoorhalle 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.",
|
||||
"landing_seo_p2": "Die entscheidenden Faktoren für den Erfolg sind Standort (treibt die Auslastung), Baukosten (CAPEX), Miet- oder Grundstückskosten sowie die Preisstrategie. Unser Finanzplaner ermöglicht es dir, alle diese Variablen interaktiv zu modellieren und die Auswirkungen auf IRR, MOIC, Cashflow und Schuldendienstdeckungsgrad in Echtzeit zu sehen. Ob du als Unternehmer deine erste Anlage prüfst, als Immobilienentwickler Padel in ein Mixed-Use-Projekt integrierst oder als Investor eine bestehende Paddelhalle bewertest — Padelnomics gibt dir die finanzielle Klarheit für fundierte Entscheidungen.",
|
||||
"landing_final_cta_sub": "Modelliere deine Investition und lass dich mit verifizierten Platz-Anbietern aus {total_countries} Ländern zusammenbringen.",
|
||||
"landing_jsonld_org_desc": "Professionelle Planungsplattform für Padelplatz-Investitionen. Finanzplaner, Anbieterverzeichnis und Marktinformationen für Padel-Unternehmer.",
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
"planner_quote_cta_check_4": "Only matching suppliers see your request",
|
||||
"planner_quote_cta_btn": "Get Quotes →",
|
||||
"planner_quote_cta_hint": "Takes ~2 minutes",
|
||||
"planner_export_inline": "Share this analysis with partners or investors",
|
||||
"planner_export_btn": "Export Business Plan (PDF) →",
|
||||
"planner_export_hint": "€99 one-time · Bank-ready",
|
||||
"planner_signup_bar_msg": "Create an account to save scenarios and compare plans.",
|
||||
@@ -1151,7 +1152,7 @@
|
||||
"about_meta_desc": "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.",
|
||||
"about_og_desc": "Built for padel entrepreneurs who need professional financial tools without consulting fees. Free planner, 60+ variables, supplier directory, and more.",
|
||||
"about_h1": "About Padelnomics",
|
||||
"about_body_p1": "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.",
|
||||
"about_body_p1": "Padel is one of the fastest-growing sports worldwide, 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.",
|
||||
"about_body_p2": "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.",
|
||||
"about_body_p3": "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.",
|
||||
"about_why_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.",
|
||||
@@ -1191,8 +1192,8 @@
|
||||
"landing_faq_a2": "No. The planner works instantly with no signup. Create an account to save scenarios, compare configurations, and export PDF reports.",
|
||||
"landing_faq_a3": "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.",
|
||||
"landing_faq_a4": "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.",
|
||||
"landing_faq_a5": "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.",
|
||||
"landing_seo_p1": "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.",
|
||||
"landing_faq_a5": "The model uses real-world defaults based on global 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.",
|
||||
"landing_seo_p1": "Padel is one of the fastest-growing racket sports globally, with demand for courts outstripping supply across markets from Germany, Spain, and Sweden to the US and Middle East. 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.",
|
||||
"landing_seo_p2": "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.",
|
||||
"landing_final_cta_sub": "Model your investment, then get matched with verified court suppliers across {total_countries} countries.",
|
||||
"landing_jsonld_org_desc": "Professional padel court investment planning platform. Financial planner, supplier directory, and market intelligence for padel entrepreneurs.",
|
||||
|
||||
@@ -20,11 +20,11 @@ DEFAULTS = {
|
||||
"venue": "indoor",
|
||||
"own": "rent",
|
||||
"dblCourts": 4,
|
||||
"sglCourts": 2,
|
||||
"sqmPerDblHall": 336,
|
||||
"sqmPerSglHall": 240,
|
||||
"sqmPerDblOutdoor": 312,
|
||||
"sqmPerSglOutdoor": 216,
|
||||
"sglCourts": 0,
|
||||
"sqmPerDblHall": 250,
|
||||
"sqmPerSglHall": 160,
|
||||
"sqmPerDblOutdoor": 230,
|
||||
"sqmPerSglOutdoor": 150,
|
||||
"ratePeak": 50,
|
||||
"rateOffPeak": 35,
|
||||
"rateSingle": 30,
|
||||
|
||||
@@ -19,7 +19,7 @@ from ..core import (
|
||||
waitlist_gate,
|
||||
)
|
||||
from ..i18n import get_translations
|
||||
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, DEFAULTS, calc, validate_state
|
||||
from .calculator import COUNTRY_CURRENCY, CURRENCY_DEFAULT, calc, validate_state
|
||||
|
||||
bp = Blueprint(
|
||||
"planner",
|
||||
@@ -99,7 +99,11 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"#EC4899", "#06B6D4", "#84CC16", "#F97316", "#475569",
|
||||
"#0EA5E9", "#A78BFA",
|
||||
]
|
||||
_cap_items = [i for i in d["capexItems"] if i["amount"] > 0]
|
||||
_cap_items = sorted(
|
||||
[i for i in d["capexItems"] if i["amount"] > 0],
|
||||
key=lambda i: i["amount"],
|
||||
reverse=True,
|
||||
)
|
||||
d["capex_chart"] = {
|
||||
"type": "doughnut",
|
||||
"data": {
|
||||
@@ -114,7 +118,7 @@ def augment_d(d: dict, s: dict, lang: str) -> None:
|
||||
"responsive": True,
|
||||
"maintainAspectRatio": False,
|
||||
"cutout": "60%",
|
||||
"plugins": {"legend": {"position": "right", "labels": {"boxWidth": 10, "font": {"size": 10}}}},
|
||||
"plugins": {"legend": {"position": "right", "labels": {"boxWidth": 10, "font": {"size": 12}, "padding": 8}}},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -322,6 +326,14 @@ async def index():
|
||||
default = None
|
||||
if g.user:
|
||||
scenario_count = await count_scenarios(g.user["id"])
|
||||
# Load specific scenario if ?scenario=<id> is set, else default
|
||||
scenario_id = request.args.get("scenario", type=int)
|
||||
if scenario_id:
|
||||
default = await fetch_one(
|
||||
"SELECT * FROM scenarios WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||
(scenario_id, g.user["id"]),
|
||||
)
|
||||
if not default:
|
||||
default = await get_default_scenario(g.user["id"])
|
||||
initial_state = json.loads(default["state_json"]) if default else {}
|
||||
s = validate_state(initial_state)
|
||||
@@ -339,8 +351,9 @@ async def index():
|
||||
lang=lang,
|
||||
active_tab="capex",
|
||||
country_presets=COUNTRY_PRESETS,
|
||||
defaults=DEFAULTS,
|
||||
currency_sym=cur["sym"],
|
||||
step=1,
|
||||
total_steps=TOTAL_WIZARD_STEPS,
|
||||
)
|
||||
|
||||
|
||||
@@ -353,7 +366,7 @@ async def calculate():
|
||||
d = calc(s, lang=lang)
|
||||
augment_d(d, s, lang)
|
||||
active_tab = form.get("activeTab", "capex")
|
||||
if active_tab not in {"capex", "operating", "cashflow", "returns", "metrics"}:
|
||||
if active_tab not in {"capex", "operating", "cashflow", "returns"}:
|
||||
active_tab = "capex"
|
||||
cur = COUNTRY_CURRENCY.get(s["country"], CURRENCY_DEFAULT)
|
||||
g.currency_sym = cur["sym"]
|
||||
@@ -368,6 +381,9 @@ async def calculate():
|
||||
)
|
||||
|
||||
|
||||
TOTAL_WIZARD_STEPS = 4
|
||||
|
||||
|
||||
@bp.route("/scenarios", methods=["GET"])
|
||||
@login_required
|
||||
async def scenario_list():
|
||||
@@ -379,24 +395,23 @@ async def scenario_list():
|
||||
@login_required
|
||||
@csrf_protect
|
||||
async def save_scenario():
|
||||
data = await request.get_json()
|
||||
name = data.get("name", "Untitled Scenario")
|
||||
state_json = data.get("state_json", "{}")
|
||||
location = data.get("location", "")
|
||||
scenario_id = data.get("id")
|
||||
form = await request.form
|
||||
name = form.get("scenario_name", "Untitled Scenario")
|
||||
# Build state_json from form data (same form as the planner)
|
||||
state_json = json.dumps(form_to_state(form))
|
||||
location = form.get("location", "")
|
||||
scenario_id = form.get("scenario_id")
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
is_first_save = not scenario_id and (await count_scenarios(g.user["id"])) == 0
|
||||
|
||||
if scenario_id:
|
||||
# Update existing
|
||||
await execute(
|
||||
"UPDATE scenarios SET name = ?, state_json = ?, location = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||
(name, state_json, location, now, scenario_id, g.user["id"]),
|
||||
(name, state_json, location, now, int(scenario_id), g.user["id"]),
|
||||
)
|
||||
else:
|
||||
# Create new
|
||||
scenario_id = await execute(
|
||||
"INSERT INTO scenarios (user_id, name, state_json, location, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(g.user["id"], name, state_json, location, now, now),
|
||||
@@ -416,8 +431,9 @@ async def save_scenario():
|
||||
except Exception as e:
|
||||
print(f"[NURTURE] Failed to add {g.user['email']} to audience: {e}")
|
||||
|
||||
count = await count_scenarios(g.user["id"])
|
||||
return jsonify({"ok": True, "id": scenario_id, "count": count})
|
||||
lang = g.get("lang", "en")
|
||||
t = get_translations(lang)
|
||||
return f'<div class="save-toast">✓ {t.get("scenario_saved", "Saved")}</div>'
|
||||
|
||||
|
||||
@bp.route("/scenarios/<int:scenario_id>", methods=["GET"])
|
||||
|
||||
@@ -6,3 +6,5 @@
|
||||
<div id="courtSummary" hx-swap-oob="true">{% include "partials/court_summary.html" %}</div>
|
||||
|
||||
<div class="wizard-preview" id="wizPreview" hx-swap-oob="true">{% include "partials/wizard_preview.html" %}</div>
|
||||
|
||||
<div id="ctaCapexValue" hx-swap-oob="true">{{ d.capex | fmt_k }} {{ t.planner_cta_estimated|default('estimated') }}</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<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)">{{ 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>
|
||||
<a href="{{ url_for('planner.index', scenario=s.id) }}" style="color:var(--bl,#3B82F6);font-size:11px;text-decoration:none">{{ t.scenario_btn_load }}</a>
|
||||
<button hx-delete="{{ url_for('planner.delete_scenario', scenario_id=s.id) }}"
|
||||
hx-target="#scenario-drawer" hx-swap="innerHTML"
|
||||
hx-confirm="Delete this scenario?"
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_capex }}</h3></div>
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>{{ t.th_item }}</th><th class="right">{{ t.th_amount }}</th></tr>
|
||||
@@ -39,6 +41,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="chart-container mt-4">
|
||||
<div class="chart-container__label">{{ t.planner_chart_capex }}</div>
|
||||
|
||||
@@ -2,32 +2,6 @@
|
||||
{% set y3_dscr = d.dscr[2].dscr if d.dscr | length >= 3 else 0 %}
|
||||
{% set is_in = s.venue == 'indoor' %}
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_return }}</h3></div>
|
||||
<div class="grid-4">
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">IRR <span class="ti">i<span class="tp">{{ t.tip_result_irr }}</span></span></div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
|
||||
<div class="metric-card__sub">{{ s.holdYears }}-year</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">MOIC <span class="ti">i<span class="tp">{{ t.tip_result_moic }}</span></span></div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
|
||||
<div class="metric-card__sub">Total return multiple</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Cash-on-Cash <span class="ti">i<span class="tp">{{ t.tip_result_coc }}</span></span></div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Y3 NCF ÷ Equity</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Payback</div>
|
||||
<div class="metric-card__value c-head">{{ ((d.paybackIdx + 1) / 12) | round(1) ~ ' yr' if d.paybackIdx >= 0 else 'N/A' }}</div>
|
||||
<div class="metric-card__sub">Months: {{ d.paybackIdx + 1 if d.paybackIdx >= 0 else '∞' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_revenue }}</h3></div>
|
||||
<div class="grid-4">
|
||||
|
||||
@@ -100,3 +100,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.metrics_detail_heading|default('Detailed Metrics') }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{% include "partials/tab_metrics.html" %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<a href="{{ url_for('planner.export') }}" class="export-cta-inline">
|
||||
<span>{{ t.planner_export_inline }}</span>
|
||||
<span class="export-cta-inline__btn">{{ t.planner_export_btn }}</span>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{% if step > 1 %}
|
||||
<button type="button" class="wiz-btn--back" onclick="showWizStep({{ step - 1 }})">{{ t.btn_back }}</button>
|
||||
{% else %}
|
||||
<div></div>
|
||||
{% endif %}
|
||||
{% if step >= total_steps %}
|
||||
<button type="button" class="wiz-btn--next" onclick="document.querySelector('.tab-btn[data-tab=\'capex\'],.bottom-nav__btn[data-tab=\'capex\']').click()">{{ t.btn_show_results }}</button>
|
||||
{% else %}
|
||||
<button type="button" class="wiz-btn--next" onclick="showWizStep({{ step + 1 }})">{{ t.btn_next }}</button>
|
||||
{% endif %}
|
||||
@@ -7,7 +7,7 @@
|
||||
<meta property="og:description" content="{{ t.planner_meta_desc }}">
|
||||
<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') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/planner.css') }}?v={{ v }}">
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -50,14 +50,30 @@
|
||||
hx-swap="innerHTML">
|
||||
{{ t.btn_my_scenarios }} ({{ scenario_count }})
|
||||
</button>
|
||||
<button id="saveScenarioBtn">{{ t.btn_save }}</button>
|
||||
<button id="saveScenarioBtn" onclick="document.getElementById('saveForm').style.display=''">{{ t.btn_save }}</button>
|
||||
</div>
|
||||
<div id="saveForm" style="display:none;position:absolute;right:1rem;top:100%;background:var(--bg-2);border:1px solid var(--border);border-radius:12px;padding:12px;box-shadow:0 4px 16px rgba(0,0,0,0.1);z-index:80;width:240px">
|
||||
<label style="font-size:11px;color:var(--txt-2);display:block;margin-bottom:4px">{{ t.scenario_name_label|default('Scenario name') }}</label>
|
||||
<input type="text" name="scenario_name" value="{{ t.scenario_default_name|default('My Scenario') }}"
|
||||
style="width:100%;padding:6px 10px;border:1px solid var(--border-2);border-radius:8px;font-size:12px;font-family:'DM Sans',sans-serif;margin-bottom:8px;box-sizing:border-box">
|
||||
<div style="display:flex;gap:6px">
|
||||
<button type="button"
|
||||
hx-post="{{ url_for('planner.save_scenario') }}"
|
||||
hx-include="#planner-form, [name=scenario_name]"
|
||||
hx-target="#save-feedback"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('saveForm').style.display='none'"
|
||||
style="flex:1;padding:6px;font-size:12px;font-weight:600;background:var(--cta);color:#fff;border:none;border-radius:8px;cursor:pointer;font-family:'DM Sans',sans-serif">{{ t.btn_save }}</button>
|
||||
<button type="button" onclick="this.closest('#saveForm').style.display='none'"
|
||||
style="padding:6px 10px;font-size:12px;background:none;border:1px solid var(--border);border-radius:8px;cursor:pointer;color:var(--txt-2)">{{ t.btn_cancel|default('Cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<nav id="nav" class="tab-nav">
|
||||
<button class="tab-btn tab-btn--active" data-tab="assumptions" onclick="setActiveTab('assumptions')">{{ t.tab_assumptions }}</button>
|
||||
{% for tab_id, tab_key in [('capex','tab_capex'),('operating','tab_operating'),('cashflow','tab_cashflow'),('returns','tab_returns'),('metrics','tab_metrics')] %}
|
||||
{% for tab_id, tab_key in [('capex','tab_capex'),('operating','tab_operating'),('cashflow','tab_cashflow'),('returns','tab_returns')] %}
|
||||
<button class="tab-btn" data-tab="{{ tab_id }}"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-target="#tab-content"
|
||||
@@ -98,7 +114,7 @@
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" id="resetDefaultsBtn" class="btn-reset">{{ t.btn_reset }}</button>
|
||||
<a href="{{ url_for('planner.index') }}" class="btn-reset">{{ t.btn_reset }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Venue -->
|
||||
@@ -151,12 +167,18 @@
|
||||
{{ slider('permitsCompliance', t.sl_permits, 0, 50000, 1000, s.permitsCompliance, t.tip_permits_compliance) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_court_config }}</h3></div>
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_court_config }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('dblCourts', t.sl_dbl_courts, 0, 30, 1, s.dblCourts, t.tip_dbl_courts) }}
|
||||
{{ slider('sglCourts', t.sl_sgl_courts, 0, 30, 1, s.sglCourts, t.tip_sgl_courts) }}
|
||||
<div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="section-header" style="margin-top:1rem"><h3>{{ t.planner_section_space_req }}</h3></div>
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_space_req }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
<div data-show-venue="indoor">
|
||||
{{ slider('sqmPerDblHall', t.sl_sqm_dbl_hall, 200, 600, 10, s.sqmPerDblHall, t.tip_sqm_dbl_hall) }}
|
||||
{{ slider('sqmPerSglHall', t.sl_sqm_sgl_hall, 120, 400, 10, s.sqmPerSglHall, t.tip_sqm_sgl_hall) }}
|
||||
@@ -165,8 +187,8 @@
|
||||
{{ slider('sqmPerDblOutdoor', t.sl_sqm_dbl_outdoor, 200, 500, 10, s.sqmPerDblOutdoor, t.tip_sqm_dbl_outdoor) }}
|
||||
{{ slider('sqmPerSglOutdoor', t.sl_sqm_sgl_outdoor, 120, 350, 10, s.sqmPerSglOutdoor, t.tip_sqm_sgl_outdoor) }}
|
||||
</div>
|
||||
<div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Pricing & Utilization -->
|
||||
@@ -174,27 +196,36 @@
|
||||
<h2 class="wizard-step__title">{{ t.planner_step2_title }}</h2>
|
||||
<p class="wizard-step__sub">{{ t.planner_step2_sub }}</p>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_pricing }}</h3><span class="hint">{{ t.planner_hint_per_court }}</span></div>
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_pricing }} <span class="wizard-details__hint">{{ t.planner_hint_per_court }}</span></summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('ratePeak', t.sl_rate_peak, 0, 150, 1, s.ratePeak, t.tip_rate_peak) }}
|
||||
{{ slider('rateOffPeak', t.sl_rate_offpeak, 0, 150, 1, s.rateOffPeak, t.tip_rate_offpeak) }}
|
||||
{{ slider('rateSingle', t.sl_rate_single, 0, 150, 1, s.rateSingle, t.tip_rate_single) }}
|
||||
{{ slider('peakPct', t.sl_peak_pct, 0, 100, 1, s.peakPct, t.tip_peak_pct) }}
|
||||
{{ slider('bookingFee', t.sl_booking_fee, 0, 30, 1, s.bookingFee, t.tip_booking_fee) }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_util }}</h3></div>
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_util }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('utilTarget', t.sl_util_target, 0, 100, 1, s.utilTarget, t.tip_util_target) }}
|
||||
{{ slider('hoursPerDay', t.sl_hours_per_day, 0, 24, 1, s.hoursPerDay, t.tip_hours_per_day) }}
|
||||
{{ slider('daysPerMonthIndoor', t.sl_days_indoor, 0, 31, 1, s.daysPerMonthIndoor, t.tip_days_indoor) }}
|
||||
{{ slider('daysPerMonthOutdoor', t.sl_days_outdoor, 0, 31, 1, s.daysPerMonthOutdoor, t.tip_days_outdoor) }}
|
||||
<div style="font-size:11px;color:var(--txt-3);margin:4px 0 8px"><b>{{ t.sl_ancillary_header }}</b></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.sl_ancillary_header }} <span class="wizard-details__hint">{{ t.planner_hint_optional|default('Optional') }}</span></summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('membershipRevPerCourt', t.sl_membership_rev, 0, 2000, 50, s.membershipRevPerCourt, t.tip_membership_rev) }}
|
||||
{{ slider('fbRevPerCourt', t.sl_fb_rev, 0, 2000, 25, s.fbRevPerCourt, t.tip_fb_rev) }}
|
||||
{{ slider('coachingRevPerCourt', t.sl_coaching_rev, 0, 2000, 25, s.coachingRevPerCourt, t.tip_coaching_rev) }}
|
||||
{{ slider('retailRevPerCourt', t.sl_retail_rev, 0, 1000, 10, s.retailRevPerCourt, t.tip_retail_rev) }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Investment & Build Costs -->
|
||||
@@ -202,9 +233,9 @@
|
||||
<h2 class="wizard-step__title">{{ t.planner_step3_title }}</h2>
|
||||
<p class="wizard-step__sub">{{ t.planner_step3_sub }}</p>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_capex }}</h3><span class="hint">{{ t.planner_hint_adjust }}</span></div>
|
||||
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_capex }} <span class="wizard-details__hint">{{ t.planner_hint_adjust }}</span></summary>
|
||||
<div class="wizard-details__body">
|
||||
<div class="pill-group">
|
||||
<label><span class="slider-group__label">{{ t.pill_glass_type }}</span><span class="ti">i<span class="tp">{{ t.tip_glass_type }}</span></span></label>
|
||||
<div class="pill-options">
|
||||
@@ -212,7 +243,6 @@
|
||||
{{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pill-group">
|
||||
<label><span class="slider-group__label">{{ t.pill_lighting_type }}</span></label>
|
||||
<div class="pill-options">
|
||||
@@ -221,10 +251,14 @@
|
||||
<span data-show-venue="outdoor">{{ pill_btn('lightingType','natural', t.pill_light_natural, s.lightingType == 'natural') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ slider('courtCostDbl', t.sl_court_cost_dbl, 0, 80000, 1000, s.courtCostDbl, t.tip_court_cost_dbl) }}
|
||||
{{ slider('courtCostSgl', t.sl_court_cost_sgl, 0, 60000, 1000, s.courtCostSgl, t.tip_court_cost_sgl) }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_building|default('Building & Facility') }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
<!-- Indoor + Buy -->
|
||||
<div data-show-capex="indoor-buy">
|
||||
{{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, t.tip_hall_cost_sqm) }}
|
||||
@@ -236,7 +270,6 @@
|
||||
{{ slider('fireProtection', t.sl_fire, 0, 500000, 5000, s.fireProtection, t.tip_fire_protection) }}
|
||||
{{ slider('planning', t.sl_planning, 0, 500000, 5000, s.planning, t.tip_planning) }}
|
||||
</div>
|
||||
|
||||
<!-- Indoor + Rent -->
|
||||
<div data-show-capex="indoor-rent">
|
||||
{{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, t.tip_floor_prep) }}
|
||||
@@ -244,7 +277,6 @@
|
||||
{{ slider('lightingUpgrade', t.sl_lighting_upgrade, 0, 100000, 1000, s.lightingUpgrade, t.tip_lighting_upgrade) }}
|
||||
{{ slider('fitout', t.sl_fitout, 0, 300000, 1000, s.fitout, t.tip_fitout) }}
|
||||
</div>
|
||||
|
||||
<!-- Outdoor -->
|
||||
<div data-show-capex="outdoor">
|
||||
{{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, t.tip_outdoor_foundation) }}
|
||||
@@ -253,11 +285,17 @@
|
||||
{{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, t.tip_outdoor_fencing) }}
|
||||
<div data-show-capex="outdoor-buy">{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_other_costs|default('Other Costs') }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('workingCapital', t.sl_working_capital, 0, 200000, 1000, s.workingCapital, t.tip_working_capital) }}
|
||||
{{ slider('contingencyPct', t.sl_contingency, 0, 30, 1, s.contingencyPct, t.tip_contingency) }}
|
||||
{{ slider('budgetTarget', t.sl_budget_target, 0, 5000000, 10000, s.budgetTarget, t.tip_budget_target) }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Operations & Financing -->
|
||||
@@ -265,51 +303,51 @@
|
||||
<h2 class="wizard-step__title">{{ t.planner_step4_title }}</h2>
|
||||
<p class="wizard-step__sub">{{ t.planner_step4_sub }}</p>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_opex }}</h3></div>
|
||||
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_opex }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
<div data-show-opex="indoor-rent">{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, t.tip_rent_sqm) }}</div>
|
||||
<div data-show-opex="outdoor-rent">{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, t.tip_outdoor_rent) }}</div>
|
||||
<div data-show-opex="buy">{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, t.tip_property_tax) }}</div>
|
||||
|
||||
{{ slider('insurance', t.sl_insurance, 0, 2000, 25, s.insurance, t.tip_insurance) }}
|
||||
{{ slider('electricity', t.sl_electricity, 0, 5000, 25, s.electricity, t.tip_electricity) }}
|
||||
|
||||
<div data-show-opex="indoor">
|
||||
{{ slider('heating', t.sl_heating, 0, 3000, 25, s.heating, t.tip_heating) }}
|
||||
{{ slider('water', t.sl_water, 0, 1000, 25, s.water, t.tip_water) }}
|
||||
</div>
|
||||
|
||||
{{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, t.tip_maintenance) }}
|
||||
|
||||
<div data-show-opex="indoor">{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, t.tip_cleaning) }}</div>
|
||||
|
||||
{{ slider('marketing', t.sl_marketing, 0, 5000, 25, s.marketing, t.tip_marketing) }}
|
||||
{{ slider('staff', t.sl_staff, 0, 20000, 100, s.staff, t.tip_staff) }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_financing }}</h3></div>
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_financing }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('loanPct', t.sl_loan_pct, 0, 100, 1, s.loanPct, t.tip_loan_pct) }}
|
||||
{{ slider('interestRate', t.sl_interest_rate, 0, 15, 0.1, s.interestRate, t.tip_interest_rate) }}
|
||||
{{ slider('loanTerm', t.sl_loan_term, 0, 30, 1, s.loanTerm, t.tip_loan_term) }}
|
||||
{{ slider('constructionMonths', t.sl_construction_months, 0, 24, 1, s.constructionMonths, t.tip_construction_months) }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_exit }}</h3></div>
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_exit }} <span class="wizard-details__hint">{{ t.planner_hint_advanced|default('Advanced') }}</span></summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, t.tip_hold_years) }}
|
||||
{{ slider('exitMultiple', t.sl_exit_multiple, 0, 20, 0.5, s.exitMultiple, t.tip_exit_multiple) }}
|
||||
{{ slider('annualRevGrowth', t.sl_annual_rev_growth, 0, 15, 0.5, s.annualRevGrowth, t.tip_annual_rev_growth) }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Wizard footer: preview bar + navigation -->
|
||||
<div class="wizard-footer">
|
||||
<div class="wizard-preview" id="wizPreview">{% include "partials/wizard_preview.html" %}</div>
|
||||
<div class="wizard-nav" id="wizNav">
|
||||
<div></div>
|
||||
<button type="button" class="wiz-btn--next" onclick="showWizStep(2)">{{ t.btn_next }}</button>
|
||||
<div class="wizard-nav" id="wizNav"
|
||||
data-back="{{ t.btn_back }}" data-next="{{ t.btn_next }}" data-results="{{ t.btn_show_results }}">
|
||||
{% include "partials/wizard_nav.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /planner-wizard -->
|
||||
@@ -323,41 +361,27 @@
|
||||
{% include tab_template %}
|
||||
</div>
|
||||
|
||||
<!-- Inline quote CTA (mobile / narrow screens) -->
|
||||
<div class="quote-inline-cta" id="quoteInlineCta" style="display:none">
|
||||
<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> {{ 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()">{{ t.planner_quote_cta_btn }}</button>
|
||||
<span class="quote-inline-cta__hint">{{ t.planner_quote_cta_hint }}</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Sidebar CTA (desktop) -->
|
||||
<!-- Sidebar CTA (desktop, simplified) -->
|
||||
<aside class="quote-sidebar" id="quoteSidebar">
|
||||
<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> {{ 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>
|
||||
<p class="quote-sidebar__desc">{{ t.planner_quote_sidebar_desc|default('Compare your estimate with real supplier quotes.') }}</p>
|
||||
<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">{{ t.planner_export_btn }}</a>
|
||||
<span class="quote-sidebar__hint">{{ t.planner_export_hint }}</span>
|
||||
</div>
|
||||
<a href="{{ url_for('planner.export') }}" class="quote-sidebar__export-link">{{ t.planner_export_btn }}</a>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile CTA bar (above bottom nav, result tabs only) -->
|
||||
<div class="cta-bottom-bar" id="ctaBottomBar">
|
||||
<button class="cta-bottom-bar__dismiss" onclick="this.parentElement.classList.remove('visible');this.parentElement.dataset.dismissed='1'" aria-label="Dismiss">×</button>
|
||||
<div class="cta-bottom-bar__text">
|
||||
<div class="cta-bottom-bar__value" id="ctaCapexValue">{{ d.capex | fmt_k }} {{ t.planner_cta_estimated|default('estimated') }}</div>
|
||||
<div class="cta-bottom-bar__hint">{{ t.planner_cta_micro|default('Free · 2 min · No obligation') }}</div>
|
||||
</div>
|
||||
<button class="cta-bottom-bar__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }} →</button>
|
||||
</div>
|
||||
|
||||
{% if not user %}
|
||||
<div class="signup-bar" id="signupBar" style="display:none">
|
||||
<span>{{ t.planner_signup_bar_msg }}</span>
|
||||
@@ -368,16 +392,46 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div id="scenario-drawer"></div>
|
||||
<div id="save-feedback"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Mobile bottom tab bar (outside .planner-app to avoid ancestor interference with position:fixed) -->
|
||||
<nav class="bottom-nav" id="bottomNav">
|
||||
<button class="bottom-nav__btn bottom-nav__btn--active" data-tab="assumptions" onclick="setActiveTab('assumptions')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span>{{ t.tab_assumptions_short|default('Setup') }}</span>
|
||||
</button>
|
||||
<button class="bottom-nav__btn" data-tab="capex"
|
||||
hx-post="{{ url_for('planner.calculate') }}" hx-target="#tab-content" hx-include="#planner-form"
|
||||
onclick="setActiveTab('capex')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
|
||||
<span>{{ t.tab_capex_short|default('CAPEX') }}</span>
|
||||
</button>
|
||||
<button class="bottom-nav__btn" data-tab="operating"
|
||||
hx-post="{{ url_for('planner.calculate') }}" hx-target="#tab-content" hx-include="#planner-form"
|
||||
onclick="setActiveTab('operating')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||
<span>{{ t.tab_operating_short|default('P&L') }}</span>
|
||||
</button>
|
||||
<button class="bottom-nav__btn" data-tab="cashflow"
|
||||
hx-post="{{ url_for('planner.calculate') }}" hx-target="#tab-content" hx-include="#planner-form"
|
||||
onclick="setActiveTab('cashflow')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>
|
||||
<span>{{ t.tab_cashflow_short|default('Cash') }}</span>
|
||||
</button>
|
||||
<button class="bottom-nav__btn" data-tab="returns"
|
||||
hx-post="{{ url_for('planner.calculate') }}" hx-target="#tab-content" hx-include="#planner-form"
|
||||
onclick="setActiveTab('returns')">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M16 8l-4 4-4-4"/><path d="M12 12v4"/></svg>
|
||||
<span>{{ t.tab_returns_short|default('Returns') }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.__COUNTRY_PRESETS__ = {{ country_presets | tojson | safe }};
|
||||
window.__DEFAULTS__ = {{ defaults | tojson | safe }};
|
||||
window.__SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
|
||||
window.__SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
|
||||
window.__QUOTE_URL__ = "{{ url_for('leads.quote_request') }}";
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/planner.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/planner.js') }}?v={{ v }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -365,10 +365,10 @@
|
||||
}
|
||||
.pill-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.pill-btn {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -409,6 +409,8 @@
|
||||
/* ── Data Tables ── */
|
||||
.data-table {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -531,7 +533,7 @@
|
||||
}
|
||||
|
||||
/* ── Spacing helpers ── */
|
||||
.mb-section { margin-bottom: 28px; }
|
||||
.mb-section { margin-bottom: 28px; max-width: 640px; margin-left: auto; margin-right: auto; overflow-x: auto; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
|
||||
@@ -627,14 +629,14 @@
|
||||
display: block;
|
||||
position: fixed;
|
||||
right: max(1rem, calc((100vw - 72rem) / 2 - 280px));
|
||||
top: 80px;
|
||||
top: calc(50px + 1.5rem);
|
||||
width: 240px;
|
||||
background: #EFF6FF;
|
||||
border: 2px solid #1D4ED8;
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 24px rgba(29,78,216,0.1);
|
||||
z-index: 40;
|
||||
z-index: 60;
|
||||
}
|
||||
.quote-sidebar__label {
|
||||
font-size: 11px;
|
||||
@@ -699,6 +701,18 @@
|
||||
color: #94A3B8;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.quote-sidebar__export-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #64748B;
|
||||
margin-top: 12px;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.quote-sidebar__export-link:hover {
|
||||
color: #0F172A;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.quote-sidebar { display: none !important; }
|
||||
}
|
||||
@@ -1122,7 +1136,205 @@
|
||||
.wizard-step { max-width: 100%; }
|
||||
.wizard-preview,
|
||||
.wizard-nav { max-width: 100%; }
|
||||
.wizard-step { padding-bottom: 100px; } /* space for sticky footer */
|
||||
.wizard-step { padding-bottom: 140px; } /* space for fixed footer + bottom nav */
|
||||
.wizard-footer {
|
||||
position: fixed;
|
||||
bottom: 56px; /* sit directly above bottom nav */
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 55;
|
||||
margin-top: 0;
|
||||
padding: 0 1.5rem;
|
||||
background: #FFFFFF;
|
||||
border-top: 1px solid #E2E8F0;
|
||||
}
|
||||
.wizard-nav {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Export inline CTA (within Returns tab) ── */
|
||||
.export-cta-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 1.5rem;
|
||||
padding: 14px 20px;
|
||||
background: var(--gn-bg);
|
||||
border: 1px solid rgba(22,163,74,0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--txt-2);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.export-cta-inline:hover {
|
||||
background: rgba(22,163,74,0.1);
|
||||
border-color: rgba(22,163,74,0.35);
|
||||
}
|
||||
.export-cta-inline__btn {
|
||||
color: var(--gn);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Mobile Bottom Navigation ── */
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
background: #FFFFFF;
|
||||
border-top: 1px solid #E2E8F0;
|
||||
padding: 4px 1.5rem;
|
||||
padding-bottom: max(4px, env(safe-area-inset-bottom));
|
||||
}
|
||||
.bottom-nav__btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 6px 4px 4px;
|
||||
border: none;
|
||||
border-top: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: #94A3B8;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.bottom-nav__btn svg { transition: color 0.15s; }
|
||||
.bottom-nav__btn--active {
|
||||
color: #1D4ED8;
|
||||
border-top-color: #1D4ED8;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.bottom-nav { display: flex; }
|
||||
.tab-nav { display: none; }
|
||||
/* Reserve space for bottom nav so content isn't hidden behind it */
|
||||
.planner-app { padding-bottom: 64px; }
|
||||
}
|
||||
|
||||
/* ── Collapsible Details (wizard sections + metrics) ── */
|
||||
.wizard-details {
|
||||
margin-bottom: 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.wizard-details__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--head);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.wizard-details__summary::-webkit-details-marker { display: none; }
|
||||
.wizard-details__summary::after {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-right: 2px solid var(--txt-3);
|
||||
border-bottom: 2px solid var(--txt-3);
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wizard-details[open] > .wizard-details__summary::after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.wizard-details__hint {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--txt-3);
|
||||
margin-left: 8px;
|
||||
}
|
||||
.wizard-details__body {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
/* ── Mobile CTA Bar (above bottom nav) ── */
|
||||
.cta-bottom-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 56px; /* above bottom nav */
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 65;
|
||||
padding: 10px 16px;
|
||||
background: var(--cta-bg);
|
||||
border-top: 1px solid rgba(29,78,216,0.15);
|
||||
box-shadow: 0 -2px 12px rgba(29,78,216,0.08);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
animation: slideUpCTA 0.3s ease;
|
||||
}
|
||||
.cta-bottom-bar__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cta-bottom-bar__value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: 'Commit Mono', ui-monospace, monospace;
|
||||
color: var(--head);
|
||||
}
|
||||
.cta-bottom-bar__hint {
|
||||
font-size: 10px;
|
||||
color: var(--txt-3);
|
||||
}
|
||||
.cta-bottom-bar__btn {
|
||||
padding: 10px 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--cta);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 10px var(--cta-shadow);
|
||||
transition: background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cta-bottom-bar__btn:hover { background: var(--cta-hover); }
|
||||
.cta-bottom-bar__dismiss {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--txt-3);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
@keyframes slideUpCTA {
|
||||
from { transform: translateY(100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.cta-bottom-bar.visible { display: flex; }
|
||||
}
|
||||
|
||||
/* ── Computing indicator ── */
|
||||
@@ -1134,3 +1346,27 @@
|
||||
margin-left: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Mobile polish ── */
|
||||
@media (max-width: 768px) {
|
||||
/* Larger slider thumb for touch targets */
|
||||
.slider-combo input[type=range]::-webkit-slider-thumb { width: 20px; height: 20px; }
|
||||
.slider-combo input[type=range]::-moz-range-thumb { width: 20px; height: 20px; }
|
||||
.slider-combo input[type=range] { height: 6px; }
|
||||
|
||||
/* Tighten metric card padding on mobile */
|
||||
.metric-card { padding: 14px; border-radius: 10px; }
|
||||
.metric-card__value { font-size: 19px; }
|
||||
.metric-card-sm .metric-card__value { font-size: 15px; }
|
||||
|
||||
/* Add padding below content for bottom nav + CTA bar */
|
||||
.planner-app main { padding-bottom: 120px; }
|
||||
|
||||
/* Hide feedback on planner mobile — too much competing for bottom space.
|
||||
planner.css only loads on planner pages so this won't affect other pages. */
|
||||
#feedback-wrap { display: none !important; }
|
||||
.feedback-label { display: none; }
|
||||
|
||||
/* Hide live summary on mobile — wizard footer is just nav buttons */
|
||||
.wizard-preview { display: none !important; }
|
||||
}
|
||||
|
||||
@@ -81,10 +81,16 @@ function setActiveTab(tab) {
|
||||
document.getElementById('h-activeTab').value = tab;
|
||||
document.getElementById('planner-wizard').style.display = isWiz ? '' : 'none';
|
||||
document.getElementById('tab-content').style.display = isWiz ? 'none' : '';
|
||||
const cta = document.getElementById('quoteInlineCta');
|
||||
if (cta) cta.style.display = isWiz ? 'none' : '';
|
||||
// Sync both top and bottom nav active states
|
||||
document.querySelectorAll('.tab-btn').forEach(b =>
|
||||
b.classList.toggle('tab-btn--active', b.dataset.tab === tab));
|
||||
document.querySelectorAll('.bottom-nav__btn').forEach(b =>
|
||||
b.classList.toggle('bottom-nav__btn--active', b.dataset.tab === tab));
|
||||
// Mobile CTA bar: show on result tabs, hide on wizard
|
||||
const ctaBar = document.getElementById('ctaBottomBar');
|
||||
if (ctaBar && !ctaBar.dataset.dismissed) {
|
||||
ctaBar.classList.toggle('visible', !isWiz);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wizard navigation ────────────────────────────────────────────────────────
|
||||
@@ -93,100 +99,19 @@ function showWizStep(n) {
|
||||
steps.forEach(s => s.classList.toggle('active', +s.dataset.wiz === n));
|
||||
document.querySelectorAll('.wiz-dot').forEach(d =>
|
||||
d.classList.toggle('wiz-dot--active', +d.dataset.wiz === n));
|
||||
const de = document.documentElement.lang === 'de';
|
||||
const prev = de ? 'Zurück' : 'Back';
|
||||
const next = de ? 'Weiter' : 'Next';
|
||||
const calc = de ? 'Berechnen →' : 'Calculate →';
|
||||
const isLast = n >= steps.length;
|
||||
const nav = document.getElementById('wizNav');
|
||||
const back = nav.dataset.back;
|
||||
const next = nav.dataset.next;
|
||||
const results = nav.dataset.results;
|
||||
const isLast = n >= steps.length;
|
||||
nav.innerHTML =
|
||||
(n > 1 ? `<button type="button" class="wiz-btn--back" onclick="showWizStep(${n - 1})">${prev}</button>` : '<div></div>') +
|
||||
(n > 1 ? `<button type="button" class="wiz-btn--back" onclick="showWizStep(${n - 1})">${back}</button>` : '<div></div>') +
|
||||
(isLast
|
||||
? `<button type="button" class="wiz-btn--next" onclick="document.querySelector('.tab-btn[data-tab=\\'capex\\']').click()">${calc}</button>`
|
||||
? `<button type="button" class="wiz-btn--next" onclick="document.querySelector('.tab-btn[data-tab=\\'capex\\'],.bottom-nav__btn[data-tab=\\'capex\\']').click()">${results}</button>`
|
||||
: `<button type="button" class="wiz-btn--next" onclick="showWizStep(${n + 1})">${next}</button>`);
|
||||
}
|
||||
|
||||
// ─── Scenarios ────────────────────────────────────────────────────────────────
|
||||
function _formState() {
|
||||
const fd = new FormData(document.getElementById('planner-form'));
|
||||
const state = {};
|
||||
for (const [k, v] of fd.entries()) {
|
||||
if (k === 'ramp' || k === 'season') { (state[k] = state[k] || []).push(Number(v)); }
|
||||
else { state[k] = v; }
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
async function saveScenario() {
|
||||
const de = document.documentElement.lang === 'de';
|
||||
const name = prompt(de ? 'Szenario-Name:' : 'Scenario name:', de ? 'Mein Szenario' : 'My Scenario');
|
||||
if (!name) return;
|
||||
const csrf = document.querySelector('[name="csrf_token"]')?.value || '';
|
||||
const res = await fetch(window.__SAVE_URL__, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
|
||||
body: JSON.stringify({ name, state_json: JSON.stringify(_formState()) }),
|
||||
});
|
||||
const fb = document.getElementById('save-feedback');
|
||||
fb.textContent = res.ok ? '✓ Saved' : '✗ Error saving';
|
||||
setTimeout(() => { fb.textContent = ''; }, 2500);
|
||||
}
|
||||
|
||||
async function loadScenario(id) {
|
||||
const res = await fetch(window.__SCENARIO_URL__ + id);
|
||||
if (!res.ok) return;
|
||||
const row = await res.json();
|
||||
const state = JSON.parse(row.state_json || '{}');
|
||||
Object.entries(state).forEach(([k, v]) => {
|
||||
if (Array.isArray(v)) {
|
||||
document.querySelectorAll(`#planner-form [name="${k}"]`).forEach((inp, i) => {
|
||||
if (v[i] !== undefined) inp.value = v[i];
|
||||
});
|
||||
} else {
|
||||
const inp = document.querySelector(`#planner-form [name="${k}"]`);
|
||||
const sync = document.querySelector(`[data-sync="${k}"]`);
|
||||
if (inp) inp.value = v;
|
||||
if (sync) sync.value = v;
|
||||
}
|
||||
});
|
||||
['venue', 'own', 'glassType', 'lightingType', 'country'].forEach(key => {
|
||||
const val = document.getElementById('h-' + key)?.value;
|
||||
if (!val) return;
|
||||
document.querySelectorAll(`[data-toggle="${key}"]`).forEach(b => {
|
||||
b.classList.toggle('toggle-btn--active', b.dataset.val === val);
|
||||
b.classList.toggle('pill-btn--active', b.dataset.val === val);
|
||||
});
|
||||
});
|
||||
updateWizardSections();
|
||||
document.querySelector('.tab-btn[data-tab="capex"]').click();
|
||||
}
|
||||
|
||||
// ─── Reset & quote ────────────────────────────────────────────────────────────
|
||||
function resetToDefaults() {
|
||||
const D = window.__DEFAULTS__ || {};
|
||||
Object.entries(D).forEach(([k, v]) => {
|
||||
if (Array.isArray(v)) {
|
||||
document.querySelectorAll(`#planner-form [name="${k}"]`).forEach((inp, i) => {
|
||||
if (v[i] !== undefined) inp.value = v[i];
|
||||
});
|
||||
} else {
|
||||
const inp = document.querySelector(`#planner-form [name="${k}"]`);
|
||||
const sync = document.querySelector(`[data-sync="${k}"]`);
|
||||
if (inp) inp.value = v;
|
||||
if (sync) sync.value = v;
|
||||
}
|
||||
});
|
||||
['venue', 'own', 'glassType', 'lightingType'].forEach(key => {
|
||||
const val = document.getElementById('h-' + key)?.value;
|
||||
if (!val) return;
|
||||
document.querySelectorAll(`[data-toggle="${key}"]`).forEach(b => {
|
||||
b.classList.toggle('toggle-btn--active', b.dataset.val === val);
|
||||
b.classList.toggle('pill-btn--active', b.dataset.val === val);
|
||||
});
|
||||
});
|
||||
updateWizardSections();
|
||||
}
|
||||
|
||||
// ─── Quote navigation ────────────────────────────────────────────────────────
|
||||
function goToQuoteForm() {
|
||||
const fd = new FormData(document.getElementById('planner-form'));
|
||||
const params = new URLSearchParams({
|
||||
@@ -196,11 +121,17 @@ function goToQuoteForm() {
|
||||
window.location.href = window.__QUOTE_URL__ + '?' + params;
|
||||
}
|
||||
|
||||
// ─── Scenario drawer toggle ─────────────────────────────────────────────────
|
||||
document.addEventListener('htmx:afterSettle', e => {
|
||||
const drawer = document.getElementById('scenario-drawer');
|
||||
if (drawer && e.detail.target === drawer && drawer.innerHTML.trim()) {
|
||||
drawer.classList.add('open');
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateWizardSections();
|
||||
document.getElementById('saveScenarioBtn')?.addEventListener('click', saveScenario);
|
||||
document.getElementById('resetDefaultsBtn')?.addEventListener('click', resetToDefaults);
|
||||
// Show signup nudge after 30 s for unauthenticated visitors
|
||||
const bar = document.getElementById('signupBar');
|
||||
if (bar) setTimeout(() => { bar.style.display = ''; }, 30_000);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<!-- Fonts (self-hosted, @font-face in output.css) -->
|
||||
|
||||
<!-- Tailwind (compiled) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}?v={{ v }}">
|
||||
|
||||
<!-- Umami Analytics -->
|
||||
<script defer src="https://umami.padelnomics.io/Z.js" data-website-id="4474414b-58d6-4c6e-89a1-df5ea1f49d70"{% if ab_tag %} data-tag="{{ ab_tag }}"{% endif %}></script>
|
||||
@@ -242,7 +242,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="flex-shrink:0;">
|
||||
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{{ t.nav_feedback }}
|
||||
<span class="feedback-label">{{ t.nav_feedback }}</span>
|
||||
</button>
|
||||
<div id="feedback-popover" hidden
|
||||
style="position:absolute;bottom:calc(100% + 0.75rem);right:0;width:300px;background:white;border:1px solid #E2E8F0;border-radius:12px;padding:1rem;box-shadow:0 8px 32px rgba(0,0,0,0.12);">
|
||||
|
||||
Reference in New Issue
Block a user