site-report.php 47 KB

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