1<!doctype html> 2<html lang="en"> 3 <head> 4 <meta charset="utf-8" /> 5 <!-- DNS prefetch and preconnect for faster API calls --> 6 <link rel="dns-prefetch" href="https://api.hoist.nz" /> 7 <link rel="preconnect" href="https://api.hoist.nz" crossorigin /> 8 <meta name="viewport" content="width=device-width, initial-scale=1" /> 9 <meta name="referrer" content="strict-origin" /> 10 <!-- Mobile browser chrome follows the navbar: brand green in light, near-black in dark. --> 11 <meta name="theme-color" content="#032B3A" media="(prefers-color-scheme: light)" /> 12 <meta name="theme-color" content="#1A1B1E" media="(prefers-color-scheme: dark)" /> 13 <meta name="description" content="New Zealand's Newest Workshop Automation Software!" /> 14 <title>Hoist</title> 15 16 <!-- Google Analytics -->
vendor: 72 bytes, lines 16-17
16 17 <script async src="https://www.googletagmanager.com/gtag/js?id=
17G-G5WM9S295M
vendor: 20 bytes, lines 17-18
17"></script> 18
18<script> 19 window.dataLayer = window.dataLayer || []; 20 function gtag() { 21 dataLayer.push(arguments); 22 } 23 gtag('js', new Date()); 24 25 // Analytics fire ONLY from production. Staging (staging.0h.nz) and 26 // local dev were streaming events into the live GA4 properties and the 27 // Meta pixel, polluting audiences, funnels and conversion counts. 28 var HOIST_ANALYTICS_ON = window.location.hostname === 'my.hoist.nz'; 29 30 // Cross-domain measurement with the marketing site (hoist.nz). 31 // Must be set before the config commands so inbound _gl links are read. 32 gtag('set', 'linker', { 33 domains: ['hoist.nz', 'www.hoist.nz', 'my.hoist.nz'], 34 accept_incoming: true, 35 }); 36 37 // Product analytics â this app's own property. 38 if (HOIST_ANALYTICS_ON) gtag('config', 'G-G5WM9S295M'); 39 40 // Marketing property, but ONLY when the visitor enters at /register. 41 // 42 // Cross-domain attribution needs both domains reporting to one property, 43 // otherwise the hoist.nz -> my.hoist.nz/register journey splits into two 44 // sessions and no trial signup can be credited to the page that produced 45 // it. Configuring it on every route instead meant logged-in workshops 46 // reported their whole working day into the marketing property - it hit 47 // 71% app traffic within a day, inflating sessions and engagement and 48 // depressing conversion rate against a denominator full of existing 49 // customers. 50 // 51 // Entering at /register is exactly the journey worth measuring, and it 52 // is the only path the marketing site links to for signup. Once 53 // configured the property stays active for that page session, so the 54 // rest of the signup flow is still captured. Someone opening the app at 55 // /dashboard for their day's work never configures it. 56 if (HOIST_ANALYTICS_ON && window.location.pathname.replace(/\/+$/, '') === '/register') { 57 gtag('config', 'G-NB4C45G109'); 58 // Google Ads conversion destination. Reads the _gcl_aw cookie written 59 // on hoist.nz (shared via .hoist.nz) so the "Hoist trial signup" 60 // conversion fired in analytics.js signUp() attributes to the ad 61 // click that produced it. 62 gtag('config', 'AW-18374508068'); 63 } 64 </script>
64 65 66 <!-- 67 Meta pixel. 68 69 The _fbc cookie carrying the ad click is written on hoist.nz and is scoped 70 to that host, so it does not exist here - which would leave every signup 71 produced by a Meta ad unattributed and report the campaign as dead. The 72 marketing site therefore appends the click ID as a _fbc query parameter on 73 its links to this domain (see MetaPixel.tsx there), and the block below 74 reinstates it as a cookie BEFORE fbq('init') so the pixel picks it up. 75 76 This is the Meta counterpart to the GA linker configured above, which 77 solves the same split-session problem for Google Analytics. 78 79 Only written when no _fbc cookie is already present: if Meta ever scopes 80 its own cookie to .hoist.nz, that one is authoritative and must win. 81 --> 82
82<script> 83 (function () { 84 try { 85 var param = new URLSearchParams(window.location.search).get('_fbc'); 86 if (!param) return; 87 if (document.cookie.indexOf('_fbc=') !== -1) return; 88 // 90 days, matching Meta's own click attribution window. 89 document.cookie = 90 '_fbc=' + encodeURIComponent(param) + ';path=/;max-age=' + 90 * 24 * 60 * 60 + ';SameSite=Lax'; 91 } catch (e) { 92 /* Attribution is never worth breaking the app over. */ 93 } 94 })(); 95 </script>
95 96
96<script> 97 // Whether this page session can ever fire a Meta conversion. CompleteRegistration 98 // (utils/analytics.js) is the only Meta event this app sends, and it has exactly one caller: 99 // analytics.signUp() in features/auth/Register.jsx, which renders only at /register. Anywhere 100 // else the pixel could not fire even if it were loaded. 101 // 102 // The GATE WRAPS THE LOADER, not just fbq('init'). The snippet below injects fbevents.js as a 103 // side effect of running at all, so gating init alone would still have downloaded the script 104 // on every page and saved nothing -- every signed-in workshop pulled it all day, landing about 105 // 1.2s into a cold first open and competing with the app's own bundle for the connection. 106 // 107 // Production only, as before: staging and dev must never feed the live pixel (they were 108 // inflating retargeting audiences). Still init only, deliberately no PageView -- every page a 109 // signed-in workshop opened was landing in the pixel, 7,891 events over three days in August 110 // 2026, roughly 75% of the whole dataset, putting existing customers into the retargeting and 111 // lookalike pools the acquisition campaigns spend against. 112 var HOIST_META_PIXEL_ON = 113 window.location.hostname === 'my.hoist.nz' && 114 window.location.pathname.replace(/\/+$/, '') === '/register'; 115 116 if (HOIST_META_PIXEL_ON) { 117 !(function (f, b, e, v, n, t, s) { 118 if (f.fbq) return; 119 n = f.fbq = function () { 120 n.callMethod ? n.callMethod.apply(n, arguments) : n.queue.push(arguments); 121 }; 122 if (!f._fbq) f._fbq = n; 123 n.push = n; 124 n.loaded = !0; 125 n.version = '2.0'; 126 n.queue = []; 127 t = b.createElement(e); 128 t.async = !0; 129 t.src = v; 130 s = b.getElementsByTagName(e)[0]; 131 s.parentNode.insertBefore(t, s); 132 })(window, document, 'script', 'https://connect.facebook.net/en_US/fbevents.js'); 133 fbq('init', '1582817223439516'); 134 } 135 </script>
135 136 137 <style> 138 /* Boot screen only. Values are the resolved --muted ground and --brand-edge ink from 139 src/index.css and src/styles/brand.css; they are literals because no stylesheet has 140 loaded yet at this point. Keep them in step with those tokens. */ 141 #boot-loader { 142 position: fixed; 143 inset: 0; 144 display: flex; 145 align-items: center; 146 justify-content: center; 147 background: #faf9f6; 148 } 149 /* The track is the brand ink at low alpha rather than a second grey, so it sits on either 150 ground without needing a third colour defined per theme. */ 151 #boot-loader .boot-track { 152 position: relative; 153 width: 190px; 154 height: 4px; 155 border-radius: 999px; 156 overflow: hidden; 157 background: rgba(3, 43, 58, 0.14); 158 } 159 #boot-loader .boot-fill { 160 position: absolute; 161 top: 0; 162 bottom: 0; 163 border-radius: 999px; 164 background: #032b3a; 165 animation: boot-slide 1.25s ease-in-out infinite; 166 } 167 #boot-loader.boot-dark { 168 background: #141414; 169 } 170 #boot-loader.boot-dark .boot-track { 171 background: rgba(217, 217, 217, 0.18); 172 } 173 #boot-loader.boot-dark .boot-fill { 174 background: #d9d9d9; 175 } 176 /* Indeterminate: the segment sweeps the track and never lands, because there is no way 177 to know how far through a lazy import the browser is. Animating the two insets rather 178 than translating a fixed-width block keeps part of the segment on the track for the 179 whole cycle; a translate large enough to clear the track leaves it visibly empty for 180 a good fraction of every loop. */ 181 @keyframes boot-slide { 182 0% { 183 left: -35%; 184 right: 100%; 185 } 186 100% { 187 left: 100%; 188 right: -35%; 189 } 190 } 191 @media (prefers-reduced-motion: reduce) { 192 /* A sweeping bar is exactly the motion this setting is asking us not to make, so the bar 193 holds still and reads as a static indicator instead. */ 194 #boot-loader .boot-fill { 195 animation: none; 196 left: 0; 197 right: 0; 198 opacity: 0.45; 199 } 200 } 201 </style> 202
202<script type="module" crossorigin src="/assets/index-_SHl6aX8.js"></script>
202 203 <link rel="modulepreload" crossorigin href="/assets/vendor-react-CuNWJD29.js"> 204 <link rel="modulepreload" crossorigin href="/assets/vendor-query-CmJ3zgYk.js"> 205 <link rel="modulepreload" crossorigin href="/assets/vendor-signalr-B6NuNrLI.js"> 206 <link rel="stylesheet" crossorigin href="/assets/index-Bs-GP6zO.css"> 207 </head> 208 <body> 209 <noscript>You need to enable JavaScript to run this app.</noscript> 210 <!-- 211 Boot screen. 212 213 #root is empty until React's first paint, which measured 438-572ms on a throttled cold load: 214 the user stared at a blank white page for half a second before anything at all appeared. This 215 is raw markup and inline CSS on purpose â it has to paint from the HTML alone, before a single 216 byte of the bundle has been parsed, so it cannot come from a component. 217 218 It sits on the SAME ground as the app's own loading state, so React taking over changes the 219 content but never the backdrop. A slim indeterminate bar rather than the steering wheel: the 220 wheel is a 19KB PNG, and index.html is served no-cache, so inlining it would have added ~25KB 221 of base64 to every single load, while linking it would have put a blocking request in front of 222 the one screen whose whole job is to appear immediately. 223 224 Removed by src/index.jsx once React has painted, with a timeout here as a backstop so a 225 failed boot can never leave the overlay covering the error screen underneath. 226 --> 227 <div id="boot-loader" role="status" aria-label="Loading Hoist"> 228 <div class="boot-track"><div class="boot-fill"></div></div> 229 </div> 230
230<script> 231 (function () { 232 // Mirrors ThemeContext exactly: stored choice, defaulting to light, with 'system' deferring 233 // to the OS. Resolving it here rather than in React is what stops a white flash before a 234 // dark-mode user's app paints. 235 try { 236 var saved = localStorage.getItem('theme') || 'light'; 237 var dark = 238 saved === 'dark' || 239 (saved === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); 240 if (dark) document.getElementById('boot-loader').classList.add('boot-dark'); 241 } catch (e) { 242 /* Private mode or blocked storage: the light default is already correct. */ 243 } 244 // Backstop only. index.jsx removes this as soon as React paints; this covers the case where 245 // the bundle never runs at all, so the overlay cannot hide whatever is underneath forever. 246 setTimeout(function () { 247 var el = document.getElementById('boot-loader'); 248 if (el) el.remove(); 249 }, 15000); 250 })(); 251 </script>
251 252 <div id="root"></div> 253 <div id="datepicker-portal"></div> 254 <!-- Vite injects the module entry point here at build time --> 255
255<script>(function(){function c(){var b=a.contentDocument||(a.contentWindow&&a.contentWindow.document);if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'a45fe3bfa9c2ba2c',t:'MTc5MTIzOTYyMw=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script>
255<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"c69b62ffc99d4342b91f3ce0c88908d4","spa":2}' crossorigin="anonymous"></script>
255 256</body> 257</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.