Skip to content

Instantly share code, notes, and snippets.

@SouravInsights
Created January 20, 2026 10:16
Show Gist options
  • Select an option

  • Save SouravInsights/f2da3b7cdc14e8b063dc4afbfb6d58e0 to your computer and use it in GitHub Desktop.

Select an option

Save SouravInsights/f2da3b7cdc14e8b063dc4afbfb6d58e0 to your computer and use it in GitHub Desktop.
The sign-in form of BeenThere
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { motion, useReducedMotion, AnimatePresence } from "framer-motion";
import { validateUsernameFormat } from "@/lib/utils/username-client";
import { debounce } from "lodash";
import { HiSparkles, HiArrowRight } from "react-icons/hi2";
import { HiMail } from "react-icons/hi";
type Props = {
helperTextColor?: string;
onSuccess?: (data: {
username: string;
displayUsername: string;
email: string;
position: number;
inviteCode: string;
}) => void;
inviteCode?: string;
};
type Step = "username" | "email";
type ValidationState = {
isValid: boolean;
isChecking: boolean;
error: undefined | string;
};
export function WaitlistClaimUsername({
helperTextColor,
onSuccess,
inviteCode,
}: Props) {
const [step, setStep] = useState<Step>("username");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [usernameValidation, setUsernameValidation] = useState<ValidationState>(
{
isValid: false,
isChecking: false,
error: undefined,
},
);
const [emailValidation, setEmailValidation] = useState<ValidationState>({
isValid: false,
isChecking: false,
error: undefined,
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const prefersReduced = useReducedMotion();
// Store the current username in a ref so our async validation
// can check if it's still relevant when it completes
const usernameRef = useRef(username);
usernameRef.current = username;
const abortControllerRef = useRef<AbortController | null>(null);
// Check username availability after the user stops typing
const debouncedCheck = useCallback(
debounce(async (name: string) => {
if (!name.trim()) return;
// Quick client-side format check first
const formatResult = validateUsernameFormat(name);
if (!formatResult.isValid) {
setUsernameValidation({
isValid: false,
isChecking: false,
error: formatResult.error,
});
return;
}
// Now check if it's actually available
setUsernameValidation((prev) => ({
...prev,
isChecking: true,
error: undefined,
}));
// Cancel any pending request from a previous keystroke
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
try {
const res = await fetch(
`/api/waitlist/check-username?username=${encodeURIComponent(name)}`,
{ signal },
);
const data = await res.json().catch(() => ({}));
// Make sure we're still checking the same username the user typed
if (usernameRef.current !== name) return;
if (res.ok && data.available) {
setUsernameValidation({
isValid: true,
isChecking: false,
error: undefined,
});
} else {
setUsernameValidation({
isValid: false,
isChecking: false,
error: data.error || "Username unavailable",
});
}
} catch (err: unknown) {
if ((err as Error)?.name === "AbortError") return;
if (usernameRef.current !== name) return;
setUsernameValidation({
isValid: false,
isChecking: false,
error: "Could not check username",
});
}
}, 500),
[],
);
// Trigger validation whenever the username changes
useEffect(() => {
// Clear the previous state while they're typing
setUsernameValidation((prev) => ({
...prev,
isValid: false,
error: undefined,
isChecking: false,
}));
if (!username.trim()) {
debouncedCheck.cancel();
return;
}
debouncedCheck(username);
return () => {
debouncedCheck.cancel();
};
}, [username, debouncedCheck]);
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setUsername(e.target.value);
};
const handleUsernameBlur = () => {
setIsFocused(false);
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value);
// Don't show "invalid email" while they're still typing
// We don't want to show "Invalid Email" while they are typing "sourav@"
setEmailValidation({
isValid: false,
isChecking: false,
error: undefined,
});
};
const handleEmailBlur = () => {
setIsFocused(false);
if (!email.trim()) {
return;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
setEmailValidation({
isValid: false,
isChecking: false,
error: "Please enter a valid email address",
});
} else {
setEmailValidation({
isValid: true,
isChecking: false,
error: undefined,
});
}
};
const handleUsernameSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// If they hit enter while we're still checking, finish the check first
debouncedCheck.flush();
if (!usernameValidation.isValid || usernameValidation.isChecking) return;
setStep("email");
};
const handleFinalSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate email if they submitted without blurring the field first
if (!emailValidation.isValid && !emailValidation.error) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email.trim()) {
return;
}
if (!emailRegex.test(email)) {
setEmailValidation({
isValid: false,
isChecking: false,
error: "Please enter a valid email address",
});
return;
}
setEmailValidation({
isValid: true,
isChecking: false,
error: undefined,
});
} else if (!emailValidation.isValid) {
return;
}
if (isSubmitting) return;
setIsSubmitting(true);
setEmailValidation((prev) => ({
...prev,
error: undefined,
isChecking: true,
}));
try {
const res = await fetch("/api/waitlist/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: username.trim(),
email: email.trim(),
inviteCode: inviteCode || undefined,
}),
});
const result = await res.json();
if (res.ok && result.success) {
onSuccess?.(result.data);
// Redirect to public profile page with an URL parameter
window.location.href = `/${result.data.username}?claimed=true`;
} else {
setEmailValidation({
isValid: false,
isChecking: false,
error: result.error || "Failed to join waitlist",
});
}
} catch (err) {
console.error("Network error:", err);
setEmailValidation({
isValid: false,
isChecking: false,
error: "Something went wrong. Please try again.",
});
} finally {
setIsSubmitting(false);
}
};
const handleBackToUsername = () => {
setStep("username");
setEmailValidation({ isValid: false, isChecking: false, error: undefined });
};
const isUsernameButtonEnabled =
usernameValidation.isValid && !usernameValidation.isChecking;
const isEmailButtonEnabled = emailValidation.isValid && !isSubmitting;
// Shared button styles
const enabledStyle: React.CSSProperties = {
background: "linear-gradient(180deg, #A9DAB0 0%, #2D6C2F 100%)",
borderRadius: 16,
fontSize: 16,
fontFamily: "var(--font-lora)",
fontWeight: 600,
color: "#fff",
border: "none",
minWidth: 120,
height: 62,
boxShadow: `
inset 0 -16px 0 rgba(45, 108, 47, 0.16),
inset 0 -16px 0 rgba(45, 108, 47, 0.16),
0 4px 4px rgba(8, 8, 8, 0.24),
0 0 12px rgba(8, 8, 8, 0.24)
`,
};
const disabledStyle: React.CSSProperties = {
background: "linear-gradient(180deg, #E6EFE6 0%, #D0D9CF 100%)",
borderRadius: 16,
fontSize: 16,
fontFamily: "var(--font-lora)",
fontWeight: 600,
boxShadow: "none",
minWidth: 120,
height: 62,
border: "1px solid rgba(0,0,0,0.06)",
color: "#6B7280",
};
const mobileEnabledStyle: React.CSSProperties = {
...enabledStyle,
height: 52,
minWidth: undefined,
};
const mobileDisabledStyle: React.CSSProperties = {
...disabledStyle,
height: 52,
minWidth: undefined,
};
const iconShadowStyle: React.CSSProperties = {
filter: "drop-shadow(0px 2px 2px #00000066)",
};
const labelShadowStyle: React.CSSProperties = {
textShadow: "0px 2px 2px #00000066",
};
return (
<motion.div
initial={prefersReduced ? undefined : { opacity: 0, y: 8 }}
animate={prefersReduced ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.45, ease: "easeOut" }}
className="w-full max-w-xl mx-auto px-4"
>
{/* Desktop / Tablet view */}
<div className="hidden sm:block">
<AnimatePresence mode="wait">
{step === "username" && (
<motion.form
key="username-form"
initial={prefersReduced ? undefined : { opacity: 0, x: -20 }}
animate={prefersReduced ? undefined : { opacity: 1, x: 0 }}
exit={prefersReduced ? undefined : { opacity: 0, x: -20 }}
transition={{ duration: 0.3, ease: "easeOut" }}
onSubmit={handleUsernameSubmit}
noValidate
className="space-y-4"
>
<div
className={`flex items-center bg-white border overflow-hidden transition-all duration-200 ${
isFocused
? "ring-4 ring-emerald-500/20 border-emerald-500"
: "border-gray-200"
}`}
style={{
borderRadius: 20,
boxShadow: isFocused
? "0px 4px 20px 0px rgba(16, 185, 129, 0.2)"
: "0px 4px 16px 0px #0000003D",
}}
>
<label
htmlFor="username-input-desktop"
className="pl-6 py-4 text-black text-xl font-medium flex-shrink-0 cursor-text"
>
beenthere.page/
</label>
<input
id="username-input-desktop"
type="text"
value={username}
onChange={handleUsernameChange}
onFocus={() => setIsFocused(true)}
onBlur={handleUsernameBlur}
placeholder="your-name"
maxLength={20}
required
autoComplete="username"
spellCheck={false}
autoFocus
aria-invalid={!!usernameValidation.error}
aria-errormessage={
usernameValidation.error
? "username-error-desktop"
: undefined
}
className="flex-1 p-2 text-black placeholder:text-gray-400 bg-transparent font-medium min-w-0"
style={{
fontSize: "20px",
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
fontWeight: 500,
outline: "none",
}}
/>
<div className="px-3 flex-shrink-0">
{usernameValidation.isChecking ? (
<div className="w-3 h-3 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
) : username && usernameValidation.isValid ? (
<div className="w-2.5 h-2.5 bg-emerald-500 rounded-full shadow-sm" />
) : username && usernameValidation.error ? (
<div className="w-2.5 h-2.5 bg-red-500 rounded-full shadow-sm" />
) : null}
</div>
<div className="p-2 flex-shrink-0">
<button
type="submit"
disabled={!isUsernameButtonEnabled}
className={`px-8 py-3 text-white font-semibold transition-all duration-200 flex items-center justify-center gap-2 ${
isUsernameButtonEnabled
? "hover:brightness-110 active:brightness-95"
: "cursor-not-allowed"
}`}
style={
isUsernameButtonEnabled ? enabledStyle : disabledStyle
}
>
<span
style={
isUsernameButtonEnabled ? labelShadowStyle : undefined
}
>
Next
</span>
<HiArrowRight
className="w-4 h-4 flex-shrink-0"
style={
isUsernameButtonEnabled ? iconShadowStyle : undefined
}
/>
</button>
</div>
</div>
</motion.form>
)}
{step === "email" && (
<motion.form
key="email-form"
initial={prefersReduced ? undefined : { opacity: 0, x: 20 }}
animate={prefersReduced ? undefined : { opacity: 1, x: 0 }}
exit={prefersReduced ? undefined : { opacity: 0, x: 20 }}
transition={{ duration: 0.3, ease: "easeOut" }}
onSubmit={handleFinalSubmit}
noValidate
className="space-y-4"
>
{/* Show what username they're claiming */}
<div className="text-center mb-4">
<p
className="text-sm text-gray-600 mb-1"
style={{ fontFamily: "var(--font-inter)" }}
>
Claiming username:
</p>
<div
className="inline-flex items-center bg-gray-50 px-3 py-1.5 rounded-lg border border-gray-100"
style={{ fontFamily: "var(--font-inter)" }}
>
<span className="text-gray-600 text-sm font-medium">
beenthere.page/
</span>
<span className="text-black text-sm font-semibold">
{username}
</span>
</div>
</div>
<div
className={`flex items-center bg-white border overflow-hidden transition-all duration-200 ${
isFocused
? "ring-4 ring-emerald-500/20 border-emerald-500"
: "border-gray-200"
}`}
style={{
borderRadius: 20,
boxShadow: isFocused
? "0px 4px 20px 0px rgba(16, 185, 129, 0.2)"
: "0px 4px 16px 0px #0000003D",
}}
>
<button
type="button"
onClick={handleBackToUsername}
className="pl-4 py-4 text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0"
aria-label="Back to username"
>
<HiArrowRight className="w-4 h-4 rotate-180" />
</button>
<div className="px-3 py-4 flex items-center gap-3 flex-shrink-0">
<HiMail className="w-5 h-5 text-gray-400" />
<label
htmlFor="email-input-desktop"
className="text-gray-600 text-lg font-medium cursor-text"
>
Email:
</label>
</div>
<input
id="email-input-desktop"
type="email"
value={email}
onChange={handleEmailChange}
onFocus={() => setIsFocused(true)}
onBlur={handleEmailBlur}
placeholder="your@email.com"
required
autoComplete="email"
autoFocus
aria-invalid={!!emailValidation.error}
aria-errormessage={
emailValidation.error ? "email-error-desktop" : undefined
}
className="flex-1 p-2 text-black placeholder:text-gray-400 bg-transparent font-medium min-w-0"
style={{
fontSize: "20px",
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
fontWeight: 500,
outline: "none",
}}
/>
<div className="px-3 flex-shrink-0">
{emailValidation.isChecking ? (
<div className="w-3 h-3 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
) : email && emailValidation.isValid ? (
<div className="w-2.5 h-2.5 bg-emerald-500 rounded-full shadow-sm" />
) : email && emailValidation.error ? (
<div className="w-2.5 h-2.5 bg-red-500 rounded-full shadow-sm" />
) : null}
</div>
<div className="p-2 flex-shrink-0">
<button
type="submit"
disabled={!isEmailButtonEnabled}
className={`px-8 py-3 text-white font-semibold transition-all duration-200 flex items-center justify-center gap-2 ${
isEmailButtonEnabled
? "hover:brightness-110 active:brightness-95"
: "cursor-not-allowed"
}`}
style={isEmailButtonEnabled ? enabledStyle : disabledStyle}
>
<HiSparkles
className="w-4 h-4 flex-shrink-0"
style={isEmailButtonEnabled ? iconShadowStyle : undefined}
/>
<span
style={
isEmailButtonEnabled ? labelShadowStyle : undefined
}
>
{isSubmitting ? "Joining..." : "Claim"}
</span>
</button>
</div>
</div>
</motion.form>
)}
</AnimatePresence>
</div>
{/* Mobile view */}
<div className="block sm:hidden">
<AnimatePresence mode="wait">
{step === "username" && (
<motion.form
key="username-form-mobile"
initial={prefersReduced ? undefined : { opacity: 0, x: -20 }}
animate={prefersReduced ? undefined : { opacity: 1, x: 0 }}
exit={prefersReduced ? undefined : { opacity: 0, x: -20 }}
transition={{ duration: 0.3, ease: "easeOut" }}
onSubmit={handleUsernameSubmit}
noValidate
className="space-y-3"
>
<div
className={`flex items-center bg-white/90 border transition-all duration-200 ${
isFocused
? "ring-4 ring-emerald-500/20 border-emerald-500"
: "border-[rgba(255,255,255,0.12)]"
}`}
style={{
borderRadius: 16,
boxShadow: isFocused
? "0px 4px 20px 0px rgba(16, 185, 129, 0.2)"
: "0px 4px 16px 0px #0000003D",
}}
>
<label
htmlFor="username-input-mobile"
className="pl-4 py-4 text-black text-sm font-medium flex-shrink-0"
>
beenthere.page/
</label>
<input
id="username-input-mobile"
type="text"
value={username}
onChange={handleUsernameChange}
onFocus={() => setIsFocused(true)}
onBlur={handleUsernameBlur}
placeholder="your-name"
maxLength={20}
required
autoComplete="username"
spellCheck={false}
autoFocus
aria-invalid={!!usernameValidation.error}
aria-errormessage={
usernameValidation.error
? "username-error-mobile"
: undefined
}
className="flex-1 p-2 text-black placeholder:text-gray-400 bg-transparent focus:outline-none min-w-0"
style={{
fontSize: "14px",
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
fontWeight: 500,
}}
/>
<div className="pl-1 pr-4 flex-shrink-0">
{usernameValidation.isChecking ? (
<div className="w-3 h-3 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
) : username && usernameValidation.isValid ? (
<div className="w-2.5 h-2.5 bg-emerald-500 rounded-full shadow-sm" />
) : username && usernameValidation.error ? (
<div className="w-2.5 h-2.5 bg-red-500 rounded-full shadow-sm" />
) : null}
</div>
</div>
<button
type="submit"
disabled={!isUsernameButtonEnabled}
className={`w-full px-6 py-4 font-semibold transition-all duration-200 flex items-center justify-center gap-2 ${
isUsernameButtonEnabled
? "hover:brightness-110 active:brightness-95"
: "cursor-not-allowed"
}`}
style={
isUsernameButtonEnabled
? mobileEnabledStyle
: mobileDisabledStyle
}
>
<HiArrowRight
className="w-4 h-4 flex-shrink-0"
style={isUsernameButtonEnabled ? iconShadowStyle : undefined}
/>
<span
style={isUsernameButtonEnabled ? labelShadowStyle : undefined}
>
Next
</span>
</button>
</motion.form>
)}
{step === "email" && (
<motion.form
key="email-form-mobile"
initial={prefersReduced ? undefined : { opacity: 0, x: 20 }}
animate={prefersReduced ? undefined : { opacity: 1, x: 0 }}
exit={prefersReduced ? undefined : { opacity: 0, x: 20 }}
transition={{ duration: 0.3, ease: "easeOut" }}
onSubmit={handleFinalSubmit}
noValidate
className="space-y-3"
>
<button
type="button"
onClick={handleBackToUsername}
className="text-sm text-gray-400 hover:text-gray-600 transition-colors mb-2"
style={{ fontFamily: "var(--font-inter)" }}
>
← beenthere.page/
<span className="text-black font-medium">{username}</span>
</button>
<div
className={`flex items-center bg-white/90 border transition-all duration-200 ${
isFocused
? "ring-4 ring-emerald-500/20 border-emerald-500"
: "border-[rgba(255,255,255,0.12)]"
}`}
style={{
borderRadius: 16,
boxShadow: isFocused
? "0px 4px 20px 0px rgba(16, 185, 129, 0.2)"
: "0px 4px 16px 0px #0000003D",
}}
>
<HiMail className="ml-4 w-4 h-4 text-gray-400 flex-shrink-0" />
<input
id="email-input-mobile"
aria-label="Enter email address"
type="email"
value={email}
onChange={handleEmailChange}
onFocus={() => setIsFocused(true)}
onBlur={handleEmailBlur}
placeholder="your@email.com"
required
autoComplete="email"
autoFocus
aria-invalid={!!emailValidation.error}
aria-errormessage={
emailValidation.error ? "email-error-mobile" : undefined
}
className="flex-1 py-4 ml-2 text-black placeholder:text-gray-400 bg-transparent focus:outline-none min-w-0"
style={{
fontSize: "14px",
fontFamily: "Inter, system-ui, -apple-system, sans-serif",
fontWeight: 500,
}}
/>
<div className="pl-1 pr-4 flex-shrink-0">
{emailValidation.isChecking ? (
<div className="w-3 h-3 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
) : email && emailValidation.isValid ? (
<div className="w-2.5 h-2.5 bg-emerald-500 rounded-full shadow-sm" />
) : email && emailValidation.error ? (
<div className="w-2.5 h-2.5 bg-red-500 rounded-full shadow-sm" />
) : null}
</div>
</div>
<button
type="submit"
disabled={!isEmailButtonEnabled}
className={`w-full px-6 py-4 font-semibold transition-all duration-200 flex items-center justify-center gap-2 ${
isEmailButtonEnabled
? "hover:brightness-110 active:brightness-95"
: "cursor-not-allowed"
}`}
style={
isEmailButtonEnabled
? mobileEnabledStyle
: mobileDisabledStyle
}
>
<HiSparkles
className="w-4 h-4 flex-shrink-0"
style={isEmailButtonEnabled ? iconShadowStyle : undefined}
/>
<span
style={isEmailButtonEnabled ? labelShadowStyle : undefined}
>
{isSubmitting ? "Joining..." : "Claim"}
</span>
</button>
</motion.form>
)}
</AnimatePresence>
</div>
{/* Validation feedback */}
<div className="text-center min-h-[1.25rem] mt-4">
<div aria-live="polite" className="inline-block">
{step === "username" && (
<>
{usernameValidation.error ? (
<motion.div
id={
typeof window !== "undefined" && window.innerWidth < 640
? "username-error-mobile"
: "username-error-desktop"
}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-sm text-red-200 bg-red-900/20 px-3 py-1.5 rounded-md backdrop-blur-sm border border-red-300/20"
style={{ fontFamily: "var(--font-inter)" }}
>
{usernameValidation.error}
</motion.div>
) : username && usernameValidation.isValid ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-sm text-emerald-200 bg-emerald-900/20 px-3 py-1.5 rounded-md backdrop-blur-sm border border-emerald-300/20"
style={{ fontFamily: "var(--font-inter)" }}
>
Available ✓
</motion.div>
) : (
<div
className="text-sm"
style={{
fontFamily: "var(--font-inter)",
color: helperTextColor,
}}
>
3-20 characters, letters and hyphens only
</div>
)}
</>
)}
{step === "email" && (
<>
{emailValidation.error ? (
<motion.div
id={
typeof window !== "undefined" && window.innerWidth < 640
? "email-error-mobile"
: "email-error-desktop"
}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-sm text-red-200 bg-red-900/20 px-3 py-1.5 rounded-md backdrop-blur-sm border border-red-300/20"
style={{ fontFamily: "var(--font-inter)" }}
>
{emailValidation.error}
</motion.div>
) : email && emailValidation.isValid ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-sm text-emerald-200 bg-emerald-900/20 px-3 py-1.5 rounded-md backdrop-blur-sm border border-emerald-300/20"
style={{ fontFamily: "var(--font-inter)" }}
>
Ready to claim ✓
</motion.div>
) : (
<div
className="text-sm"
style={{
fontFamily: "var(--font-inter)",
color: helperTextColor,
}}
>
{`We'll secure your username with this email`}
</div>
)}
</>
)}
</div>
</div>
</motion.div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment