mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-27 04:00:10 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8153096a52 | ||
|
|
41615dfab9 | ||
|
|
e6be45f3d4 |
@@ -18,6 +18,7 @@ import {
|
||||
import { refreshSession } from "../../lib/session";
|
||||
import { hasActiveSubscription } from "../../lib/billing";
|
||||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||
import { Reveal, RevealGroup } from "../motion/reveal";
|
||||
|
||||
interface SignupFormProps {
|
||||
onSuccess: (isNewUser?: boolean) => void;
|
||||
@@ -115,74 +116,97 @@ export const SignupForm = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-white/70 ml-1">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus={autoFocus}
|
||||
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center ml-1">
|
||||
<label className="text-xs font-semibold text-white/70">
|
||||
Password
|
||||
{/* Fields cascade in as their own beat, leading with the email field on
|
||||
an almost-zero delay so the primary input lands immediately — the
|
||||
entrance never gates typing (inputs stay focusable mid-cascade).
|
||||
Mirrors the login page. */}
|
||||
<RevealGroup
|
||||
inView={false}
|
||||
delayChildren={0.04}
|
||||
stagger={0.08}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Reveal className="space-y-2">
|
||||
<label className="text-xs font-semibold text-white/70 ml-1">
|
||||
Email
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoFocus={autoFocus}
|
||||
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<Reveal className="space-y-2">
|
||||
<div className="flex justify-between items-center ml-1">
|
||||
<label className="text-xs font-semibold text-white/70">
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal className="pt-2">
|
||||
<Button
|
||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Create account"
|
||||
)}
|
||||
</Button>
|
||||
</Reveal>
|
||||
</RevealGroup>
|
||||
</form>
|
||||
|
||||
<div className="relative flex items-center justify-center py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
</div>
|
||||
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
{/* Secondary sign-up options continue the same cascade just after the
|
||||
fields (delay continues from the form group's three children above:
|
||||
0.04 + 3 × 0.08). */}
|
||||
<RevealGroup
|
||||
inView={false}
|
||||
delayChildren={0.28}
|
||||
stagger={0.08}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Reveal className="relative flex items-center justify-center py-2">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
</div>
|
||||
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
|
||||
or
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<GoogleLoginButton
|
||||
mode="signup"
|
||||
onSuccess={handleGoogleSuccess}
|
||||
onError={handleGoogleError}
|
||||
/>
|
||||
<Reveal>
|
||||
<GoogleLoginButton
|
||||
mode="signup"
|
||||
onSuccess={handleGoogleSuccess}
|
||||
onError={handleGoogleError}
|
||||
/>
|
||||
</Reveal>
|
||||
</RevealGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,23 @@ import type { ElementType, ReactNode } from "react";
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import { DUR_SLOW, EASE_OUT } from "../../lib/motion";
|
||||
|
||||
// `motion(as)` returns a BRAND-NEW component on every call. Calling it during
|
||||
// render would hand each render a different component type, so React would
|
||||
// unmount and remount the whole subtree on every re-render — stealing focus
|
||||
// from any <input> inside (this is what broke the login form: typing a
|
||||
// character re-rendered the page and blurred the field). Cache one motion
|
||||
// component per element type so the reference stays stable across renders.
|
||||
const motionTagCache = new Map<ElementType, ElementType>();
|
||||
|
||||
function motionTag(as: ElementType): ElementType {
|
||||
let cached = motionTagCache.get(as);
|
||||
if (!cached) {
|
||||
cached = motion(as) as ElementType;
|
||||
motionTagCache.set(as, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
interface RevealProps {
|
||||
children: ReactNode;
|
||||
/** Render as a different element/component (e.g. "section", "li", Link). */
|
||||
@@ -49,7 +66,7 @@ export function RevealGroup({
|
||||
className?: string;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const MotionTag = motion(as);
|
||||
const MotionTag = motionTag(as);
|
||||
|
||||
const variants: Variants = {
|
||||
initial: {},
|
||||
@@ -83,22 +100,30 @@ export function Reveal({
|
||||
className,
|
||||
}: RevealProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const MotionTag = motion(as);
|
||||
|
||||
const variants: Variants = {
|
||||
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: DUR_SLOW, ease: EASE_OUT, delay },
|
||||
},
|
||||
};
|
||||
const MotionTag = motionTag(as);
|
||||
|
||||
// When `inView` is left undefined the element behaves as a stagger child:
|
||||
// it inherits the parent RevealGroup's orchestration and must NOT drive its
|
||||
// own initial/animate props (that would override the cascade).
|
||||
const standalone = inView !== undefined;
|
||||
|
||||
// Only a standalone reveal sets its own `delay`. For a stagger child the
|
||||
// delay MUST be omitted: framer-motion lets a child's explicit transition
|
||||
// `delay` override the parent's `staggerChildren`/`delayChildren`, so leaving
|
||||
// it in (even as 0) collapses the cascade and every child animates at once.
|
||||
const variants: Variants = {
|
||||
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: DUR_SLOW,
|
||||
ease: EASE_OUT,
|
||||
...(standalone ? { delay } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<MotionTag
|
||||
className={className}
|
||||
|
||||
@@ -72,8 +72,6 @@ const Login = () => {
|
||||
/>
|
||||
<AuthHeader title="Welcome Back" subtitle="Log in to your account" />
|
||||
|
||||
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
|
||||
<Reveal>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
@@ -87,93 +85,108 @@ const Login = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-white/70 ml-1">
|
||||
Email or Username
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com or username"
|
||||
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center ml-1">
|
||||
<label className="text-xs font-semibold text-white/70">
|
||||
Password
|
||||
{/* The form fields cascade in as their own beat, distinct from the
|
||||
shell/header settling. We lead with the email field on an almost-zero
|
||||
delay so the primary input lands immediately — the entrance never
|
||||
gates typing: inputs stay focusable and accept keystrokes while the
|
||||
password/button below are still cascading in. */}
|
||||
<RevealGroup
|
||||
inView={false}
|
||||
delayChildren={0.04}
|
||||
stagger={0.08}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Reveal className="space-y-2">
|
||||
<label className="text-xs font-semibold text-white/70 ml-1">
|
||||
Email or Username
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs text-primary hover:text-primary-400 transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com or username"
|
||||
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
tabIndex={-1}
|
||||
</Reveal>
|
||||
<Reveal className="space-y-2">
|
||||
<div className="flex justify-between items-center ml-1">
|
||||
<label className="text-xs font-semibold text-white/70">
|
||||
Password
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-xs text-primary hover:text-primary-400 transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min. 8 characters"
|
||||
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal className="pt-2">
|
||||
<Button
|
||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Log in"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
|
||||
) : (
|
||||
"Log in"
|
||||
)}
|
||||
</Button>
|
||||
</Reveal>
|
||||
</RevealGroup>
|
||||
</form>
|
||||
</Reveal>
|
||||
|
||||
<Reveal className="relative my-6 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
</div>
|
||||
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
|
||||
or
|
||||
</span>
|
||||
</Reveal>
|
||||
{/* Secondary sign-in options pick up the same cascade just after the
|
||||
fields (delay continues from the form group's three children above:
|
||||
0.04 + 3 × 0.08). */}
|
||||
<RevealGroup inView={false} delayChildren={0.28} stagger={0.08}>
|
||||
<Reveal className="relative my-6 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
</div>
|
||||
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
|
||||
or
|
||||
</span>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<GoogleLoginButton
|
||||
mode="login"
|
||||
onSuccess={handleGoogleSuccess}
|
||||
onError={handleGoogleError}
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<GoogleLoginButton
|
||||
mode="login"
|
||||
onSuccess={handleGoogleSuccess}
|
||||
onError={handleGoogleError}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal>
|
||||
<AuthFooter>
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/signup"
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</AuthFooter>
|
||||
</Reveal>
|
||||
<Reveal>
|
||||
<AuthFooter>
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/signup"
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</AuthFooter>
|
||||
</Reveal>
|
||||
</RevealGroup>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
|
||||
import Seo from "../../components/seo";
|
||||
import { Reveal, RevealGroup } from "../../components/motion/reveal";
|
||||
import { Reveal } from "../../components/motion/reveal";
|
||||
|
||||
const Signup = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -14,26 +14,25 @@ const Signup = () => {
|
||||
/>
|
||||
<AuthHeader title="Create an Account" subtitle="Join thousands of creators" />
|
||||
|
||||
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
|
||||
<Reveal>
|
||||
<SignupForm
|
||||
onSuccess={() => navigate("/welcome")}
|
||||
signupSource="artcraft"
|
||||
/>
|
||||
</Reveal>
|
||||
{/* SignupForm runs its own field cascade internally (see signup-form.tsx).
|
||||
The footer picks up the tail of that cascade — the form's six staggered
|
||||
steps land by ~0.36s, so the footer follows at ~0.44s. */}
|
||||
<SignupForm
|
||||
onSuccess={() => navigate("/welcome")}
|
||||
signupSource="artcraft"
|
||||
/>
|
||||
|
||||
<Reveal>
|
||||
<AuthFooter>
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</AuthFooter>
|
||||
</Reveal>
|
||||
</RevealGroup>
|
||||
<Reveal inView={false} delay={0.44}>
|
||||
<AuthFooter>
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</AuthFooter>
|
||||
</Reveal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user