diff --git a/CHANGELOG.md b/CHANGELOG.md index a32c4d8..74e5154 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed +- planner: full HTMX refactor — replaced 847-line SPA `planner.js` with server-rendered Jinja2 tab partials; planner now uses `hx-post /planner/calculate` + form state; all tab content (CAPEX, Operating, Cash Flow, Returns, Metrics) rendered server-side; Chart.js data embedded as ` diff --git a/padelnomics/src/padelnomics/planner/templates/partials/tab_cashflow.html b/padelnomics/src/padelnomics/planner/templates/partials/tab_cashflow.html new file mode 100644 index 0000000..f5feeac --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/tab_cashflow.html @@ -0,0 +1,69 @@ +{% set y1ncf = d.annuals[0].ncf if d.annuals else 0 %} +{% set y3ncf = d.annuals[2].ncf if d.annuals | length >= 3 else 0 %} +{% set payback_label = t.payback_not_reached if d.paybackIdx < 0 else ('Month ' ~ (d.paybackIdx + 1)) %} +{% set payback_sub = '' if d.paybackIdx < 0 else ('~' ~ ((d.paybackIdx + 1) / 12) | round(1) ~ ' years') %} + +
+
+
{{ t.card_y1_ncf }}
+
{{ y1ncf | int | fmt_currency }}
+
+
+
{{ t.card_y3_ncf }}
+
{{ y3ncf | int | fmt_currency }}
+
{{ t.sub_stabilized }}
+
+
+
{{ t.card_payback }}
+
{{ payback_label }}
+
{{ payback_sub }}
+
+
+
{{ t.card_initial_inv }}
+
{{ d.capex | fmt_currency }}
+
+
+ +
+
{% if lang == 'de' %}Monatlicher Netto-Cashflow (60 Monate){% else %}Monthly Net Cash Flow (60 Months){% endif %}
+
+
+ + +
+
{% if lang == 'de' %}Kumulierter Cashflow{% else %}Cumulative Cash Flow{% endif %}
+
+
+ + +
+

{% if lang == 'de' %}Jahresübersicht{% else %}Annual Summary{% endif %}

+ + + + + + + + + + + + + + {% for y in d.annuals %} + {% set dscr = (y.ebitda / y.ds) if y.ds > 0 else 999 %} + {% set util = ((y.booked / y.avail * 100) | int) if y.avail > 0 else 0 %} + + + + + + + + + + {% endfor %} + +
{{ t.th_year }}{{ t.th_revenue }}{{ t.th_ebitda }}{{ t.th_debt_service }}{{ t.th_net_cf }}{{ t.th_dscr }}{{ t.th_util }}
Year {{ y.year }}{{ y.revenue | int | fmt_currency }}{{ y.ebitda | int | fmt_currency }}{{ y.ds | int | fmt_currency }}{{ y.ncf | int | fmt_currency }}{{ '∞' if dscr > 99 else dscr | fmt_x }}{{ util }}%
+
diff --git a/padelnomics/src/padelnomics/planner/templates/partials/tab_metrics.html b/padelnomics/src/padelnomics/planner/templates/partials/tab_metrics.html new file mode 100644 index 0000000..197f702 --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/tab_metrics.html @@ -0,0 +1,159 @@ +{% set y3_rev = d.annuals[2].revenue if d.annuals | length >= 3 else 0 %} +{% set y3_dscr = d.dscr[2].dscr if d.dscr | length >= 3 else 0 %} +{% set is_in = s.venue == 'indoor' %} + +
+

{{ t.metrics_return }}

+
+
+
IRR
+
{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}
+
{{ s.holdYears }}-year
+
+
+
MOIC
+
{{ d.moic | fmt_x }}
+
Total return multiple
+
+
+
Cash-on-Cash
+
{{ d.cashOnCash | fmt_pct }}
+
Y3 NCF ÷ Equity
+
+
+
Payback
+
{{ ((d.paybackIdx + 1) / 12) | round(1) ~ ' yr' if d.paybackIdx >= 0 else 'N/A' }}
+
Months: {{ d.paybackIdx + 1 if d.paybackIdx >= 0 else '∞' }}
+
+
+
+ +
+

{{ t.metrics_revenue }}

+
+
+
RevPAH
+
{{ d.revPAH | fmt_currency }}
+
Revenue per Available Hour
+
+
+
Revenue / m²
+
{{ d.revPerSqm | int | fmt_currency }}
+
Annual net revenue ÷ area
+
+
+
Revenue / Court
+
{{ (y3_rev / [1, d.totalCourts] | max) | int | fmt_currency }}
+
Year 3 annual
+
+
+
Avg Booked Rate
+
{{ d.weightedRate | int | fmt_currency }}
+
Blended peak/off-peak
+
+
+
+ +
+

{{ t.metrics_cost }}

+
+
+
EBITDA Margin
+
{{ d.ebitdaMargin | fmt_pct }}
+
Operating profit margin
+
+
+
OpEx Ratio
+
{{ d.opexRatio | fmt_pct }}
+
OpEx ÷ Revenue
+
+
+
Occupancy Cost
+
{{ d.rentRatio | fmt_pct }}
+
Rent ÷ Revenue
+
+
+
Cost / Booked Hour
+
{{ d.costPerBookedHr | fmt_currency }}
+
All-in cost per hour sold
+
+
+
+ +
+

{{ t.metrics_debt }}

+
+
+
DSCR (Y3)
+
{{ '∞' if y3_dscr > 99 else y3_dscr | fmt_x }}
+
Min 1.2x for banks
+
+
+
LTV
+
{{ d.ltv | fmt_pct }}
+
Loan ÷ Total Investment
+
+
+
Debt Yield
+
{{ d.debtYield | fmt_pct }}
+
Stab. EBITDA ÷ Loan
+
+
+
Monthly Debt Service
+
{{ d.monthlyPayment | int | fmt_currency }}
+
P&I payment
+
+
+
+ +
+

{{ t.metrics_invest }}

+
+
+
CAPEX / Court
+
{{ d.capexPerCourt | int | fmt_currency }}
+
Total investment per court
+
+
+
CAPEX / m²
+
{{ d.capexPerSqm | int | fmt_currency }}
+
Investment per floor area
+
+
+
Yield on Cost
+
{{ d.yieldOnCost | fmt_pct }}
+
Stab. EBITDA ÷ CAPEX
+
+
+
Exit Value
+
{{ d.exitValue | fmt_k }}
+
{{ s.exitMultiple }}x Y3 EBITDA
+
+
+
+ +
+

{{ t.metrics_ops }}

+
+
+
Break-Even Util.
+
{{ d.breakEvenUtil | fmt_pct }}
+
{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day
+
+
+
Y3 Utilization
+
{{ d.avgUtil | fmt_pct }}
+
Effective avg utilization
+
+
+
Available Hours/mo
+
{{ d.availHoursMonth | fmt_n }}
+
All courts combined
+
+
+
Operating Months
+
{{ '12' if is_in else '~' ~ s.season | selectattr('>', 0) | list | length }}
+
{{ 'Year-round' if is_in else 'Seasonal' }}
+
+
+
diff --git a/padelnomics/src/padelnomics/planner/templates/partials/tab_operating.html b/padelnomics/src/padelnomics/planner/templates/partials/tab_operating.html new file mode 100644 index 0000000..f46e975 --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/tab_operating.html @@ -0,0 +1,95 @@ +{% set margin = (d.ebitdaMonth / d.netRevMonth * 100) | round(1) if d.netRevMonth > 0 else 0 %} +{% set y3_rev = d.annuals[2].revenue if d.annuals | length >= 3 else 0 %} +
+
+
{{ t.card_net_rev_mo }}
+
{{ d.netRevMonth | int | fmt_currency }}
+
{{ t.sub_stabilized }}
+
+
+
{{ t.card_ebitda_mo }}
+
{{ d.ebitdaMonth | int | fmt_currency }}
+
{{ margin }}% margin
+
+
+
{{ t.card_annual_rev }}
+
{{ y3_rev | int | fmt_currency }}
+
{{ t.sub_year3 }}
+
+
+
{{ t.card_rev_pah }}
+
{{ d.revPAH | fmt_currency }}
+
Revenue per available hour
+
+
+ +
+
+
{% if lang == 'de' %}Monatlicher Umsatzaufbau (Anlaufphase){% else %}Monthly Revenue Build-Up (Ramp Period){% endif %}
+
+
+
+
{% if lang == 'de' %}Stabilisierte monatliche GuV{% else %}Stabilized Monthly P&L{% endif %}
+
+
+
+ + + +
+

{% if lang == 'de' %}Einnahmequellen (stabilisierter Monat){% else %}Revenue Streams (Stabilized Month){% endif %}

+ {% set streams = [ + (t.stream_court_rental, d.courtRevMonth - d.feeDeduction), + (t.stream_equipment, d.racketRev + d.ballMargin), + (t.stream_memberships, d.membershipRev), + (t.stream_fb, d.fbRev), + (t.stream_coaching, d.coachingRev), + (t.stream_retail, d.retailRev), + ] %} + {% set total_stream = namespace(v=0) %} + {% for name, val in streams %}{% set total_stream.v = total_stream.v + val %}{% endfor %} + + + + {% for name, val in streams %} + + + + + + {% endfor %} + + + + + + +
{{ t.th_stream }}{{ t.th_monthly }}{{ t.th_share }}
{{ name }}{{ val | int | fmt_currency }}{{ ((val / total_stream.v * 100) | int if total_stream.v > 0 else 0) }}%
{{ t.table_total_net_rev }}{{ total_stream.v | int | fmt_currency }}100%
+
+ +
+

{% if lang == 'de' %}Monatliche OPEX-Aufschlüsselung{% else %}Monthly OpEx Breakdown{% endif %}

+ + + + {% for item in d.opexItems %} + + + + + {% endfor %} + + + + + +
{{ t.th_item }}{{ t.th_monthly }}
{{ item.name }}{% if item.info %} ({{ item.info }}){% endif %}{{ item.amount | fmt_currency }}
{{ t.table_total_opex }}{{ d.opex | fmt_currency }}
+
+ +{% if s.venue == 'outdoor' %} +
+

{% if lang == 'de' %}Outdoor-Saisonalität{% else %}Outdoor Seasonality{% endif %}

+
+
+ +{% endif %} diff --git a/padelnomics/src/padelnomics/planner/templates/partials/tab_returns.html b/padelnomics/src/padelnomics/planner/templates/partials/tab_returns.html new file mode 100644 index 0000000..5983a2e --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/tab_returns.html @@ -0,0 +1,102 @@ +
+
+
{{ t.card_irr }}
+
{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}
+
{{ '✓ Above 20%' if d.irr_ok and d.irr > 0.2 else '✗ Below target' }}
+
+
+
{{ t.card_moic }}
+
{{ d.moic | fmt_x }}
+
{{ '✓ Above 2.0x' if d.moic > 2 else '✗ Below 2.0x' }}
+
+
+
{{ t.card_break_even }}
+
{{ d.breakEvenUtil | fmt_pct }}
+
{{ d.breakEvenHrsPerCourt | round(1) }} hrs/court/day
+
+
+
{{ t.card_cash_on_cash }}
+
{{ d.cashOnCash | fmt_pct }}
+
Year 3 NCF ÷ Equity
+
+
+ +
+
+
{% if lang == 'de' %}Exit-Bewertungs-Wasserfall{% else %}Exit Valuation Waterfall{% endif %}
+
+ {% set wf_rows = [ + (t.wf_stab_ebitda, d.stabEbitda | int | fmt_currency, 'c-head'), + (t.wf_exit_multiple, s.exitMultiple ~ 'x', 'c-head'), + (t.wf_enterprise_value, d.exitValue | int | fmt_currency, 'c-blue'), + (t.wf_remaining_loan, d.remainingLoan | int | fmt_currency, 'c-red'), + (t.wf_net_exit, d.netExit | int | fmt_currency, 'c-green' if d.netExit > 0 else 'c-red'), + (t.wf_cum_cf, (d.totalReturned - d.netExit) | int | fmt_currency, 'c-head'), + (t.wf_total_returns, d.totalReturned | int | fmt_currency, 'c-green' if d.totalReturned > 0 else 'c-red'), + (t.wf_investment, d.capex | fmt_currency, 'c-head'), + (t.wf_moic, d.moic | fmt_x, 'c-green' if d.moic > 2 else 'c-red'), + ] %} + {% for label, value, cls in wf_rows %} +
+ {{ label }} + {{ value }} +
+ {% endfor %} +
+
+
+
{% if lang == 'de' %}DSCR nach Jahr{% else %}DSCR by Year{% endif %}
+
+
+
+ + +
+

{% if lang == 'de' %}Auslastungs-Sensitivität{% else %}Utilization Sensitivity{% endif %}

+ + + + + + + + + + + + {% for row in d.sens_rows %} + + + + + + + + {% endfor %} + +
{{ t.th_utilization }}{{ t.th_monthly_rev }}{{ t.th_monthly_ncf }}{{ t.th_annual_ncf }}{{ t.th_dscr }}
{% if row.is_target %}→ {% endif %}{{ row.util }}%{% if row.is_target %} ←{% endif %}{{ row.rev | fmt_currency }}{{ row.ncf | fmt_currency }}{{ row.annual | fmt_currency }}{{ '∞' if row.dscr > 99 else row.dscr | fmt_x }}
+
+ +
+

{% if lang == 'de' %}Preis-Sensitivität (bei Ziel-Auslastung){% else %}Pricing Sensitivity (at target utilization){% endif %}

+ + + + + + + + + + + {% for row in d.price_rows %} + + + + + + + {% endfor %} + +
{{ t.th_price_change }}{{ t.th_avg_rate }}{{ t.th_monthly_rev }}{{ t.th_monthly_ncf }}
{% if row.is_base %}→ {% endif %}{{ '+' if row.delta >= 0 else '' }}{{ row.delta }}%{% if row.is_base %} (base){% endif %}{{ row.adj_rate | fmt_currency }}{{ row.rev | fmt_currency }}{{ row.ncf | fmt_currency }}
+
diff --git a/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html b/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html new file mode 100644 index 0000000..9b4fdec --- /dev/null +++ b/padelnomics/src/padelnomics/planner/templates/partials/wizard_preview.html @@ -0,0 +1,13 @@ +{% set cf = d.ebitdaMonth - d.monthlyPayment %} +
+
{{ t.wiz_capex }}
+
{{ d.capex | fmt_k }}
+
+
+
{{ t.wiz_monthly_cf }}
+
{{ cf | fmt_k }}{{ t.wiz_mo }}
+
+
+
{{ t.wiz_irr }} ({{ s.holdYears }}yr)
+
{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}
+
diff --git a/padelnomics/src/padelnomics/planner/templates/planner.html b/padelnomics/src/padelnomics/planner/templates/planner.html index 5d4b039..f7689a6 100644 --- a/padelnomics/src/padelnomics/planner/templates/planner.html +++ b/padelnomics/src/padelnomics/planner/templates/planner.html @@ -17,11 +17,36 @@ {% endblock %} +{% macro slider(name, label, min, max, step, value, tip='') %} +
+ +
+ + +
+
+{% endmacro %} + +{% macro pill_btn(key, val, label, active, extra_js='') %} + +{% endmacro %} + {% block content %}

{% if lang == 'de' %}Padel-Platz Finanzrechner{% else %}Padel Court Financial Planner{% endif %}

- + {{ s.venue == 'indoor' and t.label_indoor or t.label_outdoor }} · {{ s.own == 'buy' and t.label_build_buy or t.label_rent }} · {{ d.totalCourts }} {{ t.label_courts }} · {{ d.capex | fmt_k }} {% if user %}
@@ -29,240 +54,326 @@ hx-get="{{ url_for('planner.scenario_list') }}" hx-target="#scenario-drawer" hx-swap="innerHTML"> - {{ planner_t.btn_my_scenarios }} ({{ scenario_count }}) + {{ t.btn_my_scenarios }} ({{ scenario_count }}) - +
{% endif %}
- +
- -
-
-
- -
+ +
+ + - -
- {% if lang == 'de' %} -

Dein Padel-Platz

-

Definiere den Typ des Padel-Platzes, den du planst.

-
- -
- -
+ + + + + + + + + {% for val in s.ramp %}{% endfor %} + {% for val in s.season %}{% endfor %} + + +
+
+
+ {% set steps = [('wiz_venue',1),('wiz_pricing',2),('wiz_costs',3),('wiz_finance',4)] %} + {% for key, n in steps %} + + {% endfor %} +
+
- {% else %} -

Your Venue

-

Define the type of facility you're planning to build.

-
- -
- -
-
- {% endif %} -
-
-
-
+ + +
{% if lang == 'de' %} -

Platzkonfiguration

+

Dein Padel-Platz

+

Definiere den Typ des Padel-Platzes, den du planst.

{% else %} -

Court Configuration

+

Your Venue

+

Define the type of facility you're planning to build.

{% endif %} -
+ +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ +
+ {% for code, lkey in [('DE','country_de'),('ES','country_es'),('IT','country_it'),('FR','country_fr'),('NL','country_nl'),('SE','country_se'),('UK','country_uk'),('US','country_us')] %} + + {% endfor %} +
+
+ {{ slider('permitsCompliance', t.sl_permits, 0, 50000, 1000, s.permitsCompliance, 'Building permits, noise studies, change-of-use, fire safety, and regulatory compliance. Adjusts automatically when you pick a country — feel free to override.') }} +
+ +
+ {% if lang == 'de' %} +

Platzkonfiguration

+ {% else %} +

Court Configuration

+ {% endif %} + {{ slider('dblCourts', t.sl_dbl_courts, 0, 30, 1, s.dblCourts, 'Standard padel court for 4 players. Most common format with highest recreational demand.') }} + {{ slider('sglCourts', t.sl_sgl_courts, 0, 30, 1, s.sglCourts, 'Narrow court for 2 players. Popular for coaching, training, and competitive play.') }} + + {% if lang == 'de' %} +

Platzbedarf

+ {% else %} +

Space Requirements

+ {% endif %} +
+ {{ slider('sqmPerDblHall', t.sl_sqm_dbl_hall, 200, 600, 10, s.sqmPerDblHall, 'Total hall space needed per double court. Includes court (200m²), safety zones, circulation, and minimum clearances. Standard: 300–350m².') }} + {{ slider('sqmPerSglHall', t.sl_sqm_sgl_hall, 120, 400, 10, s.sqmPerSglHall, 'Total hall space needed per single court. Includes court (120m²), safety zones, and access. Standard: 200–250m².') }} +
+
+ {{ slider('sqmPerDblOutdoor', t.sl_sqm_dbl_outdoor, 200, 500, 10, s.sqmPerDblOutdoor, 'Outdoor land area per double court. Includes court area, drainage slopes, access paths, and buffer zones. Standard: 280–320m².') }} + {{ slider('sqmPerSglOutdoor', t.sl_sqm_sgl_outdoor, 120, 350, 10, s.sqmPerSglOutdoor, 'Outdoor land area per single court. Includes court, surrounding space, and access paths. Standard: 180–220m².') }} +
+
{% include "partials/court_summary.html" %}
+
+
+ + +
{% if lang == 'de' %} -

Platzbedarf

+

Preise & Auslastung

+

Lege Deine Platztarife, Betriebszeiten und Nebeneinnahmen fest.

{% else %} -

Space Requirements

+

Pricing & Utilization

+

Set your court rates, operating schedule, and ancillary revenue streams.

{% endif %} -
-
-
-
- -
- {% if lang == 'de' %} -

Preise & Auslastung

-

Lege Deine Platztarife, Betriebszeiten und Nebeneinnahmen fest.

-
-

Preise

Pro Platz und Stunde
-
-
-
-

Auslastung & Betrieb

-
-
- {% else %} -

Pricing & Utilization

-

Set your court rates, operating schedule, and ancillary revenue streams.

-
-

Pricing

Per court per hour
-
-
-
-

Utilization & Operations

-
-
- {% endif %} -
+
+ {% if lang == 'de' %} +

Preise

Pro Platz und Stunde
+ {% else %} +

Pricing

Per court per hour
+ {% endif %} + {{ slider('ratePeak', t.sl_rate_peak, 0, 150, 1, s.ratePeak, 'Price per court per hour during peak times (evenings 17:00–22:00 and weekends). Highest demand period.') }} + {{ slider('rateOffPeak', t.sl_rate_offpeak, 0, 150, 1, s.rateOffPeak, 'Price per court per hour during off-peak (weekday mornings/afternoons). Typically 30–40% lower than peak.') }} + {{ slider('rateSingle', t.sl_rate_single, 0, 150, 1, s.rateSingle, 'Hourly rate for single-width courts. Usually lower than doubles since fewer players share the cost.') }} + {{ slider('peakPct', t.sl_peak_pct, 0, 100, 1, s.peakPct, 'Percentage of total booked hours at peak rate. Higher means more revenue but harder to fill off-peak slots.') }} + {{ slider('bookingFee', t.sl_booking_fee, 0, 30, 1, s.bookingFee, 'Commission taken by booking platforms like Playtomic or Matchi. Typically 5–15% of court revenue.') }} +
- -
- {% if lang == 'de' %} -

Investition & Baukosten

-

Konfiguriere Baukosten, Glas- und Beleuchtungsoptionen sowie Dein Budgetziel.

-
-

Bau & CAPEX

Nach Szenario anpassen
-
+
+ {% if lang == 'de' %} +

Auslastung & Betrieb

+ {% else %} +

Utilization & Operations

+ {% endif %} + {{ slider('utilTarget', t.sl_util_target, 0, 100, 1, s.utilTarget, 'Percentage of available court-hours that are actually booked. 35–45% is realistic for new venues, 50%+ is strong.') }} + {{ slider('hoursPerDay', t.sl_hours_per_day, 0, 24, 1, s.hoursPerDay, 'Total operating hours per day. Typical padel venues run 7:00–23:00 (16h). Some extend to 6:00–24:00.') }} + {{ slider('daysPerMonthIndoor', t.sl_days_indoor, 0, 31, 1, s.daysPerMonthIndoor, 'Average operating days per month for indoor venue. ~29 accounts for holidays and maintenance closures.') }} + {{ slider('daysPerMonthOutdoor', t.sl_days_outdoor, 0, 31, 1, s.daysPerMonthOutdoor, 'Average playable days per month outdoors. Reduced by rain, extreme heat, or cold weather.') }} +
{{ t.sl_ancillary_header }}
+ {{ slider('membershipRevPerCourt', t.sl_membership_rev, 0, 2000, 50, s.membershipRevPerCourt, 'Monthly membership/subscription income per court. From loyalty programs, monthly plans, or club memberships.') }} + {{ slider('fbRevPerCourt', t.sl_fb_rev, 0, 2000, 25, s.fbRevPerCourt, 'Food & Beverage revenue per court per month. Income from bar, café, restaurant, or vending machines at the venue.') }} + {{ slider('coachingRevPerCourt', t.sl_coaching_rev, 0, 2000, 25, s.coachingRevPerCourt, 'Revenue from coaching sessions, clinics, tournaments, and events allocated per court per month.') }} + {{ slider('retailRevPerCourt', t.sl_retail_rev, 0, 1000, 10, s.retailRevPerCourt, 'Revenue from pro shop sales: grip tape, overgrips, accessories, and branded merchandise per court per month.') }} +
- {% else %} -

Investment & Build Costs

-

Configure construction costs, glass and lighting options, and your budget target.

-
-

Construction & CAPEX

Adjust per scenario
-
-
- {% endif %} -
- -
- {% if lang == 'de' %} -

Betrieb & Finanzierung

-

Monatliche Betriebskosten, Kreditkonditionen und Exit-Annahmen.

-
-

Monatliche Betriebskosten

-
-
-
-

Finanzierung

-
-
-
-

Exit-Annahmen

-
-
- {% else %} -

Operations & Financing

-

Monthly operating costs, loan terms, and exit assumptions.

-
-

Monthly Operating Costs

-
-
-
-

Financing

-
-
-
-

Exit Assumptions

-
-
- {% endif %} -
+ +
+ {% if lang == 'de' %} +

Investition & Baukosten

+

Konfiguriere Baukosten, Glas- und Beleuchtungsoptionen sowie Dein Budgetziel.

+ {% else %} +

Investment & Build Costs

+

Configure construction costs, glass and lighting options, and your budget target.

+ {% endif %} - - +
+ {% if lang == 'de' %} +

Bau & CAPEX

Nach Szenario anpassen
+ {% else %} +

Construction & CAPEX

Adjust per scenario
+ {% endif %} + +
+ +
+ {{ pill_btn('glassType','standard', t.pill_glass_standard, s.glassType == 'standard') }} + {{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }} +
+
+ +
+ +
+ {{ pill_btn('lightingType','led_standard', t.pill_light_led_standard, s.lightingType == 'led_standard') }} + {{ pill_btn('lightingType','led_competition', t.pill_light_led_competition, s.lightingType == 'led_competition') }} + {{ pill_btn('lightingType','natural', t.pill_light_natural, s.lightingType == 'natural') }} +
+
+ + {{ slider('courtCostDbl', t.sl_court_cost_dbl, 0, 80000, 1000, s.courtCostDbl, 'Base price of one double padel court. The glass type multiplier is applied automatically.') }} + {{ slider('courtCostSgl', t.sl_court_cost_sgl, 0, 60000, 1000, s.courtCostSgl, 'Base price of one single padel court. Generally 60–70% of a double court cost.') }} + + +
+ {{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, 'Construction cost per m² for a new hall (Warmhalle). Includes structure, insulation, and cladding. Requires 10–12m clear height.') }} + {{ slider('foundationSqm', t.sl_foundation_sqm, 0, 400, 5, s.foundationSqm, 'Foundation cost per m². Depends on soil conditions, load-bearing requirements, and local ground water levels.') }} + {{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, 'Land purchase price per m². Rural: €20–60. Suburban: €60–150. Urban: €150–300+. Varies hugely by location.') }} + {{ slider('hvac', t.sl_hvac, 0, 500000, 5000, s.hvac, 'Heating, ventilation, and air conditioning. Essential for indoor comfort and humidity control. Cost scales with hall volume.') }} + {{ slider('electrical', t.sl_electrical, 0, 400000, 5000, s.electrical, 'Complete electrical installation: court lighting (LED, 500+ lux), power distribution, panels, and outlets.') }} + {{ slider('sanitary', t.sl_sanitary, 0, 400000, 5000, s.sanitary, 'Changing rooms, showers, toilets, and plumbing. Includes fixtures, tiling, waterproofing, and ventilation.') }} + {{ slider('fireProtection', t.sl_fire, 0, 500000, 5000, s.fireProtection, 'Fire detection, sprinkler suppression, emergency exits, and smoke ventilation. Often the biggest surprise cost for large halls.') }} + {{ slider('planning', t.sl_planning, 0, 500000, 5000, s.planning, 'Architectural planning, structural engineering, building permits, zoning applications, and regulatory compliance costs.') }} +
+ + +
+ {{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, 'Floor leveling, sealing, and preparation for court installation in an existing rented building.') }} + {{ slider('hvacUpgrade', t.sl_hvac_upgrade, 0, 200000, 1000, s.hvacUpgrade, 'Upgrading existing HVAC in a rented building to handle sports venue airflow and humidity requirements.') }} + {{ slider('lightingUpgrade', t.sl_lighting_upgrade, 0, 100000, 1000, s.lightingUpgrade, 'Upgrading existing lighting to meet padel requirements: minimum 500 lux, no glare, even distribution across courts.') }} + {{ slider('fitout', t.sl_fitout, 0, 300000, 1000, s.fitout, 'Interior fit-out for reception, lounge, viewing areas, and common spaces when renting an existing building.') }} +
+ + +
+ {{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, 'Concrete pad per m² for outdoor courts. Needs proper drainage, level surface, and frost-resistant construction.') }} + {{ slider('outdoorSiteWork', t.sl_outdoor_site_work, 0, 60000, 500, s.outdoorSiteWork, 'Grading, drainage installation, utilities connection, and site preparation for outdoor courts.') }} + {{ slider('outdoorLighting', t.sl_outdoor_lighting, 0, 20000, 500, s.outdoorLighting, 'Floodlight installation per court. LED recommended for energy efficiency. Must meet competition standards if applicable.') }} + {{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, 'Perimeter fencing around outdoor court area. Includes wind screens, security gates, and ball containment nets.') }} +
{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, 'Land purchase price per m². Varies by location, zoning, and accessibility.') }}
+
+ + {{ slider('workingCapital', t.sl_working_capital, 0, 200000, 1000, s.workingCapital, 'Cash reserve for operating losses during ramp-up phase and seasonal dips. Critical buffer — underfunding is a common startup failure.') }} + {{ slider('contingencyPct', t.sl_contingency, 0, 30, 1, s.contingencyPct, 'Percentage buffer on total CAPEX for unexpected costs. 10–15% is standard for construction, 15–20% for complex projects.') }} + {{ slider('budgetTarget', t.sl_budget_target, 0, 5000000, 10000, s.budgetTarget, 'Set your total budget to see how your planned CAPEX compares. Leave at 0 to hide the budget indicator.') }} +
+
+ + +
+ {% if lang == 'de' %} +

Betrieb & Finanzierung

+

Monatliche Betriebskosten, Kreditkonditionen und Exit-Annahmen.

+ {% else %} +

Operations & Financing

+

Monthly operating costs, loan terms, and exit assumptions.

+ {% endif %} + +
+ {% if lang == 'de' %}

Monatliche Betriebskosten

+ {% else %}

Monthly Operating Costs

{% endif %} + +
{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, 'Monthly rent per square meter for indoor hall space. Varies by location, building quality, and lease terms.') }}
+
{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, 'Monthly land rent for outdoor court area. Much cheaper than indoor space but weather-dependent.') }}
+
{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, 'Monthly property tax when owning the building/land. Grundsteuer in Germany, varies by municipality and property value.') }}
+ + {{ slider('insurance', t.sl_insurance, 0, 2000, 25, s.insurance, 'Monthly insurance premium covering liability, property damage, business interruption, and equipment.') }} + {{ slider('electricity', t.sl_electricity, 0, 5000, 25, s.electricity, 'Monthly electricity cost. Major driver for indoor venues due to court lighting, HVAC, and equipment.') }} + +
+ {{ slider('heating', t.sl_heating, 0, 3000, 25, s.heating, 'Monthly heating cost for indoor venue. Significant in northern European climates during winter months.') }} + {{ slider('water', t.sl_water, 0, 1000, 25, s.water, 'Monthly water cost for showers, toilets, cleaning, and potentially outdoor court irrigation.') }} +
+ + {{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, 'Monthly court and facility maintenance: glass cleaning, surface repair, net replacement, and equipment upkeep.') }} + +
{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, 'Monthly professional cleaning of courts, changing rooms, common areas, and reception.') }}
+ + {{ slider('marketing', t.sl_marketing, 0, 5000, 25, s.marketing, 'Monthly spend on marketing, booking platform subscriptions, website, social media, and customer acquisition.') }} + {{ slider('staff', t.sl_staff, 0, 20000, 100, s.staff, 'Monthly staff costs including wages, social contributions, and benefits. Many venues run lean using automated booking and access systems.') }} +
+ +
+ {% if lang == 'de' %}

Finanzierung

+ {% else %}

Financing

{% endif %} + {{ slider('loanPct', t.sl_loan_pct, 0, 100, 1, s.loanPct, 'Percentage of total CAPEX financed by debt. Banks typically offer 70–85%. Higher with personal guarantees or subsidies.') }} + {{ slider('interestRate', t.sl_interest_rate, 0, 15, 0.1, s.interestRate, 'Annual interest rate on the loan. Depends on creditworthiness, collateral, market conditions, and bank relationship.') }} + {{ slider('loanTerm', t.sl_loan_term, 0, 30, 1, s.loanTerm, 'Loan repayment period in years. Longer terms mean lower monthly payments but more total interest paid.') }} + {{ slider('constructionMonths', t.sl_construction_months, 0, 24, 1, s.constructionMonths, 'Months of construction/setup before opening. Costs accrue (loan interest, rent) but no revenue is generated.') }} +
+ +
+ {% if lang == 'de' %}

Exit-Annahmen

+ {% else %}

Exit Assumptions

{% endif %} + {{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, 'Investment holding period before exit/sale. Typical for PE/investors: 5–7 years. Owner-operators may hold indefinitely.') }} + {{ slider('exitMultiple', t.sl_exit_multiple, 0, 20, 0.5, s.exitMultiple, 'EBITDA multiple used to value the business at exit. Reflects market demand, brand strength, and growth potential. Small business: 4–6x, strong brand: 6–8x.') }} + {{ slider('annualRevGrowth', t.sl_annual_rev_growth, 0, 15, 0.5, s.annualRevGrowth, 'Expected annual revenue growth rate after the initial 12-month ramp-up period. Driven by price increases and utilization gains.') }} +
+
+ + + +
+ + + + - -
-
-
-
-
{% if lang == 'de' %}CAPEX-Aufschlüsselung{% else %}CAPEX Breakdown{% endif %}
-
-
-
- - -
-
-
-
-
{% if lang == 'de' %}Monatlicher Umsatzaufbau (Anlaufphase){% else %}Monthly Revenue Build-Up (Ramp Period){% endif %}
-
-
-
-
{% if lang == 'de' %}Stabilisierte monatliche GuV{% else %}Stabilized Monthly P&L{% endif %}
-
-
-
-
-

{% if lang == 'de' %}Einnahmequellen (stabilisierter Monat){% else %}Revenue Streams (Stabilized Month){% endif %}

-
-
-
-

{% if lang == 'de' %}Monatliche OPEX-Aufschlüsselung{% else %}Monthly OpEx Breakdown{% endif %}

-
-
-
-

{% if lang == 'de' %}Outdoor-Saisonalität{% else %}Outdoor Seasonality{% endif %}

-
-
-
- - -
-
-
-
{% if lang == 'de' %}Monatlicher Netto-Cashflow (60 Monate){% else %}Monthly Net Cash Flow (60 Months){% endif %}
-
-
-
-
{% if lang == 'de' %}Kumulierter Cashflow{% else %}Cumulative Cash Flow{% endif %}
-
-
-
-

{% if lang == 'de' %}Jahresübersicht{% else %}Annual Summary{% endif %}

-
-
-
- - -
-
-
-
-
{% if lang == 'de' %}Exit-Bewertungs-Wasserfall{% else %}Exit Valuation Waterfall{% endif %}
-
-
-
-
{% if lang == 'de' %}DSCR nach Jahr{% else %}DSCR by Year{% endif %}
-
-
-
-
-

{% if lang == 'de' %}Auslastungs-Sensitivität{% else %}Utilization Sensitivity{% endif %}

-
-
-
-

{% if lang == 'de' %}Preis-Sensitivität (bei Ziel-Auslastung){% else %}Pricing Sensitivity (at target utilization){% endif %}

-
-
-
- - -
-

{{ planner_t.metrics_return }}

-

{{ planner_t.metrics_revenue }}

-

{{ planner_t.metrics_cost }}

-

{{ planner_t.metrics_debt }}

-

{{ planner_t.metrics_invest }}

-

{{ planner_t.metrics_ops }}

-
-
+
- + {% if not user %} -
+ @@ -311,15 +422,11 @@ {% block scripts %} {% endblock %} diff --git a/padelnomics/src/padelnomics/static/js/planner.js b/padelnomics/src/padelnomics/static/js/planner.js index b288a31..cafc718 100644 --- a/padelnomics/src/padelnomics/static/js/planner.js +++ b/padelnomics/src/padelnomics/static/js/planner.js @@ -1,846 +1,207 @@ -// ── State ────────────────────────────────────────────────── -const S = { - venue:'indoor', own:'rent', - dblCourts:4, sglCourts:2, - sqmPerDblHall:336, sqmPerSglHall:240, sqmPerDblOutdoor:312, sqmPerSglOutdoor:216, - ratePeak:50, rateOffPeak:35, rateSingle:30, - peakPct:40, hoursPerDay:16, daysPerMonthIndoor:29, daysPerMonthOutdoor:25, - bookingFee:10, utilTarget:40, - membershipRevPerCourt:500, fbRevPerCourt:300, coachingRevPerCourt:200, retailRevPerCourt:80, - racketRentalRate:15, racketPrice:5, racketQty:2, ballRate:10, ballPrice:3, ballCost:1.5, - courtCostDbl:25000, courtCostSgl:15000, shipping:3000, - hallCostSqm:500, foundationSqm:150, landPriceSqm:60, - hvac:100000, electrical:60000, sanitary:80000, parking:50000, - fitout:40000, planning:100000, fireProtection:80000, - floorPrep:12000, hvacUpgrade:20000, lightingUpgrade:10000, - outdoorFoundation:35, outdoorSiteWork:8000, outdoorLighting:4000, outdoorFencing:6000, - equipment:2000, workingCapital:15000, contingencyPct:10, - country:'DE', permitsCompliance:12000, - budgetTarget:0, glassType:'standard', lightingType:'led_standard', - rentSqm:4, outdoorRent:400, insurance:300, electricity:600, heating:400, - maintenance:300, cleaning:300, marketing:350, staff:0, propertyTax:250, water:125, - loanPct:85, interestRate:5, loanTerm:10, constructionMonths:0, - holdYears:5, exitMultiple:6, annualRevGrowth:2, - ramp:[.25,.35,.45,.55,.65,.75,.82,.88,.93,.96,.98,1], - season:[0,0,0,.7,.9,1,1,1,.8,0,0,0], -}; +'use strict'; -// Freeze a copy of defaults before any overrides -const DEFAULTS = Object.freeze(JSON.parse(JSON.stringify(S))); +// ─── Chart management ───────────────────────────────────────────────────────── +const _charts = {}; -// 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: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 = {}; - -// ── Wizard state ────────────────────────────────────────── -let wizStep = 1; -const WIZ_STEPS = [ - {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 ──────────────────────────────────────────────── -const $=s=>document.querySelector(s); -const $$=s=>document.querySelectorAll(s); -const fmt=n=>new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n); -const fmtK=n=>Math.abs(n)>=1e6?`\u20AC${(n/1e6).toFixed(1)}M`:Math.abs(n)>=1e3?`\u20AC${(n/1e3).toFixed(0)}K`:fmt(n); -const fmtP=n=>`${(n*100).toFixed(1)}%`; -const fmtX=n=>`${n.toFixed(2)}x`; -const fmtN=n=>new Intl.NumberFormat('de-DE').format(Math.round(n)); -const fE=v=>fmt(v), fP=v=>v+'%', fN=v=>v, fR=v=>v+'x', fY=v=>v+' yr', fH=v=>v+'h', fD=v=>'\u20AC'+v, fM=v=>v+' mo'; - -function ti(text){ - if(!text) return ''; - return ` i${text}`; -} - -function pillSelect(key,label,options,tip){ - let h=`
`; - for(const opt of options){ - h+=``; - } - h+='
'; - return h; -} - -const COUNTRY_PRESETS = { - DE: { permitsCompliance: 12000 }, - ES: { permitsCompliance: 25000 }, - IT: { permitsCompliance: 18000 }, - FR: { permitsCompliance: 15000 }, - NL: { permitsCompliance: 10000 }, - SE: { permitsCompliance: 8000 }, - UK: { permitsCompliance: 10000 }, - US: { permitsCompliance: 15000 }, -}; -// Track which keys the user has manually adjusted -const _userAdjusted = new Set(); - -function bindPills(){ - document.querySelectorAll('.pill-btn').forEach(b=>{ - b.onclick=()=>{ - const k=b.dataset.key, v=b.dataset.val; - S[k]=v; - // Update active state within same group - b.closest('.pill-options').querySelectorAll('.pill-btn').forEach(btn=>{ - btn.classList.toggle('pill-btn--active',btn.dataset.val===v); - }); - // Apply country presets when country changes - if(k==='country'){ - const preset = COUNTRY_PRESETS[v] || COUNTRY_PRESETS.DE; - for(const [pk,pv] of Object.entries(preset)){ - if(!_userAdjusted.has(pk)){ S[pk]=pv; } - } - buildCountryPill(); rebuildCapexInputs(); bindSliders(); bindPills(); - } - // Rebuild inputs if lighting options depend on venue - if(k==='glassType'||k==='lightingType'){ - rebuildCapexInputs(); bindSliders(); bindPills(); - } - render(); - }; +function initCharts() { + document.querySelectorAll('script[type="application/json"][id$="-data"]').forEach(el => { + const id = el.id.slice(0, -5); // strip '-data' + const canvas = document.getElementById(id); + if (!canvas) return; + try { + if (_charts[id]) { _charts[id].destroy(); delete _charts[id]; } + _charts[id] = new Chart(canvas.getContext('2d'), JSON.parse(el.textContent)); + } catch (e) { console.warn('Chart init failed:', id, e); } }); } -function cardHTML(label,value,sub,cls='',tip=''){ - const cc = cls==='green'?'c-green':cls==='red'?'c-red':cls==='blue'?'c-blue':cls==='amber'?'c-amber':'c-head'; - return `
-
${label}${ti(tip)}
-
${value}
- ${sub?`
${sub}
`:''} -
`; -} -function cardSmHTML(label,value,sub,cls='',tip=''){ - const cc = cls==='green'?'c-green':cls==='red'?'c-red':cls==='blue'?'c-blue':cls==='amber'?'c-amber':'c-head'; - return `
-
${label}${ti(tip)}
-
${value}
- ${sub?`
${sub}
`:''} -
`; -} - -// ── Server-side calculation ────────────────────────────── -let _lastD = window.__PADELNOMICS_INITIAL_D__ || null; -let _calcTimer = null; -let _calcController = null; - -function fetchCalc(){ - if(_calcController) _calcController.abort(); - _calcController = new AbortController(); - const app = $('.planner-app'); - if(app) app.classList.add('planner-app--computing'); - fetch(window.__PADELNOMICS_CALC_URL__, { - method:'POST', - headers:{'Content-Type':'application/json'}, - body:JSON.stringify({state:S}), - signal:_calcController.signal, - }) - .then(r=>r.json()) - .then(d=>{ - _lastD = d; - _calcController = null; - if(app) app.classList.remove('planner-app--computing'); - renderWith(d); - }) - .catch(e=>{ - if(e.name!=='AbortError'){ - _calcController = null; - if(app) app.classList.remove('planner-app--computing'); - } - }); -} - -function scheduleCalc(){ - if(_calcTimer) clearTimeout(_calcTimer); - _calcTimer = setTimeout(fetchCalc, 200); -} - -// ── UI Builders ─────────────────────────────────────────── -function buildNav(){ - const n = $('#nav'); - n.innerHTML = TABS.map(t=>``).join(''); - n.querySelectorAll('button').forEach(b=>b.onclick=()=>{activeTab=b.dataset.tab;render()}); -} - -function slider(key,label,min,max,step,fmtFn,tip){ - return `
- -
- - -
-
`; -} - -function buildInputs(){ - 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',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',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',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.')+ - '
'+tr('sl_ancillary_header','Ancillary Revenue (per court/month):')+'
'+ - 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',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',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',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',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',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: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',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',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',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',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',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; -} - -function rebuildOpexInputs(){ - const isIn=S.venue==='indoor', isBuy=S.own==='buy'; - let h=''; - if(!isBuy){ - 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',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',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; -} - -function buildToggle(id,opts,key){ - const el = $(`#${id}`); - el.innerHTML = opts.map(o=>``).join(''); - el.querySelectorAll('button').forEach(b=>b.onclick=()=>{ - S[key]=b.dataset.val; - // Update active state on all buttons in this toggle group - el.querySelectorAll('button').forEach(btn=>{ - btn.classList.toggle('toggle-btn--active', btn.dataset.val===S[key]); - }); - rebuildSpaceInputs(); rebuildCapexInputs(); rebuildOpexInputs(); bindSliders(); bindPills(); render(); - }); -} - -function bindSliders(){ - document.querySelectorAll('input[type=range][data-key]').forEach(inp=>{ - inp.oninput = () => { - const k=inp.dataset.key; - S[k]=parseFloat(inp.value); - if(k==='permitsCompliance') _userAdjusted.add(k); - const numInp = document.querySelector(`input[type=number][data-numfor="${k}"]`); - if(numInp) numInp.value = S[k]; - render(); - }; - }); - document.querySelectorAll('input[type=number][data-numfor]').forEach(inp=>{ - inp.oninput = () => { - const k=inp.dataset.numfor; - const v = parseFloat(inp.value); - if(isNaN(v)) return; - S[k]=v; - if(k==='permitsCompliance') _userAdjusted.add(k); - const rangeInp = document.querySelector(`input[type=range][data-key="${k}"]`); - if(rangeInp) rangeInp.value = v; - render(); - }; - }); -} - -// ── Render ───────────────────────────────────────────────── -function render(){ - // Update tab visibility immediately (no server call needed) - $$('.tab-btn').forEach(b=>{ - b.classList.toggle('tab-btn--active', b.dataset.tab===activeTab); - }); - $$('.tab').forEach(t=>{ - t.classList.toggle('active',t.id===`tab-${activeTab}`); - }); - - // Show signup bar on results tabs for guests - const sb=$('#signupBar'); - if(sb) sb.style.display=activeTab!=='assumptions'?'flex':'none'; - - // Show quote sidebar + inline CTA on all tabs - const qs=$('#quoteSidebar'); - if(qs) qs.style.display='block'; - const qi=$('#quoteInlineCta'); - if(qi) qi.style.display='block'; - - // If we have cached data, render immediately with it - if(_lastD) renderWith(_lastD); - - // Schedule server-side recalculation - scheduleCalc(); -} - -function renderWith(d){ - const isIn=S.venue==='indoor'; - 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(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); - if(activeTab==='cashflow') renderCashflow(d); - if(activeTab==='returns') renderReturns(d); - if(activeTab==='metrics') renderMetrics(d); - - if(activeTab==='operating'){ - const sec = $('#seasonSection'); - if(isIn){ sec.classList.remove('visible'); } - else { sec.classList.add('visible'); renderSeasonChart(); } - } - - if(activeTab==='assumptions') renderWizPreview(); -} - -// ── Table helper ── -const TH = t => `${t}`; -const THR = t => `${t}`; - -function renderCapex(d){ - $('#capexCards').innerHTML = - 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){ - const over=d.budgetVariance>0; - const cls=over?'red':'green'; - const sign=over?'+':''; - $('#capexCards').innerHTML+=`
-
${over?tr('budget_over','BUDGET OVER'):tr('budget_under','BUDGET UNDER')}
-
${sign}${fmt(Math.round(d.budgetVariance))}
-
${d.budgetPct.toFixed(0)}% of ${fmtK(d.budgetTarget)} budget
-
`; - } - - let rows = d.capexItems.map(i=>`${i.name}${i.info?` (${i.info})`:''}${fmt(i.amount)}`).join(''); - rows += `${tr('table_total_capex','TOTAL CAPEX')}${fmt(d.capex)}`; - $('#capexTable').innerHTML = `${TH(tr('th_item','Item'))}${THR(tr('th_amount','Amount'))}${rows}
`; - - renderChart('chartCapex','doughnut',{ - labels:d.capexItems.filter(i=>i.amount>0).map(i=>i.name), - datasets:[{data:d.capexItems.filter(i=>i.amount>0).map(i=>i.amount), - backgroundColor:['#3B82F6','#10B981','#F59E0B','#8B5CF6','#EC4899','#06B6D4','#84CC16','#F97316','#6366F1','#14B8A6','#A855F7','#EF4444','#22C55E','#EAB308','#2563EB'], - borderWidth:0}] - },{plugins:{legend:{position:'right',labels:{color:'#64748B',font:{size:10,family:'Inter'},boxWidth:10,padding:6}}}}); -} - -function renderOperating(d){ - const margin = d.netRevMonth>0?(d.ebitdaMonth/d.netRevMonth*100).toFixed(1):0; - $('#opCards').innerHTML = - 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=[ - [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 `${n}${fmt(Math.round(v))}${pct}%`; - }).join(''); - sRows+=`${tr('table_total_net_rev','Total Net Revenue')}${fmt(Math.round(totalStream))}100%`; - $('#revenueTable').innerHTML=`${TH(tr('th_stream','Stream'))}${THR(tr('th_monthly','Monthly'))}${THR(tr('th_share','Share'))}${sRows}
`; - - let oRows=d.opexItems.map(i=>`${i.name}${i.info?` (${i.info})`:''}${fmt(i.amount)}`).join(''); - oRows+=`${tr('table_total_opex','Total Monthly OpEx')}${fmt(d.opex)}`; - $('#opexDetailTable').innerHTML=`${TH(tr('th_item','Item'))}${THR(tr('th_monthly','Monthly'))}${oRows}
`; - - const rampData = d.months.slice(0,24); - renderChart('chartRevRamp','bar',{ - labels:rampData.map(m=>'M'+m.m), - datasets:[ - {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: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), - datasets:[{data:plData.map(p=>p.val),backgroundColor:plData.map(p=>p.val>=0?'rgba(16,185,129,0.6)':'rgba(239,68,68,0.5)'),borderRadius:4}] - },{indexAxis:'y',scales:{x:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}},y:{ticks:{color:'#64748B',font:{size:10}}}},plugins:{legend:{display:false}}}); -} - -function renderCashflow(d){ - 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(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:''), - datasets:[{data:d.months.map(m=>Math.round(m.ncf)), - backgroundColor:d.months.map(m=>m.ncf>=0?'rgba(16,185,129,0.5)':'rgba(239,68,68,0.4)'),borderRadius:2}] - },{scales:{x:{ticks:{color:'#94A3B8',font:{size:9}}},y:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{display:false}}}); - - renderChart('chartCum','line',{ - labels:d.months.map(m=>m.m%6===1?'M'+m.m:''), - datasets:[{data:d.months.map(m=>Math.round(m.cum)),borderColor:'#3B82F6',backgroundColor:'rgba(59,130,246,0.08)',fill:true,pointRadius:0,tension:0.3}] - },{scales:{x:{ticks:{color:'#94A3B8',font:{size:9}}},y:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{display:false}}}); - - let rows = d.annuals.map(y=>{ - const dscr = y.ds>0?y.ebitda/y.ds:999; - const util = y.avail>0?(y.booked/y.avail*100).toFixed(0):0; - return ` - Year ${y.year} - ${fmt(Math.round(y.revenue))} - ${fmt(Math.round(y.ebitda))} - ${fmt(Math.round(y.ds))} - ${fmt(Math.round(y.ncf))} - ${dscr>99?'\u221E':fmtX(dscr)} - ${util}% - `; - }).join(''); - $('#annualTable').innerHTML=`${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.'))}${rows}
`; -} - -function renderReturns(d){ - const irrOk=isFinite(d.irr)&&!isNaN(d.irr); - $('#retCards').innerHTML = - 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 = [ - [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])=>`
${l}${v}
`).join(''); - - renderChart('chartDSCR','bar',{ - labels:d.dscr.map(x=>'Y'+x.year), - datasets:[{data:d.dscr.map(x=>Math.min(x.dscr,10)),backgroundColor:d.dscr.map(x=>x.dscr>=1.2?'rgba(16,185,129,0.5)':'rgba(239,68,68,0.5)'),borderRadius:4}] - },{scales:{x:{ticks:{color:'#94A3B8'}},y:{ticks:{color:'#94A3B8',font:{size:9}},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{display:false}}}); - - const utils = [15,20,25,30,35,40,45,50,55,60,65,70]; - const isIn = S.venue==='indoor'; - const wRate = d.weightedRate; - const revPerHr = wRate*(1-S.bookingFee/100)+(S.racketRentalRate/100)*S.racketQty*S.racketPrice+(S.ballRate/100)*(S.ballPrice-S.ballCost); - let sRows = utils.map(u=>{ - const booked = d.availHoursMonth*(u/100); - const rev = booked*revPerHr + d.totalCourts*(S.membershipRevPerCourt+S.fbRevPerCourt+S.coachingRevPerCourt+S.retailRevPerCourt)*(u/Math.max(S.utilTarget,1)); - const ncf = rev-d.opex-d.monthlyPayment; - const annual = ncf*(isIn?12:6); - const ebitda = rev-d.opex; - const dscr = d.annualDebtService>0?(ebitda*(isIn?12:6))/d.annualDebtService:999; - const isTarget = u===S.utilTarget; - return `${isTarget?'\u2192 ':''} ${u}%${isTarget?' \u2190':''}${fmt(Math.round(rev))}${fmt(Math.round(ncf))}${fmt(Math.round(annual))}${dscr>99?'\u221E':fmtX(dscr)}`; - }).join(''); - $('#sensTable').innerHTML=`${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'))}${sRows}
`; - - const prices = [-20,-10,-5,0,5,10,15,20]; - let pRows = prices.map(delta=>{ - const adjRate = wRate*(1+delta/100); - const booked = d.bookedHoursMonth; - const rev = booked*adjRate*(1-S.bookingFee/100)+booked*((S.racketRentalRate/100)*S.racketQty*S.racketPrice+(S.ballRate/100)*(S.ballPrice-S.ballCost))+d.totalCourts*(S.membershipRevPerCourt+S.fbRevPerCourt+S.coachingRevPerCourt+S.retailRevPerCourt); - const ncf = rev-d.opex-d.monthlyPayment; - const isBase = delta===0; - return `${isBase?'\u2192 ':''}${delta>=0?'+':''}${delta}%${isBase?' (base)':''}${fmt(Math.round(adjRate))}${fmt(Math.round(rev))}${fmt(Math.round(ncf))}`; - }).join(''); - $('#priceSensTable').innerHTML=`${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'))}${pRows}
`; -} - -function renderMetrics(d){ - const isIn=S.venue==='indoor'; - const irrOk=isFinite(d.irr)&&!isNaN(d.irr); - const annRev = d.annuals.length>=3?d.annuals[2].revenue:0; - - $('#mReturn').innerHTML = - cardSmHTML('IRR',irrOk?fmtP(d.irr):'N/A',`${S.holdYears}-year`,irrOk&&d.irr>.2?'green':'red','Internal Rate of Return. Annualized return accounting for the timing of all cash flows over the holding period.')+ - cardSmHTML('MOIC',fmtX(d.moic),'Total return multiple',d.moic>2?'green':'red','Multiple on Invested Capital. Total cash returned divided by total cash invested. 2.0x means you doubled your money.')+ - cardSmHTML('Cash-on-Cash',fmtP(d.cashOnCash),'Y3 NCF \u00F7 Equity',d.cashOnCash>.15?'green':'amber','Year 3 net cash flow divided by equity invested. Measures annual cash yield on your own capital, ignoring asset appreciation.')+ - cardSmHTML('Payback',d.paybackIdx>=0?`${((d.paybackIdx+1)/12).toFixed(1)} yr`:'N/A','Months: '+(d.paybackIdx>=0?d.paybackIdx+1:'\u221E'),'','Months until cumulative net cash flows fully recover the initial CAPEX investment. Shorter payback = lower risk.'); - - $('#mRevenue').innerHTML = - cardSmHTML('RevPAH',fmt(d.revPAH),'Revenue per Available Hour','blue','Revenue Per Available Hour. Net revenue divided by total available court-hours (booked + unbooked). Measures capacity monetization.')+ - cardSmHTML('Revenue / m\u00B2',fmt(Math.round(d.revPerSqm)),'Annual net revenue \u00F7 area','blue','Annual net revenue divided by total venue floor area. Benchmarks how efficiently you use your space compared to other venues.')+ - cardSmHTML('Revenue / Court',fmt(Math.round(annRev/Math.max(1,d.totalCourts))),'Year 3 annual','','Year 3 annual revenue divided by number of courts. Useful for comparing venue performance across different sizes.')+ - cardSmHTML('Avg Booked Rate',fmt(Math.round(d.weightedRate)),'Blended peak/off-peak','','Weighted average hourly rate across peak, off-peak, and single court bookings. The effective price per court-hour.'); - - $('#mCost').innerHTML = - cardSmHTML('EBITDA Margin',fmtP(d.ebitdaMargin),'Operating profit margin',d.ebitdaMargin>.3?'green':'amber','EBITDA as percentage of net revenue. Measures what share of revenue becomes operating profit. Higher = more efficient operations.')+ - cardSmHTML('OpEx Ratio',fmtP(d.opexRatio),'OpEx \u00F7 Revenue','','Monthly operating expenses divided by net revenue. Lower ratio means more of each euro earned is profit. Target: <60%.')+ - cardSmHTML('Occupancy Cost',fmtP(d.rentRatio),'Rent \u00F7 Revenue',d.rentRatio<.3?'green':'red','Rent as percentage of net revenue. Key metric for rented venues. Above 30% is risky \u2014 it squeezes margins on everything else.')+ - cardSmHTML('Cost / Booked Hour',fmt(d.costPerBookedHr),'All-in cost per hour sold','','Total monthly costs (OpEx + debt service) divided by booked hours. Your true all-in cost to deliver one hour of court time.'); - - const y3dscr = d.dscr.length>=3?d.dscr[2].dscr:0; - $('#mDebt').innerHTML = - cardSmHTML('DSCR (Y3)',y3dscr>99?'\u221E':fmtX(y3dscr),'Min 1.2x for banks',y3dscr>=1.2?'green':'red','Debt Service Coverage Ratio. Annual EBITDA divided by annual loan payments (principal + interest). Banks require minimum 1.2x, prefer 1.5x+.')+ - cardSmHTML('LTV',fmtP(d.ltv),'Loan \u00F7 Total Investment','','Loan-to-Value ratio. Total debt as percentage of total investment cost. Banks typically cap at 80\u201385%. Lower = less financial risk.')+ - cardSmHTML('Debt Yield',fmtP(d.debtYield),'Stab. EBITDA \u00F7 Loan',d.debtYield>.1?'green':'amber','Stabilized EBITDA divided by total loan amount. Alternative lender risk metric. Above 10% is healthy, indicating the loan is well-supported by earnings.')+ - cardSmHTML('Monthly Debt Service',fmt(Math.round(d.monthlyPayment)),'P&I payment','red','Monthly loan payment including both principal repayment and interest. This is a fixed cost that must be paid regardless of revenue.'); - - $('#mInvest').innerHTML = - cardSmHTML('CAPEX / Court',fmt(Math.round(d.capexPerCourt)),'Total investment per court','','Total CAPEX divided by number of courts. Key benchmark for comparing build costs across scenarios and competitor venues.')+ - cardSmHTML('CAPEX / m\u00B2',fmt(Math.round(d.capexPerSqm)),'Investment per floor area','','Total CAPEX divided by total venue area. Measures construction cost efficiency per unit of space.')+ - cardSmHTML('Yield on Cost',fmtP(d.yieldOnCost),'Stab. EBITDA \u00F7 CAPEX',d.yieldOnCost>.08?'green':'amber','Stabilized annual EBITDA divided by total CAPEX. Measures the annual return generated by the physical asset. Target: >8%.')+ - cardSmHTML('Exit Value',fmtK(d.exitValue),`${S.exitMultiple}x Y3 EBITDA`,'','Estimated sale value of the business at exit. Calculated as stabilized EBITDA multiplied by the exit EBITDA multiple.'); - - $('#mOps').innerHTML = - cardSmHTML('Break-Even Util.',fmtP(d.breakEvenUtil),`${d.breakEvenHrsPerCourt.toFixed(1)} hrs/court/day`,d.breakEvenUtil<.35?'green':'amber','Minimum utilization needed to cover all costs. The lower this is, the safer the business \u2014 more room for underperformance.')+ - cardSmHTML('Y3 Utilization',fmtP(d.avgUtil),'Effective avg utilization','','Average effective utilization in Year 3. Should be at or near your target utilization, accounting for ramp-up completion.')+ - cardSmHTML('Available Hours/mo',fmtN(d.availHoursMonth),'All courts combined','','Total available court-hours per month across all courts. Operating hours \u00D7 days per month \u00D7 number of courts.')+ - cardSmHTML('Operating Months',isIn?'12':'~'+S.season.filter(s=>s>0).length,isIn?'Year-round':'Seasonal','','Number of months per year the venue generates revenue. Indoor: 12. Outdoor: depends on climate, typically 6\u20138 months.'); -} - -function renderSeasonChart(){ - 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}] - },{scales:{x:{ticks:{color:'#94A3B8'}},y:{max:110,ticks:{color:'#94A3B8'},grid:{color:'rgba(0,0,0,0.04)'}}},plugins:{legend:{display:false}}}); -} - -// ── Chart Helper ────────────────────────────────────────── -function renderChart(canvasId,type,data,opts={}){ - if(charts[canvasId]) charts[canvasId].destroy(); - const ctx = document.getElementById(canvasId); - if(!ctx) return; - const defaults = { - responsive:true, maintainAspectRatio:false, animation:{duration:0}, - scales:{}, - plugins:{legend:{labels:{color:'#64748B',font:{family:'Inter',size:10}}}}, - }; - if(type==='doughnut'||type==='pie'){ - delete defaults.scales; - defaults.cutout = '55%'; - } else { - defaults.scales = { - x:{ticks:{color:'#94A3B8',font:{size:9,family:'Inter'}},grid:{display:false},border:{color:'#E2E8F0'}}, - y:{ticks:{color:'#94A3B8',font:{size:9,family:'Commit Mono'}},grid:{color:'rgba(0,0,0,0.04)'},border:{color:'#E2E8F0'}}, - }; - } - charts[canvasId] = new Chart(ctx,{type,data,options:deepMerge(defaults,opts)}); -} - -function deepMerge(t,s){ - const o={...t}; - for(const k in s){ - if(s[k]&&typeof s[k]==='object'&&!Array.isArray(s[k])&&t[k]&&typeof t[k]==='object') o[k]=deepMerge(t[k],s[k]); - else o[k]=s[k]; - } - return o; -} - -// ── Scenario Save/Load ──────────────────────────────────── -function saveScenario(){ - 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__, { - method: 'POST', - headers: {'Content-Type':'application/json', 'X-CSRF-Token': csrf}, - body: JSON.stringify({name, state_json: JSON.stringify(S)}), - }) - .then(r=>r.json()) - .then(data=>{ - if(data.ok){ - const fb = document.getElementById('save-feedback'); - fb.innerHTML = `
${tr('toast_saved','Scenario saved!')}
`; - const countBtn = document.getElementById('scenarioListBtn'); - if(countBtn) countBtn.textContent = `${tr('btn_my_scenarios','My Scenarios')} (${data.count})`; - } - }); -} - -function loadScenario(id){ - fetch(window.__PADELNOMICS_SCENARIO_URL__ + id) - .then(r=>r.json()) - .then(data=>{ - if(data.state_json){ - const state = JSON.parse(data.state_json); - Object.assign(S, state); - buildInputs(); - bindSliders(); - bindPills(); - render(); - document.getElementById('scenario-drawer').classList.remove('open'); - } - }); -} - -let _resetPending = false; -let _resetTimer = null; -function resetToDefaults(){ - const btn = document.getElementById('resetDefaultsBtn'); - if(!_resetPending){ - _resetPending = true; - btn.textContent = tr('btn_reset_confirm','Sure? Reset'); - btn.classList.add('btn-reset--confirm'); - _resetTimer = setTimeout(()=>{ - _resetPending = false; - btn.textContent = tr('btn_reset','Reset to Defaults'); - btn.classList.remove('btn-reset--confirm'); - }, 3000); - return; - } - clearTimeout(_resetTimer); - _resetPending = false; - btn.textContent = tr('btn_reset','Reset to Defaults'); - btn.classList.remove('btn-reset--confirm'); - Object.assign(S, JSON.parse(JSON.stringify(DEFAULTS))); - _userAdjusted.clear(); - buildInputs(); - bindSliders(); - bindPills(); - render(); -} - -// Wire up save button -document.addEventListener('DOMContentLoaded', () => { - const resetBtn = document.getElementById('resetDefaultsBtn'); - if(resetBtn) resetBtn.onclick = resetToDefaults; - - const saveBtn = document.getElementById('saveScenarioBtn'); - if(saveBtn) saveBtn.onclick = saveScenario; - - const listBtn = document.getElementById('scenarioListBtn'); - if(listBtn) { - listBtn.addEventListener('click', () => { - document.getElementById('scenario-drawer').classList.add('open'); - }); +document.addEventListener('htmx:afterSettle', initCharts); + +// ─── Slider ↔ number sync ───────────────────────────────────────────────────── +document.addEventListener('input', e => { + const el = e.target; + if (el.type === 'range') { + const num = el.closest('.slider-combo')?.querySelector('[data-sync]'); + if (num) num.value = el.value; + } else if (el.dataset.sync) { + const rng = el.closest('.slider-combo')?.querySelector('[type="range"]'); + if (rng) { rng.value = el.value; rng.dispatchEvent(new InputEvent('input', { bubbles: true })); } } }); -// ── Wizard navigation ───────────────────────────────────── -function buildWizardNav(){ - const dots = $('#wizardDots'); - if(!dots) return; - dots.innerHTML = WIZ_STEPS.map(s=>{ - const cls = s.n===wizStep?'wiz-dot wiz-dot--active':s.n${s.n${s.label}`; - }).join(''); - dots.querySelectorAll('button').forEach(b=>b.onclick=()=>{ - wizStep=parseInt(b.dataset.wiz); - showWizStep(); +// ─── Toggle buttons (venue / own / glassType / lightingType / country) ──────── +function setToggle(btn, key, val) { + const h = document.getElementById('h-' + key); + if (h) h.value = val; + document.querySelectorAll(`[data-toggle="${key}"]`).forEach(b => { + const on = b.dataset.val === val; + b.classList.toggle('toggle-btn--active', on); + b.classList.toggle('pill-btn--active', on); + }); + updateWizardSections(); +} + +function setCountryPreset(code) { + const p = (window.__COUNTRY_PRESETS__ || {})[code]; + if (!p) return; + Object.entries(p).forEach(([k, v]) => { + 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; }); } -function showWizStep(){ - document.querySelectorAll('.wizard-step').forEach(el=>{ - el.classList.toggle('active',parseInt(el.dataset.wiz)===wizStep); +// ─── Conditional wizard sections ────────────────────────────────────────────── +function _setSection(el, visible) { + el.style.display = visible ? '' : 'none'; + // Disable inputs in hidden sections so they're excluded from form submission + el.querySelectorAll('input[name]').forEach(i => { i.disabled = !visible; }); +} + +function updateWizardSections() { + const venue = document.getElementById('h-venue')?.value || 'indoor'; + const own = document.getElementById('h-own')?.value || 'rent'; + const combo = venue + '-' + own; + document.querySelectorAll('[data-show-venue]').forEach(el => + _setSection(el, el.dataset.showVenue === venue)); + document.querySelectorAll('[data-show-capex]').forEach(el => { + const v = el.dataset.showCapex; + _setSection(el, v === combo || v === venue); + }); + document.querySelectorAll('[data-show-opex]').forEach(el => { + const v = el.dataset.showOpex; + _setSection(el, v === combo || v === venue || v === own); }); - buildWizardNav(); - renderWizNav(); - if(_lastD) renderWizPreview(); } -function renderWizPreview(){ - const el=$('#wizPreview'); - if(!el||!_lastD) return; - const d=_lastD; - const cf = d.ebitdaMonth - d.monthlyPayment; - const cfCls = cf>=0?'c-green':'c-red'; - const irrOk = isFinite(d.irr)&&!isNaN(d.irr); - el.innerHTML=` -
-
${tr('wiz_capex','CAPEX')}
-
${fmtK(d.capex)}
-
-
-
${tr('wiz_monthly_cf','Monthly CF')}
-
${fmtK(cf)}${tr('wiz_mo','/mo')}
-
-
-
${tr('wiz_irr','IRR')} (${S.holdYears}yr)
-
${irrOk?fmtP(d.irr):'N/A'}
-
`; +// ─── Tab switching ──────────────────────────────────────────────────────────── +function setActiveTab(tab) { + const isWiz = tab === 'assumptions'; + 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' : ''; + document.querySelectorAll('.tab-btn').forEach(b => + b.classList.toggle('tab-btn--active', b.dataset.tab === tab)); } -function renderWizNav(){ - const el=$('#wizNav'); - if(!el) return; - let left='', right=''; +// ─── Wizard navigation ──────────────────────────────────────────────────────── +function showWizStep(n) { + const steps = document.querySelectorAll('.wizard-step'); + 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'); + nav.innerHTML = + (n > 1 ? `` : '
') + + (isLast + ? `` + : ``); +} - if(wizStep>1){ - left=``; - } else { - left='
'; +// ─── 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; } } - - if(wizStep<4){ - right=``; - } else if(wizStep===4){ - right=``; - } - - el.innerHTML=left+right; + return state; } -// ── Navigate to standalone quote form ───────────────────── -function goToQuoteForm(){ - const p = new URLSearchParams({ - venue: S.venue, - courts: S.dblCourts + S.sglCourts, - glass: S.glassType, - lighting: S.lightingType, - country: S.country, +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()) }), }); - if(S.budgetTarget) p.set('budget', S.budgetTarget); - window.location.href = (window.__PADELNOMICS_QUOTE_URL__ || '/leads/quote') + '?' + p.toString(); + const fb = document.getElementById('save-feedback'); + fb.textContent = res.ok ? '✓ Saved' : '✗ Error saving'; + setTimeout(() => { fb.textContent = ''; }, 2500); } -// ── Init ────────────────────────────────────────────────── -buildNav(); -buildInputs(); -bindSliders(); -bindPills(); -showWizStep(); -// Use server-provided initial data for first render (no API call needed) -if(_lastD){ - renderWith(_lastD); - // Update tab visibility - $$('.tab-btn').forEach(b=>b.classList.toggle('tab-btn--active', b.dataset.tab===activeTab)); - $$('.tab').forEach(t=>t.classList.toggle('active',t.id===`tab-${activeTab}`)); - // Show CTAs - const _qs=$('#quoteSidebar'); if(_qs) _qs.style.display='block'; - const _qi=$('#quoteInlineCta'); if(_qi) _qi.style.display='block'; -} else { - render(); +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(); +} + +function goToQuoteForm() { + const fd = new FormData(document.getElementById('planner-form')); + const params = new URLSearchParams({ + courts: fd.get('dblCourts') || '0', + venue: fd.get('venue') || 'indoor', + }); + window.location.href = window.__QUOTE_URL__ + '?' + params; +} + +// ─── 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); +}); diff --git a/padelnomics/tests/test_phase0.py b/padelnomics/tests/test_phase0.py index 8871944..5f0d0e4 100644 --- a/padelnomics/tests/test_phase0.py +++ b/padelnomics/tests/test_phase0.py @@ -43,14 +43,15 @@ class TestGuestMode: assert resp.status_code == 200 async def test_calculate_endpoint_works_without_login(self, client): - """POST /planner/calculate returns valid JSON for guest.""" + """POST /planner/calculate returns HTML partial for guest.""" resp = await client.post( "/en/planner/calculate", - json={"state": {"dblCourts": 4}}, + data={"dblCourts": "4", "activeTab": "capex"}, ) assert resp.status_code == 200 - data = await resp.get_json() - assert "capex" in data + html = (await resp.data).decode() + # HTMX endpoint returns an HTML partial containing CAPEX data + assert "capex" in html.lower() or "metric-card" in html async def test_scenario_routes_require_login(self, client): """Save/load/delete/list scenarios still require auth."""