Front doors by hostname, waiting list, invitation and texted-code sign-in pages, Examples, People
This commit is contained in:
parent
793de70406
commit
4203b931ab
8 changed files with 599 additions and 15 deletions
|
|
@ -49,10 +49,58 @@ var Session = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ONE WEBSITE, SEVERAL FRONT DOORS
|
||||||
|
// The hostname the page was loaded from decides the look (a stylesheet), the
|
||||||
|
// name in the corner, the menu and which pages exist. The settings come from
|
||||||
|
// the server (`site_config`), keyed on the hostname sent with every call, and
|
||||||
|
// are cached so the look is right on the first paint of the next page.
|
||||||
|
// `?site=book` on any address previews another front door for this tab.
|
||||||
|
// This is presentation, not permission: what an account may do is decided by
|
||||||
|
// its session on the server.
|
||||||
|
var Site = {
|
||||||
|
told: (function () {
|
||||||
|
var pick = null;
|
||||||
|
try {
|
||||||
|
pick = new URLSearchParams(location.search).get("site");
|
||||||
|
if (pick) sessionStorage.setItem("ptl_site_pick", pick);
|
||||||
|
else pick = sessionStorage.getItem("ptl_site_pick");
|
||||||
|
} catch (e) {}
|
||||||
|
return (pick || location.hostname || "").toLowerCase();
|
||||||
|
})(),
|
||||||
|
key: function () { return "ptl_site:" + this.told; },
|
||||||
|
get: function () {
|
||||||
|
try { return JSON.parse(localStorage.getItem(this.key()) || "null"); } catch (e) { return null; }
|
||||||
|
},
|
||||||
|
set: function (cfg) {
|
||||||
|
if (!cfg) return;
|
||||||
|
try { localStorage.setItem(this.key(), JSON.stringify(cfg)); } catch (e) {}
|
||||||
|
this.apply(cfg);
|
||||||
|
},
|
||||||
|
apply: function (cfg) {
|
||||||
|
cfg = cfg || this.get();
|
||||||
|
if (!cfg) return;
|
||||||
|
document.documentElement.setAttribute("data-site", cfg.key || "");
|
||||||
|
var want = cfg.theme ? "/assets/site-" + cfg.theme + ".css" : "";
|
||||||
|
var link = document.getElementById("ptl-site-css");
|
||||||
|
if (want && !link) {
|
||||||
|
link = document.createElement("link");
|
||||||
|
link.id = "ptl-site-css"; link.rel = "stylesheet"; link.href = want;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
} else if (link && link.getAttribute("href") !== want) {
|
||||||
|
if (want) link.href = want; else link.remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Where somebody with no session is sent: the texted-code page once that is
|
||||||
|
// the rule, the email-only page until then.
|
||||||
|
signIn: function () { return ((this.get() || {}).auth === "sms") ? "/login.html" : "/register.html"; }
|
||||||
|
};
|
||||||
|
Site.apply();
|
||||||
|
|
||||||
async function api(action, payload) {
|
async function api(action, payload) {
|
||||||
var body = Object.assign({ action: action }, payload || {});
|
var body = Object.assign({ action: action }, payload || {});
|
||||||
var tok = Session.token();
|
var tok = Session.token();
|
||||||
if (tok) body.session = tok;
|
if (tok) body.session = tok;
|
||||||
|
body.site = Site.told;
|
||||||
var res;
|
var res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(API, {
|
res = await fetch(API, {
|
||||||
|
|
@ -72,10 +120,11 @@ async function api(action, payload) {
|
||||||
// failing oddly on every call afterwards.
|
// failing oddly on every call afterwards.
|
||||||
if (r && r.signed_out) {
|
if (r && r.signed_out) {
|
||||||
Session.clear();
|
Session.clear();
|
||||||
if (!/register\.html/.test(location.pathname)) {
|
if (!/(register|login|join|demo)\.html/.test(location.pathname)) {
|
||||||
location.href = "/register.html?next=" + encodeURIComponent(location.pathname);
|
location.href = Site.signIn() + "?next=" + encodeURIComponent(location.pathname + location.search);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (r && r.site) Site.set(r.site);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,8 +142,13 @@ function navBar(current) {
|
||||||
|
|
||||||
function paint(list) {
|
function paint(list) {
|
||||||
var old = document.querySelector("nav");
|
var old = document.querySelector("nav");
|
||||||
|
var site = Site.get() || { brand: ["Pay", "To", "Laugh"], home: "/" };
|
||||||
|
var b3 = site.brand || ["Pay", "To", "Laugh"];
|
||||||
|
// A page that does not exist at this front door sends the visitor home.
|
||||||
|
if (current && site.pages && site.pages.indexOf(current) < 0 && site.home
|
||||||
|
&& location.pathname !== site.home) { location.replace(site.home); return; }
|
||||||
var html = '<nav><div class="inner">'
|
var html = '<nav><div class="inner">'
|
||||||
+ '<a class="brand" href="/">Pay<span>To</span>Laugh</a>'
|
+ '<a class="brand" href="' + esc(site.home || "/") + '">' + esc(b3[0]) + '<span>' + esc(b3[1] || "") + '</span>' + esc(b3[2] || "") + '</a>'
|
||||||
+ list.map(function (l) {
|
+ list.map(function (l) {
|
||||||
return '<a class="link' + (l.id === current ? ' on' : '') + '" href="'
|
return '<a class="link' + (l.id === current ? ' on' : '') + '" href="'
|
||||||
+ esc(l.href) + '">' + esc(l.label) + '</a>';
|
+ esc(l.href) + '">' + esc(l.label) + '</a>';
|
||||||
|
|
@ -102,7 +156,7 @@ function navBar(current) {
|
||||||
+ '<div class="who">'
|
+ '<div class="who">'
|
||||||
+ (email
|
+ (email
|
||||||
? esc(email) + ' <button type="button" id="ptl-signout">Sign out</button>'
|
? esc(email) + ' <button type="button" id="ptl-signout">Sign out</button>'
|
||||||
: '<a class="link" href="/register.html">Sign in</a>')
|
: '<a class="link" href="' + Site.signIn() + '">Sign in</a>')
|
||||||
+ '</div></div></nav>';
|
+ '</div></div></nav>';
|
||||||
if (old) old.outerHTML = html;
|
if (old) old.outerHTML = html;
|
||||||
else document.body.insertAdjacentHTML("afterbegin", html);
|
else document.body.insertAdjacentHTML("afterbegin", html);
|
||||||
|
|
@ -110,29 +164,36 @@ function navBar(current) {
|
||||||
if (b) b.onclick = async function () {
|
if (b) b.onclick = async function () {
|
||||||
await api("sign_out", {});
|
await api("sign_out", {});
|
||||||
Session.clear();
|
Session.clear();
|
||||||
location.href = "/register.html";
|
location.href = Site.signIn();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
paint(items);
|
paint(items);
|
||||||
if (Session.token()) {
|
// The server says what this visitor's menu is - signed in or not - and what
|
||||||
api("whoami", {}).then(function (r) {
|
// this front door looks like. api() stores the site settings as they arrive.
|
||||||
|
api(Session.token() ? "whoami" : "site_config", {}).then(function (r) {
|
||||||
if (r && r.ok && r.menu) {
|
if (r && r.ok && r.menu) {
|
||||||
try { localStorage.setItem(Session.menuKey, JSON.stringify(r.menu)); } catch (e) {}
|
try { localStorage.setItem(Session.menuKey, JSON.stringify(r.menu)); } catch (e) {}
|
||||||
paint(r.menu);
|
paint(r.menu);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireSession() {
|
function requireSession() {
|
||||||
if (!Session.token()) {
|
if (!Session.token()) {
|
||||||
location.href = "/register.html?next=" + encodeURIComponent(location.pathname);
|
location.href = Site.signIn() + "?next=" + encodeURIComponent(location.pathname + location.search);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return Session.get();
|
return Session.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only ever a path on this site: a `next` from the address bar is not trusted
|
||||||
|
// to be one.
|
||||||
|
function safeNext(next, fallback) {
|
||||||
|
return (typeof next === "string" && /^\/[A-Za-z0-9_\-\/.]*(\?[^#]*)?$/.test(next) && next.indexOf("//") < 0)
|
||||||
|
? next : (fallback || "/");
|
||||||
|
}
|
||||||
|
|
||||||
function showMsg(id, text, kind) {
|
function showMsg(id, text, kind) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
|
||||||
28
assets/site-book.css
Normal file
28
assets/site-book.css
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/* The "Book to Film" front door: the same pages, a different look.
|
||||||
|
Loaded on top of app.css when the site settings name the theme "book"
|
||||||
|
(assets/app.js, Site.apply). Everything in app.css is drawn from these
|
||||||
|
variables, so a look is mostly a palette and a typeface. */
|
||||||
|
:root{
|
||||||
|
--bg:#f6f1e7; --panel:#fffdf8; --line:#ddd3c0;
|
||||||
|
--ink:#231f1a; --muted:#6f6657; --accent:#8a2f1c; --accent2:#a8761b;
|
||||||
|
--bad:#b4531f; --good:#2f7d4f;
|
||||||
|
}
|
||||||
|
body{font-family:Georgia,"Iowan Old Style","Times New Roman",serif;font-size:17px}
|
||||||
|
nav{background:#231f1a;border-bottom:0;margin-bottom:0}
|
||||||
|
nav .brand{color:#f6f1e7;font-family:Georgia,serif;letter-spacing:.01em}
|
||||||
|
nav .brand span{color:#e0a458}
|
||||||
|
nav a.link{color:#c9bfae}
|
||||||
|
nav a.link:hover,nav a.link.on{color:#fff}
|
||||||
|
nav .who,nav .who button{color:#c9bfae}
|
||||||
|
header.page h1{font-family:Georgia,serif;font-weight:600;letter-spacing:0}
|
||||||
|
h2.sec{color:var(--accent)}
|
||||||
|
input[type=text],input[type=email],input[type=url],textarea,select{background:#fff;color:var(--ink)}
|
||||||
|
.btn{background:var(--accent);border-radius:4px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||||
|
.btn.ghost{color:var(--ink)}
|
||||||
|
.card,.item,.ex{border-radius:6px;box-shadow:0 1px 0 rgba(35,31,26,.06)}
|
||||||
|
.msg.err{background:#fbe9e2;border-color:#e6b9a8;color:#7a2c14}
|
||||||
|
.msg.ok{background:#e6f3ea;border-color:#b5d8c1;color:#1f5a38}
|
||||||
|
.chip,.quote{background:#fff}
|
||||||
|
.chip.on{background:#f3e7df}
|
||||||
|
.item .ph{background:#fff}
|
||||||
|
footer{color:var(--muted)}
|
||||||
84
demo.html
Normal file
84
demo.html
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Examples</title>
|
||||||
|
<meta name="description" content="Videos people have made here, each with the script it was filmed from.">
|
||||||
|
<link rel="stylesheet" href="/assets/app.css">
|
||||||
|
<style>
|
||||||
|
.ex{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:18px;
|
||||||
|
display:grid;grid-template-columns:minmax(200px,320px) 1fr;gap:24px;align-items:start}
|
||||||
|
@media(max-width:700px){.ex{grid-template-columns:1fr}}
|
||||||
|
.ex video{width:100%;border-radius:10px;background:#000;border:1px solid var(--line);max-height:560px}
|
||||||
|
.ex h3{margin:0 0 4px;font-size:20px}
|
||||||
|
.ex .kind{font:600 11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-bottom:10px}
|
||||||
|
.ex .about{color:var(--muted);margin:0 0 12px}
|
||||||
|
.ex ol{margin:0;padding-left:22px}
|
||||||
|
.ex ol li{margin-bottom:8px}
|
||||||
|
.ex details summary{cursor:pointer;color:var(--accent);font-size:14.5px;margin-bottom:10px}
|
||||||
|
.cta{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="page">
|
||||||
|
<h1 id="h1">See what it makes</h1>
|
||||||
|
<p id="lead">Each of these was written, voiced and filmed here. The script is beside the video it became.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
<div id="list"><p style="color:var(--muted)">Loading the examples…</p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="ctawrap" style="display:none">
|
||||||
|
<div class="card cta">
|
||||||
|
<div style="flex:1;min-width:240px">
|
||||||
|
<b>Want to make one?</b><br>
|
||||||
|
<span style="color:var(--muted)">We are letting people in a few at a time.</span>
|
||||||
|
</div>
|
||||||
|
<a class="btn" href="/join.html" style="text-decoration:none">Join the waiting list</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer id="foot"></footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
<script>
|
||||||
|
navBar("demo");
|
||||||
|
|
||||||
|
function words(site) {
|
||||||
|
if (site && site.key === "book") {
|
||||||
|
document.getElementById("h1").textContent = "A book, on screen";
|
||||||
|
document.getElementById("lead").textContent = "Scenes filmed from a manuscript: the screenplay lines are beside the film they became.";
|
||||||
|
}
|
||||||
|
document.getElementById("foot").textContent = ((site && site.name) || "PayToLaugh") + (site && site.tagline ? " · " + site.tagline : "");
|
||||||
|
}
|
||||||
|
words(Site.get());
|
||||||
|
|
||||||
|
(async function () {
|
||||||
|
var r = await api("demo_list", {});
|
||||||
|
words(Site.get());
|
||||||
|
var box = document.getElementById("list");
|
||||||
|
if (!r || !r.ok) { box.innerHTML = ""; showMsg("msg", (r && r.error) || "The examples could not be loaded."); return; }
|
||||||
|
if (!Session.token()) document.getElementById("ctawrap").style.display = "block";
|
||||||
|
if (!r.examples.length) { box.innerHTML = '<p style="color:var(--muted)">Examples are on their way.</p>'; return; }
|
||||||
|
box.innerHTML = r.examples.map(function (x) {
|
||||||
|
return '<div class="ex"><div><video controls preload="metadata" playsinline'
|
||||||
|
+ (x.poster ? ' poster="' + esc(x.poster) + '"' : '') + ' src="' + esc(x.video) + '"></video></div>'
|
||||||
|
+ '<div><div class="kind">' + (x.kind === "book" ? "from a book" : "a pet with opinions") + '</div>'
|
||||||
|
+ '<h3>' + esc(x.title) + '</h3>'
|
||||||
|
+ (x.note ? '<p class="about">' + esc(x.note) + '</p>' : '')
|
||||||
|
+ (x.script.length
|
||||||
|
? '<details' + (x.script.length <= 6 ? ' open' : '') + '><summary>The script, ' + x.script.length + (x.script.length === 1 ? ' line' : ' lines') + '</summary><ol>'
|
||||||
|
+ x.script.map(function (l) { return '<li>' + esc(l) + '</li>'; }).join("") + '</ol></details>'
|
||||||
|
: '')
|
||||||
|
+ '</div></div>';
|
||||||
|
}).join("");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
index.html
16
index.html
|
|
@ -9,6 +9,15 @@ try {
|
||||||
var __m = location.pathname.match(/^\/v\/([0-9a-fA-F]{6,12})\/?$/);
|
var __m = location.pathname.match(/^\/v\/([0-9a-fA-F]{6,12})\/?$/);
|
||||||
if (__m) location.replace("/v/index.html?c=" + __m[1] + location.hash);
|
if (__m) location.replace("/v/index.html?c=" + __m[1] + location.hash);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
/* Each front door has its own first page (assets/app.js, Site). When the
|
||||||
|
settings remembered for this hostname name a different one, go there before
|
||||||
|
anything is painted; a first-ever visit is sent on by the script at the foot
|
||||||
|
of this page once the settings arrive. */
|
||||||
|
try {
|
||||||
|
var __s = JSON.parse(localStorage.getItem("ptl_site:" + location.hostname.toLowerCase()) || "null");
|
||||||
|
if (__s && __s.home && __s.home !== "/" && location.pathname === "/"
|
||||||
|
&& !/[?&]site=/.test(location.search)) location.replace(__s.home);
|
||||||
|
} catch (e) {}
|
||||||
</script>
|
</script>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
@ -216,5 +225,12 @@ try {
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
<script>
|
||||||
|
// Learn this front door's settings, and go to its first page if it is not this one.
|
||||||
|
api("site_config", {}).then(function (r) {
|
||||||
|
if (r && r.ok && r.site && r.site.home && r.site.home !== "/" && location.pathname === "/") location.replace(r.site.home);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
103
join.html
Normal file
103
join.html
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Join the waiting list</title>
|
||||||
|
<meta name="description" content="We are letting people in a few at a time. Leave your name, email and cell phone and we will send you an invitation.">
|
||||||
|
<link rel="stylesheet" href="/assets/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="page">
|
||||||
|
<h1>Join the waiting list</h1>
|
||||||
|
<p>We are letting people in a few at a time. Leave your details and we will email you an
|
||||||
|
invitation when your place comes up. <a href="/demo.html">See what people have made</a>.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section style="max-width:560px">
|
||||||
|
<div class="card" id="formcard">
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
<form id="form" novalidate>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label for="first">First name</label>
|
||||||
|
<input type="text" id="first" autocomplete="given-name" maxlength="60" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="last">Last name</label>
|
||||||
|
<input type="text" id="last" autocomplete="family-name" maxlength="60" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email address</label>
|
||||||
|
<span class="hint">Your invitation is sent here, and it is how you sign in.</span>
|
||||||
|
<input type="email" id="email" autocomplete="email" placeholder="you@example.com" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="phone">Cell phone (United States)</label>
|
||||||
|
<span class="hint">When you sign in we text a short code to this number. It has to be a US cell phone that can receive texts.</span>
|
||||||
|
<input type="text" id="phone" inputmode="tel" autocomplete="tel-national" placeholder="(555) 555-0142" maxlength="20" required>
|
||||||
|
</div>
|
||||||
|
<!-- Not for people. Left empty by anyone filling this in by hand. -->
|
||||||
|
<div style="position:absolute;left:-6000px" aria-hidden="true">
|
||||||
|
<label for="company">Company</label>
|
||||||
|
<input type="text" id="company" tabindex="-1" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="go" type="submit">Put me on the list</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="done" style="display:none">
|
||||||
|
<h2 style="margin:0 0 10px;font-size:22px">You are on the list.</h2>
|
||||||
|
<p style="margin:0;color:var(--muted)">We will email <b id="doneEmail"></b> when your place comes up.
|
||||||
|
The email has a link; open it and we text a code to your phone to finish signing in.
|
||||||
|
There is nothing else to do for now.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note" style="margin-top:22px">
|
||||||
|
We use your phone number only to text sign-in codes, and your email only for your
|
||||||
|
invitation and your account. Message and data rates may apply to the texts.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer id="foot"></footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
<script>
|
||||||
|
navBar("join");
|
||||||
|
(function () { var s = Site.get() || {}; document.getElementById("foot").textContent = (s.name || "PayToLaugh") + (s.tagline ? " · " + s.tagline : ""); })();
|
||||||
|
|
||||||
|
// Ten digits, shown the way people write them.
|
||||||
|
document.getElementById("phone").addEventListener("input", function () {
|
||||||
|
var d = this.value.replace(/\D/g, "");
|
||||||
|
if (d.length === 11 && d[0] === "1") d = d.slice(1);
|
||||||
|
d = d.slice(0, 10);
|
||||||
|
this.value = d.length > 6 ? "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6)
|
||||||
|
: d.length > 3 ? "(" + d.slice(0, 3) + ") " + d.slice(3) : d;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("form").addEventListener("submit", async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
hideMsg("msg");
|
||||||
|
var v = function (id) { return document.getElementById(id).value.trim(); };
|
||||||
|
var digits = v("phone").replace(/\D/g, "");
|
||||||
|
if (!v("first") || !v("last")) { showMsg("msg", "Please give your first and last name."); return; }
|
||||||
|
if (!/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(v("email"))) { showMsg("msg", "That email address does not look right."); return; }
|
||||||
|
if (digits.length !== 10) { showMsg("msg", "Please give a US cell phone number, ten digits with the area code."); return; }
|
||||||
|
|
||||||
|
var btn = document.getElementById("go");
|
||||||
|
btn.disabled = true; btn.textContent = "One moment…";
|
||||||
|
var r = await api("waitlist_join", { first_name: v("first"), last_name: v("last"), email: v("email"),
|
||||||
|
phone: v("phone"), company: v("company") });
|
||||||
|
btn.disabled = false; btn.textContent = "Put me on the list";
|
||||||
|
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "That did not work. Please try again."); return; }
|
||||||
|
document.getElementById("doneEmail").textContent = v("email");
|
||||||
|
document.getElementById("formcard").style.display = "none";
|
||||||
|
document.getElementById("done").style.display = "block";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
129
login.html
Normal file
129
login.html
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Sign in</title>
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<link rel="stylesheet" href="/assets/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="page">
|
||||||
|
<h1 id="h1">Sign in</h1>
|
||||||
|
<p id="lead">Enter your email address and we will text a code to the phone on your account.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section style="max-width:520px">
|
||||||
|
<div class="card">
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
|
||||||
|
<!-- Step 1, returning visitors only: which account. An invitation skips this. -->
|
||||||
|
<form id="who" style="display:none">
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email address</label>
|
||||||
|
<input type="email" id="email" autocomplete="email" placeholder="you@example.com" required>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="send" type="submit">Text me a code</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Step 2: the code from the text message. -->
|
||||||
|
<form id="code" style="display:none">
|
||||||
|
<div class="field">
|
||||||
|
<label for="digits">The code we texted you</label>
|
||||||
|
<span class="hint" id="sentto"></span>
|
||||||
|
<input type="text" id="digits" inputmode="numeric" autocomplete="one-time-code" maxlength="8"
|
||||||
|
placeholder="123456" style="font-size:22px;letter-spacing:.3em;max-width:220px" required>
|
||||||
|
</div>
|
||||||
|
<button class="btn" id="check" type="submit">Sign in</button>
|
||||||
|
<button class="btn ghost" id="again" type="button" style="margin-left:8px">Send a new code</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note" style="margin-top:22px" id="foot">
|
||||||
|
No invitation yet? <a href="/join.html">Join the waiting list</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
<script>
|
||||||
|
navBar("");
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
var invite = params.get("invite") || "";
|
||||||
|
var next = safeNext(params.get("next"), "");
|
||||||
|
var email = "";
|
||||||
|
|
||||||
|
// The invitation is a credential. Take it out of the address bar straight
|
||||||
|
// away so it is not left in the history or copied with the link.
|
||||||
|
if (invite && history.replaceState) history.replaceState(null, "", "/login.html");
|
||||||
|
|
||||||
|
function show(id) {
|
||||||
|
["who", "code"].forEach(function (f) { document.getElementById(f).style.display = f === id ? "block" : "none"; });
|
||||||
|
var first = document.querySelector("#" + id + " input");
|
||||||
|
if (first) first.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedIn(r) {
|
||||||
|
Session.start(r, r.user.email, r.user.display_name || "");
|
||||||
|
showMsg("msg", "You are in. Taking you through…", "ok");
|
||||||
|
var home = (r.site && r.site.home && r.site.home !== "/") ? r.site.home : "/create.html";
|
||||||
|
setTimeout(function () { location.href = next || home; }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCode() {
|
||||||
|
hideMsg("msg");
|
||||||
|
var r = invite ? await api("invite_open", { invite: invite }) : await api("login_start", { email: email });
|
||||||
|
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "That did not work. Please try again."); return false; }
|
||||||
|
if (invite) {
|
||||||
|
document.getElementById("h1").textContent = r.first_name ? "Welcome, " + r.first_name : "Welcome";
|
||||||
|
document.getElementById("lead").textContent = "One step to finish: we have texted a code to your phone.";
|
||||||
|
document.getElementById("sentto").textContent = "Sent to " + (r.phone_hint || "your phone") + ". It can take a minute to arrive.";
|
||||||
|
} else if (r.sms === false) {
|
||||||
|
showMsg("msg", "Text messages are not switched on yet, so codes cannot be sent. Please try again later.");
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
document.getElementById("sentto").textContent = "If that address has an account, a code is on its way to the phone on it.";
|
||||||
|
}
|
||||||
|
show("code");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("who").addEventListener("submit", async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
email = document.getElementById("email").value.trim();
|
||||||
|
if (!email) { showMsg("msg", "Please enter your email address."); return; }
|
||||||
|
var b = document.getElementById("send");
|
||||||
|
b.disabled = true;
|
||||||
|
await sendCode();
|
||||||
|
b.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("again").addEventListener("click", async function () {
|
||||||
|
this.disabled = true;
|
||||||
|
if (await sendCode()) showMsg("msg", "A new code is on its way.", "ok");
|
||||||
|
this.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("code").addEventListener("submit", async function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
hideMsg("msg");
|
||||||
|
var digits = document.getElementById("digits").value.replace(/\D/g, "");
|
||||||
|
if (digits.length < 4) { showMsg("msg", "Enter the code from the text message."); return; }
|
||||||
|
var b = document.getElementById("check");
|
||||||
|
b.disabled = true;
|
||||||
|
var r = invite ? await api("invite_verify", { invite: invite, code: digits })
|
||||||
|
: await api("login_verify", { email: email, code: digits });
|
||||||
|
b.disabled = false;
|
||||||
|
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "That code is not right."); return; }
|
||||||
|
signedIn(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Session.token() && !invite) location.replace(next || "/");
|
||||||
|
else if (invite) sendCode().then(function (ok) { if (!ok) document.getElementById("foot").style.display = "block"; });
|
||||||
|
else show("who");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
160
people.html
Normal file
160
people.html
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>People</title>
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<link rel="stylesheet" href="/assets/app.css">
|
||||||
|
<style>
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:14px}
|
||||||
|
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||||
|
th{font:600 11px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted)}
|
||||||
|
td .sub{color:var(--muted);font-size:12.5px}
|
||||||
|
.pill{display:inline-block;font:600 11px/1 var(--mono);padding:4px 8px;border-radius:5px;border:1px solid var(--line);color:var(--muted)}
|
||||||
|
.pill.joined{color:var(--good);border-color:var(--good)} .pill.invited{color:var(--accent2);border-color:var(--accent2)}
|
||||||
|
.linkbox{margin-top:8px;display:flex;gap:8px}
|
||||||
|
.linkbox input{font:12px var(--mono)}
|
||||||
|
.btn.small{padding:7px 12px;font-size:13px}
|
||||||
|
.state{display:flex;gap:18px;flex-wrap:wrap;color:var(--muted);font-size:14px;margin-bottom:6px}
|
||||||
|
.state b{color:var(--ink)}
|
||||||
|
.exrow{display:grid;grid-template-columns:150px 1fr;gap:16px;padding:14px 0;border-bottom:1px solid var(--line)}
|
||||||
|
.exrow video{width:100%;border-radius:8px;background:#000}
|
||||||
|
.exrow input[type=text]{margin-bottom:8px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header class="page">
|
||||||
|
<h1>People</h1>
|
||||||
|
<p>Who is waiting, who has been invited, who is in — and which finished videos are shown on the Examples page.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="msg" id="msg"></div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="sec">How people get in</h2>
|
||||||
|
<div class="card"><div class="state" id="state">Loading…</div>
|
||||||
|
<p style="color:var(--muted);margin:8px 0 0;font-size:14px" id="statenote"></p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="sec">The waiting list</h2>
|
||||||
|
<div class="card" style="overflow-x:auto"><table id="wl"></table></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="sec">Accounts</h2>
|
||||||
|
<div class="card" style="overflow-x:auto"><table id="users"></table></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="sec">Examples page</h2>
|
||||||
|
<div class="card" id="examples">Loading…</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/assets/app.js"></script>
|
||||||
|
<script>
|
||||||
|
navBar("people");
|
||||||
|
requireSession();
|
||||||
|
|
||||||
|
function day(s) { return s ? when(s) : ""; }
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
var r = await api("waitlist_list", {});
|
||||||
|
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "That could not be loaded."); return; }
|
||||||
|
document.getElementById("state").innerHTML =
|
||||||
|
'<span>Sign-in today: <b>' + (r.auth === "sms" ? "invitation and a texted code" : "an email address is enough (prototype rule)") + '</b></span>'
|
||||||
|
+ '<span>Text messages: <b>' + (r.sms ? "switched on" : "not switched on yet") + '</b></span>';
|
||||||
|
document.getElementById("statenote").textContent = r.sms
|
||||||
|
? "An invitation link texts a code to the person's phone and signs them in."
|
||||||
|
: "Until texting is switched on, an invitation link opens but cannot send its code. Invitations made now work as soon as it is.";
|
||||||
|
document.getElementById("wl").innerHTML =
|
||||||
|
'<tr><th>Who</th><th>Phone</th><th>Asked</th><th>Where they are</th><th></th></tr>'
|
||||||
|
+ (r.waitlist.length ? r.waitlist.slice().reverse().map(function (w) {
|
||||||
|
return '<tr><td><b>' + esc(w.first_name) + ' ' + esc(w.last_name) + '</b><div class="sub">' + esc(w.email) + '</div></td>'
|
||||||
|
+ '<td>' + esc(w.phone || "") + '</td>'
|
||||||
|
+ '<td>' + esc(day(w.created_at)) + '<div class="sub">' + esc(w.site || "") + '</div></td>'
|
||||||
|
+ '<td><span class="pill ' + esc(w.status) + '">' + esc(w.status) + '</span>'
|
||||||
|
+ (w.invited_at ? '<div class="sub">invited ' + esc(day(w.invited_at)) + (w.opened ? ', link opened' : ', not opened yet') + '</div>' : '')
|
||||||
|
+ (w.joined_at ? '<div class="sub">joined ' + esc(day(w.joined_at)) + '</div>' : '') + '</td>'
|
||||||
|
+ '<td>' + (w.status === "joined" ? '' : '<button type="button" class="btn small" data-invite="' + esc(w.id) + '">'
|
||||||
|
+ (w.invites ? 'New invitation link' : 'Invite') + '</button>') + '<div data-link="' + esc(w.id) + '"></div></td></tr>';
|
||||||
|
}).join("") : '<tr><td colspan="5" style="color:var(--muted)">Nobody has joined the list yet. The page people use is <a href="/join.html">/join.html</a>.</td></tr>');
|
||||||
|
document.getElementById("users").innerHTML =
|
||||||
|
'<tr><th>Account</th><th>Phone</th><th>Since</th><th>Video made</th></tr>'
|
||||||
|
+ r.users.map(function (u) {
|
||||||
|
return '<tr><td><b>' + esc(u.name || "") + '</b><div class="sub">' + esc(u.email) + '</div></td><td>'
|
||||||
|
+ (u.phone ? esc(u.phone) : '<span class="sub">none — cannot receive a code</span> '
|
||||||
|
+ '<button type="button" class="btn small ghost" data-phone="' + esc(u.email) + '">Add a phone</button>')
|
||||||
|
+ '</td><td>' + esc(day(u.created_at)) + '</td><td>' + esc(u.minutes) + ' min</td></tr>';
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExamples() {
|
||||||
|
var r = await api("demo_candidates", {});
|
||||||
|
var box = document.getElementById("examples");
|
||||||
|
if (!r || !r.ok) { box.textContent = (r && r.error) || "That could not be loaded."; return; }
|
||||||
|
box.innerHTML = '<p style="color:var(--muted);margin-top:0">Tick a finished video to show it on <a href="/demo.html">the Examples page</a>, '
|
||||||
|
+ 'with its script beside it. Anybody can see that page, signed in or not.</p>'
|
||||||
|
+ (r.projects.length ? r.projects.map(function (p) {
|
||||||
|
return '<div class="exrow"><video controls preload="none" src="' + esc(p.video) + '"></video><div>'
|
||||||
|
+ '<label><input type="checkbox" data-demo="' + esc(p.id) + '"' + (p.demo ? ' checked' : '') + '> Show on the Examples page</label> '
|
||||||
|
+ '<span class="sub" style="color:var(--muted);font-size:12.5px">' + esc(p.kind === "book" ? "from a book" : "pet video") + ' · ' + esc(day(p.created_at)) + ' · ' + esc(p.owner || "") + '</span>'
|
||||||
|
+ '<input type="text" maxlength="120" data-demotitle="' + esc(p.id) + '" value="' + esc(p.title || "") + '" placeholder="Title shown to visitors">'
|
||||||
|
+ '<input type="text" maxlength="400" data-demonote="' + esc(p.id) + '" value="' + esc(p.demo_note || "") + '" placeholder="One line about it (optional)">'
|
||||||
|
+ '<button type="button" class="btn small ghost" data-demosave="' + esc(p.id) + '">Save the words</button></div></div>';
|
||||||
|
}).join("") : '<p>No finished videos yet.</p>');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", async function (e) {
|
||||||
|
var b = e.target.closest && e.target.closest("button");
|
||||||
|
if (!b) return;
|
||||||
|
var id;
|
||||||
|
if ((id = b.getAttribute("data-invite"))) {
|
||||||
|
b.disabled = true;
|
||||||
|
var r = await api("waitlist_invite", { waitlist_id: id });
|
||||||
|
b.disabled = false;
|
||||||
|
if (!r || !r.ok) { showMsg("msg", (r && r.error) || "The invitation could not be made."); return; }
|
||||||
|
var slot = document.querySelector('[data-link="' + id + '"]');
|
||||||
|
slot.innerHTML = '<div class="linkbox"><input type="text" readonly value="' + esc(r.link) + '"><button type="button" class="btn small ghost" data-copy="1">Copy</button></div>'
|
||||||
|
+ '<div class="sub" style="color:var(--muted);font-size:12.5px;margin-top:4px">Send this link to ' + esc(r.first_name || r.email)
|
||||||
|
+ ' yourself for now — we do not send email yet. It works once, until ' + esc(day(r.expires_at)) + ', and is shown only this one time.</div>';
|
||||||
|
showMsg("msg", "Invitation made for " + r.email + ". Copy the link below their name and send it to them.", "ok");
|
||||||
|
} else if (b.getAttribute("data-copy")) {
|
||||||
|
var inp = b.parentElement.querySelector("input");
|
||||||
|
inp.select();
|
||||||
|
try { await navigator.clipboard.writeText(inp.value); b.textContent = "Copied"; } catch (x) { document.execCommand("copy"); b.textContent = "Copied"; }
|
||||||
|
} else if ((id = b.getAttribute("data-phone"))) {
|
||||||
|
var ph = prompt("US cell phone for " + id + " (ten digits). A sign-in code will be texted to it.");
|
||||||
|
if (!ph) return;
|
||||||
|
var r2 = await api("set_user_phone", { target_email: id, phone: ph });
|
||||||
|
if (!r2 || !r2.ok) { showMsg("msg", (r2 && r2.error) || "That phone could not be saved."); return; }
|
||||||
|
showMsg("msg", "Phone saved for " + id + ": " + r2.phone, "ok");
|
||||||
|
load();
|
||||||
|
} else if ((id = b.getAttribute("data-demosave"))) {
|
||||||
|
var r3 = await api("set_demo", { project_id: id,
|
||||||
|
demo: document.querySelector('[data-demo="' + id + '"]').checked,
|
||||||
|
demo_title: document.querySelector('[data-demotitle="' + id + '"]').value,
|
||||||
|
demo_note: document.querySelector('[data-demonote="' + id + '"]').value });
|
||||||
|
showMsg("msg", r3 && r3.ok ? "Saved." : ((r3 && r3.error) || "That could not be saved."), r3 && r3.ok ? "ok" : "err");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("change", async function (e) {
|
||||||
|
var id = e.target.getAttribute && e.target.getAttribute("data-demo");
|
||||||
|
if (!id) return;
|
||||||
|
var r = await api("set_demo", { project_id: id, demo: e.target.checked,
|
||||||
|
demo_title: document.querySelector('[data-demotitle="' + id + '"]').value,
|
||||||
|
demo_note: document.querySelector('[data-demonote="' + id + '"]').value });
|
||||||
|
if (!r || !r.ok) { e.target.checked = !e.target.checked; showMsg("msg", (r && r.error) || "That could not be saved."); return; }
|
||||||
|
showMsg("msg", e.target.checked ? "Now on the Examples page." : "Taken off the Examples page.", "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
load();
|
||||||
|
loadExamples();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -46,7 +46,9 @@
|
||||||
<script>
|
<script>
|
||||||
navBar("");
|
navBar("");
|
||||||
var params = new URLSearchParams(location.search);
|
var params = new URLSearchParams(location.search);
|
||||||
var next = params.get("next") || "/create.html";
|
var next = safeNext(params.get("next"), "/create.html");
|
||||||
|
// Once sign-in is by texted code, this page has nothing to offer.
|
||||||
|
if ((Site.get() || {}).auth === "sms") location.replace("/login.html" + location.search);
|
||||||
|
|
||||||
document.getElementById("form").addEventListener("submit", async function (e) {
|
document.getElementById("form").addEventListener("submit", async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -60,6 +62,7 @@ document.getElementById("form").addEventListener("submit", async function (e) {
|
||||||
var r = await api("register", { email: email, display_name: name });
|
var r = await api("register", { email: email, display_name: name });
|
||||||
btn.disabled = false; btn.textContent = "Continue";
|
btn.disabled = false; btn.textContent = "Continue";
|
||||||
|
|
||||||
|
if (r.sms_only) { location.replace("/login.html" + location.search); return; }
|
||||||
if (!r.ok) { showMsg("msg", r.error || "That did not work. Please try again."); return; }
|
if (!r.ok) { showMsg("msg", r.error || "That did not work. Please try again."); return; }
|
||||||
// The token is the credential from here on; the email is only for display.
|
// The token is the credential from here on; the email is only for display.
|
||||||
Session.start(r, r.user.email, r.user.display_name || name);
|
Session.start(r, r.user.email, r.user.display_name || name);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue