unprotect webapp pages and signup cta on generate (#1504)

- public viewable pages
- signup cta on generate if not logged in
This commit is contained in:
BFlat authored and GitHub committed 2026-05-12 22:57:10 +07:00
1 parent 9ddf603ac8
commit 0c2dd82161
8 files changed
+183 -125

No files matched your search

+16 -10
View File
@@ -31,6 +31,7 @@ import {
} from "../components/ui/sidebar";
import { AppSidebar } from "../components/sidebar/app-sidebar";
import { TopBar } from "../components/topbar/topbar";
import { SignupCtaModal } from "../components/signup-cta-modal";
import { useSession } from "../lib/session";
function ScrollToTop() {
@@ -112,19 +113,22 @@ export function App() {
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/forgot-password/verify" element={<VerifyReset />} />
{/* Protected — auth gate + sidebar/topbar chrome */}
<Route element={<RequireAuth />}>
<Route element={<ProtectedLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/background-change" element={<CreateVFX />} />
{/* Public — sidebar/topbar chrome, but no auth gate. Generate actions
inside the create pages pop a signup CTA modal for logged-out users. */}
<Route element={<ProtectedLayout />}>
<Route path="/" element={<Home />} />
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/support" element={<Support />} />
<Route path="/pricing" element={<Pricing />} />
{/* Protected — sign-in required (user-owned content / billing flows) */}
<Route element={<RequireAuth />}>
<Route path="/media" element={<Media />} />
<Route path="/media/:id" element={<Media />} />
<Route path="/support" element={<Support />} />
<Route path="/library" element={<Library />} />
<Route path="/library/:filter" element={<Library />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/welcome" element={<Welcome />} />
<Route path="/onboarding" element={<Onboarding />} />
<Route path="/checkout/success" element={<CheckoutSuccess />} />
@@ -141,11 +145,13 @@ export function App() {
path="/portal_closed"
element={<Navigate to="/checkout/cancel" replace />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
<ToastContainer />
<SignupCtaModal />
</>
);
}
@@ -1,11 +1,6 @@
import { type ReactNode } from "react";
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faSpinnerThird,
type IconDefinition,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { type PopoverItem } from "@storyteller/ui-popover";
import Seo from "../../components/seo";
@@ -13,13 +8,10 @@ interface CreateMediaPageShellProps {
// SEO
title: string;
description: string;
// Auth state
// Auth state — `authChecked` gates the initial spinner so we don't flash
// logged-out chrome while the session resolves. Pages stay viewable for
// logged-out users; the signup CTA modal is triggered at generate time.
authChecked: boolean;
isLoggedIn: boolean;
// Unauthenticated state
heroIcon: IconDefinition;
heroTitle: string;
heroSubtitle: string;
// Content
hasContent: boolean;
emptyStateTitle: string;
@@ -40,10 +32,6 @@ export function CreateMediaPageShell({
title,
description,
authChecked,
isLoggedIn,
heroIcon,
heroTitle,
heroSubtitle,
hasContent,
emptyStateTitle,
emptyStateSubtitle,
@@ -64,45 +52,6 @@ export function CreateMediaPageShell({
);
}
if (!isLoggedIn) {
return (
<div className="relative h-full overflow-x-hidden bg-[#101014] text-white">
<Seo title={title} description={description} />
<div className="pointer-events-none absolute inset-x-0 top-0 z-0 flex justify-center">
<div className="h-[600px] w-[600px] rounded-full bg-gradient-to-br from-primary/30 via-blue-500/20 to-teal-400/10 opacity-40 blur-[120px]" />
</div>
<div className="relative z-10 flex h-full flex-col items-center justify-center px-4">
<FontAwesomeIcon
icon={heroIcon}
className="mb-6 text-5xl text-white/20"
/>
<h1 className="mb-3 text-4xl font-semibold">{heroTitle}</h1>
<p className="mb-8 max-w-md text-center text-lg text-white/60">
{heroSubtitle}
</p>
<div className="flex gap-3">
<Link to="/login">
<Button
variant="primary"
className="bg-white px-6 py-2.5 font-semibold text-black shadow-md hover:bg-white/90"
>
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="px-6 py-2.5 font-semibold shadow-md"
>
Sign up
</Button>
</Link>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full w-full bg-[#101014] text-white">
<Seo title={title} description={description} />
@@ -120,14 +120,19 @@ export function useGalleryData(options: {
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
);
// Initial load + filter change
// Initial load + filter change. When logged out (no username), clear the
// loading flag so the shell renders the empty state instead of a spinner.
useEffect(() => {
if (!username) return;
setItems([]);
setPageIndex(0);
isLoadingRef.current = false;
if (!username) {
setHasMore(false);
setIsInitialLoading(false);
return;
}
setHasMore(true);
setIsInitialLoading(true);
isLoadingRef.current = false;
loadItems(true);
}, [username, JSON.stringify(filterMediaClasses)]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -0,0 +1,118 @@
import { Link, useLocation } from "react-router-dom";
import { create } from "zustand";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { useSession } from "../lib/session";
interface SignupCtaState {
isOpen: boolean;
open: () => void;
close: () => void;
}
const useSignupCtaStore = create<SignupCtaState>((set) => ({
isOpen: false,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}));
/**
* Hook that exposes the logged-in flag plus an imperative `openSignupCta()`
* trigger. Use it at the top of a generate handler:
*
* const { loggedIn, openSignupCta } = useSignupCta();
* if (!loggedIn) { openSignupCta(); return; }
*/
export function useSignupCta(): {
loggedIn: boolean;
openSignupCta: () => void;
} {
const { loggedIn } = useSession();
const openSignupCta = useSignupCtaStore((s) => s.open);
return { loggedIn, openSignupCta };
}
const PERKS: string[] = [
"Generate images and videos with top AI models",
"Save your work and access it from any device",
];
export function SignupCtaModal() {
const isOpen = useSignupCtaStore((s) => s.isOpen);
const close = useSignupCtaStore((s) => s.close);
const location = useLocation();
const from = encodeURIComponent(location.pathname + location.search);
return (
<Modal
isOpen={isOpen}
onClose={close}
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/10 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
resizable={false}
childPadding={false}
backdropClassName="bg-black/80"
>
<div className="relative overflow-hidden">
{/* Off-center ambient glow — feels designed, not generic */}
<div
aria-hidden
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
/>
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
Start <span className="text-primary">crafting</span> in seconds.
</h2>
<p className="mt-3 max-w-[20rem] text-[15px] leading-relaxed text-white/55">
Free to try. Sign up and pick up where you left off.
</p>
<ul className="mt-7 space-y-3">
{PERKS.map((perk) => (
<li
key={perk}
className="flex items-start gap-3 text-[14px] text-white/75"
>
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/15">
<FontAwesomeIcon
icon={faCheck}
className="text-[9px] text-primary"
/>
</span>
<span>{perk}</span>
</li>
))}
</ul>
<div className="mt-8 flex flex-col gap-2">
<Link to={`/signup?from=${from}`} onClick={close} className="block">
<Button
variant="primary"
className="w-full h-12 text-sm font-semibold rounded-full"
>
Create free account
</Button>
</Link>
<Link
to={`/login?from=${from}`}
onClick={close}
className="text-center text-[13px] text-white/55 hover:text-white transition-colors py-2"
>
Already have an account?{" "}
<span className="font-medium text-white/90">Log in</span>
</Link>
</div>
</div>
</div>
</Modal>
);
}
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { faImage } from "@fortawesome/pro-solid-svg-icons";
import { FilterMediaClasses } from "@storyteller/api";
import type { OmniGenImageModelInfo } from "@storyteller/api";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
@@ -30,6 +29,7 @@ import {
useOmniGenImageModels,
getModelCreatorIconPath,
} from "../../lib/omni-gen-hooks";
import { useSignupCta } from "../../components/signup-cta-modal";
// ── Constants ────────────────────────────────────────────────────────────
@@ -63,6 +63,7 @@ function buildModelPopoverItems(
export default function CreateImage() {
const { user, authChecked } = useAuthCheck();
const { loggedIn, openSignupCta } = useSignupCta();
const { promptBoxRef, promptHeight } = usePromptHeight();
// Fetch models from API
@@ -277,6 +278,10 @@ export default function CreateImage() {
);
const handleGenerate = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
return;
}
if (!prompt.trim() || isGenerating || !selectedModel) return;
setIsGenerating(true);
@@ -335,6 +340,8 @@ export default function CreateImage() {
setIsGenerating(false);
}
}, [
loggedIn,
openSignupCta,
prompt,
isGenerating,
selectedModel,
@@ -358,10 +365,6 @@ export default function CreateImage() {
title="Create Image - ArtCraft"
description="Generate stunning AI images with ArtCraft"
authChecked={authChecked}
isLoggedIn={!!user}
heroIcon={faImage}
heroTitle="Create Image"
heroSubtitle="Sign in to generate stunning AI images with multiple models"
hasContent={hasContent}
emptyStateTitle="Generate Image"
emptyStateSubtitle="Add a prompt, then generate"
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird, faSparkles } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import {
PromptBoxVFX,
VFXResultCard,
@@ -10,6 +8,7 @@ import {
} from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
import { useAuthCheck } from "../../components/generation-gallery";
import { useSignupCta } from "../../components/signup-cta-modal";
import { toast } from "../../components/toast/toast";
import { uploadImage } from "../../components/prompt-box/upload-image";
import { uploadVideo } from "../../components/prompt-box/upload-media";
@@ -21,6 +20,7 @@ import {
export default function CreateVFX() {
const { user, authChecked } = useAuthCheck();
const { loggedIn, openSignupCta } = useSignupCta();
const history = useVFXStore((s) => s.history);
const startResult = useVFXStore((s) => s.startResult);
@@ -190,6 +190,10 @@ export default function CreateVFX() {
}, [user, completeResult, failResult, seedFromSession, updateMediaForResult]);
const handleSubmit = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
return;
}
if (!source || !reference || isSubmitting) return;
setIsSubmitting(true);
const id = startResult();
@@ -230,6 +234,8 @@ export default function CreateVFX() {
);
pollersRef.current.set(id, cancel);
}, [
loggedIn,
openSignupCta,
source,
reference,
prompt,
@@ -251,48 +257,6 @@ export default function CreateVFX() {
);
}
if (!user) {
return (
<div className="relative min-h-screen overflow-x-hidden bg-[#101014] text-white">
<Seo
title="Background Change - ArtCraft"
description="Swap the backdrop of a video using a reference image."
/>
<div className="pointer-events-none absolute inset-x-0 top-0 z-0 flex justify-center">
<div className="h-[600px] w-[600px] rounded-full bg-gradient-to-br from-primary/30 via-blue-500/20 to-teal-400/10 opacity-40 blur-[120px]" />
</div>
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4">
<FontAwesomeIcon
icon={faSparkles}
className="mb-6 text-5xl text-white/20"
/>
<h1 className="mb-3 text-4xl font-bold">Background Change</h1>
<p className="mb-8 max-w-md text-center text-lg text-white/60">
Sign in to swap the backdrop of a video using a reference image.
</p>
<div className="flex gap-3">
<Link to="/login">
<Button
variant="primary"
className="bg-white px-6 py-2.5 font-semibold text-black shadow-md hover:bg-white/90"
>
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="px-6 py-2.5 font-semibold shadow-md"
>
Sign up
</Button>
</Link>
</div>
</div>
</div>
);
}
return (
<div className="relative h-full w-full bg-[#101014] text-white">
<Seo
@@ -3,7 +3,6 @@ import { flushSync } from "react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faClock,
faFilm,
faWaveformLines,
} from "@fortawesome/pro-solid-svg-icons";
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
@@ -48,6 +47,7 @@ import {
useOmniGenVideoModels,
getModelCreatorIconPath,
} from "../../lib/omni-gen-hooks";
import { useSignupCta } from "../../components/signup-cta-modal";
// ── Constants ────────────────────────────────────────────────────────────
@@ -162,6 +162,7 @@ function resolveDurationForModel(
export default function CreateVideo() {
const { user, authChecked } = useAuthCheck();
const { loggedIn, openSignupCta } = useSignupCta();
const { promptBoxRef, promptHeight } = usePromptHeight();
// Fetch models from API
@@ -704,6 +705,10 @@ export default function CreateVideo() {
}, []);
const handleGenerate = useCallback(async () => {
if (!loggedIn) {
openSignupCta();
return;
}
if (
!prompt.trim() ||
isGeneratingRef.current ||
@@ -849,6 +854,8 @@ export default function CreateVideo() {
setIsGenerating(false);
isGeneratingRef.current = false;
}, [
loggedIn,
openSignupCta,
prompt,
needsImage,
isReferenceMode,
@@ -888,10 +895,6 @@ export default function CreateVideo() {
title="Create Video - ArtCraft"
description="Generate stunning AI videos with ArtCraft"
authChecked={authChecked}
isLoggedIn={!!user}
heroIcon={faFilm}
heroTitle="Create Video"
heroSubtitle="Sign in to generate stunning AI videos with multiple models"
hasContent={hasContent}
emptyStateTitle="Generate Video"
emptyStateSubtitle="Add a prompt, then generate"
@@ -14,6 +14,8 @@ type AppCard = {
href: string;
icon: IconDefinition;
accent: string;
iconBg: string;
iconColor: string;
};
const APPS: AppCard[] = [
@@ -23,6 +25,8 @@ const APPS: AppCard[] = [
href: "/create-image",
icon: faImage,
accent: "from-blue-500/20 to-blue-500/0",
iconBg: "bg-blue-500/25 border-blue-400/30",
iconColor: "text-blue-300",
},
{
label: "Video",
@@ -30,6 +34,8 @@ const APPS: AppCard[] = [
href: "/create-video",
icon: faVideo,
accent: "from-purple-500/20 to-purple-500/0",
iconBg: "bg-purple-500/25 border-purple-400/30",
iconColor: "text-purple-300",
},
{
label: "Background Change",
@@ -37,6 +43,8 @@ const APPS: AppCard[] = [
href: "/background-change",
icon: faWandMagicSparkles,
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/25 border-emerald-400/30",
iconColor: "text-emerald-300",
},
];
@@ -54,14 +62,16 @@ export function Home() {
<Link
key={app.href}
to={app.href}
className="group relative overflow-hidden rounded-2xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] hover:border-white/[0.12] transition-all p-5"
className="bg-ui-controls/50 group relative overflow-hidden rounded-2xl p-5 hover:border-white/20 hover:bg-ui-controls"
>
<div
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none`}
aria-hidden
/>
<div className="relative flex items-start gap-4">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-white/[0.06] border border-white/[0.06] text-white/85">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor}`}
>
<FontAwesomeIcon icon={app.icon} className="text-base" />
</div>
<div className="min-w-0 flex-1">
@@ -71,7 +81,7 @@ export function Home() {
</h3>
<FontAwesomeIcon
icon={faArrowRight}
className="text-[11px] text-white/40 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all"
className="text-sm text-white/40 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all"
/>
</div>
<p className="mt-1 text-sm text-white/55 leading-snug">