From 37caf3db66af9e8944da6ed9ee89f467f1195650 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sun, 22 Feb 2026 15:26:55 +0100 Subject: [PATCH] feat(planner): mobile-first redesign with bottom nav, collapsible sections, CTA bar - Add sticky bottom tab bar on mobile (<768px) with 5 tabs (Setup, CAPEX, P&L, Cash, Returns) - Merge Metrics tab into Returns as collapsible
section - Wrap wizard input groups in collapsible
elements to reduce scroll fatigue - Add contextual CTA bar above bottom nav showing CAPEX estimate + "Get Quotes" button - Simplify desktop sidebar CTA (remove checklist, add text export link) - Convert loadScenario/resetToDefaults/saveScenario from client-side JS to HTMX/navigation - Convert wizard nav buttons to server-rendered partial (removes i18n from JS) - Remove 3 unused window.__*__ globals, reduce planner.js from 208 to 131 lines - Increase slider thumb size to 20px on mobile for better touch targets - Add bottom padding to main content for bottom nav clearance Co-Authored-By: Claude Opus 4.6 --- web/src/padelnomics/planner/routes.py | 52 ++- .../partials/calculate_response.html | 2 + .../templates/partials/scenario_list.html | 2 +- .../templates/partials/tab_metrics.html | 26 -- .../templates/partials/tab_returns.html | 12 + .../templates/partials/wizard_nav.html | 10 + .../planner/templates/planner.html | 360 ++++++++++-------- web/src/padelnomics/static/css/planner.css | 203 ++++++++++ web/src/padelnomics/static/js/planner.js | 115 +----- 9 files changed, 491 insertions(+), 291 deletions(-) create mode 100644 web/src/padelnomics/planner/templates/partials/wizard_nav.html diff --git a/web/src/padelnomics/planner/routes.py b/web/src/padelnomics/planner/routes.py index b21b06a..ac64207 100644 --- a/web/src/padelnomics/planner/routes.py +++ b/web/src/padelnomics/planner/routes.py @@ -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", @@ -322,7 +322,15 @@ async def index(): default = None if g.user: scenario_count = await count_scenarios(g.user["id"]) - default = await get_default_scenario(g.user["id"]) + # Load specific scenario if ?scenario= 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) lang = g.get("lang", "en") @@ -339,8 +347,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 +362,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 +377,21 @@ async def calculate(): ) +TOTAL_WIZARD_STEPS = 4 + + +@bp.route("/wizard-nav") +async def wizard_nav(): + """HTMX endpoint: render wizard Back/Next/Calculate buttons for a given step.""" + step = request.args.get("step", 1, type=int) + step = max(1, min(step, TOTAL_WIZARD_STEPS)) + lang = g.get("lang", "en") + t = get_translations(lang) + return await render_template( + "partials/wizard_nav.html", step=step, total_steps=TOTAL_WIZARD_STEPS, t=t, + ) + + @bp.route("/scenarios", methods=["GET"]) @login_required async def scenario_list(): @@ -379,24 +403,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 +439,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'
✓ {t.get("scenario_saved", "Saved")}
' @bp.route("/scenarios/", methods=["GET"]) diff --git a/web/src/padelnomics/planner/templates/partials/calculate_response.html b/web/src/padelnomics/planner/templates/partials/calculate_response.html index 0d4742e..a68f022 100644 --- a/web/src/padelnomics/planner/templates/partials/calculate_response.html +++ b/web/src/padelnomics/planner/templates/partials/calculate_response.html @@ -6,3 +6,5 @@
{% include "partials/court_summary.html" %}
{% include "partials/wizard_preview.html" %}
+ +
{{ d.capex | fmt_k }} {{ t.planner_cta_estimated|default('estimated') }}
diff --git a/web/src/padelnomics/planner/templates/partials/scenario_list.html b/web/src/padelnomics/planner/templates/partials/scenario_list.html index fe9aa61..be3c0c5 100644 --- a/web/src/padelnomics/planner/templates/partials/scenario_list.html +++ b/web/src/padelnomics/planner/templates/partials/scenario_list.html @@ -10,7 +10,7 @@
{{ s.name }}
{% if s.is_default %}{{ t.scenario_badge_default }}{% endif %} - + {{ t.scenario_btn_load }} +{% else %} +
+{% endif %} +{% if step >= total_steps %} + +{% else %} + +{% endif %} diff --git a/web/src/padelnomics/planner/templates/planner.html b/web/src/padelnomics/planner/templates/planner.html index 08c3e5c..4e24800 100644 --- a/web/src/padelnomics/planner/templates/planner.html +++ b/web/src/padelnomics/planner/templates/planner.html @@ -50,14 +50,30 @@ hx-swap="innerHTML"> {{ t.btn_my_scenarios }} ({{ scenario_count }}) - + +
+ {% endif %}
@@ -174,27 +196,36 @@

{{ t.planner_step2_title }}

{{ t.planner_step2_sub }}

-
-

{{ t.planner_section_pricing }}

{{ t.planner_hint_per_court }}
- {{ 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) }} -
+
+ {{ t.planner_section_pricing }} {{ t.planner_hint_per_court }} +
+ {{ 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) }} +
+
-
-

{{ t.planner_section_util }}

- {{ 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) }} -
{{ t.sl_ancillary_header }}
- {{ 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) }} -
+
+ {{ t.planner_section_util }} +
+ {{ 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) }} +
+
+ +
+ {{ t.sl_ancillary_header }} {{ t.planner_hint_optional|default('Optional') }} +
+ {{ 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) }} +
+
@@ -202,62 +233,69 @@

{{ t.planner_step3_title }}

{{ t.planner_step3_sub }}

-
-

{{ t.planner_section_capex }}

{{ t.planner_hint_adjust }}
+
+ {{ t.planner_section_capex }} {{ t.planner_hint_adjust }} +
+
+ +
+ {{ 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, t.tip_court_cost_dbl) }} + {{ slider('courtCostSgl', t.sl_court_cost_sgl, 0, 60000, 1000, s.courtCostSgl, t.tip_court_cost_sgl) }} +
+
-
- -
- {{ pill_btn('glassType','standard', t.pill_glass_standard, s.glassType == 'standard') }} - {{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }} +
+ {{ t.planner_section_building|default('Building & Facility') }} +
+ +
+ {{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, t.tip_hall_cost_sqm) }} + {{ slider('foundationSqm', t.sl_foundation_sqm, 0, 400, 5, s.foundationSqm, t.tip_foundation_sqm) }} + {{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }} + {{ slider('hvac', t.sl_hvac, 0, 500000, 5000, s.hvac, t.tip_hvac) }} + {{ slider('electrical', t.sl_electrical, 0, 400000, 5000, s.electrical, t.tip_electrical) }} + {{ slider('sanitary', t.sl_sanitary, 0, 400000, 5000, s.sanitary, t.tip_sanitary) }} + {{ 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) }} +
+ +
+ {{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, t.tip_floor_prep) }} + {{ slider('hvacUpgrade', t.sl_hvac_upgrade, 0, 200000, 1000, s.hvacUpgrade, t.tip_hvac_upgrade) }} + {{ 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) }} +
+ +
+ {{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, t.tip_outdoor_foundation) }} + {{ slider('outdoorSiteWork', t.sl_outdoor_site_work, 0, 60000, 500, s.outdoorSiteWork, t.tip_outdoor_site_work) }} + {{ slider('outdoorLighting', t.sl_outdoor_lighting, 0, 20000, 500, s.outdoorLighting, t.tip_outdoor_lighting) }} + {{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, t.tip_outdoor_fencing) }} +
{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }}
+
-
- -
- {{ 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') }} -
+
+ {{ t.planner_section_other_costs|default('Other Costs') }} +
+ {{ 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) }}
- - {{ 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) }} - - -
- {{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, t.tip_hall_cost_sqm) }} - {{ slider('foundationSqm', t.sl_foundation_sqm, 0, 400, 5, s.foundationSqm, t.tip_foundation_sqm) }} - {{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }} - {{ slider('hvac', t.sl_hvac, 0, 500000, 5000, s.hvac, t.tip_hvac) }} - {{ slider('electrical', t.sl_electrical, 0, 400000, 5000, s.electrical, t.tip_electrical) }} - {{ slider('sanitary', t.sl_sanitary, 0, 400000, 5000, s.sanitary, t.tip_sanitary) }} - {{ 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) }} -
- - -
- {{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, t.tip_floor_prep) }} - {{ slider('hvacUpgrade', t.sl_hvac_upgrade, 0, 200000, 1000, s.hvacUpgrade, t.tip_hvac_upgrade) }} - {{ 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) }} -
- - -
- {{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, t.tip_outdoor_foundation) }} - {{ slider('outdoorSiteWork', t.sl_outdoor_site_work, 0, 60000, 500, s.outdoorSiteWork, t.tip_outdoor_site_work) }} - {{ slider('outdoorLighting', t.sl_outdoor_lighting, 0, 20000, 500, s.outdoorLighting, t.tip_outdoor_lighting) }} - {{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, t.tip_outdoor_fencing) }} -
{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }}
-
- - {{ 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) }} -
+
@@ -265,51 +303,50 @@

{{ t.planner_step4_title }}

{{ t.planner_step4_sub }}

-
-

{{ t.planner_section_opex }}

- -
{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, t.tip_rent_sqm) }}
-
{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, t.tip_outdoor_rent) }}
-
{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, t.tip_property_tax) }}
- - {{ 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) }} - -
- {{ 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) }} +
+ {{ t.planner_section_opex }} +
+
{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, t.tip_rent_sqm) }}
+
{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, t.tip_outdoor_rent) }}
+
{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, t.tip_property_tax) }}
+ {{ 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) }} +
+ {{ 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) }} +
+ {{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, t.tip_maintenance) }} +
{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, t.tip_cleaning) }}
+ {{ 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) }}
+
- {{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, t.tip_maintenance) }} +
+ {{ t.planner_section_financing }} +
+ {{ 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) }} +
+
-
{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, t.tip_cleaning) }}
- - {{ 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) }} -
- -
-

{{ t.planner_section_financing }}

- {{ 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) }} -
- -
-

{{ t.planner_section_exit }}

- {{ 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) }} -
+
+ {{ t.planner_section_exit }} {{ t.planner_hint_advanced|default('Advanced') }} +
+ {{ 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) }} +
+
@@ -323,41 +360,27 @@ {% include tab_template %} - - - + + +
+ +
+
{{ d.capex | fmt_k }} {{ t.planner_cta_estimated|default('estimated') }}
+
{{ t.planner_cta_micro|default('Free · 2 min · No obligation') }}
+
+ +
+ {% if not user %} {% endblock %} {% block scripts %} diff --git a/web/src/padelnomics/static/css/planner.css b/web/src/padelnomics/static/css/planner.css index 0cf2a30..edf4bf8 100644 --- a/web/src/padelnomics/static/css/planner.css +++ b/web/src/padelnomics/static/css/planner.css @@ -699,6 +699,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; } } @@ -1125,6 +1137,181 @@ .wizard-step { padding-bottom: 100px; } /* space for sticky footer */ } +/* ── Export inline CTA (within Returns tab) ── */ +.export-cta-inline { + margin-top: 1.5rem; + padding: 14px 16px; + background: var(--gn-bg); + border: 1px solid rgba(22,163,74,0.2); + border-radius: 12px; + font-size: 12px; + color: var(--txt-2); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.export-cta-inline a { + color: var(--gn); + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} +.export-cta-inline a:hover { text-decoration: underline; } + +/* ── Mobile Bottom Navigation ── */ +.bottom-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 60; + background: var(--bg-2); + border-top: 1px solid var(--border); + padding: 4px 0; + 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; + background: transparent; + color: var(--txt-3); + font-size: 10px; + font-weight: 600; + font-family: 'DM Sans', sans-serif; + cursor: pointer; + transition: color 0.15s; + -webkit-tap-highlight-color: transparent; +} +.bottom-nav__btn svg { transition: color 0.15s; } +.bottom-nav__btn--active { + color: var(--rd); +} +@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); + overflow: hidden; +} +.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: 55; + padding: 10px 16px; + padding-bottom: max(10px, env(safe-area-inset-bottom)); + 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 ── */ .planner-app--computing .planner-header h1::after { content: 'computing\2026'; @@ -1134,3 +1321,19 @@ 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; } +} diff --git a/web/src/padelnomics/static/js/planner.js b/web/src/padelnomics/static/js/planner.js index 1d8942a..7b211bc 100644 --- a/web/src/padelnomics/static/js/planner.js +++ b/web/src/padelnomics/static/js/planner.js @@ -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,11 @@ 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'); - nav.innerHTML = - (n > 1 ? `` : '
') + - (isLast - ? `` - : ``); -} - -// ─── 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(); + // Fetch server-rendered nav buttons (moves i18n to templates) + htmx.ajax('GET', '/planner/wizard-nav?step=' + n, '#wizNav'); } +// ─── Quote navigation ──────────────────────────────────────────────────────── function goToQuoteForm() { const fd = new FormData(document.getElementById('planner-form')); const params = new URLSearchParams({ @@ -196,11 +113,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);