src/ApplicationBundle/Modules/HoneybeeWeb/Resources/views/pages/tools/rooftop-estimate.html.twig line 1

Open in your IDE?
  1. {% include '@Application/inc/central_header.html.twig' %}
  2. {% include '@HoneybeeWeb/inc/_web_design_system.html.twig' %}
  3. <style>
  4. .re-wrap { max-width: 1200px; margin: 0 auto; padding: 0 28px; }
  5. .re-hero { background: var(--n-cream); padding: 120px 0 40px; }
  6. .re-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 24px; align-items: start; }
  7. @media (max-width: 940px) { .re-grid { grid-template-columns: 1fr; } }
  8. .re-card { background: var(--n-white); border: 1px solid var(--n-border-md); border-radius: var(--n-radius); padding: 22px; box-shadow: var(--n-shadow-sm); }
  9. .re-map { width: 100%; height: 420px; border-radius: var(--n-radius-sm); border: 1px solid var(--n-border-md); background: var(--n-cream-2); }
  10. .re-search { width: 100%; padding: 12px 14px; border: 1.5px solid var(--n-border-md); border-radius: var(--n-radius-sm); font-size: 14px; font-family: var(--n-font); margin-bottom: 12px; }
  11. .re-field { margin-bottom: 14px; }
  12. .re-field label { display: block; font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--n-muted); margin-bottom: 6px; }
  13. .re-field input, .re-field select { width: 100%; padding: 11px 12px; border: 1.5px solid var(--n-border-md); border-radius: var(--n-radius-sm); font-size: 14px; font-family: var(--n-font); }
  14. .re-toggle { display: flex; gap: 0; border: 1.5px solid var(--n-border-md); border-radius: var(--n-radius-sm); overflow: hidden; }
  15. .re-toggle button { flex: 1; padding: 11px; background: var(--n-white); border: none; font-size: 13px; font-weight: 600; cursor: pointer; color: var(--n-muted); font-family: var(--n-font); }
  16. .re-toggle button.active { background: var(--n-dark); color: #fff; }
  17. .re-area-pill { display: inline-flex; align-items: center; gap: 8px; background: var(--n-amber-dim); color: var(--n-amber); font-weight: 700; font-size: 13px; padding: 7px 14px; border-radius: 100px; margin-bottom: 12px; }
  18. .re-kpis { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 4px 0 18px; }
  19. .re-kpi { background: var(--n-cream); border: 1px solid var(--n-border); border-radius: var(--n-radius-sm); padding: 14px 16px; }
  20. .re-kpi .v { font-family: 'Montserrat', sans-serif; font-weight: 900; font-size: 1.45rem; color: var(--n-dark); line-height: 1.1; }
  21. .re-kpi .l { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--n-muted); margin-top: 4px; }
  22. .re-kpi.hl { background: var(--n-dark); } .re-kpi.hl .v { color: #fff; } .re-kpi.hl .l { color: rgba(255,255,255,.55); }
  23. .re-boq { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
  24. .re-boq th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--n-muted); padding: 8px 10px; border-bottom: 1.5px solid var(--n-border-md); }
  25. .re-boq td { padding: 9px 10px; border-bottom: 1px solid var(--n-border); color: var(--n-dark); }
  26. .re-boq td.num, .re-boq th.num { text-align: right; font-variant-numeric: tabular-nums; }
  27. .re-boq tfoot td { font-weight: 800; border-top: 1.5px solid var(--n-border-md); border-bottom: none; }
  28. .re-note { font-size: 12px; color: var(--n-muted); line-height: 1.6; margin-top: 14px; padding: 12px 14px; background: var(--n-cream); border-radius: var(--n-radius-sm); border-left: 3px solid var(--n-amber); }
  29. .re-muted { color: var(--n-muted); font-size: 13px; }
  30. #re-results { display: none; }
  31. .re-spinner { display:none; font-size:13px; color:var(--n-amber); font-weight:600; }
  32. </style>
  33. <section class="re-hero">
  34.     <div class="re-wrap">
  35.         <div class="n-label">Instant Estimate · C&amp;I Rooftop Solar</div>
  36.         <h1 style="font-family:'Montserrat',sans-serif;font-size:clamp(1.9rem,4vw,2.8rem);font-weight:900;color:var(--n-dark);letter-spacing:-.025em;line-height:1.08;margin:0 0 14px;max-width:18ch">
  37.             Your address → an <em style="font-style:italic;color:var(--n-amber)">instant rooftop solar design.</em>
  38.         </h1>
  39.         <p class="n-body" style="max-width:62ch;margin:0">Enter a site address and HoneyBee auto-detects the roof, sizes the array, and returns an indicative system size, bill of quantities and payback — using location-specific yield data. No match? Trace the roof on the map. Confirmed after a HoneyCore site assessment.</p>
  40.     </div>
  41. </section>
  42. <section style="background:var(--n-cream);padding:8px 0 100px">
  43.     <div class="re-wrap">
  44.         <div class="re-grid">
  45.             {# ── LEFT: map + roof capture ── #}
  46.             <div class="re-card">
  47.                 <label style="display:block;font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--n-muted);margin-bottom:6px">Enter a site address</label>
  48.                 <div style="display:flex;gap:8px;margin-bottom:6px">
  49.                     <input id="re-search" class="re-search" style="margin-bottom:0;flex:1" type="text" placeholder="🔍  e.g. Alexanderplatz, Berlin…">
  50.                     <button id="re-auto" class="n-btn n-btn-amber" style="white-space:nowrap"><i class="fa-solid fa-bolt"></i> Auto design</button>
  51.                 </div>
  52.                 <p id="re-auto-status" class="re-muted" style="margin:0 0 10px;display:none"></p>
  53.                 <div id="re-map" class="re-map"></div>
  54.                 <div style="display:flex;justify-content:space-between;align-items:center;margin-top:12px;flex-wrap:wrap;gap:10px">
  55.                     <span id="re-area-pill" class="re-area-pill" style="display:none"><i class="fa-solid fa-draw-polygon"></i> <span id="re-area-val">0</span> m² roof drawn</span>
  56.                     <div style="display:flex;gap:8px">
  57.                         <button id="re-redraw" class="n-btn n-btn-outline" style="padding:8px 16px;font-size:13px;display:none"><i class="fa-solid fa-rotate-left"></i> Redraw</button>
  58.                     </div>
  59.                 </div>
  60.                 <p class="re-muted" style="margin-top:10px"><i class="fa-solid fa-circle-info" style="color:var(--n-amber)"></i> Tip: zoom to your roof, click each corner to trace the outline, then close the shape. No map? <a href="#" id="re-manual-link" style="color:var(--n-amber);font-weight:600">Enter area manually</a>.</p>
  61.                 <div id="re-manual" class="re-field" style="display:none;margin-top:10px">
  62.                     <label>Roof area (m²)</label>
  63.                     <input id="re-manual-area" type="number" min="10" step="10" placeholder="e.g. 1200">
  64.                 </div>
  65.             </div>
  66.             {# ── RIGHT: controls + results ── #}
  67.             <div>
  68.                 <div class="re-card">
  69.                     <div class="re-field">
  70.                         <label>Sizing mode</label>
  71.                         <div class="re-toggle">
  72.                             <button type="button" id="re-mode-roof" class="active">Fit the roof</button>
  73.                             <button type="button" id="re-mode-load">Match my usage</button>
  74.                         </div>
  75.                     </div>
  76.                     <div class="re-field" id="re-load-field" style="display:none">
  77.                         <label>Monthly electricity use (kWh)</label>
  78.                         <input id="re-monthly" type="number" min="0" step="100" placeholder="e.g. 25000">
  79.                     </div>
  80.                     <div class="re-field">
  81.                         <label>Roof type</label>
  82.                         <select id="re-rooftype">
  83.                             <option value="10">Flat roof (C&amp;I) — 10° tilt</option>
  84.                             <option value="30">Pitched roof — 30° tilt</option>
  85.                         </select>
  86.                     </div>
  87.                     <div class="re-field">
  88.                         <label>Electricity tariff (€/kWh)</label>
  89.                         <input id="re-tariff" type="number" min="0.01" step="0.01" value="0.22">
  90.                     </div>
  91.                     <button id="re-go" class="n-btn n-btn-amber" style="width:100%;justify-content:center">Generate estimate <i class="fa-solid fa-bolt"></i></button>
  92.                     <span id="re-spin" class="re-spinner" style="display:block;margin-top:10px;text-align:center">Calculating yield &amp; BoQ…</span>
  93.                     <p id="re-err" class="re-muted" style="color:#b4452f;margin-top:10px;display:none"></p>
  94.                 </div>
  95.                 <div id="re-results" class="re-card" style="margin-top:16px">
  96.                     <div class="re-kpis">
  97.                         <div class="re-kpi hl"><div class="v" id="k-kwp">—</div><div class="l">System size (kWp)</div></div>
  98.                         <div class="re-kpi"><div class="v" id="k-gen">—</div><div class="l">Annual yield (kWh)</div></div>
  99.                         <div class="re-kpi"><div class="v" id="k-capex">—</div><div class="l">Indicative CAPEX</div></div>
  100.                         <div class="re-kpi"><div class="v" id="k-payback">—</div><div class="l">Simple payback</div></div>
  101.                     </div>
  102.                     <div style="font-size:12px;color:var(--n-muted);margin-bottom:6px"><span id="k-panels">—</span> modules · <span id="k-savings">—</span>/yr saved · <span id="k-co2">—</span> t CO₂/yr avoided · yield source: <span id="k-src">—</span></div>
  103.                     <table class="re-boq">
  104.                         <thead><tr><th>Item</th><th class="num">Qty</th><th>Unit</th><th class="num">Unit €</th><th class="num">Total €</th></tr></thead>
  105.                         <tbody id="re-boq-body"></tbody>
  106.                         <tfoot><tr><td colspan="4">Indicative CAPEX (ex-VAT)</td><td class="num" id="re-boq-total">—</td></tr></tfoot>
  107.                     </table>
  108.                     <div class="re-note" id="re-disclaimer"></div>
  109.                     <a href="{{ url('honeybee_contact') }}" class="n-btn n-btn-amber" style="width:100%;justify-content:center;margin-top:16px">Book a HoneyCore Site Assessment <i class="fa-solid fa-arrow-right"></i></a>
  110.                     <p class="re-muted" style="text-align:center;margin-top:8px">From €499/site, credited against deployment.</p>
  111.                 </div>
  112.             </div>
  113.         </div>
  114.     </div>
  115. </section>
  116. <script>
  117. (function () {
  118.     var state = { area: 0, lat: 0, lng: 0, mode: 'roof', map: null, poly: null, drawer: null };
  119.     // ── Controls ──
  120.     var $ = function (id) { return document.getElementById(id); };
  121.     function setMode(m) {
  122.         state.mode = m;
  123.         $('re-mode-roof').classList.toggle('active', m === 'roof');
  124.         $('re-mode-load').classList.toggle('active', m === 'load');
  125.         $('re-load-field').style.display = (m === 'load') ? 'block' : 'none';
  126.     }
  127.     $('re-mode-roof').onclick = function () { setMode('roof'); };
  128.     $('re-mode-load').onclick = function () { setMode('load'); };
  129.     $('re-manual-link').onclick = function (e) { e.preventDefault(); $('re-manual').style.display = 'block'; };
  130.     $('re-manual-area').oninput = function () { state.area = parseFloat(this.value) || 0; reflectArea(); };
  131.     function reflectArea() {
  132.         if (state.area > 0) { $('re-area-pill').style.display = 'inline-flex'; $('re-area-val').textContent = Math.round(state.area).toLocaleString(); }
  133.     }
  134.     // ── Google Maps (drawing + places). Defined globally for the API callback. ──
  135.     window.initRooftop = function () {
  136.         try {
  137.             var map = new google.maps.Map($('re-map'), { center: { lat: 48.40, lng: 9.99 }, zoom: 18, mapTypeId: 'satellite', tilt: 0, streetViewControl: false });
  138.             state.map = map;
  139.             // Address autocomplete is OPTIONAL — the legacy Places Autocomplete isn't available to
  140.             // newer Google Maps projects, so isolate it so its failure never disables the map/draw.
  141.             try {
  142.                 if (google.maps.places && google.maps.places.Autocomplete) {
  143.                     var ac = new google.maps.places.Autocomplete($('re-search'));
  144.                     ac.bindTo('bounds', map);
  145.                     ac.addListener('place_changed', function () {
  146.                         var p = ac.getPlace();
  147.                         if (p.geometry) { map.setCenter(p.geometry.location); map.setZoom(20); }
  148.                     });
  149.                 }
  150.             } catch (acErr) { /* no autocomplete — typing a full address still works for Auto design */ }
  151.             // Google removed DrawingManager from the Maps JS API — use a lightweight click-to-trace polygon.
  152.             state.points = [];
  153.             state.poly = new google.maps.Polygon({ map: map, paths: [], fillColor: '#C07D2A', fillOpacity: .25, strokeColor: '#C07D2A', strokeWeight: 2, clickable: false });
  154.             var recompute = function () {
  155.                 state.poly.setPath(state.points);
  156.                 if (state.points.length >= 3) {
  157.                     state.area = google.maps.geometry.spherical.computeArea(state.poly.getPath());
  158.                     var lat = 0, lng = 0;
  159.                     state.points.forEach(function (p) { lat += p.lat(); lng += p.lng(); });
  160.                     state.lat = lat / state.points.length; state.lng = lng / state.points.length;
  161.                     reflectArea();
  162.                 }
  163.             };
  164.             map.addListener('click', function (e) { state.points.push(e.latLng); recompute(); $('re-redraw').style.display = 'inline-flex'; });
  165.             state.clearDraw = function () { state.points = []; state.poly.setPath([]); state.area = 0; $('re-area-pill').style.display = 'none'; };
  166.             $('re-redraw').onclick = state.clearDraw;
  167.         } catch (e) {
  168.             $('re-map').innerHTML = '<div style="padding:40px;text-align:center;color:#6B6E7F">Map unavailable — use “Enter area manually”.</div>';
  169.             $('re-manual').style.display = 'block';
  170.         }
  171.     };
  172.     window.gm_authFailure = function () {
  173.         $('re-map').innerHTML = '<div style="padding:40px;text-align:center;color:#6B6E7F">Map unavailable — use “Enter area manually”.</div>';
  174.         $('re-manual').style.display = 'block';
  175.     };
  176.     // ── Generate estimate ──
  177.     $('re-go').onclick = function () {
  178.         $('re-err').style.display = 'none';
  179.         if (!state.area || state.area < 5) { $('re-err').textContent = 'Draw a roof outline on the map (or enter an area) first.'; $('re-err').style.display = 'block'; return; }
  180.         // manual-area mode without a map: approximate location from search text is not available → require map for yield, else use lat 50 fallback
  181.         var lat = state.lat || 50, lng = state.lng || 9;
  182.         $('re-spin').style.display = 'block';
  183.         var body = new URLSearchParams({
  184.             lat: lat, lng: lng, area_m2: state.area, mode: state.mode,
  185.             monthly_kwh: $('re-monthly').value || 0, tariff: $('re-tariff').value || 0.22, tilt: $('re-rooftype').value || 10
  186.         });
  187.         fetch('{{ url('honeybee_rooftop_calc') }}', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
  188.             .then(function (r) { return r.json(); })
  189.             .then(function (d) {
  190.                 $('re-spin').style.display = 'none';
  191.                 if (!d.ok) { $('re-err').textContent = d.error || 'Could not calculate.'; $('re-err').style.display = 'block'; return; }
  192.                 render(d);
  193.             })
  194.             .catch(function () { $('re-spin').style.display = 'none'; $('re-err').textContent = 'Network error — please try again.'; $('re-err').style.display = 'block'; });
  195.     };
  196.     // ── Auto design from address (geocode → Solar API / OSM → PVGIS) ──
  197.     $('re-auto').onclick = function () {
  198.         var addr = ($('re-search').value || '').trim();
  199.         $('re-err').style.display = 'none'; $('re-auto-status').style.display = 'none';
  200.         if (!addr) { $('re-search').focus(); return; }
  201.         $('re-auto').disabled = true; $('re-auto').innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Designing…';
  202.         var body = new URLSearchParams({ address: addr, mode: state.mode, monthly_kwh: $('re-monthly').value || 0, tariff: $('re-tariff').value || 0.22, tilt: $('re-rooftype').value || 10 });
  203.         fetch('{{ url('honeybee_rooftop_auto') }}', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() })
  204.             .then(function (r) { return r.json(); })
  205.             .then(function (d) {
  206.                 $('re-auto').disabled = false; $('re-auto').innerHTML = '<i class="fa-solid fa-bolt"></i> Auto design';
  207.                 if (d.ok) { render(d); return; }
  208.                 if (d.needs_manual && state.map && d.lat) {
  209.                     state.map.setCenter({ lat: d.lat, lng: d.lng }); state.map.setZoom(20);
  210.                     if (state.clearDraw) state.clearDraw();
  211.                     var s = $('re-auto-status'); s.style.display = 'block';
  212.                     s.innerHTML = '<i class="fa-solid fa-circle-info" style="color:var(--n-amber)"></i> ' + (d.error || 'Trace the roof on the map below.') + (d.formatted_address ? ' <b>' + d.formatted_address + '</b>' : '');
  213.                 } else {
  214.                     $('re-err').textContent = d.error || 'Could not auto-detect — draw the roof on the map.'; $('re-err').style.display = 'block';
  215.                 }
  216.             })
  217.             .catch(function () { $('re-auto').disabled = false; $('re-auto').innerHTML = '<i class="fa-solid fa-bolt"></i> Auto design'; $('re-err').textContent = 'Network error — please try again.'; $('re-err').style.display = 'block'; });
  218.     };
  219.     function eur(n) { return '€' + Math.round(n).toLocaleString('de-DE'); }
  220.     function render(d) {
  221.         $('k-kwp').textContent = d.kwp.toLocaleString();
  222.         $('k-gen').textContent = Math.round(d.annual_gen_kwh).toLocaleString();
  223.         $('k-capex').textContent = eur(d.capex_eur);
  224.         $('k-payback').textContent = d.payback_years ? d.payback_years + ' yrs' : '—';
  225.         $('k-panels').textContent = d.panels.toLocaleString();
  226.         $('k-savings').textContent = eur(d.annual_savings);
  227.         $('k-co2').textContent = d.co2_tonnes_yr;
  228.         $('k-src').textContent = (d.yield_source === 'PVGIS' ? 'PVGIS (live)' : 'climate estimate') + (d.roof_source ? ' · roof via ' + d.roof_source : '');
  229.         var tb = $('re-boq-body'); tb.innerHTML = '';
  230.         d.boq.forEach(function (r) {
  231.             tb.innerHTML += '<tr><td>' + r.item + '</td><td class="num">' + r.qty.toLocaleString() + '</td><td>' + r.unit + '</td><td class="num">' + eur(r.unit_price) + '</td><td class="num">' + eur(r.total) + '</td></tr>';
  232.         });
  233.         $('re-boq-total').textContent = eur(d.capex_eur) + '  (' + eur(d.eur_per_kwp) + '/kWp)';
  234.         $('re-disclaimer').textContent = (d.formatted_address ? '📍 ' + d.formatted_address + ' — ' : '') + d.disclaimer;
  235.         $('re-results').style.display = 'block';
  236.         $('re-results').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  237.     }
  238. })();
  239. </script>
  240. <script async defer src="https://maps.googleapis.com/maps/api/js?key={{ maps_key|default('AIzaSyBJxyUy8a_U2rSdIUApVDoK_dcvgGkoeDk') }}&libraries=places,geometry&callback=initRooftop&v=weekly"></script>
  241. {% include '@HoneybeeWeb/footer/central_footer.html.twig' %}