index.php 49 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945
  1. <?php
  2. $apiBase = getenv('API_BASE') ?: 'https://api.modulos.com.au/ask';
  3. $demoBearer = getenv('DEMO_TOKEN') ?: '';
  4. ?>
  5. <!doctype html>
  6. <html lang="en">
  7. <head>
  8. <meta charset="utf-8">
  9. <meta name="viewport" content="width=device-width, initial-scale=1">
  10. <!-- Primary SEO -->
  11. <title>AI Planning Scheme Assistant for Tasmania — SPPs, LPS & Overlay Lookup</title>
  12. <meta name="description" content="Get instant, clause-cited answers from the Tasmanian Planning Scheme — zones, overlays, setbacks and acceptable solutions. Free to try, no signup needed.">
  13. <link rel="canonical" href="https://tasplanning.report/">
  14. <meta name="robots" content="index,follow,max-snippet:-1,max-image-preview:large,max-video-preview:-1">
  15. <!-- Helpful resource hints -->
  16. <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
  17. <link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
  18. <link rel="dns-prefetch" href="//cdn.jsdelivr.net">
  19. <link rel="dns-prefetch" href="//www.googletagmanager.com">
  20. <!-- (Optional) Legacy keywords -->
  21. <meta name="keywords" content="Tasmania's AI Planning Scheme, TPS, State Planning Provisions, SPPs, Local Provisions Schedule, LPS, planning overlays, planning codes, acceptable solutions, performance criteria, Tasmania planning, property report, development standards, council planning Tasmania, building design Tasmania, planning assessment Tasmania">
  22. <!-- Open Graph -->
  23. <meta property="og:type" content="website">
  24. <meta property="og:locale" content="en_AU">
  25. <meta property="og:site_name" content="Tasmania's Planning Scheme Assistant">
  26. <meta property="og:url" content="https://tasplanning.report/">
  27. <meta property="og:title" content="Tasmania's AI Planning Scheme Assistant — Faster Property Assessments">
  28. <meta property="og:description" content="Get instant, clause-cited answers from the Tasmanian Planning Scheme — zones, overlays, setbacks and acceptable solutions. Free to try, no signup needed.">
  29. <meta property="og:image" content="https://tasplanning.report/image/og-image.jpg">
  30. <meta property="og:image:alt" content="Tasmania's AI Planning Scheme Assistant interface preview">
  31. <meta property="og:image:width" content="1200">
  32. <meta property="og:image:height" content="630">
  33. <!-- Hreflang -->
  34. <link rel="alternate" hreflang="en-au" href="https://tasplanning.report/">
  35. <link rel="alternate" hreflang="x-default" href="https://tasplanning.report/">
  36. <!-- Theming / PWA -->
  37. <meta name="theme-color" content="#000000">
  38. <meta name="color-scheme" content="dark light">
  39. <link rel="manifest" href="/site.webmanifest">
  40. <!-- Twitter -->
  41. <meta name="twitter:card" content="summary_large_image">
  42. <meta name="twitter:domain" content="tasplanning.report">
  43. <meta name="twitter:site" content="@tasplanningreport">
  44. <meta name="twitter:creator" content="@tasplanningreport">
  45. <meta name="twitter:title" content="Tasmania's AI Planning Scheme Assistant — Faster Property Assessments">
  46. <meta name="twitter:description" content="Look up SPPs and LPS rules, overlays, and codes in seconds. Built for Tasmanian architects, building designers and homeowners to simplify planning assessments with clear, cited sources.">
  47. <meta name="twitter:image" content="https://tasplanning.report/image/og-image.jpg">
  48. <meta name="twitter:image:alt" content="Tasmania's AI Planning Scheme Assistant interface preview">
  49. <!-- Favicons -->
  50. <link rel="icon" href="/favicon.ico">
  51. <link rel="apple-touch-icon" href="/image/apple-touch-icon.png">
  52. <!-- OpenSearch (optional but nice) -->
  53. <link rel="search" type="application/opensearchdescription+xml" title="Tas Planning Search" href="/opensearch.xml">
  54. <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" >
  55. <link href="css/report.css?cache=1" rel="stylesheet" />
  56. <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
  57. <!-- Google tag (gtag.js) -->
  58. <script async src="https://www.googletagmanager.com/gtag/js?id=G-LWEHQVCWEZ"></script>
  59. <script>
  60. window.dataLayer = window.dataLayer || [];
  61. function gtag(){dataLayer.push(arguments);}
  62. gtag('js', new Date());
  63. gtag('config', 'G-LWEHQVCWEZ');
  64. </script>
  65. <!-- Google Tag Manager -->
  66. <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  67. new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  68. j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
  69. 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
  70. })(window,document,'script','dataLayer','GTM-M5PFLGZT');</script>
  71. <!-- End Google Tag Manager -->
  72. </head>
  73. <body>
  74. <!-- Google Tag Manager (noscript) -->
  75. <noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-M5PFLGZT"
  76. height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
  77. <!-- End Google Tag Manager (noscript) -->
  78. <!-- ============================================================
  79. NAV
  80. ============================================================ -->
  81. <nav class="site-nav" id="top">
  82. <div class="container">
  83. <div class="nav-inner">
  84. <a class="nav-brand" href="#top">
  85. <!-- Replace src with your actual logo -->
  86. <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
  87. <rect width="28" height="28" rx="6" fill="var(--accent-dim)" stroke="rgba(45,220,138,0.25)" stroke-width="1"/>
  88. <path d="M8 20 L14 8 L20 20" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
  89. <path d="M10.5 16 L17.5 16" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
  90. </svg>
  91. Tasmanian Planning Scheme
  92. </a>
  93. <div class="nav-links" id="navLinks">
  94. <a href="#how">How it works</a>
  95. <a href="#benefits">Features</a>
  96. <a href="#ncc">NCC</a>
  97. <a href="#pro">Pro</a>
  98. <a href="/faq">FAQ</a>
  99. </div>
  100. <div style="display:flex;align-items:center;gap:14px;">
  101. <div class="nav-status">
  102. <span class="status-dot"></span>
  103. <span class="nav-status-text">API live</span>
  104. </div>
  105. <button class="nav-toggle" id="navToggle" aria-label="Menu">&#9776;</button>
  106. </div>
  107. </div>
  108. </div>
  109. </nav>
  110. <!-- ============================================================
  111. HERO
  112. ============================================================ -->
  113. <section class="hero" id="hero">
  114. <div class="container">
  115. <div class="hero-content">
  116. <div class="hero-badge">
  117. <span class="status-dot"></span>
  118. Free to try — no signup required
  119. </div>
  120. <h1 class="display">
  121. The AI assistant built for<br>
  122. <em>Tasmanian planning</em> assessments.
  123. </h1>
  124. <p class="lead" style="margin-top:20px; max-width: 560px;">
  125. Get zone summaries, setback tables, and cited assessment notes in minutes — not hours.
  126. Every answer traces back to the exact SPP or LPS clause.
  127. </p>
  128. <!-- Quick action pills -->
  129. <div class="pill-bar" style="margin-top:28px;">
  130. <button class="qa-pill" data-qa="Summarise the Village Zone acceptable solutions table."><i class="bi bi-map"></i> Zone summary</button>
  131. <button class="qa-pill" data-qa="List overlays for 19 Main Street, Hobart and the clauses to check."><i class="bi bi-layers"></i> Overlay check</button>
  132. <button class="qa-pill" data-qa="What is the parking rate for a commercial office? Cite the clause."><i class="bi bi-p-circle"></i> Car parking</button>
  133. <button class="qa-pill" data-qa="Create a setbacks table (front/side/rear) for the Village Zone with A vs P and sources."><i class="bi bi-table"></i> Setbacks table</button>
  134. </div>
  135. <!-- Active property context indicator (shows when arriving from site-report.php) -->
  136. <div id="heroCtxBar" style="display:none;margin-bottom:12px;padding:8px 14px;background:rgba(45,220,138,0.07);border:1px solid rgba(45,220,138,0.2);border-radius:8px;font-size:0.8rem;color:var(--text-secondary);align-items:center;gap:8px;flex-wrap:wrap;">
  137. <i class="bi bi-geo-alt" style="color:var(--accent);flex-shrink:0;"></i>
  138. <span id="heroCtxText"></span>
  139. <a href="#" id="heroCtxClear" style="margin-left:auto;color:var(--text-muted);font-size:0.72rem;">clear</a>
  140. </div>
  141. <!-- Search form -->
  142. <form id="heroAskForm" class="search-wrap">
  143. <i class="bi bi-search search-icon"></i>
  144. <input id="heroQuery" class="search-input" type="text"
  145. placeholder='Try: "What are the acceptable solutions for setbacks in the Village Zone?"'
  146. autocomplete="off" />
  147. <button class="search-btn" type="submit">Ask</button>
  148. </form>
  149. <!-- Address detection hint -->
  150. <div id="heroAddressBar" style="display:none;margin-top:8px;padding:6px 14px;background:rgba(45,220,138,0.06);border:1px solid rgba(45,220,138,0.15);border-radius:6px;font-size:0.78rem;color:var(--text-secondary);align-items:center;gap:8px;">
  151. <i class="bi bi-geo-alt" style="color:var(--accent);flex-shrink:0;"></i>
  152. Looks like an address — <a href="/site-report.php" style="color:var(--accent);">look up property data</a> first for zone &amp; overlay context.
  153. </div>
  154. <p class="search-hint">
  155. Press ⏎ or ⌘/Ctrl+⏎ to search. Or
  156. <a href="#" id="openDemoLink">open the interactive demo</a>.
  157. Cites clause numbers, links to SPPs/LPS viewer, supports follow-up questions.
  158. </p>
  159. <!-- Trust signals -->
  160. <div class="trust-line">
  161. <div class="trust-item"><i class="bi bi-shield-check"></i> Private by default</div>
  162. <div class="trust-item"><i class="bi bi-link-45deg"></i> Live clause links</div>
  163. <div class="trust-item"><i class="bi bi-file-earmark-text"></i> Google Docs export</div>
  164. <div class="trust-item"><i class="bi bi-key"></i> Bring your own API key</div>
  165. </div>
  166. <!-- Coming soon rail -->
  167. <div class="coming-rail">
  168. <span class="coming-chip"><i class="bi bi-cone"></i> Coming soon: <a href="#ncc">National Construction Code</a></span>
  169. <span class="coming-chip"><i class="bi bi-cone"></i> Coming soon: <a href="#as">Australian Standards</a></span>
  170. <span class="coming-chip new"><i class="bi bi-stars"></i> New: <a href="#pro">Pro report (waitlist)</a></span>
  171. </div>
  172. </div>
  173. </div>
  174. </section>
  175. <!-- ============================================================
  176. INLINE RESULTS
  177. ============================================================ -->
  178. <section class="results-section" id="results" style="display:none;">
  179. <div class="container">
  180. <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
  181. <h2 style="font-size:1.5rem;">Answer</h2>
  182. <button class="btn btn-ghost btn-sm" id="resultsReset"><i class="bi bi-x-circle"></i> Clear</button>
  183. </div>
  184. <div id="heroStatus" class="status-bar" style="display:none;" aria-live="polite">
  185. <div class="spinner"></div>
  186. <span>Thinking…</span>
  187. </div>
  188. <div class="results-grid">
  189. <div class="result-card">
  190. <div class="answer-label">Answer</div>
  191. <div id="answerText">Ask a question above to see the answer here.</div>
  192. </div>
  193. <div class="sources-card">
  194. <div class="answer-label">Sources</div>
  195. <div id="answerSources" style="font-size:0.85rem;color:var(--text-secondary);">When available, sources will appear here.</div>
  196. </div>
  197. </div>
  198. </div>
  199. </section>
  200. <!-- ============================================================
  201. HOW IT WORKS
  202. ============================================================ -->
  203. <section class="section" id="how">
  204. <div class="container">
  205. <div class="section-label">Process</div>
  206. <h2>How it works</h2>
  207. <p class="lead" style="margin-top:12px; max-width:500px;">Three steps from site query to cited assessment notes.</p>
  208. <div class="steps-grid">
  209. <div class="step-card fade-up">
  210. <div class="step-num">1</div>
  211. <div class="step-icon"><i class="bi bi-pin-map"></i></div>
  212. <h3>Ask</h3>
  213. <p>Describe the site and what you need — setbacks, overlays, acceptable solutions. Paste an address or PID.</p>
  214. </div>
  215. <div class="step-card fade-up">
  216. <div class="step-num">2</div>
  217. <div class="step-icon"><i class="bi bi-table"></i></div>
  218. <h3>Generate</h3>
  219. <p>The assistant drafts sections with tables mapping use and works to correct clauses, including performance criteria and links.</p>
  220. </div>
  221. <div class="step-card fade-up">
  222. <div class="step-num">3</div>
  223. <div class="step-icon"><i class="bi bi-check2-square"></i></div>
  224. <h3>Export</h3>
  225. <p>Send to Google Docs or copy as Markdown. Continue refining — every update keeps sources intact.</p>
  226. </div>
  227. </div>
  228. <div style="display:flex;gap:12px;margin-top:32px;flex-wrap:wrap;">
  229. <button class="btn btn-primary" id="openDemoBtn"><i class="bi bi-eye"></i> Try a demo</button>
  230. <a href="/local_state-planning-scheme" class="btn btn-outline"><i class="bi bi-arrow-right"></i> Open full assistant</a>
  231. </div>
  232. </div>
  233. </section>
  234. <!-- ============================================================
  235. BENEFITS
  236. ============================================================ -->
  237. <section class="section" id="benefits" style="border-top:1px solid var(--border);">
  238. <div class="container">
  239. <div class="section-label">Why teams use it</div>
  240. <h2>Built for speed <em style="font-style:italic;color:var(--accent);">and</em> accuracy</h2>
  241. <p class="lead" style="margin-top:12px;max-width:520px;">For Tasmanian planners, building designers, and consultants.</p>
  242. <div class="benefits-grid">
  243. <div class="benefit-card fade-up">
  244. <div class="benefit-icon"><i class="bi bi-journal-check"></i></div>
  245. <h3>Source-backed answers</h3>
  246. <p>Every statement is traceable to SPPs or your council's LPS, ready for copy-paste into reports.</p>
  247. </div>
  248. <div class="benefit-card fade-up">
  249. <div class="benefit-icon"><i class="bi bi-speedometer2"></i></div>
  250. <h3>Faster drafting</h3>
  251. <p>Generate zone summaries, acceptable solutions tables, and assessment notes in minutes.</p>
  252. </div>
  253. <div class="benefit-card fade-up">
  254. <div class="benefit-icon"><i class="bi bi-shield-check"></i></div>
  255. <h3>Tas-specific guardrails</h3>
  256. <p>Built for the Tasmanian Planning Scheme — no generic fluff, just compliant outputs.</p>
  257. </div>
  258. <div class="benefit-card fade-up">
  259. <div class="benefit-icon"><i class="bi bi-diagram-2"></i></div>
  260. <h3>Fits your workflow</h3>
  261. <p>Export to Google Docs, keep editable tables, and refine via short follow-ups.</p>
  262. </div>
  263. <div class="benefit-card fade-up">
  264. <div class="benefit-icon"><i class="bi bi-link-45deg"></i></div>
  265. <h3>Live clause links</h3>
  266. <p>One-click back to the online viewer for the exact clause, schedule, or overlay.</p>
  267. </div>
  268. <div class="benefit-card fade-up">
  269. <div class="benefit-icon"><i class="bi bi-lock"></i></div>
  270. <h3>Private by default</h3>
  271. <p>Your prompts and drafts stay in your workspace. Bring your own API if required.</p>
  272. </div>
  273. </div>
  274. <!-- Testimonial -->
  275. <div class="testimonial fade-up">
  276. <div class="avatar">JP</div>
  277. <div>
  278. <blockquote>"Knocked an hour off my zone summary workflow. The clause links are the killer feature — I can verify the exact wording in two clicks."</blockquote>
  279. <cite>James P. — Planning consultant, Hobart</cite>
  280. </div>
  281. </div>
  282. </div>
  283. </section>
  284. <!-- ============================================================
  285. NCC
  286. ============================================================ -->
  287. <section class="coming-section" id="ncc">
  288. <div class="container">
  289. <div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;">
  290. <span class="coming-chip"><i class="bi bi-cone"></i> Coming soon</span>
  291. <span class="coming-chip">Building controls</span>
  292. </div>
  293. <h2>National Construction Code</h2>
  294. <p class="lead" style="margin-top:12px;max-width:520px;">Bring NCC context into planning summaries where it's relevant — without mixing it up with SPP/LPS rules. Clean separation; clear citations.</p>
  295. <div class="coming-grid">
  296. <div class="glow-card fade-up">
  297. <h3>Relevant parts & clauses</h3>
  298. <p>Surface applicable NCC parts by building class and proposal.</p>
  299. <span class="coming-chip"><i class="bi bi-diagram-3"></i> Structured refs</span>
  300. </div>
  301. <div class="glow-card fade-up">
  302. <h3>Clear separation</h3>
  303. <p>NCC shown alongside planning outputs but never conflated with SPP/LPS.</p>
  304. <span class="coming-chip"><i class="bi bi-shield-check"></i> Guardrails</span>
  305. </div>
  306. <div class="glow-card fade-up" style="display:flex;flex-direction:column;justify-content:space-between;">
  307. <h3>Be first to try it</h3>
  308. <a href="#pro" class="btn btn-outline" style="align-self:flex-start;margin-top:16px;"><i class="bi bi-envelope"></i> Join waitlist</a>
  309. </div>
  310. </div>
  311. </div>
  312. </section>
  313. <!-- ============================================================
  314. AS
  315. ============================================================ -->
  316. <section class="coming-section" id="as">
  317. <div class="container">
  318. <div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;">
  319. <span class="coming-chip"><i class="bi bi-cone"></i> Coming soon</span>
  320. <span class="coming-chip">Referenced standards</span>
  321. </div>
  322. <h2>Australian Standards</h2>
  323. <p class="lead" style="margin-top:12px;max-width:520px;">Surface standards where they're referenced by clause — not generic dumps, just what matters.</p>
  324. <div class="coming-grid">
  325. <div class="glow-card fade-up">
  326. <h3>Clause-linked</h3>
  327. <p>Only show standards cited by the relevant controls.</p>
  328. </div>
  329. <div class="glow-card fade-up">
  330. <h3>Short summaries</h3>
  331. <p>Plain-English notes to decide if deeper reading is needed.</p>
  332. </div>
  333. <div class="glow-card fade-up" style="display:flex;flex-direction:column;justify-content:space-between;">
  334. <h3>Want early access?</h3>
  335. <a href="#pro" class="btn btn-outline" style="align-self:flex-start;margin-top:16px;"><i class="bi bi-envelope"></i> Join waitlist</a>
  336. </div>
  337. </div>
  338. </div>
  339. </section>
  340. <!-- ============================================================
  341. BUILT FOR TASMANIA
  342. ============================================================ -->
  343. <section class="section" style="border-top:1px solid var(--border);">
  344. <div class="container">
  345. <div class="built-card">
  346. <div>
  347. <div class="section-label">Under the hood</div>
  348. <h2>Built for Tasmania</h2>
  349. <p style="color:var(--text-secondary);margin-top:14px;font-size:0.93rem;line-height:1.7;">
  350. Understands the structure of the Tasmanian Planning Scheme: State Planning Provisions (SPPs),
  351. Local Provisions Schedules (LPS), zones, codes, overlays, and terms.
  352. Outputs mirror how councils expect reports to read.
  353. </p>
  354. </div>
  355. <ul>
  356. <li>Zone summaries with Acceptable Solutions / Performance Criteria</li>
  357. <li>Use tables, works tables, and overlay checks</li>
  358. <li>Clause numbers and viewer links included</li>
  359. <li>Follows Tasmanian council report conventions</li>
  360. <li>Supports follow-up questions without losing context</li>
  361. </ul>
  362. </div>
  363. </div>
  364. </section>
  365. <!-- ============================================================
  366. PRO
  367. ============================================================ -->
  368. <section class="pro-section" id="pro">
  369. <div class="container">
  370. <div style="max-width:620px;position:relative;z-index:1;">
  371. <div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;">
  372. <span class="coming-chip new"><i class="bi bi-stars"></i> Pro</span>
  373. <span class="coming-chip"><i class="bi bi-cone"></i> Coming soon</span>
  374. </div>
  375. <h2>Full planning report —<br>from one detailed brief</h2>
  376. <p class="lead" style="margin-top:14px;">
  377. Give the site and proposal once. Get a structured report: zone summary, overlays,
  378. A vs P tables, assessment notes, and export-ready sections — each clause cited.
  379. </p>
  380. <div class="pro-features">
  381. <div class="pro-feature"><i class="bi bi-check2"></i> Zone + codes tables with A/P</div>
  382. <div class="pro-feature"><i class="bi bi-check2"></i> Clause-linked sources throughout</div>
  383. <div class="pro-feature"><i class="bi bi-check2"></i> Google Docs export</div>
  384. <div class="pro-feature"><i class="bi bi-check2"></i> NCC/AS hooks (when released)</div>
  385. </div>
  386. <form id="waitlistForm" class="waitlist-form">
  387. <input type="email" id="waitlistEmail" placeholder="you@studio.com" required>
  388. <button class="btn btn-primary" type="submit"><i class="bi bi-envelope"></i> Join waitlist</button>
  389. </form>
  390. <p class="waitlist-hint">No spam. We'll email when Pro is available with early-access pricing.</p>
  391. </div>
  392. </div>
  393. </section>
  394. <!-- ============================================================
  395. CTA
  396. ============================================================ -->
  397. <section class="cta-section">
  398. <div class="container">
  399. <div class="section-label" style="justify-content:center;">Get started</div>
  400. <h2>Ready to try it on your next site?</h2>
  401. <p class="lead">Start with a zone, clause, or quick question. Refine with follow-ups, then export.</p>
  402. <div class="cta-buttons">
  403. <button class="btn btn-primary btn-lg" onclick="window.open('/site-report','_blank')"><i class="bi bi-rocket"></i> Launch assistant</button>
  404. <a href="#how" class="btn btn-outline btn-lg"><i class="bi bi-play-circle"></i> See how it works</a>
  405. </div>
  406. <p class="cta-note">No setup required. Bring your own API key if you prefer.</p>
  407. </div>
  408. </section>
  409. <!-- ============================================================
  410. DEMO MODAL
  411. ============================================================ -->
  412. <div class="modal-overlay" id="demoModal">
  413. <div class="modal-box">
  414. <div class="modal-header">
  415. <h5><i class="bi bi-terminal" style="color:var(--accent);margin-right:8px;"></i>Try a demo query</h5>
  416. <button class="modal-close" id="modalClose" aria-label="Close">&#x2715;</button>
  417. </div>
  418. <div class="modal-body">
  419. <div class="modal-sidebar">
  420. <div class="sample-label">Sample queries</div>
  421. <div class="sample-list" id="demoSamples">
  422. <button class="sample-btn active" type="button">What are the acceptable solutions for front and side setbacks in the Village Zone?</button>
  423. <button class="sample-btn" type="button">List the car parking rate for a medical centre and show the clause reference.</button>
  424. <button class="sample-btn" type="button">Do overlays apply to 19 Main Street, Hobart (example)? Which clauses should be checked?</button>
  425. </div>
  426. <div style="margin-top:20px;">
  427. <div style="margin-bottom:10px;">
  428. <div class="sample-label" style="margin-bottom:5px;">Council (optional)</div>
  429. <select id="demoCouncil" style="width:100%;background:var(--bg-card);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-family:inherit;font-size:0.82rem;padding:7px 10px;outline:none;">
  430. <option value="">SPP only</option>
  431. </select>
  432. </div>
  433. <div id="demoCtxBar" style="display:none;padding:8px 10px;background:rgba(45,220,138,0.07);border:1px solid rgba(45,220,138,0.2);border-radius:6px;font-size:0.75rem;color:var(--text-secondary);margin-bottom:10px;">
  434. <i class="bi bi-geo-alt" style="color:var(--accent);margin-right:5px;"></i><span id="demoCtxText"></span>
  435. </div>
  436. <div class="tps-toggle">
  437. <label class="toggle-track">
  438. <input type="checkbox" id="demoAllowTPS" checked>
  439. <span class="toggle-knob"></span>
  440. </label>
  441. <span class="toggle-label">Include State Planning Provisions (SPP)</span>
  442. </div>
  443. <button id="demoRunBtn" class="btn btn-primary" style="width:100%;justify-content:center;"><i class="bi bi-play"></i> Run demo</button>
  444. <p class="api-note">API: <code id="demoApiBase"></code></p>
  445. </div>
  446. </div>
  447. <div class="modal-main">
  448. <div class="modal-form-label">Your question</div>
  449. <textarea id="demoQuery" class="modal-textarea" rows="3" placeholder="Type or click a sample…">What are the acceptable solutions for front and side setbacks in the Village Zone?</textarea>
  450. <div id="demoStatus" class="status-bar" style="display:none;">
  451. <div class="spinner"></div>
  452. <span>Asking the assistant…</span>
  453. </div>
  454. <div class="modal-form-label">Response</div>
  455. <div id="demoResults" class="modal-results">Results will appear here…</div>
  456. </div>
  457. </div>
  458. <div class="modal-footer">
  459. <button class="btn btn-ghost btn-sm" id="modalCloseFooter">Close</button>
  460. </div>
  461. </div>
  462. </div>
  463. <!-- ============================================================
  464. FOOTER
  465. ============================================================ -->
  466. <footer class="site-footer">
  467. <div class="container">
  468. <div class="footer-inner">
  469. <div class="footer-copy">© <span id="year"></span> Tas Planning Assistant</div>
  470. <div class="footer-links">
  471. <a href="/privacy">Privacy</a>
  472. <a href="/terms">Terms</a>
  473. <a href="/faq">FAQ</a>
  474. <a href="#top"><i class="bi bi-arrow-up"></i> Back to top</a>
  475. </div>
  476. </div>
  477. </div>
  478. </footer>
  479. <!-- ============================================================
  480. JAVASCRIPT
  481. ============================================================ -->
  482. <!-- JS (load once, matching CSS version 5.3.8) -->
  483. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
  484. <!-- jQuery only if you need it; Bootstrap 5 itself doesn't require jQuery -->
  485. <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
  486. <script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js" integrity="sha512-uto9mlQzrs59VwILcLiRYeLKPPbS/bT71da/OEBYEwcdNUk8jYIy+D176RYoop1Da+f9mvkYrmj5MCLZWEtQuA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  487. <script>
  488. (function () {
  489. 'use strict';
  490. // ── Config ──────────────────────────────────────────────────
  491. window.APP_API_BASE = <?= json_encode($apiBase) ?>; // ← ADD THIS
  492. window.DEMO_BEARER = <?= json_encode($demoBearer) ?>; // ← ADD THIS
  493. const API_BASE = window.APP_API_BASE || (location.origin + '/ask');
  494. const DEMO_TOKEN = window.DEMO_BEARER || '';
  495. // ── Helpers ─────────────────────────────────────────────────
  496. function $(id) { return document.getElementById(id); }
  497. function authHeaders() {
  498. const h = { 'Content-Type': 'application/json' };
  499. if (DEMO_TOKEN) h['Authorization'] = 'Bearer ' + DEMO_TOKEN;
  500. return h;
  501. }
  502. // Fetch from /ask with proper field names matching the backend
  503. async function askAPI(question, scope = 'state_only', council = null) {
  504. const body = { query: question, top_k: 8, scope };
  505. if (council) body.council = council;
  506. const res = await fetch(API_BASE, {
  507. method: 'POST',
  508. headers: authHeaders(),
  509. credentials: 'omit',
  510. body: JSON.stringify(body)
  511. });
  512. const text = await res.text();
  513. if (!res.ok) throw new Error(text || 'HTTP ' + res.status);
  514. try { return JSON.parse(text); }
  515. catch (_) { return { answer: text, sources: [] }; }
  516. }
  517. // Render source chips into a container element
  518. function renderSources(container, sources) {
  519. if (!sources || !sources.length) {
  520. container.textContent = 'No sources returned.';
  521. return;
  522. }
  523. container.innerHTML = sources.map((s, i) => {
  524. const label = s.title || s.clause || s.source_file || ('Source ' + (i + 1));
  525. const url = s.url || s.href || null;
  526. const citeId = s.id || (i + 1);
  527. if (url) return `<a class="btn btn-ghost btn-sm" style="margin:0 6px 6px 0;font-size:0.78rem;" target="_blank" href="${url}" data-cite-id="${citeId}">${label} <i class="bi bi-arrow-up-right-square"></i></a>`;
  528. return `<span style="display:inline-block;padding:4px 10px;border:1px solid var(--border);border-radius:6px;font-size:0.78rem;color:var(--text-secondary);margin:0 6px 6px 0;">${label}${s.page != null ? ' p.' + s.page : ''}</span>`;
  529. }).join('');
  530. }
  531. // ── Hero form ────────────────────────────────────────────────
  532. const heroForm = $('heroAskForm');
  533. const heroInput = $('heroQuery');
  534. const resultsEl = $('results');
  535. const heroStatus = $('heroStatus');
  536. const answerEl = $('answerText');
  537. const sourcesEl = $('answerSources');
  538. const resetBtn = $('resultsReset');
  539. function showResults() {
  540. resultsEl.style.display = '';
  541. resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
  542. }
  543. function setHeroLoading(on) {
  544. heroStatus.style.display = on ? 'flex' : 'none';
  545. }
  546. // ── Property context (from site-report.php via localStorage) ────
  547. let heroCtx = null;
  548. const ADDRESS_RE = /\b\d+[a-z]?\s+[a-z][a-z\s'-]{2,35}\b(?:\s+(?:street|st|road|rd|avenue|ave|drive|dr|court|ct|place|pl|crescent|cr|lane|ln|way|close|cl|circuit|grove|gr|terrace|tce|boulevard|blvd|highway|hwy))?/i;
  549. function loadHeroCtx() {
  550. try {
  551. const raw = localStorage.getItem('tpr_builder_ctx');
  552. if (!raw) return;
  553. const { ctx, written_at } = JSON.parse(raw);
  554. if (!ctx || (Date.now() - written_at > 30 * 60 * 1000)) return;
  555. heroCtx = ctx;
  556. // Hero context bar
  557. const bar = $('heroCtxBar');
  558. const text = $('heroCtxText');
  559. if (bar && text) {
  560. const zones = Array.isArray(ctx.planning_zones) ? ctx.planning_zones.join(', ') : (ctx.planning_zones || '');
  561. text.textContent = [ctx.address, ctx.council ? `Council: ${ctx.council}` : '', zones ? `Zone: ${zones}` : ''].filter(Boolean).join(' · ');
  562. bar.style.display = 'flex';
  563. }
  564. // Demo modal context bar
  565. const demoBar = $('demoCtxBar');
  566. const demoText = $('demoCtxText');
  567. if (demoBar && demoText) {
  568. demoText.textContent = ctx.address || '';
  569. demoBar.style.display = '';
  570. }
  571. } catch(e) {}
  572. }
  573. function clearHeroCtx() {
  574. heroCtx = null;
  575. localStorage.removeItem('tpr_builder_ctx');
  576. const bar = $('heroCtxBar');
  577. if (bar) bar.style.display = 'none';
  578. const demoBar = $('demoCtxBar');
  579. if (demoBar) demoBar.style.display = 'none';
  580. }
  581. function buildHeroQuery(question) {
  582. if (!heroCtx) return { query: question, scope: 'state_only', council: null };
  583. const zones = Array.isArray(heroCtx.planning_zones) ? heroCtx.planning_zones.join(', ') : (heroCtx.planning_zones || '');
  584. const codes = Array.isArray(heroCtx.planning_codes) ? heroCtx.planning_codes.join(', ') : (heroCtx.planning_codes || '');
  585. const parts = [
  586. heroCtx.address ? `Address: ${heroCtx.address}` : '',
  587. heroCtx.council ? `Council: ${heroCtx.council}` : '',
  588. zones ? `Zone(s): ${zones}` : '',
  589. codes ? `Codes/overlays: ${codes}` : '',
  590. heroCtx.area_sqm ? `Site area: ${heroCtx.area_sqm} m²` : '',
  591. ].filter(Boolean).join('; ');
  592. const council = heroCtx.council || null;
  593. const scope = council ? 'state_plus_local' : 'state_only';
  594. return { query: `[Site context — ${parts}]\n\n${question}`, scope, council };
  595. }
  596. async function runHeroQuery(question) {
  597. if (!question) { heroInput.focus(); return; }
  598. showResults();
  599. setHeroLoading(true);
  600. answerEl.textContent = '';
  601. sourcesEl.innerHTML = '';
  602. const { query, scope, council } = buildHeroQuery(question);
  603. try {
  604. const data = await askAPI(query, scope, council);
  605. answerEl.textContent = data.answer || data.text || 'No answer received.';
  606. const sources = Array.isArray(data.sources) ? data.sources : (Array.isArray(data.citations) ? data.citations : []);
  607. renderSources(sourcesEl, sources);
  608. telemetry('search_result', { ok: true });
  609. } catch (e) {
  610. answerEl.innerHTML = `<span style="color:#f08080;">Error: ${e.message || e}</span>`;
  611. } finally {
  612. setHeroLoading(false);
  613. }
  614. }
  615. heroForm?.addEventListener('submit', e => {
  616. e.preventDefault();
  617. const q = (heroInput?.value || '').trim();
  618. telemetry('search_performed', { query: q, source: 'hero' });
  619. runHeroQuery(q);
  620. });
  621. heroInput?.addEventListener('keydown', e => {
  622. if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
  623. e.preventDefault();
  624. runHeroQuery((heroInput.value || '').trim());
  625. }
  626. });
  627. resetBtn?.addEventListener('click', () => {
  628. answerEl.textContent = 'Ask a question above to see the answer here.';
  629. sourcesEl.textContent = 'When available, sources will appear here.';
  630. resultsEl.style.display = 'none';
  631. });
  632. // ── Quick-action pills ───────────────────────────────────────
  633. document.querySelectorAll('[data-qa]').forEach(btn => {
  634. btn.addEventListener('click', () => {
  635. const q = btn.getAttribute('data-qa');
  636. if (heroInput) heroInput.value = q;
  637. heroForm?.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
  638. });
  639. });
  640. // ── Demo modal ───────────────────────────────────────────────
  641. const demoModal = $('demoModal');
  642. const demoQuery = $('demoQuery');
  643. const demoResults = $('demoResults');
  644. const demoStatus = $('demoStatus');
  645. const demoRunBtn = $('demoRunBtn');
  646. const samplesEl = $('demoSamples');
  647. const allowEl = $('demoAllowTPS');
  648. const apiBaseEl = $('demoApiBase');
  649. if (apiBaseEl) apiBaseEl.textContent = API_BASE;
  650. function openModal() {
  651. demoModal.classList.add('open');
  652. document.body.style.overflow = 'hidden';
  653. demoQuery?.focus({ preventScroll: true });
  654. }
  655. function closeModal() {
  656. demoModal.classList.remove('open');
  657. document.body.style.overflow = '';
  658. }
  659. document.querySelectorAll('#openDemoBtn, #openDemoLink, #ctaDemoBtn').forEach(el => {
  660. el.addEventListener('click', e => { e.preventDefault(); openModal(); });
  661. });
  662. $('modalClose')?.addEventListener('click', closeModal);
  663. $('modalCloseFooter')?.addEventListener('click', closeModal);
  664. demoModal?.addEventListener('click', e => { if (e.target === demoModal) closeModal(); });
  665. document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
  666. // Sample query click
  667. samplesEl?.addEventListener('click', e => {
  668. const btn = e.target.closest('button');
  669. if (!btn) return;
  670. samplesEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
  671. btn.classList.add('active');
  672. if (demoQuery) demoQuery.value = btn.textContent.trim();
  673. demoQuery?.focus();
  674. });
  675. function setDemoLoading(on) {
  676. demoStatus.style.display = on ? 'flex' : 'none';
  677. if (demoRunBtn) demoRunBtn.disabled = on;
  678. }
  679. async function runDemo() {
  680. const question = (demoQuery?.value || '').trim();
  681. if (!question) { demoQuery?.focus(); return; }
  682. demoResults.textContent = '';
  683. setDemoLoading(true);
  684. telemetry('search_performed', { query: question, source: 'demo' });
  685. const demoCouncilEl = $('demoCouncil');
  686. const council = (demoCouncilEl?.value || heroCtx?.council || '').trim() || null;
  687. const allowTPS = !!allowEl?.checked;
  688. const scope = allowTPS ? (council ? 'state_plus_local' : 'state_only') : (council ? 'local_only' : 'any');
  689. // Build query with site context if available
  690. let demoQuestion = question;
  691. if (heroCtx) {
  692. const zones = Array.isArray(heroCtx.planning_zones) ? heroCtx.planning_zones.join(', ') : (heroCtx.planning_zones || '');
  693. const parts = [
  694. heroCtx.address ? `Address: ${heroCtx.address}` : '',
  695. heroCtx.council ? `Council: ${heroCtx.council}` : '',
  696. zones ? `Zone(s): ${zones}` : '',
  697. ].filter(Boolean).join('; ');
  698. demoQuestion = `[Site context — ${parts}]\n\n${question}`;
  699. }
  700. try {
  701. const data = await askAPI(demoQuestion, scope, council);
  702. const msg = data.answer || data.output || data.markdown || data.result || data.text || '';
  703. const sources = Array.isArray(data.sources) ? data.sources : (Array.isArray(data.citations) ? data.citations : []);
  704. demoResults.textContent = msg || JSON.stringify(data);
  705. if (sources.length) {
  706. const frag = document.createElement('div');
  707. frag.style.cssText = 'margin-top:14px;';
  708. frag.innerHTML = '<div style="font-size:0.72rem;font-weight:500;letter-spacing:0.1em;text-transform:uppercase;color:var(--text-muted);margin-bottom:8px;">Sources</div>' +
  709. sources.map((s, i) => {
  710. const label = s.title || s.clause || s.url || ('Source ' + (i + 1));
  711. const url = s.url || s.href || '#';
  712. return `<a target="_blank" href="${url}" class="btn btn-ghost btn-sm" style="margin:0 6px 6px 0;">${label} <i class="bi bi-arrow-up-right-square"></i></a>`;
  713. }).join('');
  714. demoResults.appendChild(frag);
  715. }
  716. } catch (e) {
  717. demoResults.innerHTML = `<div style="color:#f08080;">Demo failed: ${e.message || e}</div><div style="font-size:0.78rem;color:var(--text-muted);margin-top:8px;">Tip: set <code style="color:var(--accent);">window.APP_API_BASE</code> or create an <code style="color:var(--accent);">/ask</code> endpoint accepting <code style="color:var(--accent);">{"question":"..."}</code>.</div>`;
  718. } finally {
  719. setDemoLoading(false);
  720. }
  721. }
  722. demoRunBtn?.addEventListener('click', runDemo);
  723. demoQuery?.addEventListener('keydown', e => {
  724. if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) runDemo();
  725. });
  726. // ── Waitlist ─────────────────────────────────────────────────
  727. $('waitlistForm')?.addEventListener('submit', async e => {
  728. e.preventDefault();
  729. const form = $('waitlistForm');
  730. const emailEl = $('waitlistEmail');
  731. const btn = form.querySelector('button[type=submit]');
  732. const email = (emailEl?.value || '').trim();
  733. if (!email) { emailEl?.focus(); return; }
  734. // Show loading state
  735. const origBtnHtml = btn.innerHTML;
  736. btn.disabled = true;
  737. btn.innerHTML = '<span style="display:inline-flex;align-items:center;gap:6px;"><span style="width:12px;height:12px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:spin .65s linear infinite;display:inline-block;"></span> Joining…</span>';
  738. try {
  739. const res = await fetch('/waitlist.php', {
  740. method: 'POST',
  741. headers: { 'Content-Type': 'application/json' },
  742. body: JSON.stringify({ email, source: 'pro_waitlist' })
  743. });
  744. const data = await res.json().catch(() => ({}));
  745. if (res.ok && data.ok) {
  746. // Replace form with success message
  747. const wrap = form.closest('.waitlist-form') || form.parentElement;
  748. form.innerHTML = '';
  749. const msg = document.createElement('div');
  750. msg.style.cssText = 'display:flex;align-items:center;gap:10px;padding:12px 0;';
  751. msg.innerHTML = '<i class="bi bi-check-circle-fill" style="color:var(--accent);font-size:1.2rem;flex-shrink:0;"></i>'
  752. + '<span style="font-size:0.88rem;color:var(--text-secondary);">'
  753. + (data.message || "You\'re on the list! We\'ll email when Pro launches.")
  754. + '</span>';
  755. form.appendChild(msg);
  756. telemetry('waitlist_join', { email, source: 'pro_waitlist' });
  757. } else {
  758. throw new Error(data.error || 'Signup failed');
  759. }
  760. } catch (err) {
  761. // Graceful fallback — don\'t show raw errors
  762. btn.disabled = false;
  763. btn.innerHTML = origBtnHtml;
  764. const hint = form.nextElementSibling;
  765. if (hint) {
  766. hint.style.color = 'var(--danger)';
  767. hint.textContent = err.message.includes('valid email')
  768. ? 'Please enter a valid email address.'
  769. : 'Something went wrong — please try again shortly.';
  770. setTimeout(() => {
  771. hint.style.color = '';
  772. hint.textContent = "No spam. We\'ll email when Pro is available with early-access pricing.";
  773. }, 4000);
  774. }
  775. }
  776. });
  777. // ── Nav toggle (mobile) ──────────────────────────────────────
  778. $('navToggle')?.addEventListener('click', () => {
  779. $('navLinks')?.classList.toggle('open');
  780. });
  781. // ── Footer year ──────────────────────────────────────────────
  782. const yearEl = $('year');
  783. if (yearEl) yearEl.textContent = new Date().getFullYear();
  784. // ── Load councils into demo dropdown ─────────────────────────
  785. (async () => {
  786. try {
  787. const apiRoot = API_BASE.replace(/\/ask\/?$/, '');
  788. const res = await fetch(`${apiRoot}/councils`, { cache: 'no-store' });
  789. const items = await res.json();
  790. const sel = $('demoCouncil');
  791. if (sel && Array.isArray(items)) {
  792. items.forEach(c => {
  793. const opt = document.createElement('option');
  794. opt.value = c; opt.textContent = c;
  795. sel.appendChild(opt);
  796. });
  797. }
  798. // Auto-select council from property context if present
  799. if (heroCtx?.council && sel) {
  800. const opt = [...sel.options].find(o => o.value.toLowerCase() === heroCtx.council.toLowerCase());
  801. if (opt) opt.selected = true;
  802. }
  803. } catch(e) {}
  804. })();
  805. // ── Property context init ────────────────────────────────────
  806. loadHeroCtx();
  807. $('heroCtxClear')?.addEventListener('click', e => { e.preventDefault(); clearHeroCtx(); });
  808. // ── Address detection on hero input ──────────────────────────
  809. heroInput?.addEventListener('input', () => {
  810. const val = heroInput.value || '';
  811. const bar = $('heroAddressBar');
  812. if (!bar) return;
  813. if (!heroCtx && val.length > 8 && ADDRESS_RE.test(val)) {
  814. bar.style.display = 'flex';
  815. } else {
  816. bar.style.display = 'none';
  817. }
  818. });
  819. // ── Telemetry ────────────────────────────────────────────────
  820. const sid = localStorage.getItem('tpr_sid') || (() => {
  821. const v = (crypto?.randomUUID?.() || String(Math.random()).slice(2) + Date.now());
  822. localStorage.setItem('tpr_sid', v);
  823. return v;
  824. })();
  825. function telemetry(type, data = {}) {
  826. const payload = { type, ts: new Date().toISOString(), sid, ua: navigator.userAgent, data };
  827. const url = API_BASE.replace(/\/ask\/?$/, '') + '/telemetry';
  828. const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' });
  829. if (navigator.sendBeacon?.(url, blob)) return; // sendBeacon never sends credentials
  830. fetch(url, {
  831. method: 'POST',
  832. headers: { 'Content-Type': 'application/json' },
  833. body: JSON.stringify(payload),
  834. credentials: 'omit', // ← telemetry needs no auth
  835. mode: 'cors'
  836. }).catch(() => {}); // ← silently swallow — analytics must never break UX
  837. }
  838. // Citation click tracking (only fires when data-cite-id is actually set)
  839. document.addEventListener('click', e => {
  840. const a = e.target.closest('a[data-cite-id]');
  841. if (a) telemetry('interaction', { action: 'clicked_citation', cite_id: a.dataset.citeId });
  842. });
  843. // ── Scroll fade-in ───────────────────────────────────────────
  844. const observer = new IntersectionObserver(entries => {
  845. entries.forEach(entry => {
  846. if (entry.isIntersecting) {
  847. entry.target.classList.add('visible');
  848. observer.unobserve(entry.target);
  849. }
  850. });
  851. }, { threshold: 0.1 });
  852. document.querySelectorAll('.fade-up').forEach(el => observer.observe(el));
  853. })();
  854. </script>
  855. </body>
  856. </html>