oxedyne/daimond/www/apply.html
29.2 KiB, 1 run
created by r2519314175:1035, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | <!DOCTYPE html> |
| 2 | <html lang="en-GB" data-theme="dark" data-tone="dark" data-ink="light"> |
| 3 | <head> |
| 4 | <meta charset="UTF-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> |
| 6 | <title>Apply to test Daimond</title> |
| 7 | <meta name="description" content="Ask for a Daimond beta passcode, or leave your address for the day the beta opens."> |
| 8 | |
| 9 | <!-- WHY THERE IS A FORM AT ALL. The landing page's beta buttons used to be |
| 10 | `mailto:` links, which means a literal address in the markup of a public |
| 11 | page. That is scraped within days and cannot be unpublished. The buttons |
| 12 | lead here instead, and an applicant lands as a row in the operator console |
| 13 | rather than in a mailbox the gateway cannot see. |
| 14 | |
| 15 | WHY IT IS ON THIS ORIGIN AND NOT THE LANDING'S. The call here is |
| 16 | same-origin: Steel front-proxies /api/* to the gateway on loopback, exactly |
| 17 | as the passcode redemption in js/passcode.js already relies on. So there is |
| 18 | no preflight, and nothing about this page depends on configuration. |
| 19 | |
| 20 | The endpoint does now allow one cross-origin caller -- `allow_origin` on |
| 21 | the /api/beta/apply route in gateway/app.jdat -- so a form on daimond.app |
| 22 | could be made to work. It would depend on that line being present in the |
| 23 | app.jdat THE SERVER IS ACTUALLY RUNNING, which is not the same file as the |
| 24 | one in this repository and has drifted from it before. A form that fails |
| 25 | silently because a config key is missing on one host is exactly the failure |
| 26 | a person filling it in cannot see. This one has nothing to drift. --> |
| 27 | |
| 28 | <!-- Not indexed. This is a step in a flow rather than a destination: somebody |
| 29 | who lands on it straight from a search engine has none of the landing's |
| 30 | context about what the beta is or what a passcode does. `follow` so the |
| 31 | links out of it still count. --> |
| 32 | <meta name="robots" content="noindex, follow"> |
| 33 | |
| 34 | <!-- The palette, before the first paint. |
| 35 | Deliberately NOT the app's eleven-palette table. index.html carries a copy |
| 36 | of that table and dev/verify_theme.mjs holds the copy to the original; a |
| 37 | third copy would be a third thing to keep in step, for a page that is read |
| 38 | once. The two ends of the range are enough here, and the operating |
| 39 | system's own preference is the right signal for a page reached from a |
| 40 | public site by somebody who may never have opened the app. --> |
| 41 | <script> |
| 42 | (function () { |
| 43 | try { |
| 44 | var light = window.matchMedia |
| 45 | && window.matchMedia('(prefers-color-scheme: light)').matches; |
| 46 | if (!light) return; // the markup already says dark |
| 47 | var root = document.documentElement; |
| 48 | root.setAttribute('data-theme', 'light'); |
| 49 | root.setAttribute('data-tone', 'light'); |
| 50 | root.setAttribute('data-ink', 'dark'); |
| 51 | } catch (e) { /* the dark default stands */ } |
| 52 | })(); |
| 53 | </script> |
| 54 | |
| 55 | <link rel="icon" type="image/svg+xml" href="assets/daimond_mark.svg"> |
| 56 | <!-- The app's own palette and the guide's document styling, so this page reads |
| 57 | as part of Daimond without restating a single colour. Only the form itself |
| 58 | is new, and it is below rather than in a new stylesheet: one page, one |
| 59 | file, nothing else to deploy. --> |
| 60 | <link rel="stylesheet" href="css/variables.css"> |
| 61 | <link rel="stylesheet" href="css/guide.css"> |
| 62 | <style> |
| 63 | /* ── The form ─────────────────────────────────────────────────────────── */ |
| 64 | |
| 65 | .ap-form { margin: 1.5rem 0 0; } |
| 66 | |
| 67 | .ap-field { margin: 1.25rem 0; } |
| 68 | |
| 69 | .ap-label { |
| 70 | display: block; |
| 71 | font-weight: 650; |
| 72 | color: var(--text-primary); |
| 73 | margin: 0 0 0.35rem; |
| 74 | } |
| 75 | |
| 76 | .ap-in, .ap-ta { |
| 77 | width: 100%; |
| 78 | box-sizing: border-box; |
| 79 | font: inherit; |
| 80 | /* A 16px floor. iOS Safari zooms the whole page whenever it focuses a |
| 81 | field with smaller text, and a page that jumps as the keyboard opens |
| 82 | is a page a phone user has to pinch back before they can read what |
| 83 | they typed. `max` so a reader who has enlarged the type keeps it. */ |
| 84 | font-size: max(1rem, 16px); |
| 85 | line-height: 1.45; |
| 86 | padding: 0.6rem 0.7rem; |
| 87 | color: var(--text-primary); |
| 88 | background: var(--bg-primary); |
| 89 | /* --border-strong, not --border: on a field the line IS the control's |
| 90 | edge rather than a divider between two visible things. The comment in |
| 91 | variables.css sets out why the app carries a second token for it. */ |
| 92 | border: 1px solid var(--border-strong); |
| 93 | border-radius: var(--radius-sm); |
| 94 | } |
| 95 | .ap-ta { min-height: 5.5rem; resize: vertical; } |
| 96 | .ap-in::placeholder, .ap-ta::placeholder { color: var(--text-muted); } |
| 97 | .ap-in:focus, .ap-ta:focus { |
| 98 | outline: 2px solid var(--accent); |
| 99 | outline-offset: 1px; |
| 100 | border-color: var(--accent); |
| 101 | } |
| 102 | .ap-in[aria-invalid="true"] { border-color: var(--danger); } |
| 103 | |
| 104 | .ap-hint { |
| 105 | margin: 0.35rem 0 0; |
| 106 | font-size: 0.88rem; |
| 107 | color: var(--text-muted); |
| 108 | } |
| 109 | .ap-fielderr { |
| 110 | margin: 0.35rem 0 0; |
| 111 | font-size: 0.9rem; |
| 112 | color: var(--danger); |
| 113 | } |
| 114 | .ap-fielderr:empty { display: none; } |
| 115 | |
| 116 | /* ── The two intents ──────────────────────────────────────────────────── */ |
| 117 | |
| 118 | .ap-choice { |
| 119 | margin: 0; |
| 120 | padding: 0; |
| 121 | border: 0; |
| 122 | } |
| 123 | .ap-choice legend { |
| 124 | padding: 0; |
| 125 | font-weight: 650; |
| 126 | color: var(--text-primary); |
| 127 | } |
| 128 | .ap-opt { |
| 129 | display: flex; |
| 130 | gap: 0.65rem; |
| 131 | align-items: flex-start; |
| 132 | margin: 0.5rem 0; |
| 133 | padding: 0.7rem 0.85rem; |
| 134 | background: var(--bg-secondary); |
| 135 | border: 1px solid var(--border); |
| 136 | border-radius: var(--radius); |
| 137 | cursor: pointer; |
| 138 | } |
| 139 | /* The chosen one, said with colour AND a heavier edge, because a fill alone |
| 140 | is the one cue a person who cannot separate those hues does not get. */ |
| 141 | .ap-opt:has(input:checked) { |
| 142 | border-color: var(--accent); |
| 143 | background: var(--accent-soft); |
| 144 | } |
| 145 | .ap-opt:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; } |
| 146 | .ap-opt input { margin: 0.25rem 0 0; flex: none; accent-color: var(--accent); } |
| 147 | .ap-opt b { display: block; } |
| 148 | .ap-opt span { color: var(--text-secondary); } |
| 149 | |
| 150 | /* ── The button ───────────────────────────────────────────────────────── */ |
| 151 | |
| 152 | .ap-btn { |
| 153 | font: inherit; |
| 154 | font-size: max(1rem, 16px); |
| 155 | font-weight: 700; |
| 156 | color: var(--on-fill, #fff); |
| 157 | background: var(--accent); |
| 158 | border: 1px solid var(--accent); |
| 159 | border-radius: var(--radius-sm); |
| 160 | padding: 0.7rem 1.4rem; |
| 161 | /* The thumb's floor. Most of the people answering this are on a phone. */ |
| 162 | min-height: 48px; |
| 163 | cursor: pointer; |
| 164 | } |
| 165 | .ap-btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); } |
| 166 | .ap-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| 167 | /* Waiting, not broken. */ |
| 168 | .ap-btn[disabled] { opacity: 0.55; cursor: default; background: var(--accent); } |
| 169 | @media (max-width: 560px) { .ap-btn { width: 100%; } } |
| 170 | |
| 171 | /* ── What came back ───────────────────────────────────────────────────── */ |
| 172 | |
| 173 | /* One region for every answer, so a reader never has to look in two places |
| 174 | to find out what happened. It is a live region: the outcome of a submit |
| 175 | is the whole point of the page and a screen reader must hear it. */ |
| 176 | .ap-say { |
| 177 | margin: 1.25rem 0 0; |
| 178 | padding: 0.8rem 1rem; |
| 179 | border: 1px solid var(--border); |
| 180 | border-left: 3px solid var(--text-muted); |
| 181 | border-radius: var(--radius-sm); |
| 182 | background: var(--bg-tertiary); |
| 183 | color: var(--text-secondary); |
| 184 | } |
| 185 | .ap-say:empty { display: none; } |
| 186 | /* The header is sticky, so anything scrolled to the very top of the |
| 187 | scrollport lands underneath it. Two of these are scrolled to by name: |
| 188 | the answer, and a field being sent back for correction. */ |
| 189 | .ap-say, .ap-field { scroll-margin-top: 6rem; } |
| 190 | .ap-say :first-child { margin-top: 0; } |
| 191 | .ap-say :last-child { margin-bottom: 0; } |
| 192 | .ap-say strong { color: var(--text-primary); } |
| 193 | .ap-say.ok { border-left-color: var(--ok); } |
| 194 | .ap-say.bad { border-left-color: var(--danger); } |
| 195 | .ap-say:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } |
| 196 | |
| 197 | /* The word "(optional)" sitting inside a field's own label. */ |
| 198 | .ap-optional { |
| 199 | font-weight: 400; |
| 200 | font-size: 0.88rem; |
| 201 | color: var(--text-muted); |
| 202 | } |
| 203 | |
| 204 | /* A quiet line above the form for somebody who has been here before. */ |
| 205 | .ap-before { |
| 206 | margin: 1rem 0 0; |
| 207 | font-size: 0.9rem; |
| 208 | color: var(--text-muted); |
| 209 | } |
| 210 | .ap-before:empty { display: none; } |
| 211 | |
| 212 | [hidden] { display: none !important; } |
| 213 | </style> |
| 214 | </head> |
| 215 | <body> |
| 216 | |
| 217 | <header class="site-head"> |
| 218 | <div class="site-head-inner"> |
| 219 | <!-- Back where they came from. The brand on the guide goes to the guide's |
| 220 | own index; here the reader arrived from the public site and that is |
| 221 | the only other page they know about. --> |
| 222 | <a class="brand" href="https://daimond.app/" aria-label="Daimond, back to daimond.app"> |
| 223 | <img class="wordmark wm-dark" src="assets/daimond_word.svg" alt="Daimond" translate="no"> |
| 224 | <img class="wordmark wm-light" src="assets/daimond_word_dark.svg" alt="Daimond" translate="no"> |
| 225 | <span class="brand-sub">Beta</span> |
| 226 | </a> |
| 227 | </div> |
| 228 | </header> |
| 229 | |
| 230 | <main> |
| 231 | |
| 232 | <!-- Two headings and two ledes, both in the markup rather than written in by |
| 233 | script. The copy on this page is copy, and it belongs where the rest of |
| 234 | the site's copy is; the script only chooses which of the two is showing. |
| 235 | The "apply" pair ships visible because that is the default intent. --> |
| 236 | <div data-for="test"> |
| 237 | <h1>Apply to test Daimond</h1> |
| 238 | <p class="lede">Daimond is in closed beta. Accounts are opened by passcode, so you cannot sign up on your own yet. Leave your address and we will send you one if there is room.</p> |
| 239 | </div> |
| 240 | <div data-for="waitlist" hidden> |
| 241 | <h1>Join the Daimond waitlist</h1> |
| 242 | <p class="lede">Daimond is in closed beta and not open for sign-up. Leave your address and we will write to you on the day that changes.</p> |
| 243 | </div> |
| 244 | |
| 245 | <p class="ap-before" id="ap-before"></p> |
| 246 | |
| 247 | <form class="ap-form" id="ap-form" novalidate> |
| 248 | |
| 249 | <fieldset class="ap-choice"> |
| 250 | <legend>What are you asking for?</legend> |
| 251 | <label class="ap-opt"> |
| 252 | <input type="radio" name="kind" value="test" checked> |
| 253 | <span><b>A passcode now.</b> You want to use Daimond while the beta is closed.</span> |
| 254 | </label> |
| 255 | <label class="ap-opt"> |
| 256 | <input type="radio" name="kind" value="waitlist"> |
| 257 | <span><b>Word when it opens.</b> You would rather wait until anyone can sign up.</span> |
| 258 | </label> |
| 259 | </fieldset> |
| 260 | |
| 261 | <div class="ap-field"> |
| 262 | <label class="ap-label" for="ap-name"> |
| 263 | Your name |
| 264 | <span class="ap-optional">(optional)</span> |
| 265 | </label> |
| 266 | <!-- Not decoration. The gateway carries this through to the passcode |
| 267 | it mints for an accepted applicant, as that code's label, which |
| 268 | is what the operator sees beside it afterwards. 80 characters is |
| 269 | BAPP_NAME_MAX, which is the label's own width. --> |
| 270 | <input class="ap-in" id="ap-name" name="name" type="text" |
| 271 | autocomplete="name" maxlength="80" |
| 272 | aria-describedby="ap-name-hint"> |
| 273 | <p class="ap-hint" id="ap-name-hint">What to call you in a reply. Whatever you go by is fine.</p> |
| 274 | </div> |
| 275 | |
| 276 | <div class="ap-field"> |
| 277 | <label class="ap-label" for="ap-email">Your email address</label> |
| 278 | <!-- `type="email"` for the keyboard it summons on a phone, and |
| 279 | `novalidate` on the form so the browser's own bubble does not |
| 280 | pre-empt the message this page writes. --> |
| 281 | <input class="ap-in" id="ap-email" name="email" type="email" |
| 282 | inputmode="email" autocomplete="email" enterkeyhint="send" |
| 283 | autocapitalize="off" spellcheck="false" required |
| 284 | maxlength="254" placeholder="you@example.com" |
| 285 | aria-describedby="ap-email-hint"> |
| 286 | <p class="ap-fielderr" id="ap-email-err" role="alert"></p> |
| 287 | <p class="ap-hint" id="ap-email-hint">The only thing we need. It is used to write to you about the beta, and for nothing else.</p> |
| 288 | </div> |
| 289 | |
| 290 | <div class="ap-field"> |
| 291 | <label class="ap-label" for="ap-note"> |
| 292 | <span data-for="test">What would you use Daimond for?</span> |
| 293 | <span data-for="waitlist" hidden>Anything you would like to tell us</span> |
| 294 | <span class="ap-optional">(optional)</span> |
| 295 | </label> |
| 296 | <!-- 600 is BAPP_NOTE_MAX. Held here as well as there, because a |
| 297 | paragraph refused after it was typed is a paragraph lost. --> |
| 298 | <textarea class="ap-ta" id="ap-note" name="note" rows="4" maxlength="600" |
| 299 | aria-describedby="ap-note-hint"></textarea> |
| 300 | <p class="ap-hint" id="ap-note-hint"> |
| 301 | <span data-for="test">A sentence is plenty. Places in each wave are limited, and this is what we read when we choose.</span> |
| 302 | <span data-for="waitlist" hidden>Nothing here is required. It is read, not filed against you.</span> |
| 303 | </p> |
| 304 | </div> |
| 305 | |
| 306 | <button class="ap-btn" id="ap-send" type="submit"> |
| 307 | <span data-for="test">Send my application</span> |
| 308 | <span data-for="waitlist" hidden>Add me to the waitlist</span> |
| 309 | </button> |
| 310 | |
| 311 | </form> |
| 312 | |
| 313 | <!-- Everything the page has to say about what just happened, in one place. |
| 314 | `tabindex="-1"` so focus can be moved here: a keyboard or screen-reader |
| 315 | user who presses the button must end up at the answer rather than back |
| 316 | at the top of the document. --> |
| 317 | <div class="ap-say" id="ap-say" role="status" aria-live="polite" tabindex="-1"></div> |
| 318 | |
| 319 | <noscript> |
| 320 | <div class="note stop"> |
| 321 | <p><strong>This form needs JavaScript.</strong> It posts what you type straight to Daimond's server, and there is nothing on this page that can do that without it. Switch it on for this site and reload, and the form will work.</p> |
| 322 | </div> |
| 323 | </noscript> |
| 324 | |
| 325 | <h2>What happens next</h2> |
| 326 | |
| 327 | <ol class="steps" data-for="test"> |
| 328 | <li>Your address arrives in the operator console at Oxedyne, where a person reads it. It goes nowhere else.</li> |
| 329 | <li>If there is room in the current wave, we email you a passcode: twelve characters, in three groups.</li> |
| 330 | <li>You open Daimond, put the passcode in, and the account is made on that device. Each passcode works once, on one device.</li> |
| 331 | </ol> |
| 332 | |
| 333 | <ol class="steps" data-for="waitlist" hidden> |
| 334 | <li>Your address arrives in the operator console at Oxedyne, where a person reads it. It goes nowhere else.</li> |
| 335 | <li>We write to you once, on the day Daimond opens for sign-up.</li> |
| 336 | <li>That is all. There is no newsletter and no second message.</li> |
| 337 | </ol> |
| 338 | |
| 339 | <div class="note" data-for="test"> |
| 340 | <p>We cannot promise a place. The waves are small, and an application we cannot take this time is held for the next one rather than thrown away.</p> |
| 341 | </div> |
| 342 | |
| 343 | <div class="note"> |
| 344 | <p><strong>What is kept:</strong> the address you type, the note if you write one, and the date. It sits on Oxedyne's own server. It is not sold and not passed to anyone. Ask, in a reply to anything we send you, and the record is deleted.</p> |
| 345 | </div> |
| 346 | |
| 347 | <p>Already have a passcode? <a href="./">Open Daimond</a> and put it in. Not sure what any of this is? <a href="https://daimond.app/">Read the site</a> first.</p> |
| 348 | |
| 349 | </main> |
| 350 | |
| 351 | <footer class="site-foot"> |
| 352 | <nav aria-label="Elsewhere"> |
| 353 | <a href="https://daimond.app/">daimond.app</a> |
| 354 | <a href="./">Open Daimond</a> |
| 355 | <a href="guide/">Guide</a> |
| 356 | </nav> |
| 357 | <span>© Complement Pty Ltd, trading as Oxedyne.</span> |
| 358 | </footer> |
| 359 | |
| 360 | <script> |
| 361 | (function () { |
| 362 | 'use strict'; |
| 363 | |
| 364 | /* ── The gateway call ───────────────────────────────────────────────────── |
| 365 | THE ONE PLACE THE REQUEST IS BUILT. Path, method, headers and body, all |
| 366 | here and nowhere else, so a change to the contract is one edit rather than |
| 367 | a search. |
| 368 | |
| 369 | The other end is gateway/src/handlers/beta.rs, whose header states it: |
| 370 | request `{email, name?, note?, intent?}`, reply `{ok:true, |
| 371 | status:"received"}`. `intent` is exactly `test` or `waitlist`; anything |
| 372 | else is a 400, and an absent one means `test`. The three field caps below |
| 373 | are that file's BAPP_EMAIL_MAX, BAPP_NAME_MAX and BAPP_NOTE_MAX, so the |
| 374 | browser refuses what the gateway would refuse, in the field rather than |
| 375 | after a round trip. |
| 376 | |
| 377 | Same-origin, so `/api/beta/apply` is Steel's proxy to the gateway on |
| 378 | loopback: no preflight, and none of that route's CORS configuration is in |
| 379 | play. No `x-daimond-api` header either -- common.rs treats a client that |
| 380 | names no version as current, and copying the app's version constant into |
| 381 | a second file is exactly the pair of numbers that eventually disagree. */ |
| 382 | var ENDPOINT = '/api/beta/apply'; |
| 383 | |
| 384 | function send(intent, email, name, note) { |
| 385 | return fetch(ENDPOINT, { |
| 386 | method: 'POST', |
| 387 | credentials: 'same-origin', |
| 388 | headers: { 'content-type': 'application/json' }, |
| 389 | body: JSON.stringify({ |
| 390 | intent: intent, |
| 391 | email: email, |
| 392 | name: name, |
| 393 | note: note, |
| 394 | }), |
| 395 | }); |
| 396 | } |
| 397 | |
| 398 | /* ── The page ─────────────────────────────────────────────────────────── */ |
| 399 | |
| 400 | var form = document.getElementById('ap-form'); |
| 401 | var nameIn = document.getElementById('ap-name'); |
| 402 | var email = document.getElementById('ap-email'); |
| 403 | var note = document.getElementById('ap-note'); |
| 404 | var btn = document.getElementById('ap-send'); |
| 405 | var say = document.getElementById('ap-say'); |
| 406 | var mailEr = document.getElementById('ap-email-err'); |
| 407 | var before = document.getElementById('ap-before'); |
| 408 | |
| 409 | /// What this browser last sent, so a return visit is not a blank form |
| 410 | /// pretending nothing ever happened. The DATE and the INTENT only -- never |
| 411 | /// the address. A shared or borrowed machine must not carry somebody's email |
| 412 | /// around in localStorage for the sake of a nicety. |
| 413 | var SENT = 'daimond-applied'; |
| 414 | |
| 415 | /// Which of the two the page is set to. `test` unless the query says |
| 416 | /// otherwise, because the landing's larger, primary button is the apply one. |
| 417 | function wanted() { |
| 418 | var q = ''; |
| 419 | try { q = new URLSearchParams(location.search).get('for') || ''; } catch (e) { q = ''; } |
| 420 | return q === 'waitlist' ? 'waitlist' : 'test'; |
| 421 | } |
| 422 | |
| 423 | /// The intent the form is currently set to. |
| 424 | function kind() { |
| 425 | var on = form.querySelector('input[name="kind"]:checked'); |
| 426 | return on && on.value === 'waitlist' ? 'waitlist' : 'test'; |
| 427 | } |
| 428 | |
| 429 | /// Show the half of the copy that belongs to this intent, and hide the other. |
| 430 | /// |
| 431 | /// Everything keyed `data-for` is a pair: one for the passcode and one for |
| 432 | /// the waitlist. Nothing is written from here -- the words are all in the |
| 433 | /// markup above -- so a change of copy is a change to the HTML alone. |
| 434 | function dress(k) { |
| 435 | var all = document.querySelectorAll('[data-for]'); |
| 436 | for (var i = 0; i < all.length; i++) { |
| 437 | all[i].hidden = all[i].getAttribute('data-for') !== k; |
| 438 | } |
| 439 | document.title = k === 'waitlist' |
| 440 | ? 'Join the Daimond waitlist' |
| 441 | : 'Apply to test Daimond'; |
| 442 | // Keep the address bar honest, so a link copied off this page opens on |
| 443 | // the same half of it. `replaceState` rather than a push: switching the |
| 444 | // radio is not somewhere the Back button should have to return through. |
| 445 | try { |
| 446 | var u = new URL(location.href); |
| 447 | u.searchParams.set('for', k); |
| 448 | history.replaceState(null, '', u.toString()); |
| 449 | } catch (e) { /* an engine without it simply keeps the old query */ } |
| 450 | } |
| 451 | |
| 452 | /// Does this look like an address at all? |
| 453 | /// |
| 454 | /// Deliberately loose. The gateway decides what it will accept and this is |
| 455 | /// only here to catch the empty box and the missing `@` before a person |
| 456 | /// waits on a round trip to be told. A strict pattern here would refuse |
| 457 | /// perfectly legal addresses that the server would have taken. |
| 458 | function plausible(s) { |
| 459 | return /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(s); |
| 460 | } |
| 461 | |
| 462 | /// Put a message in the one region, in the given state, and -- for an answer |
| 463 | /// rather than a progress note -- take the reader to it. `html` is written by |
| 464 | /// this file only; nothing a user typed and nothing the server said is ever |
| 465 | /// passed through it as markup. |
| 466 | /// |
| 467 | /// `land` is false while the request is in flight. Moving focus off the |
| 468 | /// button to say "Sending…" would take the keyboard away from the control |
| 469 | /// the reader is standing on and then take it somewhere else again a moment |
| 470 | /// later; the live region announces the progress without any of that. |
| 471 | function tell(state, html, land) { |
| 472 | say.className = 'ap-say' + (state ? ' ' + state : ''); |
| 473 | say.innerHTML = html; |
| 474 | if (land === false) return; |
| 475 | try { say.focus(); } catch (e) { /* not focusable in this engine */ } |
| 476 | } |
| 477 | |
| 478 | /// The gateway's own sentence, when it sent one worth reading. |
| 479 | /// |
| 480 | /// The house shape for a refusal is `{ok:false, reason, error}` -- see |
| 481 | /// gateway/src/handlers/passcode.rs, which this endpoint sits beside. The |
| 482 | /// `error` field is English written for a person, so it is preferred over |
| 483 | /// anything invented here; `reason` is the machine word and is never shown. |
| 484 | function said(j) { |
| 485 | var s = j && typeof j.error === 'string' ? j.error.trim() : ''; |
| 486 | return s ? esc(s) : ''; |
| 487 | } |
| 488 | |
| 489 | /// Text from the server, made safe to place in the page. |
| 490 | function esc(s) { |
| 491 | return String(s) |
| 492 | .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
| 493 | } |
| 494 | |
| 495 | /// Nothing was recorded. Said in the same words every time, because the one |
| 496 | /// thing a person needs after a failure is to know whether to do it again. |
| 497 | var NOTHING = 'Nothing was recorded, so try again in a few minutes. ' |
| 498 | + 'What you typed is still here.'; |
| 499 | |
| 500 | /// The page has done its job; put the form away and say what will happen. |
| 501 | /// |
| 502 | /// THERE IS NO "you have already applied" HERE, and there must not be. The |
| 503 | /// gateway answers a fresh application, a repeat and one inside its cooldown |
| 504 | /// with the same `{ok:true}` on purpose (see the module header of |
| 505 | /// gateway/src/handlers/beta.rs): an endpoint that told them apart would |
| 506 | /// answer "is this address known to Daimond" for any address anybody cared |
| 507 | /// to type. So this page cannot know, does not guess, and says the same |
| 508 | /// thing the server did. |
| 509 | function done(k) { |
| 510 | form.hidden = true; |
| 511 | before.textContent = ''; |
| 512 | // The lede is an instruction -- "leave your address" -- and it has been |
| 513 | // followed. Leaving it above the confirmation would tell somebody to do |
| 514 | // the thing they have just done. |
| 515 | var lede = document.querySelectorAll('.lede'); |
| 516 | for (var i = 0; i < lede.length; i++) lede[i].hidden = true; |
| 517 | var toApp = ' <a href="./">Open Daimond</a> when your passcode arrives.'; |
| 518 | if (k === 'waitlist') { |
| 519 | tell('ok', '<p><strong>You are on the list.</strong> We will write to you ' |
| 520 | + 'once, on the day Daimond opens for sign-up. Nothing else happens ' |
| 521 | + 'on this page, and you can close it.</p>'); |
| 522 | } else { |
| 523 | tell('ok', '<p><strong>Your application is in.</strong> A person at Oxedyne ' |
| 524 | + 'reads it. If there is room in the current wave you will get a ' |
| 525 | + 'passcode by email; if there is not, the application is held for the ' |
| 526 | + 'next one.' + toApp + '</p>'); |
| 527 | } |
| 528 | try { |
| 529 | localStorage.setItem(SENT, JSON.stringify({ intent: k, at: Date.now() })); |
| 530 | } catch (e) { /* private mode: the confirmation above is the whole record */ } |
| 531 | } |
| 532 | |
| 533 | /// A line for somebody who has been here before, from this browser. |
| 534 | function recall() { |
| 535 | var raw = null; |
| 536 | try { raw = localStorage.getItem(SENT); } catch (e) { return; } |
| 537 | if (!raw) return; |
| 538 | var rec = null; |
| 539 | try { rec = JSON.parse(raw); } catch (e) { return; } |
| 540 | if (!rec || !rec.at) return; |
| 541 | var when = new Date(rec.at).toLocaleDateString('en-GB', |
| 542 | { day: 'numeric', month: 'long', year: 'numeric' }); |
| 543 | before.textContent = 'You sent something from this browser on ' + when |
| 544 | + '. Sending another does no harm, and does not move you up any list.'; |
| 545 | } |
| 546 | |
| 547 | /// Hold the intent still while a request is in flight, or let it go again. |
| 548 | function lock(on) { |
| 549 | var radios = form.querySelectorAll('input[name="kind"]'); |
| 550 | for (var i = 0; i < radios.length; i++) radios[i].disabled = !!on; |
| 551 | } |
| 552 | |
| 553 | /// Put the form back the way it was, ready to be sent again. Called from |
| 554 | /// every failure arm, so no arm can forget half of it and leave a disabled |
| 555 | /// button over a message telling somebody to try again. |
| 556 | function revert(label) { |
| 557 | btn.disabled = false; |
| 558 | btn.innerHTML = label; |
| 559 | lock(false); |
| 560 | } |
| 561 | |
| 562 | // ── Wiring ──────────────────────────────────────────────────────────────── |
| 563 | |
| 564 | form.addEventListener('change', function (e) { |
| 565 | if (e.target && e.target.name === 'kind') dress(kind()); |
| 566 | }); |
| 567 | |
| 568 | form.addEventListener('submit', function (e) { |
| 569 | e.preventDefault(); |
| 570 | go(); |
| 571 | }); |
| 572 | |
| 573 | async function go() { |
| 574 | var addr = (email.value || '').trim(); |
| 575 | var who = (nameIn.value || '').trim(); |
| 576 | var body = (note.value || '').trim(); |
| 577 | var k = kind(); |
| 578 | |
| 579 | mailEr.textContent = ''; |
| 580 | email.removeAttribute('aria-invalid'); |
| 581 | |
| 582 | if (!addr) { |
| 583 | mailEr.textContent = 'Type the address we should write to.'; |
| 584 | email.setAttribute('aria-invalid', 'true'); |
| 585 | email.focus(); |
| 586 | return; |
| 587 | } |
| 588 | if (!plausible(addr)) { |
| 589 | mailEr.textContent = 'That does not look like an email address. ' |
| 590 | + 'Check it and try again.'; |
| 591 | email.setAttribute('aria-invalid', 'true'); |
| 592 | email.focus(); |
| 593 | return; |
| 594 | } |
| 595 | |
| 596 | // The button carries both labels and only shows one; "Sending…" replaces |
| 597 | // them for the duration, so the markup is put back rather than rebuilt. |
| 598 | // The intent is frozen with it: the two labels the restore brings back |
| 599 | // are the ones that were right when the request went out, and a radio |
| 600 | // changed mid-flight would leave them describing the other half. |
| 601 | var was = btn.innerHTML; |
| 602 | btn.disabled = true; |
| 603 | btn.textContent = 'Sending…'; |
| 604 | lock(true); |
| 605 | tell('', '<p>Sending…</p>', false); |
| 606 | |
| 607 | var r; |
| 608 | try { |
| 609 | r = await send(k, addr, who, body); |
| 610 | } catch (err) { |
| 611 | // The request never left this device. That is a different fact from a |
| 612 | // refusal, and it is the one that decides whether trying again is |
| 613 | // worth anything -- so it is never reported as the server saying no. |
| 614 | revert(was); |
| 615 | tell('bad', '<p><strong>That did not get out.</strong> Daimond\'s server ' |
| 616 | + 'could not be reached from this device, so nothing arrived and ' |
| 617 | + 'nothing was recorded. Check your connection and try again.</p>'); |
| 618 | return; |
| 619 | } |
| 620 | |
| 621 | var j = null; |
| 622 | try { j = await r.json(); } catch (e2) { j = null; } |
| 623 | |
| 624 | // Taken. `ok:true` is the contract; a 2xx with a body this page cannot |
| 625 | // parse is NOT treated as success, because the one thing worse than a |
| 626 | // failure here is telling somebody they are on a list they are not on. |
| 627 | if (r.ok && j && j.ok === true) { done(k); return; } |
| 628 | |
| 629 | revert(was); |
| 630 | |
| 631 | var msg = said(j); |
| 632 | |
| 633 | // A refusal this page knows the shape of is said in this page's own |
| 634 | // words, and the server's sentence is dropped -- the two say the same |
| 635 | // thing, and printing both reads as a stutter. The server's sentence is |
| 636 | // kept for the refusals below, where it is the only thing that knows |
| 637 | // WHICH field was wrong, and for anything unrecognised. |
| 638 | if (r.status === 429) { |
| 639 | tell('bad', '<p><strong>Too many attempts from this connection.</strong> ' |
| 640 | + 'Wait a few minutes and try again. Nothing was recorded.</p>'); |
| 641 | return; |
| 642 | } |
| 643 | // 413 sits with the 400s rather than with the retries: the body was too |
| 644 | // big, and pressing the button again with the same words in the box |
| 645 | // sends the same body. The gateway's sentence names what was too long. |
| 646 | if (r.status === 400 || r.status === 413 || r.status === 422) { |
| 647 | tell('bad', '<p><strong>The server would not take that.</strong> ' |
| 648 | + (msg || 'Check what you typed and try again.') + ' ' |
| 649 | + 'Nothing was recorded.</p>'); |
| 650 | return; |
| 651 | } |
| 652 | // The form is shut, or its store is at its ceiling. Not a fault the |
| 653 | // reader can wait out over a coffee, so it does not tell them to: the |
| 654 | // gateway's own sentence says what is going on, and the advice is to |
| 655 | // come back rather than to press the button again. |
| 656 | if (r.status === 503) { |
| 657 | tell('bad', '<p><strong>' |
| 658 | + (msg || 'Daimond is not taking applications just now.') |
| 659 | + '</strong> Nothing was recorded. Try again another day, or watch ' |
| 660 | + '<a href="https://daimond.app/">daimond.app</a> for the beta ' |
| 661 | + 'opening.</p>'); |
| 662 | return; |
| 663 | } |
| 664 | // Not deployed here, or not deployed yet. Worth its own sentence: it is |
| 665 | // the one failure a person can do absolutely nothing about, and it must |
| 666 | // not read as though they typed something wrong. |
| 667 | if (r.status === 404 || r.status === 405 || r.status === 501) { |
| 668 | tell('bad', '<p><strong>This server is not taking applications.</strong> ' |
| 669 | + 'The form reached it and it had nowhere to put this, which is our ' |
| 670 | + 'fault and not yours. ' + NOTHING + '</p>'); |
| 671 | return; |
| 672 | } |
| 673 | // A 2xx that did not say `ok:true`. If it named a reason, it refused; if |
| 674 | // it did not, this page genuinely does not know whether the application |
| 675 | // landed, and saying either "sent" or "refused" would be a guess about |
| 676 | // the one thing the reader came here to find out. |
| 677 | if (r.ok) { |
| 678 | tell('bad', msg |
| 679 | ? '<p><strong>The server refused that.</strong> ' + msg + ' ' + NOTHING + '</p>' |
| 680 | : '<p><strong>The server answered, but not in a way this page ' |
| 681 | + 'understands.</strong> So it cannot tell you whether your ' |
| 682 | + 'application was recorded or not. Try again in a few minutes; ' |
| 683 | + 'if the first one did land, a second changes nothing.</p>'); |
| 684 | return; |
| 685 | } |
| 686 | tell('bad', '<p><strong>The server refused that.</strong> ' |
| 687 | + (msg || ('It answered HTTP ' + r.status + '.')) + ' ' + NOTHING + '</p>'); |
| 688 | } |
| 689 | |
| 690 | // The intent the link carried, then the line for a returning visitor. |
| 691 | (function start() { |
| 692 | var k = wanted(); |
| 693 | var on = form.querySelector('input[name="kind"][value="' + k + '"]'); |
| 694 | if (on) on.checked = true; |
| 695 | dress(k); |
| 696 | recall(); |
| 697 | })(); |
| 698 | |
| 699 | })(); |
| 700 | </script> |
| 701 | |
| 702 | </body> |
| 703 | </html> |