**UX fixes:**

- Fixed a jarring post-auth redirect: the webapp auth layout used to bounce the
  user home the moment login/signup completed, then yank them to Stripe once
  the checkout session finished creating. The auth layout now owns the
  checkout redirect: it keeps the user on the auth screen with a
  "Taking you to secure checkout" spinner until the Stripe redirect fires
  (falling back to home if checkout fails to start)
- The website signup and login pages show the same inline spinner while the
  checkout session is being created
- A logged-in user opening a `/signup?plan=...` link directly is now sent
  straight to checkout instead of seeing the signup form
- Signup pages now autofocus the email field (the form's `autoFocus` prop was
  never passed by either app)
This commit is contained in:
BFlat committed 2026-07-02 16:01:54 +07:00
1 parent c28cb63a8a
commit ade1ff65a3
7 files changed
+232 -144

No files matched your search

@@ -1,8 +1,17 @@
import { ReactNode } from "react";
import { Outlet, useNavigate, Navigate } from "react-router-dom";
import { ReactNode, useEffect, useRef } from "react";
import {
Outlet,
useNavigate,
Navigate,
useSearchParams,
} from "react-router-dom";
import { faArrowLeft, faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TruchetPattern } from "@storyteller/ui-vfx";
import {
checkoutIntentFromSearchParams,
redirectToCheckout,
} from "@storyteller/ui-pricing-table";
import { AuthShowcase } from "./auth-showcase";
import { useMediaQuery } from "../ui/use-media-query";
import { useSession } from "../../lib/session";
@@ -21,13 +30,34 @@ export const AuthLayout = () => {
const showShowcase = useMediaQuery("(min-width: 1024px)");
const navigate = useNavigate();
const { loggedIn, authChecked } = useSession();
const [searchParams] = useSearchParams();
// Set when a pricing-page purchase click routed the user through auth. The
// layout owns the Stripe redirect: as soon as the session flips to logged
// in (post signup/login, or arriving already signed in), it swaps the form
// for a spinner and sends the user to checkout, instead of bouncing home
// while the checkout session is still being created.
const checkoutIntent = checkoutIntentFromSearchParams(searchParams);
const startedCheckoutRef = useRef(false);
useEffect(() => {
if (!loggedIn || !checkoutIntent || startedCheckoutRef.current) {
return;
}
startedCheckoutRef.current = true;
redirectToCheckout(checkoutIntent).then((redirected) => {
if (!redirected) {
navigate("/", { replace: true });
}
});
}, [loggedIn, checkoutIntent, navigate]);
const handleBack = () => {
navigate("/");
};
// Already signed in? Never show the auth form — go straight home.
if (loggedIn) {
// Already signed in? Never show the auth form — go straight home, unless a
// checkout redirect is pending (handled above).
if (loggedIn && !checkoutIntent) {
return <Navigate to="/" replace />;
}
@@ -74,7 +104,17 @@ export const AuthLayout = () => {
<div className="flex flex-1 flex-col justify-center px-8 py-10 sm:px-10">
<div className="mx-auto w-full max-w-sm">
{authChecked ? (
{loggedIn && checkoutIntent ? (
<div className="flex flex-col items-center gap-4 py-12 text-white/60">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-2xl text-white/40"
/>
<span className="text-sm">
Taking you to secure checkout…
</span>
</div>
) : authChecked ? (
<Outlet />
) : (
<div className="flex justify-center py-12">
@@ -9,10 +9,7 @@ import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import {
checkoutIntentFromSearchParams,
redirectToCheckout,
} from "@storyteller/ui-pricing-table";
import { checkoutIntentFromSearchParams } from "@storyteller/ui-pricing-table";
import {
getLandingUrl,
getReferralCode,
@@ -100,10 +97,10 @@ export const SignupForm = ({
hasActiveSubscription(),
]);
// A pricing-page purchase click brought the user here; resume Stripe
// checkout for the plan they picked.
const checkoutIntent = checkoutIntentFromSearchParams(searchParams);
if (checkoutIntent && (await redirectToCheckout(checkoutIntent))) {
// A pricing-page purchase click brought the user here; stay put. The
// session refresh above flipped loggedIn, so AuthLayout is already
// redirecting to Stripe checkout for the plan they picked.
if (checkoutIntentFromSearchParams(searchParams)) {
return;
}
@@ -185,7 +182,10 @@ export const SignupForm = ({
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Create account"
)}
@@ -9,11 +9,12 @@ import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import { checkoutIntentFromSearchParams } from "@storyteller/ui-pricing-table";
import {
checkoutIntentFromSearchParams,
redirectToCheckout,
} from "@storyteller/ui-pricing-table";
import { AuthHeader, AuthFooter, GoogleLoginButton } from "../../components/auth";
AuthHeader,
AuthFooter,
GoogleLoginButton,
} from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { refreshSession } from "../../lib/session";
@@ -24,8 +25,9 @@ const Login = () => {
const [searchParams] = useSearchParams();
const fromParam = searchParams.get("from");
const redirectTo = fromParam && fromParam.startsWith("/") ? fromParam : "/";
// Set when a pricing-page purchase click routed the user through auth;
// after login we resume Stripe checkout for that plan.
// Set when a pricing-page purchase click routed the user through auth.
// AuthLayout owns the Stripe redirect once loggedIn flips; this page just
// has to not navigate away in the meantime.
const checkoutIntent = checkoutIntentFromSearchParams(searchParams);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -51,7 +53,7 @@ const Login = () => {
// navigating — otherwise RequireAuth on the destination sees loggedIn=false
// and bounces straight back to /login?from=…
await refreshSession(true);
if (checkoutIntent && (await redirectToCheckout(checkoutIntent))) {
if (checkoutIntent) {
return;
}
navigate(redirectTo);
@@ -67,7 +69,7 @@ const Login = () => {
refreshSession(true),
hasActiveSubscription(),
]);
if (checkoutIntent && (await redirectToCheckout(checkoutIntent))) {
if (checkoutIntent) {
return;
}
navigate(subscribed ? redirectTo : "/pricing");
@@ -159,7 +161,10 @@ const Login = () => {
disabled={isLoading}
>
{isLoading ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Log in"
)}
@@ -1,8 +1,5 @@
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import {
checkoutIntentFromSearchParams,
redirectToCheckout,
} from "@storyteller/ui-pricing-table";
import { checkoutIntentFromSearchParams } from "@storyteller/ui-pricing-table";
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal } from "../../components/motion/reveal";
@@ -12,10 +9,11 @@ const Signup = () => {
const [searchParams] = useSearchParams();
const checkoutIntent = checkoutIntentFromSearchParams(searchParams);
const handleSuccess = async () => {
// A pricing-page purchase click brought the user here; resume Stripe
// checkout for the plan they picked instead of the welcome flow.
if (checkoutIntent && (await redirectToCheckout(checkoutIntent))) {
const handleSuccess = () => {
// A pricing-page purchase click brought the user here; stay put. Once the
// session refresh flips loggedIn, AuthLayout swaps in a spinner and
// redirects to Stripe checkout for the plan they picked.
if (checkoutIntent) {
return;
}
navigate("/welcome");
@@ -27,15 +25,15 @@ const Signup = () => {
title="Sign Up - ArtCraft"
description="Create your ArtCraft account."
/>
<AuthHeader title="Create an Account" subtitle="Join thousands of creators" />
<AuthHeader
title="Create an Account"
subtitle="Join thousands of creators"
/>
{/* 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={handleSuccess}
signupSource="artcraft"
/>
<SignupForm onSuccess={handleSuccess} signupSource="artcraft" autoFocus />
<Reveal inView={false} delay={0.44}>
<AuthFooter>
@@ -21,6 +21,7 @@ const Login = () => {
// Set when a pricing-page purchase click routed the user through auth;
// after login we resume Stripe checkout for that plan.
const checkoutIntent = checkoutIntentFromSearchParams(searchParams);
const [redirectingToCheckout, setRedirectingToCheckout] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
@@ -42,8 +43,12 @@ const Login = () => {
if (response.success) {
window.dispatchEvent(new Event("auth-change"));
if (checkoutIntent && (await redirectToCheckout(checkoutIntent))) {
return;
if (checkoutIntent) {
setRedirectingToCheckout(true);
if (await redirectToCheckout(checkoutIntent)) {
return;
}
setRedirectingToCheckout(false);
}
navigate("/");
} else {
@@ -81,13 +86,23 @@ const Login = () => {
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold mb-2">Welcome Back</h1>
<p className="text-white/60 text-sm">Log in to your account</p>
</div>
{redirectingToCheckout ? (
<div className="flex flex-col items-center gap-4 py-12 text-white/60">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-2xl text-white/40"
/>
<span className="text-sm">Taking you to secure checkout…</span>
</div>
) : (
<>
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold mb-2">Welcome Back</h1>
<p className="text-white/60 text-sm">Log in to your account</p>
</div>
<div className="space-y-4">
{/*<GoogleLoginButton
<div className="space-y-4">
{/*<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
@@ -103,89 +118,93 @@ const Login = () => {
</div>
*/}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</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/20 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
</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/20 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"
>
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} />
</button>
</div>
</div>
<div className="pt-2">
<Button
className="w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
type="submit"
disabled={isLoading}
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
handleLogin();
}}
>
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
"Log in"
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
{error}
</div>
)}
</Button>
</div>
</form>
</div>
<div className="mt-8 text-center text-sm text-white/60">
Don't have an account?{" "}
<Link
to={{ pathname: "/signup", search: searchParams.toString() }}
className="text-primary hover:text-primary-400 font-semibold transition-colors"
>
Sign up
</Link>
</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/20 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
</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/20 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"
>
<FontAwesomeIcon
icon={showPassword ? faEyeSlash : faEye}
/>
</button>
</div>
</div>
<div className="pt-2">
<Button
className="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>
</form>
</div>
<div className="mt-8 text-center text-sm text-white/60">
Don't have an account?{" "}
<Link
to={{ pathname: "/signup", search: searchParams.toString() }}
className="text-primary hover:text-primary-400 font-semibold transition-colors"
>
Sign up
</Link>
</div>
</>
)}
</div>
</main>
@@ -1,4 +1,7 @@
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
checkoutIntentFromSearchParams,
redirectToCheckout,
@@ -10,12 +13,17 @@ const Signup = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const checkoutIntent = checkoutIntentFromSearchParams(searchParams);
const [redirectingToCheckout, setRedirectingToCheckout] = useState(false);
const handleSuccess = async () => {
// A pricing-page purchase click brought the user here; resume Stripe
// checkout for the plan they picked instead of the welcome flow.
if (checkoutIntent && (await redirectToCheckout(checkoutIntent))) {
return;
if (checkoutIntent) {
setRedirectingToCheckout(true);
if (await redirectToCheckout(checkoutIntent)) {
return;
}
setRedirectingToCheckout(false);
}
navigate("/welcome");
};
@@ -37,25 +45,42 @@ const Signup = () => {
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold mb-2">Create an Account</h1>
<p className="text-white/60 text-sm">Join thousands of creators</p>
</div>
{redirectingToCheckout ? (
<div className="flex flex-col items-center gap-4 py-12 text-white/60">
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin text-2xl text-white/40"
/>
<span className="text-sm">Taking you to secure checkout…</span>
</div>
) : (
<>
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold mb-2">
Create an Account
</h1>
<p className="text-white/60 text-sm">
Join thousands of creators
</p>
</div>
<SignupForm
onSuccess={handleSuccess}
signupSource="artcraft"
/>
<SignupForm
onSuccess={handleSuccess}
signupSource="artcraft"
autoFocus
/>
<div className="mt-8 text-center text-sm text-white/60">
Already have an account?{" "}
<Link
to={{ pathname: "/login", search: searchParams.toString() }}
className="text-primary hover:text-primary-400 font-semibold transition-colors"
>
Log in
</Link>
</div>
<div className="mt-8 text-center text-sm text-white/60">
Already have an account?{" "}
<Link
to={{ pathname: "/login", search: searchParams.toString() }}
className="text-primary hover:text-primary-400 font-semibold transition-colors"
>
Log in
</Link>
</div>
</>
)}
</div>
</main>
@@ -30,7 +30,8 @@ export const checkoutIntentFromSearchParams = (
if (!plan || !PAID_PLAN_SLUGS.has(plan)) {
return null;
}
const cadence = params.get(CADENCE_PARAM) === "monthly" ? "monthly" : "yearly";
const cadence =
params.get(CADENCE_PARAM) === "monthly" ? "monthly" : "yearly";
return { plan, cadence };
};