site-report.php 46 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063
  1. <?php require_once __DIR__ . '/_bootstrap.php';
  2. /* =========================================================================
  3. * Site Report – Tasmanian Property Lookup (PHP 8.3+)
  4. * -------------------------------------------------------------------------
  5. * - Google Maps API key served from environment, never exposed in source
  6. * - Enter address (TAS only) → pulls parcel + planning data via list_lookup.php
  7. * - Leaflet map with parcel overlay
  8. * - Generate AI report via generate_planning_report.php
  9. * - Open Section Builder with BroadcastChannel / postMessage handoff
  10. * =======================================================================*/
  11. // ── Security: API key from environment only, never hardcoded ──────────────
  12. // Set GMAPS_API_KEY in your .env / docker-compose environment block.
  13. // The key is passed to a PHP proxy endpoint (/gmaps-key.php) so it never
  14. // appears in page source. Falls back to direct injection only on localhost.
  15. $GMAPS_API_KEY = getenv('GMAPS_API_KEY') ?: '';
  16. $LOOKUP_ENDPOINT = './list_lookup.php';
  17. $REPORT_ENDPOINT = './generate_planning_report.php';
  18. // Use APP_ENV=local (set in .env) to enable the dev shortcut of inlining the
  19. // Maps key directly. Never derive this from REMOTE_ADDR — inside Docker the
  20. // client address is the container gateway (172.x.x.x), not 127.0.0.1, so the
  21. // old check always evaluated to false and the inline path was never reachable.
  22. $IS_LOCAL = (getenv('APP_ENV') === 'local');
  23. // Proxy the key: expose it only if request comes from same origin
  24. $KEY_ENDPOINT = './gmaps-key.php';
  25. ?>
  26. <!doctype html>
  27. <html lang="en">
  28. <head>
  29. <meta charset="utf-8">
  30. <meta name="viewport" content="width=device-width, initial-scale=1">
  31. <title>Property Lookup — Tasmanian Planning Scheme Assistant</title>
  32. <meta name="description" content="Look up SPPs and LPS rules, overlays, and codes for any Tasmanian property. Parcel data, zone summaries and AI report generation.">
  33. <link rel="canonical" href="https://tasplanning.report/site-report">
  34. <meta name="robots" content="noindex,follow">
  35. <link rel="preconnect" href="https://fonts.googleapis.com">
  36. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  37. <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&display=swap" rel="stylesheet">
  38. <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
  39. <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
  40. <script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
  41. <script defer src="https://unpkg.com/leaflet-image@latest/leaflet-image.js"></script>
  42. <script defer src="https://unpkg.com/html2pdf.js@0.10.1/dist/html2pdf.bundle.min.js"></script>
  43. <link rel="icon" href="/favicon.ico">
  44. <link rel="stylesheet" href="/css/design-tokens.css">
  45. <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
  46. <style>
  47. /* ── Page-specific token overrides ───────────────────────────────── */
  48. :root {
  49. --warn: #f0b060; /* slightly warmer than the shared default */
  50. --radius: 12px;
  51. --radius-lg: 18px;
  52. --radius-sm: 6px;
  53. --transition: 0.18s cubic-bezier(0.4,0,0.2,1);
  54. }
  55. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  56. html { scroll-behavior: smooth; }
  57. body {
  58. font-family: var(--sans);
  59. background: var(--bg);
  60. color: var(--text-primary);
  61. font-size: 15px;
  62. line-height: 1.65;
  63. -webkit-font-smoothing: antialiased;
  64. min-height: 100vh;
  65. }
  66. ::selection { background: var(--accent); color: #0b0f0e; }
  67. a { color: var(--accent); text-decoration: none; }
  68. /* ── Nav ─────────────────────────────────────────────────────────── */
  69. .site-nav {
  70. position: sticky; top: 0; z-index: 200;
  71. background: rgba(11,15,14,0.9);
  72. backdrop-filter: blur(12px);
  73. border-bottom: 1px solid var(--border);
  74. }
  75. .nav-inner {
  76. max-width: 1280px; margin: 0 auto; padding: 0 24px;
  77. display: flex; align-items: center; justify-content: space-between;
  78. height: 58px;
  79. }
  80. .nav-brand {
  81. display: flex; align-items: center; gap: 10px;
  82. font-size: 0.88rem; font-weight: 500; color: var(--text-primary);
  83. text-decoration: none;
  84. }
  85. .nav-brand img { width: 26px; height: 26px; border-radius: 5px; }
  86. .nav-links { display: flex; align-items: center; gap: 4px; }
  87. .nav-links a {
  88. font-size: 0.82rem; color: var(--text-secondary); padding: 5px 11px;
  89. border-radius: var(--radius-sm); text-decoration: none;
  90. transition: all var(--transition);
  91. }
  92. .nav-links a:hover { color: var(--text-primary); background: rgba(255,255,255,0.05); }
  93. .nav-links a.active { color: var(--accent); }
  94. .status-dot {
  95. width: 7px; height: 7px; border-radius: 50%;
  96. background: var(--accent); box-shadow: 0 0 6px var(--accent-glow);
  97. display: inline-block;
  98. animation: pulse 2.5s ease-in-out infinite;
  99. }
  100. @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.45} }
  101. .nav-status { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: var(--text-muted); }
  102. /* ── Layout ──────────────────────────────────────────────────────── */
  103. .page-wrap { max-width: 1280px; margin: 0 auto; padding: 32px 24px 80px; }
  104. .page-header { margin-bottom: 28px; }
  105. .page-header h1 {
  106. font-family: var(--serif); font-size: clamp(1.6rem, 3vw, 2.2rem);
  107. line-height: 1.15; font-weight: 400; margin-bottom: 8px;
  108. }
  109. .page-header h1 em { font-style: italic; color: var(--accent); }
  110. .page-header p { font-size: 0.88rem; color: var(--text-secondary); }
  111. /* ── Cards ───────────────────────────────────────────────────────── */
  112. .card {
  113. background: var(--bg-card); border: 1px solid var(--border);
  114. border-radius: var(--radius-lg); padding: 24px;
  115. }
  116. .card + .card, .card + .results-card { margin-top: 16px; }
  117. /* ── Search form ─────────────────────────────────────────────────── */
  118. .search-label {
  119. font-size: 0.72rem; font-weight: 500; letter-spacing: 0.1em;
  120. text-transform: uppercase; color: var(--text-muted); margin-bottom: 10px;
  121. display: block;
  122. }
  123. .search-row { display: flex; gap: 10px; align-items: stretch; }
  124. .search-wrap { position: relative; flex: 1; }
  125. .search-input {
  126. width: 100%;
  127. background: var(--bg-1); border: 1px solid var(--border);
  128. border-radius: var(--radius); padding: 13px 16px 13px 44px;
  129. color: var(--text-primary); font-family: var(--sans); font-size: 0.93rem;
  130. outline: none; transition: border-color var(--transition), box-shadow var(--transition);
  131. }
  132. .search-input::placeholder { color: var(--text-muted); }
  133. .search-input:focus {
  134. border-color: var(--accent);
  135. box-shadow: 0 0 0 3px var(--accent-dim);
  136. }
  137. .search-icon {
  138. position: absolute; left: 15px; top: 50%; transform: translateY(-50%);
  139. color: var(--text-muted); font-size: 1rem; pointer-events: none;
  140. }
  141. .btn {
  142. display: inline-flex; align-items: center; gap: 7px;
  143. padding: 11px 20px; border-radius: var(--radius);
  144. font-family: var(--sans); font-size: 0.85rem; font-weight: 500;
  145. cursor: pointer; transition: all var(--transition);
  146. border: none; text-decoration: none; white-space: nowrap;
  147. }
  148. .btn-primary {
  149. background: var(--accent); color: #0b0f0e;
  150. }
  151. .btn-primary:hover:not(:disabled) {
  152. background: #3bf59a; transform: translateY(-1px);
  153. box-shadow: 0 0 16px var(--accent-glow);
  154. }
  155. .btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
  156. .btn-outline {
  157. background: transparent; color: var(--text-secondary);
  158. border: 1px solid var(--border-hover);
  159. }
  160. .btn-outline:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
  161. .btn-outline:disabled { opacity: 0.35; cursor: not-allowed; }
  162. .btn-ghost {
  163. background: transparent; color: var(--text-muted);
  164. border: 1px solid var(--border);
  165. }
  166. .btn-ghost:hover { border-color: var(--border-hover); color: var(--text-secondary); }
  167. .btn-sm { padding: 7px 14px; font-size: 0.78rem; border-radius: var(--radius-sm); }
  168. .hint-text { font-size: 0.75rem; color: var(--text-muted); margin-top: 9px; }
  169. .hint-text a { color: var(--text-secondary); border-bottom: 1px solid var(--border); }
  170. /* ── Error / status ──────────────────────────────────────────────── */
  171. .error-bar {
  172. display: none; align-items: center; gap: 9px;
  173. background: rgba(240,128,128,0.08); border: 1px solid rgba(240,128,128,0.25);
  174. border-radius: var(--radius-sm); padding: 10px 14px;
  175. color: var(--danger); font-size: 0.83rem; margin-top: 12px;
  176. }
  177. .error-bar.show { display: flex; }
  178. /* ── Map ─────────────────────────────────────────────────────────── */
  179. #pb-map {
  180. height: 320px; border-radius: var(--radius);
  181. overflow: hidden; background: var(--bg-2);
  182. border: 1px solid var(--border);
  183. }
  184. /* ── Results card ────────────────────────────────────────────────── */
  185. .results-card {
  186. background: var(--bg-card); border: 1px solid var(--border);
  187. border-radius: var(--radius-lg); overflow: hidden;
  188. animation: slideIn 0.3s ease;
  189. }
  190. @keyframes slideIn { from { opacity:0; transform: translateY(10px); } to { opacity:1; transform: none; } }
  191. .results-header {
  192. padding: 20px 24px 16px;
  193. border-bottom: 1px solid var(--border);
  194. display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
  195. }
  196. .results-address {
  197. font-family: var(--serif); font-size: 1.25rem; line-height: 1.2;
  198. color: var(--text-primary);
  199. }
  200. .council-badge {
  201. display: inline-flex; align-items: center; gap: 6px;
  202. background: var(--accent-dim); border: 1px solid rgba(45,220,138,0.2);
  203. border-radius: 999px; padding: 4px 12px;
  204. font-size: 0.75rem; color: var(--accent); white-space: nowrap; flex-shrink: 0;
  205. }
  206. .results-body { padding: 20px 24px; }
  207. /* Data grid */
  208. .data-grid {
  209. display: grid;
  210. grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  211. gap: 12px; margin-bottom: 20px;
  212. }
  213. .data-cell {
  214. background: var(--bg-2); border: 1px solid var(--border);
  215. border-radius: var(--radius-sm); padding: 12px 14px;
  216. }
  217. .data-cell .label {
  218. font-size: 0.68rem; font-weight: 500; letter-spacing: 0.09em;
  219. text-transform: uppercase; color: var(--text-muted); margin-bottom: 5px;
  220. }
  221. .data-cell .value {
  222. font-size: 0.88rem; color: var(--text-primary); font-weight: 500;
  223. word-break: break-word;
  224. }
  225. .data-cell.wide { grid-column: span 2; }
  226. .data-cell.accent-cell { border-color: rgba(45,220,138,0.2); }
  227. .data-cell.accent-cell .value { color: var(--accent); }
  228. /* Zone / code pills */
  229. .pill-group { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
  230. .zone-pill {
  231. display: inline-flex; align-items: center; gap: 5px;
  232. padding: 3px 10px; border-radius: 999px; font-size: 0.75rem;
  233. border: 1px solid rgba(45,220,138,0.25); color: var(--accent);
  234. background: var(--accent-dim);
  235. }
  236. .code-pill {
  237. display: inline-flex; align-items: center; gap: 5px;
  238. padding: 3px 10px; border-radius: 999px; font-size: 0.75rem;
  239. border: 1px solid var(--border); color: var(--text-secondary);
  240. background: var(--bg-2);
  241. }
  242. /* Divider */
  243. .divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
  244. /* Action bar */
  245. .action-bar {
  246. display: flex; flex-wrap: wrap; gap: 8px;
  247. padding: 16px 24px; border-top: 1px solid var(--border);
  248. background: var(--bg-1);
  249. }
  250. /* ── Report output ───────────────────────────────────────────────── */
  251. .report-card {
  252. background: var(--bg-card); border: 1px solid var(--border);
  253. border-radius: var(--radius-lg); margin-top: 16px;
  254. animation: slideIn 0.3s ease;
  255. }
  256. .report-header {
  257. padding: 16px 24px; border-bottom: 1px solid var(--border);
  258. display: flex; align-items: center; justify-content: space-between;
  259. }
  260. .report-header h3 { font-size: 0.9rem; font-weight: 500; }
  261. .report-body {
  262. padding: 24px;
  263. color: var(--text-secondary); font-size: 0.9rem; line-height: 1.75;
  264. }
  265. .report-body h1, .report-body h2, .report-body h3 {
  266. color: var(--text-primary); font-family: var(--serif);
  267. font-weight: 400; margin: 1.4em 0 0.6em;
  268. }
  269. .report-body h1 { font-size: 1.5rem; }
  270. .report-body h2 { font-size: 1.2rem; }
  271. .report-body h3 { font-size: 1rem; font-family: var(--sans); font-weight: 500; }
  272. .report-body p { margin-bottom: 0.8em; }
  273. .report-body ul, .report-body ol { padding-left: 1.4em; margin-bottom: 0.8em; }
  274. .report-body table { width: 100%; border-collapse: collapse; font-size: 0.83rem; margin: 1em 0; }
  275. .report-body th {
  276. background: var(--bg-2); color: var(--text-secondary);
  277. padding: 8px 10px; text-align: left; font-weight: 500;
  278. border-bottom: 1px solid var(--border); font-size: 0.75rem; letter-spacing: 0.05em;
  279. }
  280. .report-body td { padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text-primary); }
  281. .report-body tr:last-child td { border-bottom: none; }
  282. /* Markdown panel */
  283. .md-panel { border-top: 1px solid var(--border); }
  284. .md-panel summary {
  285. padding: 12px 24px; font-size: 0.8rem; color: var(--text-muted);
  286. cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px;
  287. }
  288. .md-panel summary:hover { color: var(--text-secondary); }
  289. .md-panel summary::before { content: '›'; font-size: 1rem; transition: transform var(--transition); }
  290. .md-panel[open] summary::before { transform: rotate(90deg); }
  291. .md-textarea {
  292. width: 100%; background: var(--bg-2); border: none;
  293. border-top: 1px solid var(--border);
  294. color: var(--text-secondary); font-family: ui-monospace, 'Cascadia Code', Menlo, monospace;
  295. font-size: 0.78rem; line-height: 1.6; padding: 16px 24px; resize: vertical;
  296. min-height: 200px; outline: none;
  297. }
  298. .md-actions { display: flex; gap: 8px; padding: 12px 24px; border-top: 1px solid var(--border); }
  299. /* ── Spinner ──────────────────────────────────────────────────────── */
  300. .spinner {
  301. width: 15px; height: 15px;
  302. border: 2px solid var(--border); border-top-color: var(--accent);
  303. border-radius: 50%; animation: spin 0.65s linear infinite; flex-shrink: 0;
  304. }
  305. @keyframes spin { to { transform: rotate(360deg); } }
  306. /* ── Responsive ──────────────────────────────────────────────────── */
  307. @media(max-width: 640px) {
  308. .search-row { flex-direction: column; }
  309. .data-grid { grid-template-columns: repeat(2, 1fr); }
  310. .data-cell.wide { grid-column: span 2; }
  311. .action-bar { flex-direction: column; }
  312. .action-bar .btn { justify-content: center; }
  313. }
  314. /* ── Print ───────────────────────────────────────────────────────── */
  315. @media print {
  316. .site-nav, .action-bar, .btn, #pb-errors { display: none !important; }
  317. .results-card, .report-card { box-shadow: none; border: 1px solid #ddd; }
  318. body { background: #fff; color: #000; }
  319. }
  320. /* Leaflet dark override */
  321. .leaflet-tile { filter: brightness(0.85) saturate(0.7); }
  322. .leaflet-control-attribution { background: rgba(11,15,14,0.8) !important; color: var(--text-muted) !important; }
  323. .leaflet-control-attribution a { color: var(--accent) !important; }
  324. </style>
  325. <script>
  326. // ── Config (no sensitive keys in source) ─────────────────────────
  327. const LOOKUP_ENDPOINT = <?php echo json_encode($LOOKUP_ENDPOINT); ?>;
  328. const REPORT_ENDPOINT = <?php echo json_encode($REPORT_ENDPOINT); ?>;
  329. const KEY_ENDPOINT = <?php echo json_encode($KEY_ENDPOINT); ?>;
  330. <?php if ($IS_LOCAL && $GMAPS_API_KEY): ?>
  331. // localhost only — key injected directly for dev convenience
  332. const GMAPS_KEY = <?php echo json_encode($GMAPS_API_KEY); ?>;
  333. <?php else: ?>
  334. const GMAPS_KEY = null; // loaded via KEY_ENDPOINT proxy in initAutocomplete
  335. <?php endif; ?>
  336. </script>
  337. <!-- Google tag (gtag.js) -->
  338. <script async src="https://www.googletagmanager.com/gtag/js?id=G-LWEHQVCWEZ"></script>
  339. <script>
  340. window.dataLayer = window.dataLayer || [];
  341. function gtag(){dataLayer.push(arguments);}
  342. gtag('js', new Date());
  343. gtag('config', 'G-LWEHQVCWEZ');
  344. </script>
  345. <!-- Google Tag Manager -->
  346. <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  347. new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  348. j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
  349. 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
  350. })(window,document,'script','dataLayer','GTM-M5PFLGZT');</script>
  351. <!-- End Google Tag Manager -->
  352. </head>
  353. <body>
  354. <!-- Google Tag Manager (noscript) -->
  355. <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M5PFLGZT"
  356. height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  357. <!-- End Google Tag Manager (noscript) -->
  358. <!-- ── Nav ──────────────────────────────────────────────────────────── -->
  359. <nav class="site-nav">
  360. <div class="nav-inner">
  361. <a class="nav-brand" href="/">
  362. <svg width="26" height="26" viewBox="0 0 28 28" fill="none">
  363. <rect width="28" height="28" rx="6" fill="var(--accent-dim)" stroke="rgba(45,220,138,0.25)" stroke-width="1"/>
  364. <path d="M8 20 L14 8 L20 20" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  365. <path d="M10.5 16 L17.5 16" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
  366. </svg>
  367. Tasmanian Planning Scheme
  368. </a>
  369. <div class="nav-links">
  370. <a href="/">Home</a>
  371. <a href="/local_state-planning-scheme.php">Assistant</a>
  372. <a href="/site-report.php" class="active">Property Lookup</a>
  373. <a href="/section-builder.php">Report Builder</a>
  374. <a href="/faq">FAQ</a>
  375. </div>
  376. <div class="nav-status">
  377. <span class="status-dot"></span>
  378. <span class="nav-status-text">API live</span>
  379. </div>
  380. </div>
  381. </nav>
  382. <!-- ── Main ─────────────────────────────────────────────────────────── -->
  383. <main class="page-wrap">
  384. <div class="page-header">
  385. <h1>Tasmanian <em>Property Lookup</em></h1>
  386. <p>Enter a Tasmanian address to retrieve parcel details, planning zones, overlays and codes — then generate an AI assessment report.</p>
  387. </div>
  388. <!-- Search card -->
  389. <div class="card">
  390. <label class="search-label" for="site_address">Site address — Tasmania only</label>
  391. <div class="search-row">
  392. <div class="search-wrap">
  393. <i class="bi bi-geo-alt search-icon"></i>
  394. <input id="site_address" class="search-input"
  395. placeholder="Start typing a Tasmanian address…"
  396. autocomplete="off" aria-label="Site address">
  397. </div>
  398. <button id="lookup-btn" class="btn btn-primary" disabled>
  399. <i class="bi bi-search"></i> Look up property
  400. </button>
  401. </div>
  402. <p class="hint-text">Select a suggestion from the dropdown. Restricted to Tasmania.</p>
  403. <div id="pb-errors" class="error-bar" role="alert">
  404. <i class="bi bi-exclamation-circle"></i>
  405. <span id="pb-errors-msg"></span>
  406. </div>
  407. </div>
  408. <!-- Hidden fields -->
  409. <input type="hidden" id="site_lat">
  410. <input type="hidden" id="site_lng">
  411. <input type="hidden" id="google_place_id">
  412. <input type="hidden" id="formatted_address">
  413. <input type="hidden" id="locality">
  414. <input type="hidden" id="state">
  415. <input type="hidden" id="postcode">
  416. <input type="hidden" id="property_id">
  417. <input type="hidden" id="title_id">
  418. <input type="hidden" id="planning_scheme">
  419. <input type="hidden" id="planning_zones">
  420. <input type="hidden" id="planning_codes">
  421. <input type="hidden" id="total_area">
  422. <!-- Results (hidden until lookup) -->
  423. <div id="pb-results" class="results-card" style="display:none;">
  424. <!-- Map -->
  425. <div style="padding: 16px 16px 0;">
  426. <div id="pb-map"></div>
  427. </div>
  428. <!-- Header -->
  429. <div class="results-header">
  430. <div>
  431. <div class="search-label" style="margin-bottom:6px;">Property identified</div>
  432. <div class="results-address" id="address">—</div>
  433. </div>
  434. <div id="summary-badge" class="council-badge" style="display:none;">
  435. <i class="bi bi-building"></i>
  436. <span id="council-name"></span>
  437. </div>
  438. </div>
  439. <!-- Data -->
  440. <div class="results-body">
  441. <!-- Core identifiers -->
  442. <div class="data-grid">
  443. <div class="data-cell accent-cell">
  444. <div class="label">Property ID</div>
  445. <div class="value" id="pb_pid">—</div>
  446. </div>
  447. <div class="data-cell">
  448. <div class="label">Title ID</div>
  449. <div class="value" id="pb_title">—</div>
  450. </div>
  451. <div class="data-cell">
  452. <div class="label">Total area</div>
  453. <div class="value" id="pb_area">—</div>
  454. </div>
  455. <div class="data-cell">
  456. <div class="label">Area m²</div>
  457. <div class="value" id="area_sqm">—</div>
  458. </div>
  459. <div class="data-cell">
  460. <div class="label">Area ha</div>
  461. <div class="value" id="area_ha">—</div>
  462. </div>
  463. <div class="data-cell">
  464. <div class="label">Tenure</div>
  465. <div class="value" id="tenure">—</div>
  466. </div>
  467. <div class="data-cell">
  468. <div class="label">Locality</div>
  469. <div class="value" id="pb_locality">—</div>
  470. </div>
  471. <div class="data-cell">
  472. <div class="label">LPI</div>
  473. <div class="value" id="lpi">—</div>
  474. </div>
  475. <div class="data-cell wide">
  476. <div class="label">Planning scheme</div>
  477. <div class="value" id="pb_scheme">—</div>
  478. </div>
  479. <div class="data-cell wide">
  480. <div class="label">LIST GUID</div>
  481. <div class="value" id="list_guid" style="font-size:0.75rem; font-family: ui-monospace, monospace;">—</div>
  482. </div>
  483. </div>
  484. <hr class="divider">
  485. <!-- Zones -->
  486. <div style="margin-bottom: 14px;">
  487. <div class="label" style="margin-bottom:8px;">Planning zones</div>
  488. <div class="pill-group" id="pb_zones_pills">
  489. <span style="color:var(--text-muted);font-size:0.8rem;">—</span>
  490. </div>
  491. </div>
  492. <!-- Codes -->
  493. <div>
  494. <div class="label" style="margin-bottom:8px;">Codes &amp; overlays</div>
  495. <div class="pill-group" id="pb_codes_pills">
  496. <span style="color:var(--text-muted);font-size:0.8rem;">—</span>
  497. </div>
  498. </div>
  499. </div>
  500. <!-- Actions -->
  501. <div class="action-bar">
  502. <button id="pb-generate" class="btn btn-primary">
  503. <i class="bi bi-stars"></i> Generate AI report
  504. </button>
  505. <button id="pb-send-builder" class="btn btn-outline">
  506. <i class="bi bi-layout-text-sidebar"></i> Open section builder
  507. </button>
  508. <button id="pb-pdf" class="btn btn-ghost btn-sm">
  509. <i class="bi bi-file-pdf"></i> Save PDF
  510. </button>
  511. </div>
  512. </div>
  513. <!-- Report output -->
  514. <div id="pb-report" class="report-card" style="display:none;">
  515. <div class="report-header">
  516. <h3><i class="bi bi-file-earmark-text" style="color:var(--accent);margin-right:6px;"></i>AI Planning Report</h3>
  517. <div style="display:flex;gap:8px;">
  518. <button id="pb-pdf-report" class="btn btn-ghost btn-sm"><i class="bi bi-file-pdf"></i> Save PDF</button>
  519. </div>
  520. </div>
  521. <div class="report-body" id="pb-report-body"></div>
  522. <details class="md-panel" id="md-panel">
  523. <summary><i class="bi bi-code-slash"></i> Markdown source</summary>
  524. <textarea id="md-source" class="md-textarea" readonly></textarea>
  525. <div class="md-actions">
  526. <button id="md-copy" class="btn btn-ghost btn-sm"><i class="bi bi-clipboard"></i> Copy markdown</button>
  527. </div>
  528. </details>
  529. </div>
  530. </main>
  531. <!-- ── Google Maps loader ─────────────────────────────────────────── -->
  532. <script>
  533. 'use strict';
  534. /* ── Utilities ─────────────────────────────────────────────────────── */
  535. const LOG = (...a) => console.log('[SiteReport]', ...a);
  536. const $ = id => document.getElementById(id);
  537. function showError(msg) {
  538. const bar = $('pb-errors');
  539. const txt = $('pb-errors-msg');
  540. if (!bar) return;
  541. txt.textContent = msg;
  542. bar.classList.add('show');
  543. }
  544. function clearError() {
  545. const bar = $('pb-errors');
  546. if (bar) bar.classList.remove('show');
  547. }
  548. function setText(id, value) {
  549. const el = $(id);
  550. if (el) el.textContent = (value ?? '—');
  551. }
  552. function renderPills(containerId, items, pillClass) {
  553. const el = $(containerId);
  554. if (!el) return;
  555. if (!items || !items.length) {
  556. el.innerHTML = '<span style="color:var(--text-muted);font-size:0.8rem;">None identified</span>';
  557. return;
  558. }
  559. el.innerHTML = items.map(z =>
  560. `<span class="${pillClass}"><i class="bi bi-${pillClass === 'zone-pill' ? 'map' : 'tag'}"></i>${z}</span>`
  561. ).join('');
  562. }
  563. function showResults() {
  564. const c = $('pb-results');
  565. if (!c) return;
  566. c.style.display = '';
  567. requestAnimationFrame(() => c.scrollIntoView({ behavior: 'smooth', block: 'start' }));
  568. }
  569. window._pbCurrentData = null;
  570. /* ── Address autocomplete ──────────────────────────────────────────── */
  571. (function() {
  572. const TAS_BOUNDS = { north:-39.0, south:-44.5, east:149.5, west:143.0 };
  573. let inited = false;
  574. function isTas(components) {
  575. const a1 = components.find(c =>
  576. (c?.types?.includes?.('administrative_area_level_1')) || c?.type === 'administrative_area_level_1'
  577. );
  578. const v = (a1?.longText || a1?.long_name || a1?.shortText || a1?.short_name || '').toUpperCase();
  579. return v === 'TAS' || v === 'TASMANIA';
  580. }
  581. function writeHiddenFromPlace(place) {
  582. const latFn = place?.location?.lat || place?.geometry?.location?.lat;
  583. const lngFn = place?.location?.lng || place?.geometry?.location?.lng;
  584. const lat = typeof latFn === 'function' ? latFn.call(place.location || place.geometry.location) : null;
  585. const lng = typeof lngFn === 'function' ? lngFn.call(place.location || place.geometry.location) : null;
  586. const comps = place?.addressComponents || place?.address_components || [];
  587. const pick = type => {
  588. const c = comps.find(x => x?.types?.includes?.(type) || x?.type === type);
  589. return c ? (c.longText || c.long_name || '') : '';
  590. };
  591. if ($('site_lat')) $('site_lat').value = lat ?? '';
  592. if ($('site_lng')) $('site_lng').value = lng ?? '';
  593. if ($('google_place_id')) $('google_place_id').value = place.id || place.place_id || '';
  594. if ($('formatted_address'))$('formatted_address').value= place.formattedAddress || place.formatted_address || place.displayName || '';
  595. if ($('locality')) $('locality').value = pick('locality') || pick('postal_town') || pick('sublocality') || '';
  596. if ($('state')) $('state').value = pick('administrative_area_level_1') || '';
  597. if ($('postcode')) $('postcode').value = pick('postal_code') || '';
  598. $('lookup-btn').disabled = !(lat && lng);
  599. }
  600. function clearHidden() {
  601. ['site_lat','site_lng','google_place_id','formatted_address','locality','state','postcode']
  602. .forEach(id => { const el = $(id); if (el) el.value = ''; });
  603. if ($('lookup-btn')) $('lookup-btn').disabled = true;
  604. }
  605. async function handleNewWidgetEvent(ev, originalInput) {
  606. try {
  607. const pred = ev.placePrediction || ev.detail?.placePrediction || ev.detail;
  608. if (!pred || typeof pred.toPlace !== 'function') return;
  609. const place = pred.toPlace();
  610. await place.fetchFields({ fields: ['id','location','formattedAddress','addressComponents','displayName'] });
  611. const comps = place.addressComponents || [];
  612. if (!isTas(comps)) { showError('Please pick an address in Tasmania.'); clearHidden(); return; }
  613. clearError();
  614. if (originalInput) originalInput.value = place.formattedAddress || place.displayName || '';
  615. writeHiddenFromPlace(place);
  616. setText('address', place.formattedAddress || place.displayName || '');
  617. } catch(e) { LOG('error resolving prediction', e); }
  618. }
  619. async function loadGmapsKey() {
  620. if (GMAPS_KEY) return GMAPS_KEY;
  621. try {
  622. const r = await fetch(KEY_ENDPOINT);
  623. const d = await r.json();
  624. return d.key || '';
  625. } catch { return ''; }
  626. }
  627. window.initAutocomplete = async function() {
  628. if (inited) return;
  629. inited = true;
  630. const input = $('site_address');
  631. if (!input) return;
  632. const key = await loadGmapsKey();
  633. if (!key) { LOG('No Google Maps key available'); return; }
  634. // Use Google's official dynamic library import bootstrap.
  635. // This pattern is copy-pasted from Google's own documentation and
  636. // correctly handles the async initialisation of importLibrary().
  637. // See: https://developers.google.com/maps/documentation/javascript/load-maps-js-api
  638. if (!window.google?.maps?.importLibrary) {
  639. await new Promise((resolve, reject) => {
  640. // Inject the bootstrap loader exactly as Google recommends
  641. window.__googleMapsResolve = resolve;
  642. const g = { key, v: 'weekly', loading: 'async' };
  643. const script = document.createElement('script');
  644. script.src = 'https://maps.googleapis.com/maps/api/js?' +
  645. Object.entries(g).map(([k,v]) => `${k}=${encodeURIComponent(v)}`).join('&') +
  646. '&callback=__googleMapsCallback';
  647. script.async = true;
  648. script.onerror = reject;
  649. window.__googleMapsCallback = () => resolve();
  650. document.head.appendChild(script);
  651. });
  652. }
  653. // importLibrary() is now available. Wait for places library to be ready.
  654. let placesLib;
  655. try {
  656. placesLib = await google.maps.importLibrary('places');
  657. } catch(e) {
  658. LOG('Failed to import places library', e);
  659. return;
  660. }
  661. input.setAttribute('autocomplete', 'off');
  662. input.addEventListener('keydown', e => { if (e.key === 'Enter') e.preventDefault(); });
  663. $('lookup-btn').disabled = true;
  664. // Use new PlaceAutocompleteElement if available, else classic fallback
  665. if (placesLib.PlaceAutocompleteElement) {
  666. LOG('Using PlaceAutocompleteElement');
  667. const pac = new placesLib.PlaceAutocompleteElement({ includedRegionCodes: ['AU'] });
  668. pac.locationRestriction = TAS_BOUNDS;
  669. pac.includedPrimaryTypes = ['street_address', 'premise', 'route'];
  670. pac.className = input.className;
  671. pac.style.cssText = input.style.cssText;
  672. input.style.display = 'none';
  673. input.parentNode.insertBefore(pac, input);
  674. pac.addEventListener('input', clearHidden);
  675. pac.addEventListener('gmp-select', ev => handleNewWidgetEvent(ev, input));
  676. pac.addEventListener('gmp-placeselect', ev => handleNewWidgetEvent(ev, input));
  677. pac.addEventListener('gmpx-placechange', ev => handleNewWidgetEvent(ev, input));
  678. } else {
  679. LOG('Falling back to classic Autocomplete');
  680. const bounds = new google.maps.LatLngBounds(
  681. { lat: TAS_BOUNDS.south, lng: TAS_BOUNDS.west },
  682. { lat: TAS_BOUNDS.north, lng: TAS_BOUNDS.east }
  683. );
  684. const ac = new placesLib.Autocomplete(input, {
  685. componentRestrictions: { country: 'au' },
  686. fields: ['place_id','geometry','formatted_address','address_components'],
  687. types: ['geocode'], bounds, strictBounds: true
  688. });
  689. input.addEventListener('input', clearHidden);
  690. ac.addListener('place_changed', () => {
  691. const place = ac.getPlace();
  692. if (!place) return;
  693. const comps = place.address_components || [];
  694. if (!isTas(comps)) { showError('Please pick an address in Tasmania.'); clearHidden(); return; }
  695. clearError();
  696. writeHiddenFromPlace(place);
  697. setText('address', place.formatted_address || '');
  698. });
  699. }
  700. };
  701. // Kick off key loading and Maps init on page load
  702. window.addEventListener('load', () => window.initAutocomplete());
  703. })();
  704. /* ── Map ───────────────────────────────────────────────────────────── */
  705. let pbMap, pbParcelLayer;
  706. function ensureMap(lat, lng) {
  707. if (pbMap) return;
  708. pbMap = L.map('pb-map', { zoomControl: true });
  709. L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
  710. maxZoom: 20,
  711. attribution: '© OpenStreetMap',
  712. crossOrigin: true
  713. }).addTo(pbMap);
  714. pbParcelLayer = L.geoJSON(null, {
  715. style: { color: '#2ddc8a', weight: 2, fillOpacity: 0.08, fillColor: '#2ddc8a' }
  716. }).addTo(pbMap);
  717. pbMap.setView([lat || -42.882, lng || 147.33], 10);
  718. }
  719. function drawParcel(boundary) {
  720. if (!pbMap || !pbParcelLayer) return;
  721. pbParcelLayer.clearLayers();
  722. if (!boundary) return;
  723. const feature = boundary.type === 'Feature' ? boundary : { type:'Feature', geometry:boundary, properties:{} };
  724. pbParcelLayer.addData(feature);
  725. try {
  726. const b = pbParcelLayer.getBounds();
  727. if (b.isValid()) pbMap.fitBounds(b, { padding: [24, 24] });
  728. } catch {}
  729. }
  730. async function captureMapPng() {
  731. return new Promise(resolve => {
  732. if (!window.pbMap || typeof leafletImage !== 'function') return resolve(null);
  733. leafletImage(pbMap, (err, canvas) => {
  734. if (err || !canvas) return resolve(null);
  735. try { resolve(canvas.toDataURL('image/png')); } catch { resolve(null); }
  736. });
  737. });
  738. }
  739. /* ── Lookup ────────────────────────────────────────────────────────── */
  740. async function fetchAndExtractData() {
  741. const lat = $('site_lat')?.value;
  742. const lng = $('site_lng')?.value;
  743. if (!lat || !lng) { showError('Please select a Google-suggested address first.'); return; }
  744. clearError();
  745. const formatArea = a => a ? (a.sqm_label || a.ha_label || '') : '';
  746. try {
  747. const knownPid = $('property_id')?.value || window._pbCurrentData?.pid || null;
  748. const resp = await fetch(LOOKUP_ENDPOINT, {
  749. method: 'POST',
  750. headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
  751. body: JSON.stringify({ lat: parseFloat(lat), lng: parseFloat(lng), pid: knownPid || undefined, full: true })
  752. });
  753. const data = await resp.json().catch(() => null);
  754. if (!resp.ok || !data || data.ok !== true) {
  755. showError((data && data.error) ? data.error : 'Lookup failed — please try again.');
  756. return;
  757. }
  758. const addrVal = $('formatted_address')?.value || $('site_address')?.value || '';
  759. // Fill text cells
  760. setText('pb_pid', data.pid || '—');
  761. setText('pb_title', data.title_id || '—');
  762. setText('pb_area', formatArea(data.total_area) || '—');
  763. setText('pb_locality',data.locality || '—');
  764. setText('pb_scheme', data.planning_scheme || '—');
  765. setText('area_sqm', data.area_sqm || '—');
  766. setText('area_ha', data.area_ha || '—');
  767. setText('tenure', data.tenure || '—');
  768. setText('lpi', data.lpi || '—');
  769. setText('list_guid', data.list_guid || '—');
  770. setText('address', addrVal || '—');
  771. // Council badge
  772. if (data.council) {
  773. $('council-name').textContent = data.council;
  774. $('summary-badge').style.display = 'inline-flex';
  775. } else {
  776. $('summary-badge').style.display = 'none';
  777. }
  778. // Zone pills
  779. renderPills('pb_zones_pills',
  780. Array.isArray(data.planning_zones) ? data.planning_zones : (data.planning_zones ? [data.planning_zones] : []),
  781. 'zone-pill'
  782. );
  783. // Code pills
  784. renderPills('pb_codes_pills',
  785. Array.isArray(data.planning_codes) ? data.planning_codes : (data.planning_codes ? [data.planning_codes] : []),
  786. 'code-pill'
  787. );
  788. // Store for downstream use
  789. window._pbCurrentData = {
  790. address: addrVal, lat: parseFloat(lat), lng: parseFloat(lng),
  791. pid: data.pid || null, title_id: data.title_id || null,
  792. council: data.council || null, planning_scheme: data.planning_scheme || null,
  793. planning_zones: data.planning_zones || [], planning_codes: data.planning_codes || [],
  794. total_area: data.total_area || null, boundary: data.boundary || null,
  795. locality: data.locality || null, area_sqm: data.area_sqm || null,
  796. area_ha: data.area_ha || null, tenure: data.tenure || null,
  797. lpi: data.lpi || null, list_guid: data.list_guid || null,
  798. raw: data.raw || null
  799. };
  800. const pidInput = $('property_id');
  801. if (pidInput && data.pid) pidInput.value = data.pid;
  802. showResults();
  803. ensureMap(parseFloat(lat), parseFloat(lng));
  804. drawParcel(data.boundary);
  805. if (!data.boundary && pbMap) pbMap.setView([parseFloat(lat), parseFloat(lng)], 17);
  806. // Resize map after reveal
  807. setTimeout(() => pbMap && pbMap.invalidateSize(), 150);
  808. } catch(e) {
  809. showError('Error fetching data: ' + e.message);
  810. }
  811. }
  812. /* ── Lookup button ─────────────────────────────────────────────────── */
  813. (function() {
  814. const btn = $('lookup-btn');
  815. const addr = $('site_address');
  816. if (!btn) return;
  817. addr?.addEventListener('keydown', e => {
  818. if (e.key === 'Enter' && !btn.disabled) { e.preventDefault(); btn.click(); }
  819. });
  820. btn.addEventListener('click', async e => {
  821. e.preventDefault();
  822. if (btn.disabled) return;
  823. const orig = btn.innerHTML;
  824. btn.disabled = true;
  825. btn.innerHTML = '<span class="spinner"></span> Looking up…';
  826. try { await fetchAndExtractData(); }
  827. finally { btn.disabled = false; btn.innerHTML = orig; }
  828. });
  829. })();
  830. /* ── Generate AI report ────────────────────────────────────────────── */
  831. (function() {
  832. document.addEventListener('DOMContentLoaded', () => {
  833. const genBtn = $('pb-generate');
  834. if (!genBtn) return;
  835. genBtn.addEventListener('click', async e => {
  836. e.preventDefault();
  837. const payload = window._pbCurrentData;
  838. if (!payload) { showError('Look up a property first.'); return; }
  839. const map_png = await captureMapPng().catch(() => null);
  840. if (map_png) payload.map_png = map_png;
  841. const orig = genBtn.innerHTML;
  842. genBtn.disabled = true;
  843. genBtn.innerHTML = '<span class="spinner"></span> Generating…';
  844. try {
  845. const resp = await fetch(REPORT_ENDPOINT, {
  846. method: 'POST',
  847. headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
  848. body: JSON.stringify(payload)
  849. });
  850. const raw = await resp.text();
  851. let out = null;
  852. try { out = JSON.parse(raw); } catch {}
  853. if (!resp.ok || !out || out.ok !== true)
  854. throw new Error((out && out.error) ? out.error : `HTTP ${resp.status}`);
  855. const bodyEl = $('pb-report-body');
  856. bodyEl.innerHTML = out.html || '<p>No report content returned.</p>';
  857. $('pb-report').style.display = '';
  858. $('md-source').value = out.markdown || '';
  859. bodyEl.scrollIntoView({ behavior: 'smooth' });
  860. } catch(e) {
  861. showError('Report error: ' + e.message);
  862. } finally {
  863. genBtn.disabled = false;
  864. genBtn.innerHTML = orig;
  865. }
  866. });
  867. });
  868. })();
  869. /* ── PDF ───────────────────────────────────────────────────────────── */
  870. async function savePDF() {
  871. const wrap = $('pb-report');
  872. if (!wrap || wrap.style.display === 'none' || !$('pb-report-body')?.innerHTML.trim())
  873. return alert('Generate the AI report first, then save as PDF.');
  874. if (typeof html2pdf === 'undefined') { window.print(); return; }
  875. const rawAddr = ($('address')?.textContent || 'Property Report').trim();
  876. const safeAddr = rawAddr.replace(/[^ \w\-(),.]/g,'').replace(/\s+/g,' ').substring(0,120);
  877. const opts = {
  878. margin: [10,10,10,10],
  879. filename: `Planning Report — ${safeAddr}.pdf`,
  880. image: { type: 'jpeg', quality: 0.95 },
  881. html2canvas: { scale: 2, useCORS: true, logging: false },
  882. jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
  883. };
  884. const btn = $('pb-pdf') || $('pb-pdf-report');
  885. const orig = btn?.innerHTML;
  886. if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner"></span> Preparing…'; }
  887. try { await html2pdf().from(wrap).set(opts).save(); }
  888. finally { if (btn) { btn.disabled = false; btn.innerHTML = orig; } }
  889. }
  890. $('pb-pdf')?.addEventListener('click', savePDF);
  891. $('pb-pdf-report')?.addEventListener('click', savePDF);
  892. /* ── Markdown copy ─────────────────────────────────────────────────── */
  893. $('md-copy')?.addEventListener('click', async () => {
  894. const ta = $('md-source');
  895. const btn = $('md-copy');
  896. if (!ta?.value) return;
  897. try {
  898. await navigator.clipboard.writeText(ta.value);
  899. const orig = btn.innerHTML;
  900. btn.innerHTML = '<i class="bi bi-check2"></i> Copied!';
  901. setTimeout(() => btn.innerHTML = orig, 1400);
  902. } catch { alert('Copy failed — select text and use Ctrl+C.'); }
  903. });
  904. /* ── Open section builder ──────────────────────────────────────────── */
  905. (function() {
  906. const btn = $('pb-send-builder');
  907. if (!btn) return;
  908. const bc = ('BroadcastChannel' in window) ? new BroadcastChannel('planning_ctx') : null;
  909. function composeContext() {
  910. const d = window._pbCurrentData || {};
  911. return {
  912. address: d.address || '', lat: d.lat || null, lng: d.lng || null,
  913. pid: d.pid || null, title_id: d.title_id || null,
  914. council: d.council || null, locality: d.locality || null,
  915. planning_scheme: d.planning_scheme || null,
  916. planning_zones: d.planning_zones || [],
  917. planning_codes: d.planning_codes || [],
  918. total_area: d.total_area || null, area_sqm: d.area_sqm || null,
  919. area_ha: d.area_ha || null, tenure: d.tenure || null,
  920. lpi: d.lpi || null, list_guid: d.list_guid || null,
  921. map_png: d.map_png || null, proposal_summary: '', use_class: ''
  922. };
  923. }
  924. btn.addEventListener('click', () => {
  925. if (!window._pbCurrentData) { showError('Look up a property first.'); return; }
  926. const ctx = composeContext();
  927. // Write context to sessionStorage BEFORE opening the tab.
  928. // section-builder.php reads it immediately on load — no timing issues,
  929. // no message passing, no retry loops needed.
  930. try {
  931. // localStorage is shared across tabs on the same origin.
  932. // sessionStorage is tab-isolated — new tabs get an empty store.
  933. const payload = { ctx, written_at: Date.now() };
  934. localStorage.setItem('tpr_builder_ctx', JSON.stringify(payload));
  935. console.log('[SiteReport] wrote to localStorage:', JSON.stringify(ctx).slice(0, 100));
  936. } catch(e) {
  937. console.warn('[SiteReport] localStorage write failed', e);
  938. }
  939. // TEMPORARY DIAGNOSTICS
  940. console.log('[SiteReport] wrote to sessionStorage:', sessionStorage.getItem('tpr_builder_ctx')?.slice(0, 100));
  941. // Open the builder — it will find the context in sessionStorage
  942. const w = window.open('section-builder.php', '_blank', 'noopener');
  943. // Also send via postMessage as a fallback for same-tab / embedded use
  944. const payload = { type: 'ctx', payload: ctx };
  945. if (w) {
  946. // Give the new tab a moment to load, then postMessage once
  947. setTimeout(() => {
  948. try { w.postMessage(payload, location.origin); } catch {}
  949. }, 1200);
  950. }
  951. });
  952. })();
  953. </script>
  954. </body>
  955. </html>