metatroncubeswdev 075b79f6b2 Hosted Quorla: sign-up, Stripe billing, one workspace per agency
- Control centre (QUORLA_CONTROL=true on app.quorlasocial.com): public /signup → Stripe Checkout,
  verified Stripe webhook (paid, updated, cancelled, payment failed), Admin → Agencies
  (approve without Stripe, suspend, resume, billing portal, resend welcome, remove)
- deploy/provision-tenants.sh (cron): one container per agency with its own .env and data,
  nginx map for <agency>.quorlasocial.com, upgrades agencies after a rebuild, archives removed data
- New workspaces: first admin only via the emailed one-time setup link (SETUP_TOKEN)
- Meta system user token can be saved in Settings (encrypted) for workspaces that can't edit .env
- docs/multi-tenant.md: DNS, wildcard cert, nginx, sudoers, cron, Stripe setup; tests for the control centre

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:35:23 -04:00

73 lines
5.3 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Start your Quorla workspace</title>
<meta name="description" content="Create a hosted Quorla workspace for your agency.">
<link rel="icon" type="image/png" href="/brand/mark.png">
<style>
:root { --navy:#242142; --navy2:#3b3778; --coral:#fc6d45; --ink:#1f2024; --muted:#5d6070; --line:#e6e5ef; --soft:#f6f5fb; --bad:#b4372b; --bg:#fff; }
* { box-sizing:border-box } [hidden]{display:none!important}
body { margin:0; background:var(--soft); color:var(--ink); font:16px/1.55 Inter,"Segoe UI",system-ui,sans-serif; -webkit-font-smoothing:antialiased }
main { max-width: 520px; margin: 0 auto; padding: 40px 16px 64px }
.logo { height: 34px; display:block; margin-bottom: 26px }
h1 { font-size: 28px; line-height:1.2; margin:0 0 6px; color:var(--navy) }
p.sub { color:var(--muted); margin:0 0 22px }
form { background:var(--bg); border:1px solid var(--line); border-radius:14px; padding:22px }
label { display:block; font-size:13.5px; font-weight:600; margin:0 0 5px; color:var(--ink) }
.f { margin-bottom: 15px }
input { width:100%; font:inherit; padding:11px 12px; border:1px solid #d3d2e0; border-radius:8px; background:#fff; color:var(--ink) }
input:focus-visible, button:focus-visible { outline:2px solid var(--coral); outline-offset:2px }
.addr { display:flex; align-items:center; border:1px solid #d3d2e0; border-radius:8px; overflow:hidden }
.addr input { border:0; border-radius:0 } .addr span { padding:0 12px; color:var(--muted); white-space:nowrap; font-size:14px; background:var(--soft); align-self:stretch; display:flex; align-items:center }
.hint { font-size:12.5px; color:var(--muted); margin-top:4px }
button { width:100%; font:600 16px/1 inherit; font-family:inherit; min-height:48px; border:0; border-radius:10px; background:var(--navy); color:#fff; cursor:pointer; margin-top:6px }
button:hover { background:var(--navy2) } button[disabled]{ opacity:.6; cursor:default }
.err { color:var(--bad); font-size:14px; margin-top:10px }
.note { font-size:13px; color:var(--muted); margin-top:16px; text-align:center }
.hp { position:absolute; left:-9999px }
a { color:var(--navy2) }
</style>
</head>
<body>
<main>
<a href="https://quorlasocial.com"><img class="logo" src="/brand/logo.png" alt="Quorla"></a>
<h1>Start your Quorla workspace</h1>
<p class="sub">Your agency gets its own private workspace: your clients, your team, your data. Set up takes a few minutes.</p>
<p class="err" id="cancelled" hidden>Checkout was cancelled. Your details are below if you'd like to try again.</p>
<form id="f" novalidate>
<div class="f"><label for="agencyName">Agency name</label><input id="agencyName" name="agencyName" required autocomplete="organization"></div>
<div class="f"><label for="ownerName">Your name</label><input id="ownerName" name="ownerName" autocomplete="name"></div>
<div class="f"><label for="ownerEmail">Work email</label><input id="ownerEmail" name="ownerEmail" type="email" required autocomplete="email"><div class="hint">We send your setup link here.</div></div>
<div class="f">
<label for="slug">Workspace address</label>
<div class="addr"><input id="slug" name="slug" required pattern="[a-z][a-z0-9-]{2,30}" placeholder="youragency" autocapitalize="none" spellcheck="false"><span id="domain">.quorlasocial.com</span></div>
<div class="hint">Lowercase letters, numbers and dashes.</div>
</div>
<input class="hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true">
<button id="go" type="submit">Continue to payment</button>
<div class="err" id="err" role="alert"></div>
</form>
<p class="note">By continuing you agree to the <a href="https://quorlasocial.com/terms">Terms</a> and <a href="https://quorlasocial.com/privacy">Privacy Policy</a>. Questions? <a href="mailto:support@quorlasocial.com">support@quorlasocial.com</a></p>
</main>
<script>
const f = document.getElementById("f"), err = document.getElementById("err"), go = document.getElementById("go");
if (new URLSearchParams(location.search).get("cancelled")) document.getElementById("cancelled").hidden = false;
const slug = document.getElementById("slug"), agency = document.getElementById("agencyName");
agency.addEventListener("input", () => { if (!slug.dataset.touched) slug.value = agency.value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^[^a-z]+/, "").slice(0, 31); });
slug.addEventListener("input", () => { slug.dataset.touched = "1"; slug.value = slug.value.toLowerCase().replace(/[^a-z0-9-]/g, ""); });
f.addEventListener("submit", async (e) => {
e.preventDefault(); err.textContent = ""; go.disabled = true; go.textContent = "One moment…";
try {
const r = await fetch("/control/signup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(f))) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || "Something went wrong. Try again.");
if (j.next === "stripe" && j.url) location.href = j.url;
else location.href = `/signup/done?w=${encodeURIComponent(slug.value)}&manual=1`;
} catch (ex) { err.textContent = ex.message; go.disabled = false; go.textContent = "Continue to payment"; }
});
</script>
</body>
</html>