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. {% include '@HoneybeeWeb/inc/_hb360_tags.html.twig' %}
  4. {% import '@HoneybeeWeb/inc/_hb360_tags.html.twig' as hb360 %}
  5. <style>
  6. .re-wrap { max-width: 1200px; margin: 0 auto; padding: 0 28px; }
  7. .re-hero { background: var(--n-cream); padding: 120px 0 40px; }
  8. .re-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 24px; align-items: start; }
  9. @media (max-width: 940px) { .re-grid { grid-template-columns: 1fr; } }
  10. .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); }
  11. .re-map { width: 100%; height: 380px; border-radius: var(--n-radius-sm); border: 1px solid var(--n-border-md); background: var(--n-cream-2); }
  12. .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; }
  13. .re-field { margin-bottom: 14px; }
  14. .re-field label { display: block; font-size: 12px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--n-muted); margin-bottom: 6px; }
  15. .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); }
  16. .re-toggle { display: flex; gap: 0; border: 1.5px solid var(--n-border-md); border-radius: var(--n-radius-sm); overflow: hidden; }
  17. .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); }
  18. .re-toggle button.active { background: var(--n-dark); color: #fff; }
  19. .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; }
  20. .re-headline { background: var(--n-dark); border-radius: var(--n-radius-sm); padding: 22px 20px; text-align: center; margin-bottom: 14px; }
  21. .re-headline .v { font-family: 'Montserrat', sans-serif; font-weight: 900; font-size: 2.4rem; color: #fff; line-height: 1.05; }
  22. .re-headline .l { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: rgba(255,255,255,.6); margin-top: 6px; }
  23. .re-kpis { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 4px 0 14px; }
  24. .re-kpi { background: var(--n-cream); border: 1px solid var(--n-border); border-radius: var(--n-radius-sm); padding: 14px 16px; }
  25. .re-kpi .v { font-family: 'Montserrat', sans-serif; font-weight: 900; font-size: 1.35rem; color: var(--n-dark); line-height: 1.1; }
  26. .re-kpi .l { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--n-muted); margin: 4px 0 6px; }
  27. .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); }
  28. .re-muted { color: var(--n-muted); font-size: 13px; }
  29. #re-results { display: none; }
  30. .re-spinner { display:none; font-size:13px; color:var(--n-amber); font-weight:600; }
  31. .re-advanced-toggle { background:none; border:none; color:var(--n-amber); font-weight:700; font-size:13px; cursor:pointer; padding:0; font-family:var(--n-font); }
  32. #re-advanced { display:none; margin-top:14px; border-top:1px dashed var(--n-border-md); padding-top:14px; }
  33. /* ── the visibly-locked full design (behind the signup wall, H1b) ── */
  34. .re-locked { position: relative; margin-top: 16px; border: 1.5px dashed var(--n-border-md); border-radius: var(--n-radius-sm); overflow: hidden; }
  35. /* Blurred BoQ sits BEHIND the overlay as a background layer; the overlay is in
  36.    normal flow so its content always fits — nothing clips. */
  37. .re-locked-blur { position: absolute; inset: 0; filter: blur(4px); pointer-events: none; user-select: none; padding: 16px; opacity: .55; overflow: hidden; }
  38. .re-locked-blur table { width: 100%; border-collapse: collapse; font-size: 13px; }
  39. .re-locked-blur td { padding: 8px 10px; border-bottom: 1px solid var(--n-border); color: var(--n-dark); }
  40. .re-locked-over { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: rgba(255,255,255,.6); padding: 24px 18px; text-align: center; }
  41. .re-locked-over .ttl { font-family:'Montserrat',sans-serif; font-weight: 900; font-size: 1.05rem; color: var(--n-dark); }
  42. .re-locked-teaser { font-size: 12.5px; color: var(--n-muted); line-height: 1.9; text-align: left; margin: 0; padding-left: 2px; list-style: none; }
  43. .re-locked-teaser li:before { content: '🔒 '; font-size: 12px; }
  44. /* Tidy stats table under the KPI cards (label · value · tag) */
  45. .re-meta { width: 100%; border-collapse: collapse; font-size: 12.5px; margin: 2px 0 10px; }
  46. .re-meta td { padding: 7px 4px; border-bottom: 1px solid var(--n-border); vertical-align: middle; }
  47. .re-meta tr:last-child td { border-bottom: none; }
  48. .re-meta .ml { color: var(--n-muted); font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; white-space: nowrap; width: 1%; padding-right: 14px; }
  49. .re-meta .mv { color: var(--n-dark); font-weight: 600; }
  50. .re-meta .mt { text-align: right; white-space: nowrap; padding-left: 10px; }
  51. .re-legend { margin: 0 0 14px; }
  52. .re-legend summary { font-size: 12px; font-weight: 600; color: var(--n-muted); cursor: pointer; }
  53. .re-legend .hb360-legend { margin-top: 6px; }
  54. /* ── SDS0 · Solar Design Studio shell (additive — simple flow untouched) ── */
  55. #sds-editor { position: relative; }
  56. #sds-editor.sds-focused { position: fixed; inset: 0; z-index: 100000; background: var(--n-cream); display: flex; flex-direction: column; animation: sdsIn .18s ease; }
  57. @keyframes sdsIn { from { opacity: .55; transform: scale(.992); } to { opacity: 1; transform: none; } }
  58. body.sds-focused-body { overflow: hidden; }
  59. #sds-bar { display: none; }
  60. .sds-focused #sds-bar { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: var(--n-dark); color: #fff; flex-wrap: wrap; }
  61. .sds-focused .re-map { flex: 1; height: auto; border-radius: 0; border: none; }
  62. #sds-bar .sds-title { font-family: 'Montserrat', sans-serif; font-weight: 900; font-size: 14px; margin-right: 4px; white-space: nowrap; }
  63. #sds-bar .sds-sub { font-size: 12px; color: rgba(255,255,255,.55); }
  64. .sds-tbtn { background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.18); color: #fff; border-radius: 6px; padding: 7px 12px; font-size: 12.5px; font-weight: 600; cursor: pointer; font-family: var(--n-font); }
  65. .sds-tbtn:hover { background: rgba(255,255,255,.16); }
  66. .sds-tbtn.active { background: var(--n-amber); border-color: var(--n-amber); color: #fff; }
  67. .sds-tbtn:disabled { opacity: .35; cursor: default; }
  68. .sds-spacer { flex: 1; }
  69. #sds-panel { display: none; margin-top: 12px; }
  70. #sds-panel.sds-show { display: block; }
  71. body.sds-focused-body #sds-panel.sds-show { position: fixed; top: 58px; right: 14px; width: 322px; max-height: calc(100vh - 76px); overflow: auto; margin: 0; z-index: 100001; }
  72. .sds-panel-card { background: var(--n-white); border: 1px solid var(--n-border-md); border-radius: var(--n-radius-sm); box-shadow: var(--n-shadow-sm); padding: 12px 14px; }
  73. .sds-zrow { border: 1px solid var(--n-border); border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; cursor: pointer; background: var(--n-white); }
  74. .sds-zrow.sel { border-color: var(--n-amber); box-shadow: 0 0 0 2px var(--n-amber-dim); }
  75. .sds-zrow .t { display: flex; align-items: center; gap: 8px; }
  76. .sds-dot { width: 12px; height: 12px; border-radius: 3px; flex: none; }
  77. .sds-zname { font-weight: 700; font-size: 13px; color: var(--n-dark); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  78. .sds-zarea { font-size: 12px; color: var(--n-muted); white-space: nowrap; }
  79. .sds-ibtn { background: none; border: none; color: var(--n-muted); cursor: pointer; padding: 3px 5px; font-size: 13px; }
  80. .sds-ibtn:hover { color: var(--n-dark); }
  81. .sds-pill { display: inline-flex; gap: 5px; align-items: baseline; font-size: 12px; margin-top: 6px; background: var(--n-amber-dim); color: var(--n-amber); font-weight: 700; border-radius: 100px; padding: 3px 10px; }
  82. .sds-pill .q { font-weight: 600; font-size: 11.5px; opacity: .8; }
  83. .sds-props { border-top: 1px dashed var(--n-border-md); margin-top: 8px; padding-top: 8px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; cursor: default; }
  84. .sds-props label { font-size: 11.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--n-muted); display: block; margin-bottom: 3px; }
  85. .sds-props input { width: 100%; padding: 6px 8px; border: 1px solid var(--n-border-md); border-radius: 6px; font-size: 12.5px; font-family: var(--n-font); }
  86. .sds-props .full { grid-column: 1 / -1; }
  87. .sds-azauto { background: none; border: none; color: var(--n-amber); font-weight: 700; font-size: 11.5px; cursor: pointer; padding: 0; text-transform: none; }
  88. .sds-hint { font-size: 12px; color: var(--n-muted); }
  89. .sds-total { border-top: 1px solid var(--n-border-md); margin-top: 6px; padding-top: 8px; font-size: 13px; font-weight: 700; color: var(--n-dark); display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
  90. .sds-settings { display: flex; gap: 8px; margin-top: 8px; }
  91. .sds-settings input { width: 100%; padding: 5px 7px; border: 1px solid var(--n-border-md); border-radius: 6px; font-size: 12px; font-family: var(--n-font); }
  92. .sds-note { font-size: 11.5px; color: var(--n-muted); margin-top: 6px; line-height: 1.5; }
  93. </style>
  94. <section class="re-hero">
  95.     <div class="re-wrap">
  96.         <div class="n-label">HoneyBee 360 · Instant Solar Estimate</div>
  97.         <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:20ch">
  98.             Your address + your bill → <em style="font-style:italic;color:var(--n-amber)">what solar would save you.</em>
  99.         </h1>
  100.         <p class="n-body" style="max-width:62ch;margin:0">Enter a site address and your monthly electricity bill. HoneyBee detects the roof, sizes the array with our PV engine, and shows the indicative system size, annual yield, bill saving and payback — with every figure honestly tagged for what is modelled and what still needs a site survey.</p>
  101.     </div>
  102. </section>
  103. <section style="background:var(--n-cream);padding:8px 0 100px">
  104.     <div class="re-wrap">
  105.         <div class="re-grid">
  106.             {# ── LEFT: address + bill + map ── #}
  107.             <div class="re-card">
  108.                 <div class="re-field">
  109.                     <label>Site address</label>
  110.                     <input id="re-search" class="re-search" style="margin-bottom:0" type="text" placeholder="🔍  e.g. Alexanderplatz, Berlin…">
  111.                 </div>
  112.                 <div class="re-field">
  113.                     <label>Monthly electricity bill (€)</label>
  114.                     <input id="re-bill" type="number" min="0" step="10" placeholder="e.g. 1400">
  115.                 </div>
  116.                 <button id="re-auto" class="n-btn n-btn-amber" style="width:100%;justify-content:center"><i class="fa-solid fa-bolt"></i> Get my instant estimate</button>
  117.                 <p id="re-auto-status" class="re-muted" style="margin:10px 0 0;display:none"></p>
  118.                 <p id="re-err" class="re-muted" style="color:#b4452f;margin-top:10px;display:none"></p>
  119.                 <span id="re-spin" class="re-spinner" style="display:none;margin-top:10px;text-align:center">Detecting roof &amp; modelling yield…</span>
  120.                 <div style="margin:16px 0 8px">
  121.                     {# SDS0: the studio wraps the SAME map element — focused mode is pure CSS
  122.                        repositioning, so the Maps instance survives and the simple flow is untouched.
  123.                        #sds-bar / #sds-panel stay empty+hidden unless sds-shell.js attaches. #}
  124.                     <div id="sds-editor">
  125.                         <div id="sds-bar"></div>
  126.                         <div id="re-map" class="re-map"></div>
  127.                     </div>
  128.                     <div style="display:flex;justify-content:space-between;align-items:center;margin-top:10px;flex-wrap:wrap;gap:10px">
  129.                         <span id="re-area-pill" class="re-area-pill" style="display:none;margin-bottom:0"><i class="fa-solid fa-draw-polygon"></i> <span id="re-area-val">0</span> m² roof drawn</span>
  130.                         <div style="display:flex;gap:8px">
  131.                             {# inline SVG — no icon-font dependency in SDS surfaces (SDS-LIB addendum) #}
  132.                             <button id="sds-expand" class="n-btn n-btn-outline" style="padding:8px 16px;font-size:13px;display:none"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" style="vertical-align:-2px"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg> Expand editor</button>
  133.                             <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>
  134.                             <button id="re-go" class="n-btn n-btn-outline" style="padding:8px 16px;font-size:13px">Estimate from drawn roof</button>
  135.                         </div>
  136.                     </div>
  137.                     <p class="re-muted" style="margin-top:8px"><i class="fa-solid fa-circle-info" style="color:var(--n-amber)"></i> No auto-match? Zoom to your roof and click each corner to trace the outline, then use “Estimate from drawn roof”.</p>
  138.                     <div id="sds-panel"></div>
  139.                     {# ── FUNNEL-3: the login-revealed DETAIL for an owned saved design
  140.                          (?mydesign=N — own-checked server-side). Figures are the stored
  141.                          studio pills, said so; the live economics panel above refreshes
  142.                          as you edit (the same indicative PVGIS-lite path — no tenant
  143.                          pricing internals exist on this surface, the deny-list law). ── #}
  144.                     {% if my_design is defined and my_design %}
  145.                     <div class="sds-panel-card" style="margin-top:12px;border-left:3px solid var(--n-amber)">
  146.                         <div style="font-weight:700;font-size:13.5px;margin-bottom:4px">
  147.                             Editing from your account: {{ my_design.title }}</div>
  148.                         <div style="font-size:12.5px;color:var(--n-muted);margin-bottom:8px">
  149.                             {% if my_design.address %}{{ my_design.address }} · {% endif %}
  150.                             {{ my_design.summary.zones }} zone{{ my_design.summary.zones == 1 ? '' : 's' }} ·
  151.                             {{ my_design.summary.modules|number_format(0, ',', '.') }} modules ·
  152.                             {{ my_design.summary.kwp|number_format(1, ',', '.') }} kWp
  153.                             {%- if my_design.summary.components > 0 %} · {{ my_design.summary.components }} component{{ my_design.summary.components == 1 ? '' : 's' }}{% endif %}
  154.                             <span style="opacity:.8">(as designed — the panel above updates live as you edit)</span>
  155.                         </div>
  156.                         <table style="width:100%;border-collapse:collapse;font-size:12px">
  157.                             <thead><tr>
  158.                                 <th style="text-align:left;color:var(--n-muted);padding:3px 6px 3px 0">Zone</th>
  159.                                 <th style="text-align:left;color:var(--n-muted);padding:3px 6px">Modules</th>
  160.                                 <th style="text-align:left;color:var(--n-muted);padding:3px 6px">kWp</th>
  161.                                 <th style="text-align:left;color:var(--n-muted);padding:3px 6px">Mount</th>
  162.                             </tr></thead>
  163.                             <tbody>
  164.                             {% for z in my_design.payload.zones %}
  165.                                 <tr>
  166.                                     <td style="padding:3px 6px 3px 0;border-top:1px dashed var(--n-border-md)">{{ z.name|default('Zone ' ~ loop.index) }}</td>
  167.                                     <td style="padding:3px 6px;border-top:1px dashed var(--n-border-md)">{{ z.shown.count|default('—') }}</td>
  168.                                     <td style="padding:3px 6px;border-top:1px dashed var(--n-border-md)">{{ z.shown.kwp is defined ? z.shown.kwp|number_format(1, ',', '.') : '—' }}</td>
  169.                                     <td style="padding:3px 6px;border-top:1px dashed var(--n-border-md)">{{ z.mountMode|default('south') }}{{ z.orientation is defined and z.orientation == 'landscape' ? ' · landscape' : '' }}</td>
  170.                                 </tr>
  171.                             {% endfor %}
  172.                             </tbody>
  173.                         </table>
  174.                         <div style="font-size:12px;color:var(--n-muted);margin-top:6px">
  175.                             Saving updates this design on your account ·
  176.                             <a href="{{ url('hb360_my_estimate') }}" style="color:var(--n-amber);font-weight:600">back to My designs</a>
  177.                         </div>
  178.                     </div>
  179.                     {% endif %}
  180.                     {# FUNNEL-1: the save-prompt CTA. Lives OUTSIDE #sds-panel (the shell owns that
  181.                        element's contents) and entirely in this twig + server files — the shell JS
  182.                        files are untouched. Shown only once the studio is active (sds-panel visible). #}
  183.                     <div id="sdsf-card" class="sds-panel-card" style="display:none;margin-top:12px">
  184.                         <div style="font-weight:700;font-size:13.5px;margin-bottom:4px">Save your design</div>
  185.                         <div style="font-size:12.5px;color:var(--n-muted);margin-bottom:10px">
  186.                             Keep this layout — it stays with this browser and attaches to your account
  187.                             the moment you sign in or create one.
  188.                         </div>
  189.                         <button id="sdsf-save" class="n-btn n-btn-amber" type="button" style="padding:7px 14px;font-size:13px">Save design</button>
  190.                         <div id="sdsf-msg" style="font-size:12.5px;margin-top:8px"></div>
  191.                         {# FUNNEL-2: Request an offer — revealed after a successful save. The consent
  192.                            copy NAMES the receiving company (fetched per country) and is ENTWURF-
  193.                            watermarked until counsel clears the final text. #}
  194.                         <div id="sdsf-offer" style="display:none;border-top:1px solid var(--n-border-md);margin-top:12px;padding-top:12px">
  195.                             <div style="font-weight:700;font-size:13.5px;margin-bottom:6px">Request an offer</div>
  196.                             <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
  197.                                 <input id="sdsf-name" type="text" placeholder="Your name *" style="padding:8px;border:1.5px solid var(--n-border-md);border-radius:6px;font-size:13px">
  198.                                 <input id="sdsf-email" type="email" placeholder="Email *" style="padding:8px;border:1.5px solid var(--n-border-md);border-radius:6px;font-size:13px">
  199.                                 <input id="sdsf-phone" type="text" placeholder="Phone (optional)" style="padding:8px;border:1.5px solid var(--n-border-md);border-radius:6px;font-size:13px">
  200.                                 <select id="sdsf-country" style="padding:8px;border:1.5px solid var(--n-border-md);border-radius:6px;font-size:13px">
  201.                                     <option value="">Country *</option>
  202.                                     <option value="DE">Germany</option><option value="BD">Bangladesh</option>
  203.                                     <option value="SG">Singapore</option><option value="AT">Austria</option>
  204.                                     <option value="CH">Switzerland</option><option value="NL">Netherlands</option>
  205.                                     <option value="BE">Belgium</option><option value="FR">France</option>
  206.                                     <option value="IT">Italy</option><option value="ES">Spain</option>
  207.                                     <option value="PL">Poland</option><option value="GB">United Kingdom</option>
  208.                                     <option value="IN">India</option><option value="US">United States</option>
  209.                                     <option value="__other">Other…</option>
  210.                                 </select>
  211.                             </div>
  212.                             <input id="sdsf-country-other" type="text" maxlength="2" placeholder="2-letter country code (e.g. PT)" style="display:none;margin-top:8px;padding:8px;border:1.5px solid var(--n-border-md);border-radius:6px;font-size:13px;width:100%">
  213.                             <textarea id="sdsf-message" placeholder="Anything the partner should know? (optional)" rows="2" style="margin-top:8px;width:100%;padding:8px;border:1.5px solid var(--n-border-md);border-radius:6px;font-size:13px"></textarea>
  214.                             <label style="display:flex;gap:8px;align-items:flex-start;margin-top:8px;font-size:12px;color:var(--n-muted);cursor:pointer">
  215.                                 <input id="sdsf-consent" type="checkbox" style="margin-top:2px">
  216.                                 <span>
  217.                                     <span style="display:inline-block;background:#fff3cd;color:#8a6d1a;border:1px solid #e8d48a;border-radius:4px;padding:0 6px;font-weight:700;font-size:11.5px;letter-spacing:.5px;margin-right:4px">ENTWURF</span>
  218.                                     I agree that my roof design and my contact details are sent to
  219.                                     <b id="sdsf-target">the responsible solar partner for my country</b>
  220.                                     so they can prepare an offer and contact me. <span style="opacity:.8">(Draft consent text — legal review pending / Rechtstext in Prüfung.)</span>
  221.                                 </span>
  222.                             </label>
  223.                             <button id="sdsf-send" class="n-btn n-btn-amber" type="button" style="margin-top:10px;padding:7px 14px;font-size:13px">Request offer</button>
  224.                             <div id="sdsf-offer-msg" style="font-size:12.5px;margin-top:8px"></div>
  225.                         </div>
  226.                     </div>
  227.                 </div>
  228.                 <button class="re-advanced-toggle" id="re-advanced-btn" type="button">⚙ Advanced settings ▾</button>
  229.                 <div id="re-advanced">
  230.                     <div class="re-field">
  231.                         <label>Sizing mode</label>
  232.                         <div class="re-toggle">
  233.                             <button type="button" id="re-mode-roof" class="active">Fit the roof</button>
  234.                             <button type="button" id="re-mode-load">Match my usage</button>
  235.                         </div>
  236.                     </div>
  237.                     <div class="re-field">
  238.                         <label>Electricity tariff (€/kWh)</label>
  239.                         <input id="re-tariff" type="number" min="0.01" step="0.01" value="0.22">
  240.                     </div>
  241.                     <div class="re-field">
  242.                         <label>Roof type</label>
  243.                         <select id="re-rooftype">
  244.                             <option value="10">Flat roof (C&amp;I) — 10° tilt</option>
  245.                             <option value="30">Pitched roof — 30° tilt</option>
  246.                         </select>
  247.                     </div>
  248.                     <div class="re-field">
  249.                         <label>Monthly use (kWh) — overrides the bill if set</label>
  250.                         <input id="re-monthly" type="number" min="0" step="100" placeholder="optional, e.g. 25000">
  251.                     </div>
  252.                     <div class="re-field">
  253.                         <label>Roof area (m²) — manual, if the map is unavailable</label>
  254.                         <input id="re-manual-area" type="number" min="10" step="10" placeholder="e.g. 1200">
  255.                     </div>
  256.                 </div>
  257.             </div>
  258.             {# ── RIGHT: the tagged estimate + the locked full design ── #}
  259.             <div>
  260.                 <div id="re-results" class="re-card">
  261.                     <div class="re-headline">
  262.                         <div class="v" id="k-headline">—</div>
  263.                         <div class="l">estimated bill saving / year <span id="k-headline-tag"></span></div>
  264.                     </div>
  265.                     <div class="re-kpis">
  266.                         <div class="re-kpi"><div class="v" id="k-kwp">—</div><div class="l">System size (kWp)</div><span id="t-kwp"></span></div>
  267.                         <div class="re-kpi"><div class="v" id="k-gen">—</div><div class="l">Annual yield (kWh)</div><span id="t-gen"></span></div>
  268.                         <div class="re-kpi"><div class="v" id="k-capex">—</div><div class="l">Indicative CAPEX</div><span id="t-capex"></span></div>
  269.                         <div class="re-kpi"><div class="v" id="k-payback">—</div><div class="l">Simple payback</div><span id="t-payback"></span></div>
  270.                     </div>
  271.                     <table class="re-meta">
  272.                         <tr><td class="ml">Modules</td><td class="mv"><span id="k-panels">—</span> pcs</td><td class="mt"></td></tr>
  273.                         <tr><td class="ml">Roof area</td><td class="mv"><span id="k-area">—</span> m²</td><td class="mt"><span id="t-roof"></span></td></tr>
  274.                         <tr><td class="ml">CO₂ avoided</td><td class="mv"><span id="k-co2">—</span> t/yr</td><td class="mt"><span id="t-co2"></span></td></tr>
  275.                         <tr><td class="ml">Grid export</td><td class="mv"><span id="k-export">—</span> kWh/yr</td><td class="mt"><span id="t-export"></span></td></tr>
  276.                         <tr><td class="ml">Data sources</td><td class="mv" colspan="2"><span id="k-src">—</span></td></tr>
  277.                     </table>
  278.                     <details class="re-legend"><summary>What the tags mean</summary>{{ hb360.legend() }}</details>
  279.                     {# The visibly-locked full design — the HB360 signup wall (H1b) #}
  280.                     <div class="re-locked">
  281.                         <div class="re-locked-blur">
  282.                             <table>
  283.                                 <tr><td>PV modules</td><td style="text-align:right">◼◼◼ pcs</td><td style="text-align:right">€ ◼◼,◼◼◼</td></tr>
  284.                                 <tr><td>Grid inverters</td><td style="text-align:right">◼ pcs</td><td style="text-align:right">€ ◼,◼◼◼</td></tr>
  285.                                 <tr><td>Mounting structure</td><td style="text-align:right">◼◼◼ set</td><td style="text-align:right">€ ◼,◼◼◼</td></tr>
  286.                                 <tr><td>DC / AC cabling &amp; protection</td><td style="text-align:right">◼◼◼ m</td><td style="text-align:right">€ ◼,◼◼◼</td></tr>
  287.                                 <tr><td>Monthly generation profile</td><td style="text-align:right" colspan="2">▁▂▄▆█▇▆▄▂▁</td></tr>
  288.                             </table>
  289.                         </div>
  290.                         <div class="re-locked-over">
  291.                             <div class="ttl"><i class="fa-solid fa-lock" style="color:var(--n-amber)"></i> The full design is ready</div>
  292.                             <ul class="re-locked-teaser" id="re-locked-teaser"></ul>
  293.                             {# Logged-in applicants skip the wall; guests go to LOGIN (which offers
  294.                                signup) — the hb360_anon cookie routes them back to their saved
  295.                                estimate after auth, so no refRoute plumbing is needed. #}
  296.                             {% set _hbApplicant = app.session.get('userType') == 8 %}
  297.                             <a href="{{ _hbApplicant ? url('hb360_my_estimate') : url('user_login') }}" class="n-btn n-btn-amber" style="justify-content:center">See the full design &amp; save this <i class="fa-solid fa-arrow-right"></i></a>
  298.                             <div class="re-muted" style="font-size:12px" id="re-saved-note">Free account · your estimate is kept for you</div>
  299.                         </div>
  300.                     </div>
  301.                     <div class="re-note" id="re-disclaimer"></div>
  302.                     <a href="{{ url('honeybee_contact') }}" class="n-btn n-btn-outline" style="width:100%;justify-content:center;margin-top:14px">Book a HoneyCore Site Assessment <i class="fa-solid fa-arrow-right"></i></a>
  303.                     <p class="re-muted" style="text-align:center;margin-top:8px">From €499/site, credited against deployment.</p>
  304.                 </div>
  305.                 <div class="re-card" id="re-empty" style="text-align:center;color:var(--n-muted);font-size:14px;line-height:1.7">
  306.                     <i class="fa-solid fa-solar-panel" style="font-size:28px;color:var(--n-amber);display:block;margin-bottom:10px"></i>
  307.                     Enter an address and your monthly bill on the left —<br>your instant estimate appears here.
  308.                 </div>
  309.             </div>
  310.         </div>
  311.     </div>
  312. </section>
  313. <script>
  314. (function () {
  315.     var state = { area: 0, lat: 0, lng: 0, mode: 'roof', map: null, poly: null, manual: false };
  316.     var $ = function (id) { return document.getElementById(id); };
  317.     function setMode(m) {
  318.         state.mode = m;
  319.         $('re-mode-roof').classList.toggle('active', m === 'roof');
  320.         $('re-mode-load').classList.toggle('active', m === 'load');
  321.     }
  322.     $('re-mode-roof').onclick = function () { setMode('roof'); };
  323.     $('re-mode-load').onclick = function () { setMode('load'); };
  324.     $('re-advanced-btn').onclick = function () {
  325.         var a = $('re-advanced');
  326.         a.style.display = a.style.display === 'block' ? 'none' : 'block';
  327.     };
  328.     $('re-manual-area').oninput = function () { state.area = parseFloat(this.value) || 0; state.manual = true; reflectArea(); };
  329.     function reflectArea() {
  330.         if (state.area > 0) { $('re-area-pill').style.display = 'inline-flex'; $('re-area-val').textContent = Math.round(state.area).toLocaleString(); }
  331.     }
  332.     // ── Google Maps (draw-to-trace fallback). Defined globally for the API callback. ──
  333.     window.initRooftop = function () {
  334.         try {
  335.             var map = new google.maps.Map($('re-map'), { center: { lat: 48.40, lng: 9.99 }, zoom: 18, mapTypeId: 'satellite', tilt: 0, streetViewControl: false });
  336.             state.map = map;
  337.             try {
  338.                 if (google.maps.places && google.maps.places.Autocomplete) {
  339.                     var ac = new google.maps.places.Autocomplete($('re-search'));
  340.                     ac.bindTo('bounds', map);
  341.                     ac.addListener('place_changed', function () {
  342.                         var p = ac.getPlace();
  343.                         if (p.geometry) { map.setCenter(p.geometry.location); map.setZoom(20); }
  344.                     });
  345.                 }
  346.             } catch (acErr) { /* no autocomplete — typing a full address still works */ }
  347.             state.points = [];
  348.             state.poly = new google.maps.Polygon({ map: map, paths: [], fillColor: '#C07D2A', fillOpacity: .25, strokeColor: '#C07D2A', strokeWeight: 2, clickable: false });
  349.             var recompute = function () {
  350.                 state.poly.setPath(state.points);
  351.                 if (state.points.length >= 3) {
  352.                     state.area = google.maps.geometry.spherical.computeArea(state.poly.getPath());
  353.                     state.manual = false;
  354.                     var lat = 0, lng = 0;
  355.                     state.points.forEach(function (p) { lat += p.lat(); lng += p.lng(); });
  356.                     state.lat = lat / state.points.length; state.lng = lng / state.points.length;
  357.                     reflectArea();
  358.                 }
  359.             };
  360.             map.addListener('click', function (e) {
  361.                 // SDS0: while the studio owns the drawing (focused, or zones exist),
  362.                 // the simple one-polygon trace stands down.
  363.                 if (window.SDSShell && SDSShell.blocksSimpleTrace()) return;
  364.                 state.points.push(e.latLng); recompute(); $('re-redraw').style.display = 'inline-flex';
  365.             });
  366.             state.clearDraw = function () { state.points = []; state.poly.setPath([]); state.area = 0; $('re-area-pill').style.display = 'none'; };
  367.             $('re-redraw').onclick = state.clearDraw;
  368.             // SDS0 studio shell (optional asset — page fully works without it).
  369.             var sdsBridge = { simple: state, clearSimple: function () { state.clearDraw(); $('re-redraw').style.display = 'none'; } };
  370.             if (window.SDSShell) { SDSShell.attach(map, sdsBridge); } else { window.__SDS_PENDING = { map: map, bridge: sdsBridge }; }
  371.         } catch (e) {
  372.             $('re-map').innerHTML = '<div style="padding:40px;text-align:center;color:#6B6E7F">Map unavailable — use “Advanced settings → Roof area (manual)”.</div>';
  373.         }
  374.     };
  375.     window.gm_authFailure = function () {
  376.         $('re-map').innerHTML = '<div style="padding:40px;text-align:center;color:#6B6E7F">Map unavailable — use “Advanced settings → Roof area (manual)”.</div>';
  377.     };
  378.     function err(msg) { $('re-err').textContent = msg; $('re-err').style.display = 'block'; }
  379.     function commonParams() {
  380.         return {
  381.             mode: state.mode,
  382.             monthly_kwh: $('re-monthly').value || 0,
  383.             monthly_bill: $('re-bill').value || 0,
  384.             tariff: $('re-tariff').value || 0.22,
  385.             tilt: $('re-rooftype').value || 10
  386.         };
  387.     }
  388.     // A drawn outline is the user's explicit statement of the roof — it always
  389.     // wins over auto-detection. Typing a NEW address invalidates the old drawing.
  390.     $('re-search').addEventListener('input', function () {
  391.         if (state.points && state.points.length && state.clearDraw) { state.clearDraw(); $('re-redraw').style.display = 'none'; }
  392.         // A new address invalidates studio zones too (different site).
  393.         if (window.SDSShell && SDSShell.clearAllZones) { SDSShell.clearAllZones(); }
  394.     });
  395.     // ── Primary flow: address (+ bill) → auto roof → estimate ──
  396.     $('re-auto').onclick = function () {
  397.         var addr = ($('re-search').value || '').trim();
  398.         $('re-err').style.display = 'none'; $('re-auto-status').style.display = 'none';
  399.         // If the user traced the roof, use THAT — not the auto-detected footprint.
  400.         if (state.points && state.points.length >= 3 && state.area > 5) {
  401.             var s = $('re-auto-status'); s.style.display = 'block';
  402.             s.innerHTML = '<i class="fa-solid fa-draw-polygon" style="color:var(--n-amber)"></i> Using your drawn roof outline (' + Math.round(state.area).toLocaleString() + ' m²).';
  403.             estimateFromArea();
  404.             return;
  405.         }
  406.         if (!addr) { $('re-search').focus(); return; }
  407.         $('re-auto').disabled = true; $('re-auto').innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Detecting roof &amp; modelling…';
  408.         var p = commonParams(); p.address = addr;
  409.         fetch('{{ url('honeybee_rooftop_auto') }}', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(p).toString() })
  410.             .then(function (r) { return r.json(); })
  411.             .then(function (d) {
  412.                 $('re-auto').disabled = false; $('re-auto').innerHTML = '<i class="fa-solid fa-bolt"></i> Get my instant estimate';
  413.                 if (d.ok) { render(d); return; }
  414.                 if (d.needs_manual && state.map && d.lat) {
  415.                     state.map.setCenter({ lat: d.lat, lng: d.lng }); state.map.setZoom(20);
  416.                     if (state.clearDraw) state.clearDraw();
  417.                     var s = $('re-auto-status'); s.style.display = 'block';
  418.                     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>' : '');
  419.                 } else {
  420.                     err(d.error || 'Could not auto-detect — trace the roof on the map.');
  421.                 }
  422.             })
  423.             .catch(function () { $('re-auto').disabled = false; $('re-auto').innerHTML = '<i class="fa-solid fa-bolt"></i> Get my instant estimate'; err('Network error — please try again.'); });
  424.     };
  425.     // ── Drawn / manual roof → estimate (shared by both buttons) ──
  426.     function estimateFromArea() {
  427.         $('re-err').style.display = 'none';
  428.         // SDS0: when studio zones exist they ARE the roof — use their SUM
  429.         // (with a single zone this equals the old single-polygon behavior).
  430.         var zs = window.SDSShell && SDSShell.estimateSource && SDSShell.estimateSource();
  431.         if (zs && zs.area > 5) { state.area = zs.area; state.lat = zs.lat; state.lng = zs.lng; state.manual = false; }
  432.         if (!state.area || state.area < 5) { err('Trace the roof on the map (or enter an area in Advanced settings) first.'); return; }
  433.         var lat = state.lat || 50, lng = state.lng || 9;
  434.         $('re-spin').style.display = 'block';
  435.         var p = commonParams();
  436.         p.lat = lat; p.lng = lng; p.area_m2 = state.area; p.roof_source = state.manual ? 'manual' : 'map';
  437.         fetch('{{ url('honeybee_rooftop_calc') }}', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(p).toString() })
  438.             .then(function (r) { return r.json(); })
  439.             .then(function (d) {
  440.                 $('re-spin').style.display = 'none';
  441.                 if (!d.ok) { err(d.error || 'Could not calculate.'); return; }
  442.                 render(d);
  443.             })
  444.             .catch(function () { $('re-spin').style.display = 'none'; err('Network error — please try again.'); });
  445.     }
  446.     $('re-go').onclick = estimateFromArea;
  447.     function eur(n) { return '€' + Math.round(n).toLocaleString('de-DE'); }
  448.     function tag(el, fig) { if (fig) { $(el).innerHTML = hb360Tag(fig.tag, fig.basis); } }
  449.     function render(d) {
  450.         var f = d.figures || {};
  451.         $('k-headline').textContent = eur(d.annual_savings);
  452.         $('k-headline-tag').innerHTML = d.headline ? hb360Tag(d.headline.tag, (f.annual_savings || {}).basis) : '';
  453.         $('k-kwp').textContent = d.kwp.toLocaleString();
  454.         $('k-gen').textContent = Math.round(d.annual_gen_kwh).toLocaleString();
  455.         $('k-capex').textContent = eur(d.capex_eur);
  456.         $('k-payback').textContent = d.payback_years ? d.payback_years + ' yrs' : '—';
  457.         tag('t-kwp', f.system_kwp); tag('t-gen', f.annual_yield); tag('t-capex', f.capex); tag('t-payback', f.payback);
  458.         $('k-panels').textContent = d.panels.toLocaleString();
  459.         $('k-co2').textContent = d.co2_tonnes_yr; tag('t-co2', f.co2);
  460.         $('k-area').textContent = (d.area_m2 || 0).toLocaleString(); tag('t-roof', f.roof_area);
  461.         $('k-export').textContent = Math.round(d.export_kwh || 0).toLocaleString(); tag('t-export', f.grid_export);
  462.         $('k-src').textContent = (d.yield_source || '—') + (d.roof_source ? ' · roof via ' + d.roof_source : '');
  463.         var tz = $('re-locked-teaser'); tz.innerHTML = '';
  464.         ((d.locked && d.locked.teaser) || []).forEach(function (t) {
  465.             var li = document.createElement('li'); li.textContent = t; tz.appendChild(li);
  466.         });
  467.         $('re-disclaimer').textContent = (d.formatted_address ? '📍 ' + d.formatted_address + ' — ' : '') + d.disclaimer;
  468.         if (d.saved) { $('re-saved-note').innerHTML = '<i class="fa-solid fa-check" style="color:var(--n-amber)"></i> Estimate saved — it will be waiting for you after sign-in.'; }
  469.         $('re-empty').style.display = 'none';
  470.         $('re-results').style.display = 'block';
  471.         $('re-results').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
  472.     }
  473. })();
  474. </script>
  475. {# ── SDS0 · Solar Design Studio shell (page-scoped assets, NOT in the gulp bundles).
  476.      sds-layout.js is SDS1's file — the shell probe-loads it and degrades gracefully
  477.      while it does not exist yet. ── #}
  478. {# SDS3: the PUBLIC studio stays TRANSIENT (no saveUrl — no new public write surface);
  479.    signInMsg renders the "sign in to save" hint in the panel. Persistence lives on the
  480.    tenant boxes at /solar-studio (session-gated). #}
  481. <script>window.SDS_BOOT = { layoutUrl: '{{ asset('honeybee_web_assets/js/sds-layout.js') }}?v=21', calcUrl: '{{ url('honeybee_rooftop_calc') }}', density: 0.18, usable: 0.75, signInMsg: 'Sign in to your HoneyBee ERP to save designs and generate priced proposals.' };</script>
  482. <script src="{{ asset('honeybee_web_assets/js/sds-shell.js') }}?v=30" defer onerror="console.warn('SDS shell unavailable — simple estimator only')"></script>
  483. {# ── FUNNEL-1: anonymous design save (CTA + wire live ONLY in this twig + server files;
  484.      the shell JS is untouched — the card consumes the shell's public exportDesign() seam).
  485.      Fully guarded: if the shell/seam is absent the card simply never shows. ── #}
  486. <script>
  487. (function () {
  488.     var saveUrl = '{{ url('honeybee_rooftop_design_save') }}';
  489.     var signUpUrl = '{{ path('sign_up', {'refRoute': 'hb360_my_estimate'}) }}';
  490.     var loginUrl = '{{ path('central_login') }}';
  491.     // FUNNEL-3: the signed-in applicant's owned design + account prefill (null for guests)
  492.     var MY_DESIGN = {{ my_design is defined and my_design ? my_design|json_encode|raw : 'null' }};
  493.     var PREFILL = {{ applicant is defined and applicant ? applicant|json_encode|raw : 'null' }};
  494.     var busy = false;
  495.     // load the owned design into the studio once the shell + engine are up (the same
  496.     // probe-wait the tenant studio uses; gives up silently — the page stays usable)
  497.     if (MY_DESIGN && MY_DESIGN.payload) {
  498.         var importTries = 0;
  499.         var importTimer = setInterval(function () {
  500.             importTries++;
  501.             var ready = window.SDSShell && typeof window.SDSShell.importDesign === 'function'
  502.                 && window.google && window.google.maps && window.SolarLayout;
  503.             if (ready || importTries > 40) {
  504.                 clearInterval(importTimer);
  505.                 if (ready) {
  506.                     try { window.SDSShell.importDesign(MY_DESIGN.payload); } catch (e) { /* page stays usable */ }
  507.                 }
  508.             }
  509.         }, 500);
  510.     }
  511.     function el(id) { return document.getElementById(id); }
  512.     function esc(s) { var d = document.createElement('div'); d.textContent = String(s == null ? '' : s); return d.innerHTML; }
  513.     function msg(html, isErr) {
  514.         var m = el('sdsf-msg');
  515.         if (m) { m.innerHTML = html; m.style.color = isErr ? '#b4452f' : 'var(--n-muted)'; m.style.fontWeight = isErr ? '600' : ''; }
  516.     }
  517.     // Show the card only while the studio is actually active (the shell toggles sds-show).
  518.     setInterval(function () {
  519.         var card = el('sdsf-card'), panel = el('sds-panel');
  520.         if (!card || !panel) { return; }
  521.         var active = panel.classList.contains('sds-show')
  522.             && window.SDSShell && typeof window.SDSShell.exportDesign === 'function';
  523.         card.style.display = active ? 'block' : 'none';
  524.     }, 800);
  525.     document.addEventListener('click', function (ev) {
  526.         if (!ev.target || ev.target.id !== 'sdsf-save' || busy) { return; }
  527.         if (!window.SDSShell || typeof window.SDSShell.exportDesign !== 'function') { return; }
  528.         var ex = window.SDSShell.exportDesign();
  529.         if (ex.error) { msg(esc(ex.error), true); return; }
  530.         busy = true;
  531.         msg('Saving…', false);
  532.         var addr = el('re-search') ? el('re-search').value : '';
  533.         fetch(saveUrl, {
  534.             method: 'POST',
  535.             headers: { 'Content-Type': 'application/json' },
  536.             body: JSON.stringify({ payload: ex.payload, address: addr,
  537.                 project_id: MY_DESIGN ? MY_DESIGN.id : 0 })
  538.         }).then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
  539.           .then(function (res) {
  540.             busy = false;
  541.             var j = res.j || {};
  542.             if (j.ok && j.saved) {
  543.                 var s = j.saved.summary || {};
  544.                 if (MY_DESIGN && j.saved.project_id === MY_DESIGN.id) {
  545.                     msg('Design updated on your account — ' + esc(s.zones) + ' zone(s) · '
  546.                         + esc(s.modules) + ' modules · ' + esc(s.kwp) + ' kWp.', false);
  547.                 } else {
  548.                     msg('Design saved — ' + esc(s.zones) + ' zone(s) · ' + esc(s.modules) + ' modules · '
  549.                         + esc(s.kwp) + ' kWp (as designed in the studio).<br>'
  550.                         + '<a href="' + signUpUrl + '" style="color:var(--n-amber);font-weight:600">Create a free account</a> or '
  551.                         + '<a href="' + loginUrl + '" style="color:var(--n-amber);font-weight:600">sign in</a> to keep it '
  552.                         + 'and see it under “My Solar Estimates”.', false);
  553.                 }
  554.                 var off = el('sdsf-offer');
  555.                 if (off) { off.style.display = 'block'; }
  556.             } else {
  557.                 msg(esc(j.error || ('Could not save (HTTP ' + res.status + ') — please try again.')), true);
  558.             }
  559.         }).catch(function () {
  560.             busy = false;
  561.             msg('Could not reach the server — your design stays in this browser tab. Try again in a moment.', true);
  562.         });
  563.     });
  564.     // ── FUNNEL-2: Request an offer (outbox insert on the server; delivery is the cron's) ──
  565.     var targetUrl = '{{ url('honeybee_rooftop_offer_target') }}';
  566.     var offerUrl = '{{ url('honeybee_rooftop_request_offer') }}';
  567.     var offerBusy = false;
  568.     // FUNNEL-3: pre-fill the contact fields from the signed-in account (editable)
  569.     if (PREFILL) {
  570.         var pn = el('sdsf-name'), pe = el('sdsf-email');
  571.         if (pn && !pn.value && PREFILL.name) { pn.value = PREFILL.name; }
  572.         if (pe && !pe.value && PREFILL.email) { pe.value = PREFILL.email; }
  573.     }
  574.     function offerMsg(html, isErr) {
  575.         var m = el('sdsf-offer-msg');
  576.         if (m) { m.innerHTML = html; m.style.color = isErr ? '#b4452f' : 'var(--n-muted)'; m.style.fontWeight = isErr ? '600' : ''; }
  577.     }
  578.     function offerCountry() {
  579.         var sel = el('sdsf-country');
  580.         if (!sel) { return ''; }
  581.         if (sel.value === '__other') {
  582.             var o = el('sdsf-country-other');
  583.             return o ? o.value.trim().toUpperCase() : '';
  584.         }
  585.         return sel.value;
  586.     }
  587.     document.addEventListener('change', function (ev) {
  588.         if (!ev.target) { return; }
  589.         if (ev.target.id === 'sdsf-country') {
  590.             var other = el('sdsf-country-other');
  591.             if (other) { other.style.display = ev.target.value === '__other' ? 'block' : 'none'; }
  592.         }
  593.         if (ev.target.id === 'sdsf-country' || ev.target.id === 'sdsf-country-other') {
  594.             var cc = offerCountry();
  595.             var tgt = el('sdsf-target');
  596.             if (!tgt || !/^[A-Z]{2}$/.test(cc)) { return; }
  597.             fetch(targetUrl + '?country=' + encodeURIComponent(cc))
  598.                 .then(function (r) { return r.json(); })
  599.                 .then(function (j) {
  600.                     if (j.ok && j.company) { tgt.textContent = j.company; }
  601.                     else { tgt.textContent = 'the responsible solar partner for my country'; if (j.unrouted) { offerMsg(esc(j.error), false); } }
  602.                 }).catch(function () { /* preview is best-effort */ });
  603.         }
  604.     });
  605.     document.addEventListener('click', function (ev) {
  606.         if (!ev.target || ev.target.id !== 'sdsf-send' || offerBusy) { return; }
  607.         var cc = offerCountry();
  608.         var consent = el('sdsf-consent');
  609.         offerBusy = true;
  610.         offerMsg('Sending…', false);
  611.         fetch(offerUrl, {
  612.             method: 'POST',
  613.             headers: { 'Content-Type': 'application/json' },
  614.             body: JSON.stringify({
  615.                 name: el('sdsf-name') ? el('sdsf-name').value : '',
  616.                 email: el('sdsf-email') ? el('sdsf-email').value : '',
  617.                 phone: el('sdsf-phone') ? el('sdsf-phone').value : '',
  618.                 country: cc,
  619.                 message: el('sdsf-message') ? el('sdsf-message').value : '',
  620.                 consent: !!(consent && consent.checked),
  621.                 project_id: MY_DESIGN ? MY_DESIGN.id : 0
  622.             })
  623.         }).then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
  624.           .then(function (res) {
  625.             offerBusy = false;
  626.             var j = res.j || {};
  627.             if (j.ok) {
  628.                 offerMsg('<b style="color:#1e7b34">Request received.</b> ' + esc(j.note || ''), false);
  629.             } else {
  630.                 offerMsg(esc(j.error || ('Could not send (HTTP ' + res.status + ').')), true);
  631.             }
  632.         }).catch(function () {
  633.             offerBusy = false;
  634.             offerMsg('Could not reach the server — please try again in a moment.', true);
  635.         });
  636.     });
  637. })();
  638. </script>
  639. <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>
  640. {% include '@HoneybeeWeb/footer/central_footer.html.twig' %}