214 lines
8.3 KiB
JavaScript
214 lines
8.3 KiB
JavaScript
// Sign Up — invite-code gated. User pastes/types their invite, gives email +
|
|
// optional name, hits "Create my workspace." Magic-link delivery on submit.
|
|
// OAuth is offered too but the invite is still required.
|
|
|
|
function SignUp() {
|
|
const [code, setCode] = React.useState("");
|
|
const [email, setEmail] = React.useState("");
|
|
const [name, setName] = React.useState("");
|
|
const [submitting, setSubmitting] = React.useState(false);
|
|
const [created, setCreated] = React.useState(false);
|
|
const [codeState, setCodeState] = React.useState("idle"); // idle | checking | ok | bad
|
|
|
|
// Validate the invite code shape (V-XXXXXX, case-insensitive) and pretend to
|
|
// verify with a debounce so the UI feels alive even with no backend.
|
|
React.useEffect(() => {
|
|
const c = code.trim().toLowerCase();
|
|
if (!c) { setCodeState("idle"); return undefined; }
|
|
const looksValid = /^v-?[a-z0-9]{4,8}$/.test(c);
|
|
if (!looksValid) { setCodeState("bad"); return undefined; }
|
|
setCodeState("checking");
|
|
const t = setTimeout(() => setCodeState("ok"), 600);
|
|
return () => clearTimeout(t);
|
|
}, [code]);
|
|
|
|
const emailValid = /\S+@\S+\.\S+/.test(email);
|
|
const valid = emailValid && codeState === "ok";
|
|
|
|
const handleSubmit = (e) => {
|
|
e.preventDefault();
|
|
if (!valid || submitting) return;
|
|
setSubmitting(true);
|
|
setTimeout(() => {
|
|
setSubmitting(false);
|
|
setCreated(true);
|
|
}, 800);
|
|
};
|
|
|
|
return (
|
|
<div className="page">
|
|
<TopBar rightLink={{ href: "index.html", label: "Back to home" }} />
|
|
|
|
<main className="auth-main">
|
|
<Glows />
|
|
|
|
<div className="auth-card">
|
|
{created ? (
|
|
<CreatedConfirmation email={email} name={name} />
|
|
) : (
|
|
<>
|
|
<div className="auth-eye">You're invited</div>
|
|
<h1 className="auth-title">
|
|
Create your <em>workspace</em>.
|
|
</h1>
|
|
<p className="auth-sub">
|
|
Paste your invite code and the email it came to. We'll have you building in seconds.
|
|
</p>
|
|
|
|
<form className="auth-form" onSubmit={handleSubmit} noValidate>
|
|
<div className="auth-field">
|
|
<label className="auth-label" htmlFor="code">Invite code</label>
|
|
<div style={{ position: "relative" }}>
|
|
<input
|
|
id="code" type="text" autoComplete="off"
|
|
required autoFocus
|
|
className="auth-input mono"
|
|
placeholder="V-XXXXXX"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
maxLength={12}
|
|
style={{ paddingRight: 44 }}
|
|
/>
|
|
<CodeStatus state={codeState} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="auth-field">
|
|
<label className="auth-label" htmlFor="email">Email</label>
|
|
<input
|
|
id="email" type="email" autoComplete="email" required
|
|
className="auth-input"
|
|
placeholder="you@somewhere.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="auth-field">
|
|
<label className="auth-label" htmlFor="name">
|
|
What should we call you? <span style={{ color: "var(--fg-faint)", letterSpacing: 0, textTransform: "none" }}>(optional)</span>
|
|
</label>
|
|
<input
|
|
id="name" type="text" autoComplete="given-name"
|
|
className="auth-input"
|
|
placeholder="First name or handle"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<button type="submit" disabled={!valid || submitting}
|
|
className="auth-btn auth-btn-primary"
|
|
style={{ marginTop: 4 }}>
|
|
{submitting ? (
|
|
<><span className="auth-spinner" /> Creating your workspace…</>
|
|
) : (
|
|
<>Create my workspace <Arrow size={13} /></>
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
<div className="auth-divider">or continue with</div>
|
|
|
|
<div className="auth-oauth">
|
|
<button type="button" className="auth-btn auth-btn-ghost">
|
|
<GoogleIcon /> Continue with Google
|
|
</button>
|
|
<button type="button" className="auth-btn auth-btn-ghost">
|
|
<AppleIcon /> Continue with Apple
|
|
</button>
|
|
</div>
|
|
|
|
<p className="auth-fine">
|
|
By creating a workspace you agree to our <a href="#">Terms</a> and <a href="#">Privacy Policy</a>.
|
|
</p>
|
|
|
|
<div className="auth-foot">
|
|
Already have an account? <a href="Sign In.html">Sign in →</a>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<TrustStrip items={["No credit card", "No homework", "🇨🇦 Built in Canada"]} />
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CodeStatus({ state }) {
|
|
const wrap = {
|
|
position: "absolute", right: 14, top: "50%", transform: "translateY(-50%)",
|
|
display: "flex", alignItems: "center", gap: 6,
|
|
fontFamily: "var(--font-mono)", fontSize: 11, letterSpacing: "0.06em",
|
|
textTransform: "uppercase",
|
|
pointerEvents: "none",
|
|
};
|
|
if (state === "idle") return null;
|
|
if (state === "checking") return (
|
|
<span style={{ ...wrap, color: "var(--fg-mute)" }}>
|
|
<span className="auth-spinner" style={{ width: 12, height: 12, borderTopColor: "var(--fg-mute)" }} />
|
|
</span>
|
|
);
|
|
if (state === "bad") return (
|
|
<span style={{ ...wrap, color: "oklch(0.65 0.18 25)" }}>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
|
<circle cx="8" cy="8" r="6.5"/><path d="M5.5 5.5l5 5M10.5 5.5l-5 5"/>
|
|
</svg>
|
|
</span>
|
|
);
|
|
if (state === "ok") return (
|
|
<span style={{ ...wrap, color: "var(--ok)" }}>
|
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="m3 8.5 3.2 3.2L13 5"/>
|
|
</svg>
|
|
Valid
|
|
</span>
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// Confirmation — we've sent the magic link AND provisioned a workspace.
|
|
// Small celebratory beat: "Welcome, <name>" if given, else "You're in."
|
|
function CreatedConfirmation({ email, name }) {
|
|
return (
|
|
<div className="auth-success">
|
|
<div className="auth-success-badge">
|
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<circle cx="14" cy="14" r="13" opacity="0.25"/>
|
|
<path d="M8 14.5 12.5 19 21 10"/>
|
|
</svg>
|
|
</div>
|
|
<div className="auth-eye">Workspace ready</div>
|
|
<h1 className="auth-title" style={{ marginTop: 10 }}>
|
|
{name ? <>Welcome, <em>{name}</em>.</> : <>You're <em>in</em>.</>}
|
|
</h1>
|
|
<p className="auth-sub">
|
|
We sent a sign-in link to <span className="email-chip">{email}</span>.
|
|
Tap it on this device to step inside your workspace.
|
|
</p>
|
|
|
|
<div className="auth-tip">
|
|
<span className="auth-tip-icon">
|
|
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
|
<path d="M3.5 12 8 3l4.5 9"/><path d="M5 9h6"/>
|
|
</svg>
|
|
</span>
|
|
<span>
|
|
While you're waiting on the email — your workspace lives at{" "}
|
|
<b style={{ color: "var(--fg)", fontWeight: 500, fontFamily: "var(--font-mono)" }}>
|
|
{(name || email.split("@")[0] || "you").toLowerCase().replace(/[^a-z0-9-]/g, "")}.vibn.app
|
|
</b>
|
|
. We'll send you the keys.
|
|
</span>
|
|
</div>
|
|
|
|
<div className="auth-foot" style={{ marginTop: 24 }}>
|
|
Already opened the email? <a href="Sign In.html">Continue here →</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById("root")).render(<SignUp />);
|