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:
@@ -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,6 +322,14 @@ async def index():
|
||||
default = None
|
||||
if g.user:
|
||||
scenario_count = await count_scenarios(g.user["id"])
|
||||
# Load specific scenario if ?scenario=<id> is set, else default
|
||||
scenario_id = request.args.get("scenario", type=int)
|
||||
if scenario_id:
|
||||
default = await fetch_one(
|
||||
"SELECT * FROM scenarios WHERE id = ? AND user_id = ? AND deleted_at IS NULL",
|
||||
(scenario_id, g.user["id"]),
|
||||
)
|
||||
if not default:
|
||||
default = await get_default_scenario(g.user["id"])
|
||||
initial_state = json.loads(default["state_json"]) if default else {}
|
||||
s = validate_state(initial_state)
|
||||
@@ -339,8 +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'<div class="save-toast">✓ {t.get("scenario_saved", "Saved")}</div>'
|
||||
|
||||
|
||||
@bp.route("/scenarios/<int:scenario_id>", methods=["GET"])
|
||||
|
||||
@@ -6,3 +6,5 @@
|
||||
<div id="courtSummary" hx-swap-oob="true">{% include "partials/court_summary.html" %}</div>
|
||||
|
||||
<div class="wizard-preview" id="wizPreview" hx-swap-oob="true">{% include "partials/wizard_preview.html" %}</div>
|
||||
|
||||
<div id="ctaCapexValue" hx-swap-oob="true">{{ d.capex | fmt_k }} {{ t.planner_cta_estimated|default('estimated') }}</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="scenario-item__name">{{ s.name }}</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
{% if s.is_default %}<span style="font-size:10px;color:var(--gn,#10B981)">{{ t.scenario_badge_default }}</span>{% endif %}
|
||||
<button onclick="loadScenario({{ s.id }})" style="background:none;border:none;color:var(--bl,#3B82F6);cursor:pointer;font-size:11px;padding:0">{{ t.scenario_btn_load }}</button>
|
||||
<a href="{{ url_for('planner.index', scenario=s.id) }}" style="color:var(--bl,#3B82F6);font-size:11px;text-decoration:none">{{ t.scenario_btn_load }}</a>
|
||||
<button hx-delete="{{ url_for('planner.delete_scenario', scenario_id=s.id) }}"
|
||||
hx-target="#scenario-drawer" hx-swap="innerHTML"
|
||||
hx-confirm="Delete this scenario?"
|
||||
|
||||
@@ -2,32 +2,6 @@
|
||||
{% set y3_dscr = d.dscr[2].dscr if d.dscr | length >= 3 else 0 %}
|
||||
{% set is_in = s.venue == 'indoor' %}
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_return }}</h3></div>
|
||||
<div class="grid-4">
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">IRR <span class="ti">i<span class="tp">{{ t.tip_result_irr }}</span></span></div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.irr_ok and d.irr > 0.2 else 'c-red' }}">{{ d.irr | fmt_pct if d.irr_ok else 'N/A' }}</div>
|
||||
<div class="metric-card__sub">{{ s.holdYears }}-year</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">MOIC <span class="ti">i<span class="tp">{{ t.tip_result_moic }}</span></span></div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.moic > 2 else 'c-red' }}">{{ d.moic | fmt_x }}</div>
|
||||
<div class="metric-card__sub">Total return multiple</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Cash-on-Cash <span class="ti">i<span class="tp">{{ t.tip_result_coc }}</span></span></div>
|
||||
<div class="metric-card__value {{ 'c-green' if d.cashOnCash > 0.15 else 'c-amber' }}">{{ d.cashOnCash | fmt_pct }}</div>
|
||||
<div class="metric-card__sub">Y3 NCF ÷ Equity</div>
|
||||
</div>
|
||||
<div class="metric-card metric-card-sm">
|
||||
<div class="metric-card__label">Payback</div>
|
||||
<div class="metric-card__value c-head">{{ ((d.paybackIdx + 1) / 12) | round(1) ~ ' yr' if d.paybackIdx >= 0 else 'N/A' }}</div>
|
||||
<div class="metric-card__sub">Months: {{ d.paybackIdx + 1 if d.paybackIdx >= 0 else '∞' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.metrics_revenue }}</h3></div>
|
||||
<div class="grid-4">
|
||||
|
||||
@@ -100,3 +100,15 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.metrics_detail_heading|default('Detailed Metrics') }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{% include "partials/tab_metrics.html" %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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 }} →</a>
|
||||
</div>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -50,14 +50,30 @@
|
||||
hx-swap="innerHTML">
|
||||
{{ t.btn_my_scenarios }} ({{ scenario_count }})
|
||||
</button>
|
||||
<button id="saveScenarioBtn">{{ t.btn_save }}</button>
|
||||
<button id="saveScenarioBtn" onclick="document.getElementById('saveForm').style.display=''">{{ t.btn_save }}</button>
|
||||
</div>
|
||||
<div id="saveForm" style="display:none;position:absolute;right:1rem;top:100%;background:var(--bg-2);border:1px solid var(--border);border-radius:12px;padding:12px;box-shadow:0 4px 16px rgba(0,0,0,0.1);z-index:80;width:240px">
|
||||
<label style="font-size:11px;color:var(--txt-2);display:block;margin-bottom:4px">{{ t.scenario_name_label|default('Scenario name') }}</label>
|
||||
<input type="text" name="scenario_name" value="{{ t.scenario_default_name|default('My Scenario') }}"
|
||||
style="width:100%;padding:6px 10px;border:1px solid var(--border-2);border-radius:8px;font-size:12px;font-family:'DM Sans',sans-serif;margin-bottom:8px;box-sizing:border-box">
|
||||
<div style="display:flex;gap:6px">
|
||||
<button type="button"
|
||||
hx-post="{{ url_for('planner.save_scenario') }}"
|
||||
hx-include="#planner-form, [name=scenario_name]"
|
||||
hx-target="#save-feedback"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.getElementById('saveForm').style.display='none'"
|
||||
style="flex:1;padding:6px;font-size:12px;font-weight:600;background:var(--cta);color:#fff;border:none;border-radius:8px;cursor:pointer;font-family:'DM Sans',sans-serif">{{ t.btn_save }}</button>
|
||||
<button type="button" onclick="this.closest('#saveForm').style.display='none'"
|
||||
style="padding:6px 10px;font-size:12px;background:none;border:1px solid var(--border);border-radius:8px;cursor:pointer;color:var(--txt-2)">{{ t.btn_cancel|default('Cancel') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<nav id="nav" class="tab-nav">
|
||||
<button class="tab-btn tab-btn--active" data-tab="assumptions" onclick="setActiveTab('assumptions')">{{ t.tab_assumptions }}</button>
|
||||
{% for tab_id, tab_key in [('capex','tab_capex'),('operating','tab_operating'),('cashflow','tab_cashflow'),('returns','tab_returns'),('metrics','tab_metrics')] %}
|
||||
{% for tab_id, tab_key in [('capex','tab_capex'),('operating','tab_operating'),('cashflow','tab_cashflow'),('returns','tab_returns')] %}
|
||||
<button class="tab-btn" data-tab="{{ tab_id }}"
|
||||
hx-post="{{ url_for('planner.calculate') }}"
|
||||
hx-target="#tab-content"
|
||||
@@ -98,7 +114,7 @@
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" id="resetDefaultsBtn" class="btn-reset">{{ t.btn_reset }}</button>
|
||||
<a href="{{ url_for('planner.index') }}" class="btn-reset">{{ t.btn_reset }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Venue -->
|
||||
@@ -151,12 +167,18 @@
|
||||
{{ slider('permitsCompliance', t.sl_permits, 0, 50000, 1000, s.permitsCompliance, t.tip_permits_compliance) }}
|
||||
</div>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_court_config }}</h3></div>
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_court_config }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('dblCourts', t.sl_dbl_courts, 0, 30, 1, s.dblCourts, t.tip_dbl_courts) }}
|
||||
{{ slider('sglCourts', t.sl_sgl_courts, 0, 30, 1, s.sglCourts, t.tip_sgl_courts) }}
|
||||
<div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="section-header" style="margin-top:1rem"><h3>{{ t.planner_section_space_req }}</h3></div>
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_space_req }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
<div data-show-venue="indoor">
|
||||
{{ slider('sqmPerDblHall', t.sl_sqm_dbl_hall, 200, 600, 10, s.sqmPerDblHall, t.tip_sqm_dbl_hall) }}
|
||||
{{ slider('sqmPerSglHall', t.sl_sqm_sgl_hall, 120, 400, 10, s.sqmPerSglHall, t.tip_sqm_sgl_hall) }}
|
||||
@@ -165,8 +187,8 @@
|
||||
{{ slider('sqmPerDblOutdoor', t.sl_sqm_dbl_outdoor, 200, 500, 10, s.sqmPerDblOutdoor, t.tip_sqm_dbl_outdoor) }}
|
||||
{{ slider('sqmPerSglOutdoor', t.sl_sqm_sgl_outdoor, 120, 350, 10, s.sqmPerSglOutdoor, t.tip_sqm_sgl_outdoor) }}
|
||||
</div>
|
||||
<div class="court-summary" id="courtSummary">{% include "partials/court_summary.html" %}</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Pricing & Utilization -->
|
||||
@@ -174,27 +196,36 @@
|
||||
<h2 class="wizard-step__title">{{ t.planner_step2_title }}</h2>
|
||||
<p class="wizard-step__sub">{{ t.planner_step2_sub }}</p>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_pricing }}</h3><span class="hint">{{ t.planner_hint_per_court }}</span></div>
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_pricing }} <span class="wizard-details__hint">{{ t.planner_hint_per_court }}</span></summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('ratePeak', t.sl_rate_peak, 0, 150, 1, s.ratePeak, t.tip_rate_peak) }}
|
||||
{{ slider('rateOffPeak', t.sl_rate_offpeak, 0, 150, 1, s.rateOffPeak, t.tip_rate_offpeak) }}
|
||||
{{ slider('rateSingle', t.sl_rate_single, 0, 150, 1, s.rateSingle, t.tip_rate_single) }}
|
||||
{{ slider('peakPct', t.sl_peak_pct, 0, 100, 1, s.peakPct, t.tip_peak_pct) }}
|
||||
{{ slider('bookingFee', t.sl_booking_fee, 0, 30, 1, s.bookingFee, t.tip_booking_fee) }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_util }}</h3></div>
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_util }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('utilTarget', t.sl_util_target, 0, 100, 1, s.utilTarget, t.tip_util_target) }}
|
||||
{{ slider('hoursPerDay', t.sl_hours_per_day, 0, 24, 1, s.hoursPerDay, t.tip_hours_per_day) }}
|
||||
{{ slider('daysPerMonthIndoor', t.sl_days_indoor, 0, 31, 1, s.daysPerMonthIndoor, t.tip_days_indoor) }}
|
||||
{{ slider('daysPerMonthOutdoor', t.sl_days_outdoor, 0, 31, 1, s.daysPerMonthOutdoor, t.tip_days_outdoor) }}
|
||||
<div style="font-size:11px;color:var(--txt-3);margin:4px 0 8px"><b>{{ t.sl_ancillary_header }}</b></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.sl_ancillary_header }} <span class="wizard-details__hint">{{ t.planner_hint_optional|default('Optional') }}</span></summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('membershipRevPerCourt', t.sl_membership_rev, 0, 2000, 50, s.membershipRevPerCourt, t.tip_membership_rev) }}
|
||||
{{ slider('fbRevPerCourt', t.sl_fb_rev, 0, 2000, 25, s.fbRevPerCourt, t.tip_fb_rev) }}
|
||||
{{ slider('coachingRevPerCourt', t.sl_coaching_rev, 0, 2000, 25, s.coachingRevPerCourt, t.tip_coaching_rev) }}
|
||||
{{ slider('retailRevPerCourt', t.sl_retail_rev, 0, 1000, 10, s.retailRevPerCourt, t.tip_retail_rev) }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Investment & Build Costs -->
|
||||
@@ -202,9 +233,9 @@
|
||||
<h2 class="wizard-step__title">{{ t.planner_step3_title }}</h2>
|
||||
<p class="wizard-step__sub">{{ t.planner_step3_sub }}</p>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_capex }}</h3><span class="hint">{{ t.planner_hint_adjust }}</span></div>
|
||||
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_capex }} <span class="wizard-details__hint">{{ t.planner_hint_adjust }}</span></summary>
|
||||
<div class="wizard-details__body">
|
||||
<div class="pill-group">
|
||||
<label><span class="slider-group__label">{{ t.pill_glass_type }}</span><span class="ti">i<span class="tp">{{ t.tip_glass_type }}</span></span></label>
|
||||
<div class="pill-options">
|
||||
@@ -212,7 +243,6 @@
|
||||
{{ pill_btn('glassType','panoramic', t.pill_glass_panoramic, s.glassType == 'panoramic') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pill-group">
|
||||
<label><span class="slider-group__label">{{ t.pill_lighting_type }}</span></label>
|
||||
<div class="pill-options">
|
||||
@@ -221,10 +251,14 @@
|
||||
<span data-show-venue="outdoor">{{ pill_btn('lightingType','natural', t.pill_light_natural, s.lightingType == 'natural') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ slider('courtCostDbl', t.sl_court_cost_dbl, 0, 80000, 1000, s.courtCostDbl, t.tip_court_cost_dbl) }}
|
||||
{{ slider('courtCostSgl', t.sl_court_cost_sgl, 0, 60000, 1000, s.courtCostSgl, t.tip_court_cost_sgl) }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_building|default('Building & Facility') }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
<!-- Indoor + Buy -->
|
||||
<div data-show-capex="indoor-buy">
|
||||
{{ slider('hallCostSqm', t.sl_hall_cost_sqm, 0, 2000, 10, s.hallCostSqm, t.tip_hall_cost_sqm) }}
|
||||
@@ -236,7 +270,6 @@
|
||||
{{ slider('fireProtection', t.sl_fire, 0, 500000, 5000, s.fireProtection, t.tip_fire_protection) }}
|
||||
{{ slider('planning', t.sl_planning, 0, 500000, 5000, s.planning, t.tip_planning) }}
|
||||
</div>
|
||||
|
||||
<!-- Indoor + Rent -->
|
||||
<div data-show-capex="indoor-rent">
|
||||
{{ slider('floorPrep', t.sl_floor_prep, 0, 100000, 1000, s.floorPrep, t.tip_floor_prep) }}
|
||||
@@ -244,7 +277,6 @@
|
||||
{{ slider('lightingUpgrade', t.sl_lighting_upgrade, 0, 100000, 1000, s.lightingUpgrade, t.tip_lighting_upgrade) }}
|
||||
{{ slider('fitout', t.sl_fitout, 0, 300000, 1000, s.fitout, t.tip_fitout) }}
|
||||
</div>
|
||||
|
||||
<!-- Outdoor -->
|
||||
<div data-show-capex="outdoor">
|
||||
{{ slider('outdoorFoundation', t.sl_outdoor_foundation, 0, 150, 1, s.outdoorFoundation, t.tip_outdoor_foundation) }}
|
||||
@@ -253,11 +285,17 @@
|
||||
{{ slider('outdoorFencing', t.sl_outdoor_fencing, 0, 40000, 500, s.outdoorFencing, t.tip_outdoor_fencing) }}
|
||||
<div data-show-capex="outdoor-buy">{{ slider('landPriceSqm', t.sl_land_price_sqm, 0, 500, 5, s.landPriceSqm, t.tip_land_price_sqm) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_other_costs|default('Other Costs') }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('workingCapital', t.sl_working_capital, 0, 200000, 1000, s.workingCapital, t.tip_working_capital) }}
|
||||
{{ slider('contingencyPct', t.sl_contingency, 0, 30, 1, s.contingencyPct, t.tip_contingency) }}
|
||||
{{ slider('budgetTarget', t.sl_budget_target, 0, 5000000, 10000, s.budgetTarget, t.tip_budget_target) }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Operations & Financing -->
|
||||
@@ -265,51 +303,50 @@
|
||||
<h2 class="wizard-step__title">{{ t.planner_step4_title }}</h2>
|
||||
<p class="wizard-step__sub">{{ t.planner_step4_sub }}</p>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_opex }}</h3></div>
|
||||
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_opex }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
<div data-show-opex="indoor-rent">{{ slider('rentSqm', t.sl_rent_sqm, 0, 25, 0.5, s.rentSqm, t.tip_rent_sqm) }}</div>
|
||||
<div data-show-opex="outdoor-rent">{{ slider('outdoorRent', t.sl_outdoor_rent, 0, 5000, 50, s.outdoorRent, t.tip_outdoor_rent) }}</div>
|
||||
<div data-show-opex="buy">{{ slider('propertyTax', t.sl_property_tax, 0, 2000, 25, s.propertyTax, t.tip_property_tax) }}</div>
|
||||
|
||||
{{ slider('insurance', t.sl_insurance, 0, 2000, 25, s.insurance, t.tip_insurance) }}
|
||||
{{ slider('electricity', t.sl_electricity, 0, 5000, 25, s.electricity, t.tip_electricity) }}
|
||||
|
||||
<div data-show-opex="indoor">
|
||||
{{ slider('heating', t.sl_heating, 0, 3000, 25, s.heating, t.tip_heating) }}
|
||||
{{ slider('water', t.sl_water, 0, 1000, 25, s.water, t.tip_water) }}
|
||||
</div>
|
||||
|
||||
{{ slider('maintenance', t.sl_maintenance, 0, 2000, 25, s.maintenance, t.tip_maintenance) }}
|
||||
|
||||
<div data-show-opex="indoor">{{ slider('cleaning', t.sl_cleaning, 0, 2000, 25, s.cleaning, t.tip_cleaning) }}</div>
|
||||
|
||||
{{ slider('marketing', t.sl_marketing, 0, 5000, 25, s.marketing, t.tip_marketing) }}
|
||||
{{ slider('staff', t.sl_staff, 0, 20000, 100, s.staff, t.tip_staff) }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_financing }}</h3></div>
|
||||
<details class="wizard-details" open>
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_financing }}</summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('loanPct', t.sl_loan_pct, 0, 100, 1, s.loanPct, t.tip_loan_pct) }}
|
||||
{{ slider('interestRate', t.sl_interest_rate, 0, 15, 0.1, s.interestRate, t.tip_interest_rate) }}
|
||||
{{ slider('loanTerm', t.sl_loan_term, 0, 30, 1, s.loanTerm, t.tip_loan_term) }}
|
||||
{{ slider('constructionMonths', t.sl_construction_months, 0, 24, 1, s.constructionMonths, t.tip_construction_months) }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="mb-section">
|
||||
<div class="section-header"><h3>{{ t.planner_section_exit }}</h3></div>
|
||||
<details class="wizard-details">
|
||||
<summary class="wizard-details__summary">{{ t.planner_section_exit }} <span class="wizard-details__hint">{{ t.planner_hint_advanced|default('Advanced') }}</span></summary>
|
||||
<div class="wizard-details__body">
|
||||
{{ slider('holdYears', t.sl_hold_years, 1, 20, 1, s.holdYears, t.tip_hold_years) }}
|
||||
{{ slider('exitMultiple', t.sl_exit_multiple, 0, 20, 0.5, s.exitMultiple, t.tip_exit_multiple) }}
|
||||
{{ slider('annualRevGrowth', t.sl_annual_rev_growth, 0, 15, 0.5, s.annualRevGrowth, t.tip_annual_rev_growth) }}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Wizard footer: preview bar + navigation -->
|
||||
<div class="wizard-footer">
|
||||
<div class="wizard-preview" id="wizPreview">{% include "partials/wizard_preview.html" %}</div>
|
||||
<div class="wizard-nav" id="wizNav">
|
||||
<div></div>
|
||||
<button type="button" class="wiz-btn--next" onclick="showWizStep(2)">{{ t.btn_next }}</button>
|
||||
{% include "partials/wizard_nav.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /planner-wizard -->
|
||||
@@ -323,41 +360,27 @@
|
||||
{% include tab_template %}
|
||||
</div>
|
||||
|
||||
<!-- Inline quote CTA (mobile / narrow screens) -->
|
||||
<div class="quote-inline-cta" id="quoteInlineCta" style="display:none">
|
||||
<div class="quote-inline-cta__label">{{ t.planner_quote_cta_label }}</div>
|
||||
<h3 class="quote-inline-cta__title">{{ t.planner_quote_cta_title }}</h3>
|
||||
<p class="quote-inline-cta__desc">{{ t.planner_quote_cta_desc }}</p>
|
||||
<ul class="quote-inline-cta__checks">
|
||||
<li><span class="quote-inline-cta__check">✓</span> {{ t.planner_quote_cta_check_1 }}</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> {{ t.planner_quote_cta_check_2 }}</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> {{ t.planner_quote_cta_check_3 }}</li>
|
||||
<li><span class="quote-inline-cta__check">✓</span> {{ t.planner_quote_cta_check_4 }}</li>
|
||||
</ul>
|
||||
<button class="quote-inline-cta__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }}</button>
|
||||
<span class="quote-inline-cta__hint">{{ t.planner_quote_cta_hint }}</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Sidebar CTA (desktop) -->
|
||||
<!-- Sidebar CTA (desktop, simplified) -->
|
||||
<aside class="quote-sidebar" id="quoteSidebar">
|
||||
<div class="quote-sidebar__label">{{ t.planner_quote_cta_label }}</div>
|
||||
<h3 class="quote-sidebar__title">{{ t.planner_quote_cta_title }}</h3>
|
||||
<p class="quote-sidebar__desc">{{ t.planner_quote_cta_desc }}</p>
|
||||
<ul class="quote-sidebar__checks">
|
||||
<li><span class="quote-sidebar__check">✓</span> {{ t.planner_quote_cta_check_1 }}</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> {{ t.planner_quote_cta_check_2 }}</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> {{ t.planner_quote_cta_check_3 }}</li>
|
||||
<li><span class="quote-sidebar__check">✓</span> {{ t.planner_quote_cta_check_4 }}</li>
|
||||
</ul>
|
||||
<p class="quote-sidebar__desc">{{ t.planner_quote_sidebar_desc|default('Compare your estimate with real supplier quotes.') }}</p>
|
||||
<button class="quote-sidebar__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }}</button>
|
||||
<span class="quote-sidebar__hint">{{ t.planner_quote_cta_hint }}</span>
|
||||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1)">
|
||||
<a href="{{ url_for('planner.export') }}" class="quote-sidebar__btn" style="background:#16A34A;text-decoration:none;display:block;text-align:center">{{ t.planner_export_btn }}</a>
|
||||
<span class="quote-sidebar__hint">{{ t.planner_export_hint }}</span>
|
||||
</div>
|
||||
<a href="{{ url_for('planner.export') }}" class="quote-sidebar__export-link">{{ t.planner_export_btn }}</a>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile CTA bar (above bottom nav, result tabs only) -->
|
||||
<div class="cta-bottom-bar" id="ctaBottomBar">
|
||||
<button class="cta-bottom-bar__dismiss" onclick="this.parentElement.classList.remove('visible');this.parentElement.dataset.dismissed='1'" aria-label="Dismiss">×</button>
|
||||
<div class="cta-bottom-bar__text">
|
||||
<div class="cta-bottom-bar__value" id="ctaCapexValue">{{ d.capex | fmt_k }} {{ t.planner_cta_estimated|default('estimated') }}</div>
|
||||
<div class="cta-bottom-bar__hint">{{ t.planner_cta_micro|default('Free · 2 min · No obligation') }}</div>
|
||||
</div>
|
||||
<button class="cta-bottom-bar__btn" onclick="goToQuoteForm()">{{ t.planner_quote_cta_btn }} →</button>
|
||||
</div>
|
||||
|
||||
{% if not user %}
|
||||
<div class="signup-bar" id="signupBar" style="display:none">
|
||||
<span>{{ t.planner_signup_bar_msg }}</span>
|
||||
@@ -368,15 +391,44 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div id="scenario-drawer"></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>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.__COUNTRY_PRESETS__ = {{ country_presets | tojson | safe }};
|
||||
window.__DEFAULTS__ = {{ defaults | tojson | safe }};
|
||||
window.__SAVE_URL__ = "{{ url_for('planner.save_scenario') }}";
|
||||
window.__SCENARIO_URL__ = "{{ url_for('planner.index') }}scenarios/";
|
||||
window.__QUOTE_URL__ = "{{ url_for('leads.quote_request') }}";
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/planner.js') }}"></script>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 ? `<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();
|
||||
// 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);
|
||||
|
||||
Reference in New Issue
Block a user