<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
/* HiveRelay UI auth shim. When the relay serves this page through a trusted
reverse proxy (ui.exposeToken), it injects <meta name="hiverelay-ui-token">
holding a token it will accept as a bearer credential. Attach it to API
calls so the wizard works behind a proxy where the localhost check can't
apply. No meta -> no-op, so direct/localhost use is unchanged. */
(function () {
try {
var m = document.querySelector('meta[name="hiverelay-ui-token"]');
var t = m && m.getAttribute('content');
if (!t) return;
var orig = window.fetch.bind(window);
window.fetch = function (input, init) {
init = init || {};
var raw = (typeof input === 'string') ? input : (input && (input.url || input.href)) || String(input || '');
var u = new URL(raw, window.location.href);
var h = new Headers((init && init.headers) || (input && input.headers) || {});
if (u.origin === window.location.origin && !h.has('Authorization')) h.set('Authorization', 'Bearer ' + t);
init.headers = h;
return orig(input, init);
};
} catch (e) { /* never block page load on the auth shim */ }
})();
</script>
<title>HiveRelay Setup</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath d='M50 10 L30 55 L45 55 L40 90 L70 45 L55 45 Z' fill='%23f5a623'/%3E%3C/svg%3E">
<style>
*,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0 }
:root {
--bg: #1a1a2e;
--bg-elev: #151a26;
--border: #222b3e;
--border-hi: #2e3b56;
--text: #d6dde8;
--text-dim: #8493a8;
--text-hi: #ffffff;
--accent: #f5a623; /* HiveRelay amber */
--accent-dim: #b8771a;
--good: #4ade80;
--bad: #f87171;
--mono: 'SF Mono','Cascadia Code','Fira Code','Consolas',monospace;
--sans: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
}
html { font-size: 16px }
body {
background: var(--bg) radial-gradient(1100px 500px at 50% -150px,#131a2b,transparent);
color: var(--text);
font-family: var(--sans);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
line-height: 1.5;
}
/* Top bar — minimal header with logo + progress dots */
.top {
width: 100%;
max-width: 720px;
margin-bottom: 2rem;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
}
.logo {
display: flex;
align-items: center;
gap: .6rem;
text-decoration: none;
color: var(--text-hi);
font-weight: 600;
font-size: 1.05rem;
}
.logo svg { width: 24px; height: 24px }
.progress {
display: flex;
gap: .35rem;
align-items: center;
}
.progress .dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--border);
transition: background .2s;
}
.progress .dot.active { background: var(--accent) }
.progress .dot.done { background: var(--accent-dim) }
.progress-label {
font-family: var(--mono);
font-size: .75rem;
color: var(--text-dim);
margin-left: .5rem;
}
/* Card — main content container */
.card {
width: 100%;
max-width: 560px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 14px;
padding: 2.5rem 2rem;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
@media (max-width: 600px) {
.card { padding: 1.75rem 1.25rem }
}
h1 {
font-size: 1.8rem;
font-weight: 600;
color: var(--text-hi);
margin-bottom: .6rem;
letter-spacing: -.01em;
}
h2 {
font-size: 1.4rem;
font-weight: 600;
color: var(--text-hi);
margin-bottom: .5rem;
}
.lead {
font-size: 1rem;
color: var(--text-dim);
margin-bottom: 2rem;
}
p {
margin-bottom: 1rem;
color: var(--text);
}
p.small {
font-size: .85rem;
color: var(--text-dim);
}
/* Form elements */
label {
display: block;
font-size: .8rem;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .04em;
margin-bottom: .4rem;
}
input[type="text"],
input[type="password"] {
display: block;
width: 100%;
background: var(--bg);
border: 1px solid var(--border-hi);
color: var(--text-hi);
font-family: var(--sans);
font-size: 1rem;
padding: .75rem .9rem;
border-radius: 8px;
transition: border-color .15s;
}
input[type="text"]:focus,
input[type="password"]:focus {
outline: none;
border-color: var(--accent);
}
.field-help {
font-size: .8rem;
color: var(--text-dim);
margin-top: .4rem;
}
.field { margin-bottom: 1.5rem }
/* Buttons */
.actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 2rem;
}
button {
background: var(--accent);
color: #000;
border: none;
font-family: var(--sans);
font-size: 1rem;
font-weight: 600;
padding: .75rem 1.5rem;
border-radius: 8px;
cursor: pointer;
transition: background .15s, transform .05s;
}
button:hover { background: #ffb73a }
button:active { transform: translateY(1px) }
button:disabled {
background: var(--border-hi);
color: var(--text-dim);
cursor: not-allowed;
}
button.secondary {
background: transparent;
color: var(--text-dim);
padding: .5rem 0;
}
button.secondary:hover {
background: transparent;
color: var(--text-hi);
}
/* Status indicators */
.status {
display: inline-flex;
align-items: center;
gap: .5rem;
font-family: var(--mono);
font-size: .85rem;
padding: .3rem .7rem;
border-radius: 999px;
margin-bottom: 1rem;
}
.status .ind {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status.good { background: rgba(74,222,128,.12); color: var(--good) }
.status.good .ind { background: var(--good); box-shadow: 0 0 8px var(--good) }
.status.bad { background: rgba(248,113,113,.12); color: var(--bad) }
.status.bad .ind { background: var(--bad) }
.status.dim { background: rgba(122,139,160,.1); color: var(--text-dim) }
.status.dim .ind { background: var(--text-dim) }
/* Choice cards (for accept-mode step) */
.choices { display: grid; gap: .75rem; margin-bottom: 1rem }
.choice {
background: var(--bg);
border: 1.5px solid var(--border-hi);
border-radius: 10px;
padding: 1rem 1.2rem;
cursor: pointer;
transition: border-color .15s, background .15s;
text-align: left;
width: 100%;
color: var(--text);
font-family: var(--sans);
}
.choice:hover { border-color: var(--accent-dim); background: #1d2747 }
.choice.selected {
border-color: var(--accent);
background: #1d2747;
}
.choice-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-hi);
margin-bottom: .25rem;
display: flex;
align-items: center;
gap: .6rem;
}
.choice-title .recommended {
font-size: .65rem;
background: var(--accent);
color: #000;
padding: .15rem .5rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: .04em;
font-weight: 700;
}
.choice-desc {
font-size: .85rem;
color: var(--text-dim);
line-height: 1.4;
}
/* Error message */
.error {
background: rgba(248,113,113,.1);
border: 1px solid rgba(248,113,113,.3);
color: var(--bad);
padding: .75rem 1rem;
border-radius: 8px;
font-size: .9rem;
margin-top: 1rem;
}
.error:empty { display: none }
.wizard-status {
min-height: 1.15rem;
color: var(--text-dim);
font-size: .86rem;
margin-bottom: 1rem;
}
.wizard-status.busy { color: var(--accent) }
.wizard-status.error { color: var(--bad) }
.wizard-status:empty { display: none }
/* Honesty box — explicit "this is new, manage expectations" */
.honesty {
margin-top: 1rem;
padding: .85rem 1rem;
background: rgba(245,166,35,.08);
border-left: 3px solid var(--accent);
border-radius: 6px;
font-size: .85rem;
color: var(--text);
}
.honesty strong { color: var(--text-hi); font-weight: 600 }
/* Complete-step celebration */
.complete-icon {
text-align: center;
margin-bottom: 1rem;
font-size: 4rem;
line-height: 1;
}
.complete-stat {
text-align: center;
margin: 1.5rem 0;
font-family: var(--mono);
font-size: 2rem;
color: var(--accent);
}
.complete-stat-label {
text-align: center;
font-size: .85rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .05em;
}
.center-text { text-align: center }
.complete-note { margin-top: 1.5rem }
.complete-actions { justify-content: center }
/* Loading state */
.loading {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid var(--text-dim);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
vertical-align: middle;
margin-right: .5rem;
}
@keyframes spin { to { transform: rotate(360deg) } }
/* Step transitions — fade between */
.step { display: none }
.step.active { display: block; animation: fadein .25s ease-out }
@keyframes fadein {
from { opacity: 0; transform: translateY(4px) }
to { opacity: 1; transform: translateY(0) }
}
</style>
</head>
<body>
<div class="top">
<a class="logo" href="dashboard" data-wizard-action="dashboard">
<svg viewBox="0 0 100 100" aria-hidden="true">
<path d="M50 10 L30 55 L45 55 L40 90 L70 45 L55 45 Z" fill="#f5a623"/>
</svg>
HiveRelay
</a>
<div class="progress" id="progress">
<div class="dot active"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<span class="progress-label" id="progress-label">Step 1 of 5</span>
</div>
</div>
<div class="card">
<div class="wizard-status" id="wizard-status" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- Step 1: Welcome -->
<section class="step active" data-step="welcome">
<h1>Welcome to HiveRelay.</h1>
<p class="lead">Earn sats for blind seeding P2P apps.</p>
<p>
Your Umbrel is about to become a paid relay for the Pear/Holepunch
app ecosystem. You'll host encrypted Hyperdrives — content the
relay can't read but can verify and serve — so apps stay reachable
while their users are offline.
</p>
<p>
Setup takes a couple of minutes. We'll name your relay, optionally
add a payout destination, and pick a default policy for incoming
requests. You can change everything later.
</p>
<div class="honesty">
<strong>Honest expectations:</strong> HiveRelay is a new
network. Early relays may earn very little while the demand-side
market is being built. The pitch is "you already paid for the
box; this is the switch to make it earn."
</div>
<div class="actions">
<span></span>
<button type="button" data-wizard-action="goto" data-step-target="relay_name">Let's go →</button>
</div>
</section>
<!-- Step 2: Name -->
<section class="step" data-step="relay_name">
<h2>Name your relay</h2>
<p class="lead">This is what other operators see in the network.</p>
<div class="field">
<label for="relay-name-input">Relay name</label>
<input type="text" id="relay-name-input" maxlength="60" placeholder="e.g. tokyo-anchor-01">
<div class="field-help">Up to 60 characters. You can change this later from the dashboard.</div>
</div>
<div class="error" id="err-relay-name"></div>
<div class="actions">
<button type="button" class="secondary" data-wizard-action="goto" data-step-target="welcome">← Back</button>
<button type="button" id="btn-relay-name" data-wizard-action="relay-name">Continue</button>
</div>
</section>
<!-- Step 3: Payout wallet (optional) -->
<section class="step" data-step="payout">
<h2>Your BTC wallet</h2>
<p class="lead">Where payments land. Optional — add it now or later.</p>
<div class="field">
<label for="payout-input">Payout destination</label>
<input type="text" id="payout-input" placeholder="you@example.com, lno1…, or bc1…" autocomplete="off" spellcheck="false">
<div class="field-help">
Paste a lightning address, BOLT12 offer, or receive address from your
own BTC wallet. Payments go straight there — the relay never holds
funds, and there's no external service or node to run. You can change
it anytime from the dashboard.
</div>
</div>
<div class="error" id="err-payout"></div>
<div class="actions">
<button type="button" class="secondary" data-wizard-action="goto" data-step-target="relay_name">← Back</button>
<span>
<button type="button" class="secondary" data-wizard-action="skip-payout">Skip</button>
<button type="button" id="btn-payout" data-wizard-action="payout">Continue</button>
</span>
</div>
</section>
<!-- Step 4: Accept mode -->
<section class="step" data-step="accept_mode">
<h2>Choose your default policy</h2>
<p class="lead">How should the relay handle incoming seed requests?</p>
<div class="choices">
<button type="button" class="choice" data-wizard-action="select-mode" data-mode="review">
<div class="choice-title">
Review
<span class="recommended">recommended</span>
</div>
<div class="choice-desc">
You see every incoming seed request and approve it manually.
The safe default while you learn how the network behaves.
</div>
</button>
<button type="button" class="choice" data-wizard-action="select-mode" data-mode="allowlist">
<div class="choice-title">Allowlist</div>
<div class="choice-desc">
Auto-accept requests from publishers you've explicitly trusted.
Add publishers from the dashboard after install.
</div>
</button>
<button type="button" class="choice" data-wizard-action="select-mode" data-mode="open">
<div class="choice-title">Open</div>
<div class="choice-desc">
Auto-accept any signed request. Pair with payment-required
to filter for paying customers only. Most permissive.
</div>
</button>
</div>
<div class="error" id="err-accept-mode"></div>
<div class="actions">
<button type="button" class="secondary" data-wizard-action="goto" data-step-target="payout">← Back</button>
<button type="button" id="btn-accept-mode" data-wizard-action="accept-mode" disabled>Continue</button>
</div>
</section>
<!-- Step 5: Complete -->
<section class="step" data-step="complete">
<div class="complete-icon">⚡</div>
<h1 class="center-text">You're online.</h1>
<p class="lead center-text">
Your relay is now discoverable on the HiveRelay network.
</p>
<div class="complete-stat">0 sats</div>
<div class="complete-stat-label">earned this month</div>
<p class="complete-note center-text">
Earnings will appear here as the network discovers and uses your
relay. Don't expect overnight numbers — be patient with the early
phase.
</p>
<div class="actions complete-actions">
<button type="button" data-wizard-action="dashboard">Open dashboard →</button>
</div>
</section>
</div>
<script>
// ─── State ───────────────────────────────────────────────────
// The wizard mirrors the server-side state machine. The server is
// the source of truth; we sync via /api/wizard on every navigation.
// This keeps reload/refresh safe — close the tab mid-wizard and
// reopen and you pick up where you left off.
const STEPS = ['welcome', 'relay_name', 'payout', 'accept_mode', 'complete']
let state = null
let selectedMode = null
let wizardActionBusy = false
const editMode = new URLSearchParams(window.location.search).get('edit') === '1'
function appBasePath () {
const p = window.location.pathname.replace(/\/+$/, '') || '/'
const routes = ['/dashboard', '/wizard']
for (const r of routes) {
if (p === r) return ''
if (p.endsWith(r)) return p.slice(0, -r.length)
}
return p === '/' ? '' : p
}
const APP_BASE = appBasePath()
function appPath (path) {
if (!path) return APP_BASE || '/'
return (APP_BASE || '') + (path.charAt(0) === '/' ? path : '/' + path)
}
document.querySelectorAll('[data-wizard-action="dashboard"][href]').forEach(el => {
el.setAttribute('href', appPath('/dashboard'))
})
// ─── API helpers ────────────────────────────────────────────
const REQUEST_TIMEOUT_MS = 10000
const WIZARD_ERROR_MAX = 180
function wizardErrorText (err, fallback) {
let msg = ''
if (err && typeof err.message === 'string') msg = err.message
else if (typeof err === 'string') msg = err
else msg = fallback || 'Request failed'
msg = String(msg)
.replace(/^[A-Z_]+: /, '')
.replace(/[\x00-\x1f\x7f]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!msg) msg = fallback || 'Request failed'
if (msg.length > WIZARD_ERROR_MAX) msg = msg.slice(0, WIZARD_ERROR_MAX - 3) + '...'
return msg
}
async function fetchWithTimeout (path, opts = {}) {
const options = { ...opts }
let timeout = null
if (typeof AbortController === 'function') {
const controller = new AbortController()
options.signal = controller.signal
timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
}
try {
return await fetch(appPath(path), options)
} finally {
if (timeout) clearTimeout(timeout)
}
}
async function api (path, opts = {}) {
const res = await fetchWithTimeout(path, {
method: opts.method || 'GET',
headers: opts.body ? { 'Content-Type': 'application/json' } : {},
body: opts.body ? JSON.stringify(opts.body) : undefined
})
let data = null
try { data = await res.json() } catch (_) {}
if (!res.ok) {
const msg = (data && data.error) || ('HTTP ' + res.status)
throw new Error(wizardErrorText(msg, 'Request failed'))
}
return data
}
async function loadState () {
state = await api('/api/wizard')
return state
}
// ─── Step rendering ─────────────────────────────────────────
function showStep (step) {
document.querySelectorAll('.step').forEach(el => {
el.classList.toggle('active', el.dataset.step === step)
})
const idx = STEPS.indexOf(step)
document.querySelectorAll('#progress .dot').forEach((dot, i) => {
dot.classList.toggle('active', i === idx)
dot.classList.toggle('done', i < idx)
})
document.getElementById('progress-label').textContent =
'Step ' + (idx + 1) + ' of ' + STEPS.length
if (state) hydrateStep(step)
}
function hydrateStep (step) {
if (step === 'relay_name') {
const input = document.getElementById('relay-name-input')
if (!input.value) input.value = state.relayName || ''
input.focus()
} else if (step === 'payout') {
const input = document.getElementById('payout-input')
if (!input.value && state.payoutDestination) input.value = state.payoutDestination
input.focus()
} else if (step === 'accept_mode') {
selectMode(state.acceptMode || 'review')
}
}
async function goNext (step) {
clearErrors()
try {
await api('/api/wizard/goto', { method: 'POST', body: { step } })
await loadState()
showStep(step)
} catch (err) {
console.error('Could not navigate:', err)
}
}
// ─── Step 2 — relay name ────────────────────────────────────
async function submitRelayName () {
const input = document.getElementById('relay-name-input')
const name = input.value.trim()
if (!name) {
showError('err-relay-name', 'Pick a name (or accept the default).')
return
}
setBusy('btn-relay-name', true)
try {
await api('/api/wizard/relay-name', { method: 'POST', body: { relayName: name } })
await goNext('payout')
} catch (err) {
showError('err-relay-name', err.message)
} finally {
setBusy('btn-relay-name', false)
}
}
// ─── Step 3 — payout address (optional) ─────────────────────
async function submitPayout () {
clearErrors()
const address = document.getElementById('payout-input').value.trim()
if (!address) { return skipPayout() } // blank = skip
setBusy('btn-payout', true)
try {
await api('/api/wizard/payout', { method: 'POST', body: { address } })
await goNext('accept_mode')
} catch (err) {
showError('err-payout', err.message)
} finally {
setBusy('btn-payout', false)
}
}
async function skipPayout () {
clearErrors()
setBusy('btn-payout', true)
try {
// Clear any prior address, then advance.
await api('/api/wizard/payout', { method: 'POST', body: { address: '' } })
await goNext('accept_mode')
} catch (err) {
showError('err-payout', err.message)
} finally {
setBusy('btn-payout', false)
}
}
// ─── Step 4 — accept mode ──────────────────────────────────
function selectMode (mode) {
selectedMode = mode
document.querySelectorAll('.choice').forEach(el => {
el.classList.toggle('selected', el.dataset.mode === mode)
})
document.getElementById('btn-accept-mode').disabled = false
}
async function submitAcceptMode () {
if (!selectedMode) {
showError('err-accept-mode', 'Pick one of the three policies.')
return
}
setBusy('btn-accept-mode', true)
try {
await api('/api/wizard/accept-mode', { method: 'POST', body: { acceptMode: selectedMode } })
// Final step — POST /complete tells the server to apply the
// wizard answers to the live config and mark the wizard done.
await api('/api/wizard/complete', { method: 'POST', body: {} })
await loadState()
showStep('complete')
} catch (err) {
showError('err-accept-mode', err.message)
} finally {
setBusy('btn-accept-mode', false)
}
}
// ─── Step 5 — done ──────────────────────────────────────────
function goToDashboard () {
window.location.href = appPath('/dashboard')
}
function wizardActionLabel (action) {
if (action === 'goto') return 'Loading setup step...'
if (action === 'relay-name') return 'Saving relay name...'
if (action === 'payout') return 'Saving payout destination...'
if (action === 'skip-payout') return 'Skipping payout destination...'
if (action === 'accept-mode') return 'Applying setup...'
if (action === 'dashboard') return 'Opening dashboard...'
return 'Working...'
}
function setWizardStatus (message, kind) {
const status = document.getElementById('wizard-status')
if (!status) return
status.textContent = message || ''
status.className = 'wizard-status' + (kind ? ' ' + kind : '')
}
function setWizardActionBusy (busy, action) {
wizardActionBusy = !!busy
document.querySelectorAll('[data-wizard-action]').forEach(control => {
if ('disabled' in control) {
control.disabled = wizardActionBusy
} else if (wizardActionBusy) {
control.setAttribute('aria-disabled', 'true')
} else {
control.removeAttribute('aria-disabled')
}
})
if (!wizardActionBusy) {
const accept = document.getElementById('btn-accept-mode')
if (accept) accept.disabled = !selectedMode
}
setWizardStatus(wizardActionBusy ? wizardActionLabel(action) : '', wizardActionBusy ? 'busy' : '')
}
function bindWizardActions () {
document.addEventListener('click', event => {
const el = event.target.closest('[data-wizard-action]')
if (!el) return
event.preventDefault()
const action = el.dataset.wizardAction
if (wizardActionBusy) return
let result
if (action === 'goto') result = goNext(el.dataset.stepTarget || 'welcome')
else if (action === 'relay-name') result = submitRelayName()
else if (action === 'payout') result = submitPayout()
else if (action === 'skip-payout') result = skipPayout()
else if (action === 'select-mode') result = selectMode(el.dataset.mode)
else if (action === 'accept-mode') result = submitAcceptMode()
else if (action === 'dashboard') result = goToDashboard()
if (result && typeof result.catch === 'function') {
setWizardActionBusy(true, action)
result
.catch(err => {
setWizardStatus(wizardErrorText(err, 'Wizard action failed'), 'error')
console.error('Wizard action failed:', err)
})
.finally(() => { setWizardActionBusy(false, action) })
}
})
}
// ─── Utility ────────────────────────────────────────────────
function showError (id, msg) {
const el = document.getElementById(id)
if (el) el.textContent = wizardErrorText(msg, '')
}
function clearErrors () {
document.querySelectorAll('.error').forEach(el => { el.textContent = '' })
}
function setBusy (btnId, busy) {
const btn = document.getElementById(btnId)
if (!btn) return
btn.disabled = busy
if (busy) {
btn.dataset.label = btn.textContent
const spinner = document.createElement('span')
spinner.className = 'loading'
btn.replaceChildren(spinner, document.createTextNode('Working...'))
} else if (btn.dataset.label) {
btn.textContent = btn.dataset.label
}
}
function renderLoadError (err) {
const card = document.querySelector('.card')
const title = document.createElement('h2')
const body = document.createElement('p')
const code = document.createElement('code')
const help = document.createElement('p')
title.textContent = "Couldn't load setup"
body.append('The wizard endpoint returned an error: ')
code.textContent = wizardErrorText(err, 'unknown')
body.append(code)
help.className = 'small'
help.textContent = "If you're not on the same machine as the relay, the wizard is locked to localhost for security. Open this URL from the Umbrel device itself."
card.replaceChildren(title, body, help)
}
// ─── Bootstrap ──────────────────────────────────────────────
// On page load: fetch current state from server and resume at the
// right step. If wizard is already complete, redirect to dashboard
// unless the dashboard opened us in edit mode.
bindWizardActions()
loadState().then(s => {
if (s.isComplete && !editMode) {
window.location.href = appPath('/dashboard')
return
}
const requested = window.location.hash === '#payout' ? 'payout' : 'relay_name'
showStep(editMode && s.isComplete ? requested : (s.step || 'welcome'))
}).catch(renderLoadError)
</script>
</body>
</html>