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 <details> section
- Wrap wizard input groups in collapsible <details> 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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-22 15:26:55 +01:00
parent 35fe934fec
commit 37caf3db66
9 changed files with 491 additions and 291 deletions

View File

@@ -19,7 +19,7 @@ from ..core import (
waitlist_gate, waitlist_gate,
) )
from ..i18n import get_translations 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( bp = Blueprint(
"planner", "planner",
@@ -322,6 +322,14 @@ async def index():
default = None default = None
if g.user: if g.user:
scenario_count = await count_scenarios(g.user["id"]) scenario_count = await count_scenarios(g.user["id"])
# Load specific scenario if ?scenario=<id> is set, else default
scenario_id = request.args.get("scenario", type=int)
if scenario_id:
default = await fetch_one(
"SELECT * FROM scenarios WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
(scenario_id, g.user["id"]),
)
if not default:
default = await get_default_scenario(g.user["id"]) default = await get_default_scenario(g.user["id"])
initial_state = json.loads(default["state_json"]) if default else {} initial_state = json.loads(default["state_json"]) if default else {}
s = validate_state(initial_state) s = validate_state(initial_state)
@@ -339,8 +347,9 @@ async def index():
lang=lang, lang=lang,
active_tab="capex", active_tab="capex",
country_presets=COUNTRY_PRESETS, country_presets=COUNTRY_PRESETS,
defaults=DEFAULTS,
currency_sym=cur["sym"], currency_sym=cur["sym"],
step=1,
total_steps=TOTAL_WIZARD_STEPS,
) )
@@ -353,7 +362,7 @@ async def calculate():
d = calc(s, lang=lang) d = calc(s, lang=lang)
augment_d(d, s, lang) augment_d(d, s, lang)
active_tab = form.get("activeTab", "capex") 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" active_tab = "capex"
cur = COUNTRY_CURRENCY.get(s["country"], CURRENCY_DEFAULT) cur = COUNTRY_CURRENCY.get(s["country"], CURRENCY_DEFAULT)
g.currency_sym = cur["sym"] 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"]) @bp.route("/scenarios", methods=["GET"])
@login_required @login_required
async def scenario_list(): async def scenario_list():
@@ -379,24 +403,23 @@ async def scenario_list():
@login_required @login_required
@csrf_protect @csrf_protect
async def save_scenario(): async def save_scenario():
data = await request.get_json() form = await request.form
name = data.get("name", "Untitled Scenario") name = form.get("scenario_name", "Untitled Scenario")
state_json = data.get("state_json", "{}") # Build state_json from form data (same form as the planner)
location = data.get("location", "") state_json = json.dumps(form_to_state(form))
scenario_id = data.get("id") location = form.get("location", "")
scenario_id = form.get("scenario_id")
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
is_first_save = not scenario_id and (await count_scenarios(g.user["id"])) == 0 is_first_save = not scenario_id and (await count_scenarios(g.user["id"])) == 0
if scenario_id: if scenario_id:
# Update existing
await execute( await execute(
"UPDATE scenarios SET name = ?, state_json = ?, location = ?, updated_at = ? WHERE id = ? AND user_id = ? AND deleted_at IS NULL", "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: else:
# Create new
scenario_id = await execute( scenario_id = await execute(
"INSERT INTO scenarios (user_id, name, state_json, location, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO scenarios (user_id, name, state_json, location, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
(g.user["id"], name, state_json, location, now, now), (g.user["id"], name, state_json, location, now, now),
@@ -416,8 +439,9 @@ async def save_scenario():
except Exception as e: except Exception as e:
print(f"[NURTURE] Failed to add {g.user['email']} to audience: {e}") print(f"[NURTURE] Failed to add {g.user['email']} to audience: {e}")
count = await count_scenarios(g.user["id"]) lang = g.get("lang", "en")
return jsonify({"ok": True, "id": scenario_id, "count": count}) t = get_translations(lang)
return f'<div class="save-toast">&check; {t.get("scenario_saved", "Saved")}</div>'
@bp.route("/scenarios/<int:scenario_id>", methods=["GET"]) @bp.route("/scenarios/<int:scenario_id>", methods=["GET"])

View File

@@ -6,3 +6,5 @@
<div id="courtSummary" hx-swap-oob="true">{% include "partials/court_summary.html" %}</div> <div id="courtSummary" hx-swap-oob="true">{% include "partials/court_summary.html" %}</div>
<div class="wizard-preview" id="wizPreview" hx-swap-oob="true">{% include "partials/wizard_preview.html" %}</div> <div class="wizard-preview" id="wizPreview" hx-swap-oob="true">{% include "partials/wizard_preview.html" %}</div>
<div id="ctaCapexValue" hx-swap-oob="true">{{ d.capex | fmt_k }} {{ t.planner_cta_estimated|default('estimated') }}</div>

View File

@@ -10,7 +10,7 @@
<div class="scenario-item__name">{{ s.name }}</div> <div class="scenario-item__name">{{ s.name }}</div>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
{% if s.is_default %}<span style="font-size:10px;color:var(--gn,#10B981)">{{ t.scenario_badge_default }}</span>{% endif %} {% if s.is_default %}<span style="font-size:10px;color:var(--gn,#10B981)">{{ t.scenario_badge_default }}</span>{% endif %}
<button onclick="loadScenario({{ s.id }})" style="background:none;border:none;color:var(--bl,#3B82F6);cursor:pointer;font-size:11px;padding:0">{{ t.scenario_btn_load }}</button> <a href="{{ url_for('planner.index', scenario=s.id) }}" style="color:var(--bl,#3B82F6);font-size:11px;text-decoration:none">{{ t.scenario_btn_load }}</a>
<button hx-delete="{{ url_for('planner.delete_scenario', scenario_id=s.id) }}" <button hx-delete="{{ url_for('planner.delete_scenario', scenario_id=s.id) }}"
hx-target="#scenario-drawer" hx-swap="innerHTML" hx-target="#scenario-drawer" hx-swap="innerHTML"
hx-confirm="Delete this scenario?" hx-confirm="Delete this scenario?"

View File

@@ -2,32 +2,6 @@
{% set y3_dscr = d.dscr[2].dscr if d.dscr | length >= 3 else 0 %} {% set y3_dscr = d.dscr[2].dscr if d.dscr | length >= 3 else 0 %}
{% set is_in = s.venue == 'indoor' %} {% set is_in = s.venue == 'indoor' %}
<div class="mb-section">
<div class="section-header"><h3>{{ t.metrics_return }}</h3></div>
<div class="grid-4">
<div class="metric-card metric-card-sm">
<div class="metric-card__label">IRR <span class="ti">i<span class="tp">{{ t.tip_result_irr }}</span></span></div>
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
<div class="metric-card__sub">{{ s.holdYears }}-year</div>
</div>
<div class="metric-card metric-card-sm">
<div class="metric-card__label">MOIC <span class="ti">i<span class="tp">{{ t.tip_result_moic }}</span></span></div>
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
<div class="metric-card__sub">Total return multiple</div>
</div>
<div class="metric-card metric-card-sm">
<div class="metric-card__label">Cash-on-Cash <span class="ti">i<span class="tp">{{ t.tip_result_coc }}</span></span></div>
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
<div class="metric-card__sub">Y3 NCF ÷ Equity</div>
</div>
<div class="metric-card metric-card-sm">
<div class="metric-card__label">Payback</div>
<div class="metric-card__value c-head">{{ ((d.paybackIdx + 1) / 12) | round(1) ~ ' yr' if d.paybackIdx >= 0 else 'N/A' }}</div>
<div class="metric-card__sub">Months: {{ d.paybackIdx + 1 if d.paybackIdx >= 0 else '∞' }}</div>
</div>
</div>
</div>
<div class="mb-section"> <div class="mb-section">
<div class="section-header"><h3>{{ t.metrics_revenue }}</h3></div> <div class="section-header"><h3>{{ t.metrics_revenue }}</h3></div>
<div class="grid-4"> <div class="grid-4">

View File

@@ -100,3 +100,15 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<details class="wizard-details">
<summary class="wizard-details__summary">{{ t.metrics_detail_heading|default('Detailed Metrics') }}</summary>
<div class="wizard-details__body">
{% include "partials/tab_metrics.html" %}
</div>
</details>
<div class="export-cta-inline">
<span>{{ t.planner_export_inline|default('Share this analysis with partners or investors') }}</span>
<a href="{{ url_for('planner.export') }}">{{ t.planner_export_btn }} &rarr;</a>
</div>

View File

@@ -0,0 +1,10 @@
{% if step > 1 %}
<button type="button" class="wiz-btn--back" onclick="showWizStep({{ step - 1 }})">{{ t.btn_back }}</button>
{% else %}
<div></div>
{% endif %}
{% if step >= total_steps %}
<button type="button" class="wiz-btn--next" onclick="document.querySelector('.tab-btn[data-tab=\'capex\'],.bottom-nav__btn[data-tab=\'capex\']').click()">{{ t.btn_show_results }}</button>
{% else %}
<button type="button" class="wiz-btn--next" onclick="showWizStep({{ step + 1 }})">{{ t.btn_next }}</button>
{% endif %}

View File

@@ -50,14 +50,30 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
{{ t.btn_my_scenarios }} ({{ scenario_count }}) {{ t.btn_my_scenarios }} ({{ scenario_count }})
</button> </button>
<button id="saveScenarioBtn">{{ t.btn_save }}</button> <button id="saveScenarioBtn" onclick="document.getElementById('saveForm').style.display=''">{{ t.btn_save }}</button>
</div>
<div id="saveForm" style="display:none;position:absolute;right:1rem;top:100%;background:var(--bg-2);border:1px solid var(--border);border-radius:12px;padding:12px;box-shadow:0 4px 16px rgba(0,0,0,0.1);z-index:80;width:240px">
<label style="font-size:11px;color:var(--txt-2);display:block;margin-bottom:4px">{{ t.scenario_name_label|default('Scenario name') }}</label>
<input type="text" name="scenario_name" value="{{ t.scenario_default_name|default('My Scenario') }}"
style="width:100%;padding:6px 10px;border:1px solid var(--border-2);border-radius:8px;font-size:12px;font-family:'DM Sans',sans-serif;margin-bottom:8px;box-sizing:border-box">
<div style="display:flex;gap:6px">
<button type="button"
hx-post="{{ url_for('planner.save_scenario') }}"
hx-include="#planner-form, [name=scenario_name]"
hx-target="#save-feedback"
hx-swap="innerHTML"
onclick="document.getElementById('saveForm').style.display='none'"
style="flex:1;padding:6px;font-size:12px;font-weight:600;background:var(--cta);color:#fff;border:none;border-radius:8px;cursor:pointer;font-family:'DM Sans',sans-serif">{{ t.btn_save }}</button>
<button type="button" onclick="this.closest('#saveForm').style.display='none'"
style="padding:6px 10px;font-size:12px;background:none;border:1px solid var(--border);border-radius:8px;cursor:pointer;color:var(--txt-2)">{{ t.btn_cancel|default('Cancel') }}</button>
</div>
</div> </div>
{% endif %} {% endif %}
</header> </header>
<nav id="nav" class="tab-nav"> <nav id="nav" class="tab-nav">
<button class="tab-btn tab-btn--active" data-tab="assumptions" onclick="setActiveTab('assumptions')">{{ t.tab_assumptions }}</button> <button class="tab-btn tab-btn--active" data-tab="assumptions" onclick="setActiveTab('assumptions')">{{ t.tab_assumptions }}</button>
{% for tab_id, tab_key in [('capex','tab_capex'),('operating','tab_operating'),('cashflow','tab_cashflow'),('returns','tab_returns'),('metrics','tab_metrics')] %} {% for tab_id, tab_key in [('capex','tab_capex'),('operating','tab_operating'),('cashflow','tab_cashflow'),('returns','tab_returns')] %}
<button class="tab-btn" data-tab="{{ tab_id }}" <button class="tab-btn" data-tab="{{ tab_id }}"
hx-post="{{ url_for('planner.calculate') }}" hx-post="{{ url_for('planner.calculate') }}"
hx-target="#tab-content" hx-target="#tab-content"
@@ -98,7 +114,7 @@
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
<button type="button" id="resetDefaultsBtn" class="btn-reset">{{ t.btn_reset }}</button> <a href="{{ url_for('planner.index') }}" class="btn-reset">{{ t.btn_reset }}</a>
</div> </div>
<!-- Step 1: Venue --> <!-- Step 1: Venue -->
@@ -151,12 +167,18 @@
{{ slider('permitsCompliance', t.sl_permits, 0, 50000, 1000, s.permitsCompliance, t.tip_permits_compliance) }} {{ slider('permitsCompliance', t.sl_permits, 0, 50000, 1000, s.permitsCompliance, t.tip_permits_compliance) }}
</div> </div>
<div class="mb-section"> <details class="wizard-details" open>
<div class="section-header"><h3>{{ t.planner_section_court_config }}</h3></div> <summary class="wizard-details__summary">{{ t.planner_section_court_config }}</summary>
<div class="wizard-details__body">
{{ slider('dblCourts', t.sl_dbl_courts, 0, 30, 1, s.dblCourts, t.tip_dbl_courts) }} {{ slider('dblCourts', t.sl_dbl_courts, 0, 30, 1, s.dblCourts, t.tip_dbl_courts) }}
{{ slider('sglCourts', t.sl_sgl_courts, 0, 30, 1, s.sglCourts, t.tip_sgl_courts) }} {{ slider('sglCourts', t.sl_sgl_courts, 0, 30, 1, s.sglCourts, t.tip_sgl_courts) }}
<div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div>
</div>
</details>
<div class="section-header" style="margin-top:1rem"><h3>{{ t.planner_section_space_req }}</h3></div> <details class="wizard-details">
<summary class="wizard-details__summary">{{ t.planner_section_space_req }}</summary>
<div class="wizard-details__body">
<div data-show-venue="indoor"> <div data-show-venue="indoor">
{{ slider('sqmPerDblHall', t.sl_sqm_dbl_hall, 200, 600, 10, s.sqmPerDblHall, t.tip_sqm_dbl_hall) }} {{ slider('sqmPerDblHall', t.sl_sqm_dbl_hall, 200, 600, 10, s.sqmPerDblHall, t.tip_sqm_dbl_hall) }}
{{ slider('sqmPerSglHall', t.sl_sqm_sgl_hall, 120, 400, 10, s.sqmPerSglHall, t.tip_sqm_sgl_hall) }} {{ slider('sqmPerSglHall', t.sl_sqm_sgl_hall, 120, 400, 10, s.sqmPerSglHall, t.tip_sqm_sgl_hall) }}
@@ -165,8 +187,8 @@
{{ slider('sqmPerDblOutdoor', t.sl_sqm_dbl_outdoor, 200, 500, 10, s.sqmPerDblOutdoor, t.tip_sqm_dbl_outdoor) }} {{ slider('sqmPerDblOutdoor', t.sl_sqm_dbl_outdoor, 200, 500, 10, s.sqmPerDblOutdoor, t.tip_sqm_dbl_outdoor) }}
{{ slider('sqmPerSglOutdoor', t.sl_sqm_sgl_outdoor, 120, 350, 10, s.sqmPerSglOutdoor, t.tip_sqm_sgl_outdoor) }} {{ slider('sqmPerSglOutdoor', t.sl_sqm_sgl_outdoor, 120, 350, 10, s.sqmPerSglOutdoor, t.tip_sqm_sgl_outdoor) }}
</div> </div>
<div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div>
</div> </div>
</details>
</div> </div>
<!-- Step 2: Pricing & Utilization --> <!-- Step 2: Pricing & Utilization -->
@@ -174,27 +196,36 @@
<h2 class="wizard-step__title">{{ t.planner_step2_title }}</h2> <h2 class="wizard-step__title">{{ t.planner_step2_title }}</h2>
<p class="wizard-step__sub">{{ t.planner_step2_sub }}</p> <p class="wizard-step__sub">{{ t.planner_step2_sub }}</p>
<div class="mb-section"> <details class="wizard-details" open>
<div class="section-header"><h3>{{ t.planner_section_pricing }}</h3><span class="hint">{{ t.planner_hint_per_court }}</span></div> <summary class="wizard-details__summary">{{ t.planner_section_pricing }} <span class="wizard-details__hint">{{ t.planner_hint_per_court }}</span></summary>
<div class="wizard-details__body">
{{ slider('ratePeak', t.sl_rate_peak, 0, 150, 1, s.ratePeak, t.tip_rate_peak) }} {{ slider('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('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('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('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) }} {{ slider('bookingFee', t.sl_booking_fee, 0, 30, 1, s.bookingFee, t.tip_booking_fee) }}
</div> </div>
</details>
<div class="mb-section"> <details class="wizard-details" open>
<div class="section-header"><h3>{{ t.planner_section_util }}</h3></div> <summary class="wizard-details__summary">{{ t.planner_section_util }}</summary>
<div class="wizard-details__body">
{{ slider('utilTarget', t.sl_util_target, 0, 100, 1, s.utilTarget, t.tip_util_target) }} {{ slider('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('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('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) }} {{ slider('daysPerMonthOutdoor', t.sl_days_outdoor, 0, 31, 1, s.daysPerMonthOutdoor, t.tip_days_outdoor) }}
<div style="font-size:11px;color:var(--txt-3);margin:4px 0 8px"><b>{{ t.sl_ancillary_header }}</b></div> </div>
</details>
<details class="wizard-details">
<summary class="wizard-details__summary">{{ t.sl_ancillary_header }} <span class="wizard-details__hint">{{ t.planner_hint_optional|default('Optional') }}</span></summary>
<div class="wizard-details__body">
{{ slider('membershipRevPerCourt', t.sl_membership_rev, 0, 2000, 50, s.membershipRevPerCourt, t.tip_membership_rev) }} {{ slider('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('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('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) }} {{ slider('retailRevPerCourt', t.sl_retail_rev, 0, 1000, 10, s.retailRevPerCourt, t.tip_retail_rev) }}
</div> </div>
</details>
</div> </div>
<!-- Step 3: Investment & Build Costs --> <!-- Step 3: Investment & Build Costs -->
@@ -202,9 +233,9 @@
<h2 class="wizard-step__title">{{ t.planner_step3_title }}</h2> <h2 class="wizard-step__title">{{ t.planner_step3_title }}</h2>
<p class="wizard-step__sub">{{ t.planner_step3_sub }}</p> <p class="wizard-step__sub">{{ t.planner_step3_sub }}</p>
<div class="mb-section"> <details class="wizard-details" open>
<div class="section-header"><h3>{{ t.planner_section_capex }}</h3><span class="hint">{{ t.planner_hint_adjust }}</span></div> <summary class="wizard-details__summary">{{ t.planner_section_capex }} <span class="wizard-details__hint">{{ t.planner_hint_adjust }}</span></summary>
<div class="wizard-details__body">
<div class="pill-group"> <div class="pill-group">
<label><span class="slider-group__label">{{ t.pill_glass_type }}</span><span class="ti">i<span class="tp">{{ t.tip_glass_type }}</span></span></label> <label><span class="slider-group__label">{{ t.pill_glass_type }}</span><span class="ti">i<span class="tp">{{ t.tip_glass_type }}</span></span></label>
<div class="pill-options"> <div class="pill-options">
@@ -212,7 +243,6 @@
{{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }} {{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }}
</div> </div>
</div> </div>
<div class="pill-group"> <div class="pill-group">
<label><span class="slider-group__label">{{ t.pill_lighting_type }}</span></label> <label><span class="slider-group__label">{{ t.pill_lighting_type }}</span></label>
<div class="pill-options"> <div class="pill-options">
@@ -221,10 +251,14 @@
<span data-show-venue="outdoor">{{ pill_btn('lightingType','natural', t.pill_light_natural, s.lightingType == 'natural') }}</span> <span data-show-venue="outdoor">{{ pill_btn('lightingType','natural', t.pill_light_natural, s.lightingType == 'natural') }}</span>
</div> </div>
</div> </div>
{{ slider('courtCostDbl', t.sl_court_cost_dbl, 0, 80000, 1000, s.courtCostDbl, t.tip_court_cost_dbl) }} {{ 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('courtCostSgl', t.sl_court_cost_sgl, 0, 60000, 1000, s.courtCostSgl, t.tip_court_cost_sgl) }}
</div>
</details>
<details class="wizard-details" open>
<summary class="wizard-details__summary">{{ t.planner_section_building|default('Building & Facility') }}</summary>
<div class="wizard-details__body">
<!-- Indoor + Buy --> <!-- Indoor + Buy -->
<div data-show-capex="indoor-buy"> <div data-show-capex="indoor-buy">
{{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, t.tip_hall_cost_sqm) }} {{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, t.tip_hall_cost_sqm) }}
@@ -236,7 +270,6 @@
{{ slider('fireProtection', t.sl_fire, 0, 500000, 5000, s.fireProtection, t.tip_fire_protection) }} {{ slider('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('planning', t.sl_planning, 0, 500000, 5000, s.planning, t.tip_planning) }}
</div> </div>
<!-- Indoor + Rent --> <!-- Indoor + Rent -->
<div data-show-capex="indoor-rent"> <div data-show-capex="indoor-rent">
{{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, t.tip_floor_prep) }} {{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, t.tip_floor_prep) }}
@@ -244,7 +277,6 @@
{{ slider('lightingUpgrade', t.sl_lighting_upgrade, 0, 100000, 1000, s.lightingUpgrade, t.tip_lighting_upgrade) }} {{ slider('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('fitout', t.sl_fitout, 0, 300000, 1000, s.fitout, t.tip_fitout) }}
</div> </div>
<!-- Outdoor --> <!-- Outdoor -->
<div data-show-capex="outdoor"> <div data-show-capex="outdoor">
{{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, t.tip_outdoor_foundation) }} {{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, t.tip_outdoor_foundation) }}
@@ -253,11 +285,17 @@
{{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, t.tip_outdoor_fencing) }} {{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, t.tip_outdoor_fencing) }}
<div data-show-capex="outdoor-buy">{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }}</div> <div data-show-capex="outdoor-buy">{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }}</div>
</div> </div>
</div>
</details>
<details class="wizard-details">
<summary class="wizard-details__summary">{{ t.planner_section_other_costs|default('Other Costs') }}</summary>
<div class="wizard-details__body">
{{ slider('workingCapital', t.sl_working_capital, 0, 200000, 1000, s.workingCapital, t.tip_working_capital) }} {{ slider('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('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('budgetTarget', t.sl_budget_target, 0, 5000000, 10000, s.budgetTarget, t.tip_budget_target) }}
</div> </div>
</details>
</div> </div>
<!-- Step 4: Operations & Financing --> <!-- Step 4: Operations & Financing -->
@@ -265,51 +303,50 @@
<h2 class="wizard-step__title">{{ t.planner_step4_title }}</h2> <h2 class="wizard-step__title">{{ t.planner_step4_title }}</h2>
<p class="wizard-step__sub">{{ t.planner_step4_sub }}</p> <p class="wizard-step__sub">{{ t.planner_step4_sub }}</p>
<div class="mb-section"> <details class="wizard-details" open>
<div class="section-header"><h3>{{ t.planner_section_opex }}</h3></div> <summary class="wizard-details__summary">{{ t.planner_section_opex }}</summary>
<div class="wizard-details__body">
<div data-show-opex="indoor-rent">{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, t.tip_rent_sqm) }}</div> <div data-show-opex="indoor-rent">{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, t.tip_rent_sqm) }}</div>
<div data-show-opex="outdoor-rent">{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, t.tip_outdoor_rent) }}</div> <div data-show-opex="outdoor-rent">{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, t.tip_outdoor_rent) }}</div>
<div data-show-opex="buy">{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, t.tip_property_tax) }}</div> <div data-show-opex="buy">{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, t.tip_property_tax) }}</div>
{{ slider('insurance', t.sl_insurance, 0, 2000, 25, s.insurance, t.tip_insurance) }} {{ slider('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('electricity', t.sl_electricity, 0, 5000, 25, s.electricity, t.tip_electricity) }}
<div data-show-opex="indoor"> <div data-show-opex="indoor">
{{ slider('heating', t.sl_heating, 0, 3000, 25, s.heating, t.tip_heating) }} {{ 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('water', t.sl_water, 0, 1000, 25, s.water, t.tip_water) }}
</div> </div>
{{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, t.tip_maintenance) }} {{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, t.tip_maintenance) }}
<div data-show-opex="indoor">{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, t.tip_cleaning) }}</div> <div data-show-opex="indoor">{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, t.tip_cleaning) }}</div>
{{ slider('marketing', t.sl_marketing, 0, 5000, 25, s.marketing, t.tip_marketing) }} {{ slider('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('staff', t.sl_staff, 0, 20000, 100, s.staff, t.tip_staff) }}
</div> </div>
</details>
<div class="mb-section"> <details class="wizard-details" open>
<div class="section-header"><h3>{{ t.planner_section_financing }}</h3></div> <summary class="wizard-details__summary">{{ t.planner_section_financing }}</summary>
<div class="wizard-details__body">
{{ slider('loanPct', t.sl_loan_pct, 0, 100, 1, s.loanPct, t.tip_loan_pct) }} {{ slider('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('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('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('constructionMonths', t.sl_construction_months, 0, 24, 1, s.constructionMonths, t.tip_construction_months) }}
</div> </div>
</details>
<div class="mb-section"> <details class="wizard-details">
<div class="section-header"><h3>{{ t.planner_section_exit }}</h3></div> <summary class="wizard-details__summary">{{ t.planner_section_exit }} <span class="wizard-details__hint">{{ t.planner_hint_advanced|default('Advanced') }}</span></summary>
<div class="wizard-details__body">
{{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, t.tip_hold_years) }} {{ slider('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('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) }} {{ slider('annualRevGrowth', t.sl_annual_rev_growth, 0, 15, 0.5, s.annualRevGrowth, t.tip_annual_rev_growth) }}
</div> </div>
</details>
</div> </div>
<!-- Wizard footer: preview bar + navigation --> <!-- Wizard footer: preview bar + navigation -->
<div class="wizard-footer"> <div class="wizard-footer">
<div class="wizard-preview" id="wizPreview">{% include "partials/wizard_preview.html" %}</div> <div class="wizard-preview" id="wizPreview">{% include "partials/wizard_preview.html" %}</div>
<div class="wizard-nav" id="wizNav"> <div class="wizard-nav" id="wizNav">
<div></div> {% include "partials/wizard_nav.html" %}
<button type="button" class="wiz-btn--next" onclick="showWizStep(2)">{{ t.btn_next }}</button>
</div> </div>
</div> </div>
</div><!-- /planner-wizard --> </div><!-- /planner-wizard -->
@@ -323,41 +360,27 @@
{% include tab_template %} {% include tab_template %}
</div> </div>
<!-- Inline quote CTA (mobile / narrow screens) -->
<div class="quote-inline-cta" id="quoteInlineCta" style="display:none">
<div class="quote-inline-cta__label">{{ t.planner_quote_cta_label }}</div>
<h3 class="quote-inline-cta__title">{{ t.planner_quote_cta_title }}</h3>
<p class="quote-inline-cta__desc">{{ t.planner_quote_cta_desc }}</p>
<ul class="quote-inline-cta__checks">
<li><span class="quote-inline-cta__check">&check;</span> {{ t.planner_quote_cta_check_1 }}</li>
<li><span class="quote-inline-cta__check">&check;</span> {{ t.planner_quote_cta_check_2 }}</li>
<li><span class="quote-inline-cta__check">&check;</span> {{ t.planner_quote_cta_check_3 }}</li>
<li><span class="quote-inline-cta__check">&check;</span> {{ t.planner_quote_cta_check_4 }}</li>
</ul>
<button class="quote-inline-cta__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }}</button>
<span class="quote-inline-cta__hint">{{ t.planner_quote_cta_hint }}</span>
</div>
</main> </main>
<!-- Sidebar CTA (desktop) --> <!-- Sidebar CTA (desktop, simplified) -->
<aside class="quote-sidebar" id="quoteSidebar"> <aside class="quote-sidebar" id="quoteSidebar">
<div class="quote-sidebar__label">{{ t.planner_quote_cta_label }}</div>
<h3 class="quote-sidebar__title">{{ t.planner_quote_cta_title }}</h3> <h3 class="quote-sidebar__title">{{ t.planner_quote_cta_title }}</h3>
<p class="quote-sidebar__desc">{{ t.planner_quote_cta_desc }}</p> <p class="quote-sidebar__desc">{{ t.planner_quote_sidebar_desc|default('Compare your estimate with real supplier quotes.') }}</p>
<ul class="quote-sidebar__checks">
<li><span class="quote-sidebar__check">&check;</span> {{ t.planner_quote_cta_check_1 }}</li>
<li><span class="quote-sidebar__check">&check;</span> {{ t.planner_quote_cta_check_2 }}</li>
<li><span class="quote-sidebar__check">&check;</span> {{ t.planner_quote_cta_check_3 }}</li>
<li><span class="quote-sidebar__check">&check;</span> {{ t.planner_quote_cta_check_4 }}</li>
</ul>
<button class="quote-sidebar__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }}</button> <button class="quote-sidebar__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }}</button>
<span class="quote-sidebar__hint">{{ t.planner_quote_cta_hint }}</span> <span class="quote-sidebar__hint">{{ t.planner_quote_cta_hint }}</span>
<div style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1)"> <a href="{{ url_for('planner.export') }}" class="quote-sidebar__export-link">{{ t.planner_export_btn }}</a>
<a href="{{ url_for('planner.export') }}" class="quote-sidebar__btn" style="background:#16A34A;text-decoration:none;display:block;text-align:center">{{ t.planner_export_btn }}</a>
<span class="quote-sidebar__hint">{{ t.planner_export_hint }}</span>
</div>
</aside> </aside>
<!-- Mobile CTA bar (above bottom nav, result tabs only) -->
<div class="cta-bottom-bar" id="ctaBottomBar">
<button class="cta-bottom-bar__dismiss" onclick="this.parentElement.classList.remove('visible');this.parentElement.dataset.dismissed='1'" aria-label="Dismiss">&times;</button>
<div class="cta-bottom-bar__text">
<div class="cta-bottom-bar__value" id="ctaCapexValue">{{ d.capex | fmt_k }} {{ t.planner_cta_estimated|default('estimated') }}</div>
<div class="cta-bottom-bar__hint">{{ t.planner_cta_micro|default('Free · 2 min · No obligation') }}</div>
</div>
<button class="cta-bottom-bar__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }} &rarr;</button>
</div>
{% if not user %} {% if not user %}
<div class="signup-bar" id="signupBar" style="display:none"> <div class="signup-bar" id="signupBar" style="display:none">
<span>{{ t.planner_signup_bar_msg }}</span> <span>{{ t.planner_signup_bar_msg }}</span>
@@ -368,15 +391,44 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="scenario-drawer"></div> <div id="scenario-drawer"></div>
<div id="save-feedback"></div> <div id="save-feedback"></div>
<!-- Mobile bottom tab bar -->
<nav class="bottom-nav" id="bottomNav">
<button class="bottom-nav__btn bottom-nav__btn--active" data-tab="assumptions" onclick="setActiveTab('assumptions')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
<span>{{ t.tab_assumptions_short|default('Setup') }}</span>
</button>
<button class="bottom-nav__btn" data-tab="capex"
hx-post="{{ url_for('planner.calculate') }}" hx-target="#tab-content" hx-include="#planner-form"
onclick="setActiveTab('capex')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
<span>{{ t.tab_capex_short|default('CAPEX') }}</span>
</button>
<button class="bottom-nav__btn" data-tab="operating"
hx-post="{{ url_for('planner.calculate') }}" hx-target="#tab-content" hx-include="#planner-form"
onclick="setActiveTab('operating')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
<span>{{ t.tab_operating_short|default('P&L') }}</span>
</button>
<button class="bottom-nav__btn" data-tab="cashflow"
hx-post="{{ url_for('planner.calculate') }}" hx-target="#tab-content" hx-include="#planner-form"
onclick="setActiveTab('cashflow')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></svg>
<span>{{ t.tab_cashflow_short|default('Cash') }}</span>
</button>
<button class="bottom-nav__btn" data-tab="returns"
hx-post="{{ url_for('planner.calculate') }}" hx-target="#tab-content" hx-include="#planner-form"
onclick="setActiveTab('returns')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M16 8l-4 4-4-4"/><path d="M12 12v4"/></svg>
<span>{{ t.tab_returns_short|default('Returns') }}</span>
</button>
</nav>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
window.__COUNTRY_PRESETS__ = {{ country_presets | tojson | safe }}; window.__COUNTRY_PRESETS__ = {{ country_presets | tojson | safe }};
window.__DEFAULTS__ = {{ defaults | tojson | safe }};
window.__SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
window.__SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
window.__QUOTE_URL__ = "{{ url_for('leads.quote_request') }}"; window.__QUOTE_URL__ = "{{ url_for('leads.quote_request') }}";
</script> </script>
<script src="{{ url_for('static', filename='js/planner.js') }}"></script> <script src="{{ url_for('static', filename='js/planner.js') }}"></script>

View File

@@ -699,6 +699,18 @@
color: #94A3B8; color: #94A3B8;
margin-top: 8px; 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) { @media (max-width: 1400px) {
.quote-sidebar { display: none !important; } .quote-sidebar { display: none !important; }
} }
@@ -1125,6 +1137,181 @@
.wizard-step { padding-bottom: 100px; } /* space for sticky footer */ .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 ── */ /* ── Computing indicator ── */
.planner-app--computing .planner-header h1::after { .planner-app--computing .planner-header h1::after {
content: 'computing\2026'; content: 'computing\2026';
@@ -1134,3 +1321,19 @@
margin-left: 10px; margin-left: 10px;
letter-spacing: 0.03em; 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; }
}

View File

@@ -81,10 +81,16 @@ function setActiveTab(tab) {
document.getElementById('h-activeTab').value = tab; document.getElementById('h-activeTab').value = tab;
document.getElementById('planner-wizard').style.display = isWiz ? '' : 'none'; document.getElementById('planner-wizard').style.display = isWiz ? '' : 'none';
document.getElementById('tab-content').style.display = isWiz ? 'none' : ''; document.getElementById('tab-content').style.display = isWiz ? 'none' : '';
const cta = document.getElementById('quoteInlineCta'); // Sync both top and bottom nav active states
if (cta) cta.style.display = isWiz ? 'none' : '';
document.querySelectorAll('.tab-btn').forEach(b => document.querySelectorAll('.tab-btn').forEach(b =>
b.classList.toggle('tab-btn--active', b.dataset.tab === tab)); 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 ──────────────────────────────────────────────────────── // ─── Wizard navigation ────────────────────────────────────────────────────────
@@ -93,100 +99,11 @@ function showWizStep(n) {
steps.forEach(s => s.classList.toggle('active', +s.dataset.wiz === n)); steps.forEach(s => s.classList.toggle('active', +s.dataset.wiz === n));
document.querySelectorAll('.wiz-dot').forEach(d => document.querySelectorAll('.wiz-dot').forEach(d =>
d.classList.toggle('wiz-dot--active', +d.dataset.wiz === n)); d.classList.toggle('wiz-dot--active', +d.dataset.wiz === n));
const de = document.documentElement.lang === 'de'; // Fetch server-rendered nav buttons (moves i18n to templates)
const prev = de ? 'Zurück' : 'Back'; htmx.ajax('GET', '/planner/wizard-nav?step=' + n, '#wizNav');
const next = de ? 'Weiter' : 'Next';
const calc = de ? 'Berechnen →' : 'Calculate →';
const isLast = n >= steps.length;
const nav = document.getElementById('wizNav');
nav.innerHTML =
(n > 1 ? `<button type="button" class="wiz-btn--back" onclick="showWizStep(${n - 1})">${prev}</button>` : '<div></div>') +
(isLast
? `<button type="button" class="wiz-btn--next" onclick="document.querySelector('.tab-btn[data-tab=\\'capex\\']').click()">${calc}</button>`
: `<button type="button" class="wiz-btn--next" onclick="showWizStep(${n + 1})">${next}</button>`);
}
// ─── Scenarios ────────────────────────────────────────────────────────────────
function _formState() {
const fd = new FormData(document.getElementById('planner-form'));
const state = {};
for (const [k, v] of fd.entries()) {
if (k === 'ramp' || k === 'season') { (state[k] = state[k] || []).push(Number(v)); }
else { state[k] = v; }
}
return state;
}
async function saveScenario() {
const de = document.documentElement.lang === 'de';
const name = prompt(de ? 'Szenario-Name:' : 'Scenario name:', de ? 'Mein Szenario' : 'My Scenario');
if (!name) return;
const csrf = document.querySelector('[name="csrf_token"]')?.value || '';
const res = await fetch(window.__SAVE_URL__, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
body: JSON.stringify({ name, state_json: JSON.stringify(_formState()) }),
});
const fb = document.getElementById('save-feedback');
fb.textContent = res.ok ? '✓ Saved' : '✗ Error saving';
setTimeout(() => { fb.textContent = ''; }, 2500);
}
async function loadScenario(id) {
const res = await fetch(window.__SCENARIO_URL__ + id);
if (!res.ok) return;
const row = await res.json();
const state = JSON.parse(row.state_json || '{}');
Object.entries(state).forEach(([k, v]) => {
if (Array.isArray(v)) {
document.querySelectorAll(`#planner-form [name="${k}"]`).forEach((inp, i) => {
if (v[i] !== undefined) inp.value = v[i];
});
} else {
const inp = document.querySelector(`#planner-form [name="${k}"]`);
const sync = document.querySelector(`[data-sync="${k}"]`);
if (inp) inp.value = v;
if (sync) sync.value = v;
}
});
['venue', 'own', 'glassType', 'lightingType', 'country'].forEach(key => {
const val = document.getElementById('h-' + key)?.value;
if (!val) return;
document.querySelectorAll(`[data-toggle="${key}"]`).forEach(b => {
b.classList.toggle('toggle-btn--active', b.dataset.val === val);
b.classList.toggle('pill-btn--active', b.dataset.val === val);
});
});
updateWizardSections();
document.querySelector('.tab-btn[data-tab="capex"]').click();
}
// ─── Reset & quote ────────────────────────────────────────────────────────────
function resetToDefaults() {
const D = window.__DEFAULTS__ || {};
Object.entries(D).forEach(([k, v]) => {
if (Array.isArray(v)) {
document.querySelectorAll(`#planner-form [name="${k}"]`).forEach((inp, i) => {
if (v[i] !== undefined) inp.value = v[i];
});
} else {
const inp = document.querySelector(`#planner-form [name="${k}"]`);
const sync = document.querySelector(`[data-sync="${k}"]`);
if (inp) inp.value = v;
if (sync) sync.value = v;
}
});
['venue', 'own', 'glassType', 'lightingType'].forEach(key => {
const val = document.getElementById('h-' + key)?.value;
if (!val) return;
document.querySelectorAll(`[data-toggle="${key}"]`).forEach(b => {
b.classList.toggle('toggle-btn--active', b.dataset.val === val);
b.classList.toggle('pill-btn--active', b.dataset.val === val);
});
});
updateWizardSections();
} }
// ─── Quote navigation ────────────────────────────────────────────────────────
function goToQuoteForm() { function goToQuoteForm() {
const fd = new FormData(document.getElementById('planner-form')); const fd = new FormData(document.getElementById('planner-form'));
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -196,11 +113,17 @@ function goToQuoteForm() {
window.location.href = window.__QUOTE_URL__ + '?' + params; 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 ───────────────────────────────────────────────────────────────────── // ─── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
updateWizardSections(); updateWizardSections();
document.getElementById('saveScenarioBtn')?.addEventListener('click', saveScenario);
document.getElementById('resetDefaultsBtn')?.addEventListener('click', resetToDefaults);
// Show signup nudge after 30 s for unauthenticated visitors // Show signup nudge after 30 s for unauthenticated visitors
const bar = document.getElementById('signupBar'); const bar = document.getElementById('signupBar');
if (bar) setTimeout(() => { bar.style.display = ''; }, 30_000); if (bar) setTimeout(() => { bar.style.display = ''; }, 30_000);