webapp: UX, CTA improvements (#1574)

## Signup, credits, and pricing polish

A round of UX improvements around signing up, running out of credits,
and the pricing page.

**Signup**
- Refreshed the signup prompt copy and added a "Continue with Google"
option (same flow as the login/signup pages)
- Added a "Sign up to create" button on the image and video pages for
logged-out visitors

**Create pages**
- Punchier empty-state headlines ("Describe anything. See it in
seconds." for images, "Describe a scene. See it in motion." for video)
- Added a subtle background pattern behind the empty state

**Out of credits**
- New modal that appears when a generation is blocked by not enough
credits
- Subscribers see "Upgrade plan" (hidden if they're already on the top
plan) plus "Buy credits"
- People without a plan get a "Choose a plan" prompt instead

**Pricing page**
- Existing subscribers get a "Buy more credits" shortcut at the top of
the page
- "Contact Us" now opens a small menu to copy the email or open your
mail app

**Cleanup**
- Removed the "Seedance 2.0 is in Early Alpha" warning from the task
queue

**Buy credits modal**
- Restyled the buy credits modal to be more appealing
This commit is contained in:
BFlat authored and GitHub committed 2026-05-26 16:08:50 +07:00
1 parent 94077a3ecb
commit ebe2129b4b
12 files changed
+634 -103

No files matched your search

@@ -36,6 +36,7 @@ import {
import { AppSidebar } from "../components/sidebar/app-sidebar";
import { TopBar } from "../components/topbar/topbar";
import { SignupCtaModal } from "../components/signup-cta-modal";
import { InsufficientCreditsModal } from "../components/insufficient-credits-modal";
import { useSession } from "../lib/session";
function ScrollToTop() {
@@ -177,6 +178,7 @@ export function App() {
</Routes>
<ToastContainer />
<SignupCtaModal />
<InsufficientCreditsModal />
</>
);
}
@@ -1,7 +1,11 @@
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { faCoins } from "@fortawesome/pro-solid-svg-icons";
import {
faCoins,
faSpinnerThird,
faArrowRight,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { BillingApi } from "@storyteller/api";
import { toast } from "./toast/toast";
@@ -52,7 +56,7 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-2xl max-h-[90vh] w-full max-w-2xl overflow-y-auto border border-white/10 bg-[#16161a] p-0 shadow-2xl"
className="rounded-2xl w-full max-w-2xl max-h-[100vh] overflow-y-auto overflow-x-hidden border border-white/5 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
@@ -60,55 +64,92 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
childPadding={false}
backdropClassName="bg-black/80"
>
<div className="p-6 sm:p-8">
<div className="mb-8 text-center">
<h2 className="mb-2 text-3xl font-bold text-white sm:text-4xl">
Buy Credits
<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-11 sm:pb-9">
<h2 className="font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
Buy <span className="text-primary">credits</span>
</h2>
<p className="text-white/60">
<p className="mt-3 text-[15px] leading-relaxed text-white/55">
One-time credit packs. No subscription required.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{CREDIT_PACKS.map((pack) => (
<button
key={pack.id}
type="button"
onClick={() => handlePurchase(pack)}
disabled={purchasingId !== null}
className="group relative flex flex-col justify-between rounded-xl border border-white/10 p-5 text-left transition-all hover:border-primary/40 hover:bg-white/[0.03] disabled:opacity-60"
>
{pack.badge && (
<div className="absolute -top-2.5 right-4 rounded-full bg-primary px-3 py-0.5 text-xs font-bold text-white shadow-lg">
{pack.badge}
</div>
)}
<div className="flex items-center gap-2.5">
<FontAwesomeIcon
icon={faCoins}
className="text-2xl text-primary"
/>
<span className="text-3xl font-bold tracking-tight text-white">
{pack.total.toLocaleString()}
</span>
</div>
<div className="mt-4 flex items-center justify-between">
<span className="text-xl font-bold text-white/60">
${pack.priceUsd}
</span>
<Button
variant="primary"
className="rounded-full pointer-events-none rounded-lg px-5 py-2 text-sm font-semibold"
<div className="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2">
{CREDIT_PACKS.map((pack) => {
const isLoading = purchasingId === pack.id;
const isPopular = !!pack.badge;
return (
<button
key={pack.id}
type="button"
onClick={() => handlePurchase(pack)}
disabled={purchasingId !== null}
className={twMerge(
"group relative flex flex-col gap-6 rounded-2xl border p-6 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60",
isPopular
? "border-primary/50 bg-primary/[0.07] hover:border-primary"
: "border-white/10 bg-white/[0.02] hover:border-white/25 hover:bg-white/[0.04]",
)}
>
{purchasingId === pack.id ? "Loading..." : "Purchase"}
</Button>
</div>
</button>
))}
{pack.badge && (
<span className="absolute -top-2.5 right-3 rounded-full bg-primary px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide text-white shadow-lg">
{pack.badge}
</span>
)}
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<FontAwesomeIcon
icon={faCoins}
className="text-primary text-lg"
/>
</span>
<div className="min-w-0">
<div className="text-4xl font-bold leading-none tracking-tight text-white">
{pack.total.toLocaleString()}
</div>
<div className="mt-0 text-sm text-white/40">credits</div>
</div>
</div>
<div className="flex items-center justify-between">
<span className="text-2xl font-semibold text-white/80">
${pack.priceUsd}
</span>
<span className="flex items-center gap-1.5 text-base font-semibold text-primary-400">
{isLoading ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="animate-spin"
/>
) : (
<>
Buy
<FontAwesomeIcon
icon={faArrowRight}
className="text-xs transition-transform group-hover:translate-x-0.5"
/>
</>
)}
</span>
</div>
</button>
);
})}
</div>
<p className="mt-6 text-center text-xs text-white/35">
Secure checkout via Stripe.
</p>
</div>
</div>
</Modal>
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
import { type PopoverItem } from "@storyteller/ui-popover";
import { TruchetPattern } from "@storyteller/ui-vfx";
import Seo from "../../components/seo";
interface CreateMediaPageShellProps {
@@ -16,6 +17,9 @@ interface CreateMediaPageShellProps {
hasContent: boolean;
emptyStateTitle: string;
emptyStateSubtitle: string;
// Optional CTA rendered under the empty-state subtitle (e.g. a signup button
// for logged-out visitors). Omitted for logged-in users.
emptyStateCta?: ReactNode;
bottomOffset: number;
// Model selector
modelItems: PopoverItem[];
@@ -35,6 +39,7 @@ export function CreateMediaPageShell({
hasContent,
emptyStateTitle,
emptyStateSubtitle,
emptyStateCta,
bottomOffset,
glowOrbs,
gridContent,
@@ -66,6 +71,26 @@ export function CreateMediaPageShell({
</div>
))}
{/* Subtle truchet pattern, only on the empty state so it never sits
behind the gallery grid. Masked to a soft center vignette. */}
{!hasContent && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
WebkitMaskImage:
"radial-gradient(ellipse 70% 60% at 50% 50%, black 20%, transparent 80%)",
}}
>
<TruchetPattern
intensity={0.5}
className="absolute inset-0 h-full w-full"
/>
</div>
)}
<div className="relative z-[1] h-full w-full">
<div className="flex h-full w-full flex-col">
{!hasContent && (
@@ -77,6 +102,7 @@ export function CreateMediaPageShell({
<span className="pt-2 text-lg text-white/80 md:text-xl">
{emptyStateSubtitle}
</span>
{emptyStateCta && <div className="pt-6">{emptyStateCta}</div>}
</div>
</div>
)}
@@ -0,0 +1,225 @@
import { useState, type ReactNode } from "react";
import { Link } 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 { BillingApi } from "@storyteller/api";
import { CreditsModal } from "./credits-modal";
interface InsufficientCreditsState {
isOpen: boolean;
planState: PlanState | null;
open: () => void;
close: () => void;
}
const useInsufficientCreditsStore = create<InsufficientCreditsState>((set) => ({
isOpen: false,
planState: null,
// Resolve the user's plan *before* showing the modal, then open with the
// result in the same update, so the correct copy renders on the first frame
// (no flash of the no-plan variant while the subscription check is in flight).
open: () => {
void fetchPlanState().then((planState) => set({ isOpen: true, planState }));
},
close: () => set({ isOpen: false }),
}));
/**
* Hook that exposes an imperative `openInsufficientCredits()` trigger. Call it
* from a generate handler when the API replies 402 Payment Required:
*
* const openInsufficientCredits = useInsufficientCredits();
* if (result.errorCode === 402) { openInsufficientCredits(); return; }
*/
export function useInsufficientCredits(): () => void {
return useInsufficientCreditsStore((s) => s.open);
}
// Biggest ArtCraft plan (SubscriptionProduct.ELITE). Users already on it have
// nothing left to upgrade to, so the "Upgrade plan" button is hidden for them.
const BIGGEST_PLAN_SLUG = "fakeyou_elite";
interface CreditsCopy {
heading: ReactNode;
subtitle: string;
perks: string[];
}
// Shown when the user already has an active plan and used up its credits.
const OUT_OF_CREDITS_COPY: CreditsCopy = {
heading: (
<>
You're out of <span className="text-primary">credits</span>.
</>
),
subtitle: "Upgrade your plan to keep creating.",
perks: [
"Unlock more image and video generations",
"Keep creating without hitting limits",
"Pick a plan that fits your workflow",
],
};
// Shown when the user has no plan yet, so they never had credits to begin with.
const NO_PLAN_COPY: CreditsCopy = {
heading: (
<>
Not enough <span className="text-primary">credits</span>.
</>
),
subtitle: "Choose a plan to start creating.",
perks: [
"Generate images and videos with the latest AI models",
"Save your work and access it anywhere",
"Pick a plan that fits your workflow",
],
};
interface PlanState {
// Has an active ArtCraft subscription. Distinguishes "ran out of plan
// credits" from "never had a plan".
hasPlan: boolean;
// Already on the biggest plan, so there is nothing left to upgrade to.
isOnBiggestPlan: boolean;
}
// Look up the signed-in user's ArtCraft subscription, if any. Mirrors the
// namespace check the topbar uses.
async function fetchPlanState(): Promise<PlanState> {
try {
const response = await new BillingApi().ListActiveSubscriptions();
const artcraftSub = response.success
? response.data?.active_subscriptions?.find(
(sub) => sub.namespace === "artcraft",
)
: undefined;
return {
hasPlan: !!artcraftSub,
isOnBiggestPlan: String(artcraftSub?.product_slug) === BIGGEST_PLAN_SLUG,
};
} catch {
return { hasPlan: false, isOnBiggestPlan: false };
}
}
export function InsufficientCreditsModal() {
const isOpen = useInsufficientCreditsStore((s) => s.isOpen);
const planState = useInsufficientCreditsStore((s) => s.planState);
const close = useInsufficientCreditsStore((s) => s.close);
const [creditsOpen, setCreditsOpen] = useState(false);
// `planState` is resolved before the modal opens (see the store's `open`), so
// the copy below is already correct on the first render.
const hasPlan = planState?.hasPlan ?? false;
const isOnBiggestPlan = planState?.isOnBiggestPlan ?? false;
const copy = hasPlan ? OUT_OF_CREDITS_COPY : NO_PLAN_COPY;
// "Buy credits" swaps this modal for the credit-packs modal so we never stack
// two backdrops on top of each other.
const handleBuyCredits = () => {
close();
setCreditsOpen(true);
};
return (
<>
<Modal
isOpen={isOpen}
onClose={close}
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/5 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]">
{copy.heading}
</h2>
<p className="mt-3 max-w-[20rem] text-[15px] leading-relaxed text-white/55">
{copy.subtitle}
</p>
<ul className="mt-7 space-y-3">
{copy.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-3">
{hasPlan ? (
<>
{!isOnBiggestPlan && (
<Link to="/pricing" onClick={close} className="block">
<Button
variant="primary"
className="w-full h-10 text-sm font-semibold rounded-full"
>
Upgrade plan
</Button>
</Link>
)}
<Button
variant={isOnBiggestPlan ? "primary" : "secondary"}
onClick={handleBuyCredits}
className="w-full h-10 text-sm font-semibold rounded-full"
>
Buy more credits
</Button>
</>
) : (
<Link to="/pricing" onClick={close} className="block">
<Button
variant="primary"
className="w-full h-10 text-sm font-semibold rounded-full"
>
Upgrade plan
</Button>
</Link>
)}
<button
type="button"
onClick={close}
className="text-center text-[13px] text-white/55 hover:text-white transition-colors py-1"
>
Maybe later
</button>
</div>
</div>
</div>
</Modal>
<CreditsModal
isOpen={creditsOpen}
onClose={() => setCreditsOpen(false)}
/>
</>
);
}
@@ -1,10 +1,12 @@
import { Link, useLocation } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useState } from "react";
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";
import { GoogleLoginButton } from "./auth";
import { refreshSession, useSession, useSessionStore } from "../lib/session";
interface SignupCtaState {
isOpen: boolean;
@@ -35,21 +37,39 @@ export function useSignupCta(): {
}
const PERKS: string[] = [
"Generate images and videos with top AI models",
"Generate images and videos with latest AI models",
"Save your work and access it from any device",
"Access to our desktop app",
];
export function SignupCtaModal() {
const isOpen = useSignupCtaStore((s) => s.isOpen);
const close = useSignupCtaStore((s) => s.close);
const navigate = useNavigate();
const location = useLocation();
const from = encodeURIComponent(location.pathname + location.search);
const [error, setError] = useState<string | null>(null);
// Mirror the login/signup pages: refresh the session so it reflects the new
// cookie, then send brand-new SSO users to set a password. Everyone else
// just closes the modal and stays on the page they were on.
const handleGoogleSuccess = async () => {
await refreshSession(true);
close();
if (useSessionStore.getState().passwordNotSet) {
navigate("/set-password");
}
};
const handleGoogleError = (message: string) => {
setError(message);
};
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)]"
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/[5%] bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
@@ -58,7 +78,7 @@ export function SignupCtaModal() {
backdropClassName="bg-black/80"
>
<div className="relative overflow-hidden">
{/* Off-center ambient glow — feels designed, not generic */}
{/* 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]"
@@ -73,7 +93,7 @@ export function SignupCtaModal() {
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.
Sign up and start creating right away.
</p>
<ul className="mt-7 space-y-3">
@@ -93,19 +113,41 @@ export function SignupCtaModal() {
))}
</ul>
<div className="mt-8 flex flex-col gap-2">
<div className="mt-8 flex flex-col gap-3">
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
{error}
</div>
)}
<Link to={`/signup?from=${from}`} onClick={close} className="block">
<Button
variant="primary"
className="w-full h-12 text-sm font-semibold rounded-full"
className="w-full h-10 text-sm font-semibold rounded-full"
>
Create free account
Create account with email
</Button>
</Link>
<div className="relative flex items-center justify-center py-1">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#161618] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</div>
<GoogleLoginButton
mode="signup"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<Link
to={`/login?from=${from}`}
onClick={close}
className="text-center text-[13px] text-white/55 hover:text-white transition-colors py-2"
className="text-center text-[13px] text-white/55 hover:text-white transition-colors py-1"
>
Already have an account?{" "}
<span className="font-medium text-white/90">Log in</span>
@@ -10,7 +10,6 @@ import {
faBroom,
faBomb,
faCircleExclamation,
faTriangleExclamation,
faCopy,
faCheck,
} from "@fortawesome/pro-solid-svg-icons";
@@ -217,7 +216,6 @@ const InProgressCard = ({
: task.estimatedTimeLeftMs != null && task.estimatedTimeLeftMs > 0
? formatTimeLeft(task.estimatedTimeLeftMs)
: null;
const isSeedance2 = task.modelType === "seedance_2p0";
const modelIconPath = task.modelType
? getModelCreatorIconPath(task.modelType)
: null;
@@ -243,21 +241,6 @@ const InProgressCard = ({
/>
)}
{task.title}
{isSeedance2 && (
<Tooltip
content="Seedance 2.0 is in Early Alpha. Generations may be slow and may experience outages."
position="top"
strategy="fixed"
className="w-[200px] text-wrap bg-yellow-400/60 backdrop-blur-3xl"
zIndex={50}
delay={100}
>
<FontAwesomeIcon
icon={faTriangleExclamation}
className="h-3 w-3 shrink-0 text-yellow-400/60 transition-all hover:text-yellow-400"
/>
</Tooltip>
)}
</div>
<div className="ml-2 shrink-0 text-xs tabular-nums text-base-fg/60">
{progressPercent}%
@@ -3,6 +3,7 @@ import { FilterMediaClasses } from "@storyteller/api";
import type { OmniGenImageModelInfo } from "@storyteller/api";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Button } from "@storyteller/ui-button";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import { PromptBox, type RefImage } from "../../components/prompt-box";
import {
@@ -27,6 +28,8 @@ import {
getModelCreatorIconPath,
} from "../../lib/omni-gen-hooks";
import { useSignupCta } from "../../components/signup-cta-modal";
import { useInsufficientCredits } from "../../components/insufficient-credits-modal";
import { faSparkles } from "@fortawesome/pro-solid-svg-icons";
// ── Constants ────────────────────────────────────────────────────────────
@@ -61,6 +64,7 @@ function buildModelPopoverItems(
export default function CreateImage() {
const { user, authChecked } = useAuthCheck();
const { loggedIn, openSignupCta } = useSignupCta();
const openInsufficientCredits = useInsufficientCredits();
const { promptBoxRef, promptHeight } = usePromptHeight();
// Fetch models from API
@@ -135,6 +139,7 @@ export default function CreateImage() {
const setBatchJobToken = useCreateImageStore((s) => s.setBatchJobToken);
const completeBatch = useCreateImageStore((s) => s.completeBatch);
const failBatch = useCreateImageStore((s) => s.failBatch);
const dismissBatch = useCreateImageStore((s) => s.dismissBatch);
const pollingCleanupsRef = useRef<Map<string, () => void>>(new Map());
// Jobs + gallery
@@ -309,7 +314,14 @@ export default function CreateImage() {
});
if (!result.success || !result.jobToken) {
failBatch(batchId, result.error ?? "Failed to start generation");
// 402 Payment Required: the user is out of credits. Drop the pending
// card and surface the upgrade modal instead of a failed-card error.
if (result.errorCode === 402) {
dismissBatch(batchId);
openInsufficientCredits();
} else {
failBatch(batchId, result.error ?? "Failed to start generation");
}
setIsGenerating(false);
return;
}
@@ -340,6 +352,7 @@ export default function CreateImage() {
}, [
loggedIn,
openSignupCta,
openInsufficientCredits,
prompt,
isGenerating,
selectedModel,
@@ -354,6 +367,7 @@ export default function CreateImage() {
setBatchJobToken,
completeBatch,
failBatch,
dismissBatch,
]);
// ── Render ────────────────────────────────────────────────────────────
@@ -364,8 +378,20 @@ export default function CreateImage() {
description="Generate stunning AI images with ArtCraft"
authChecked={authChecked}
hasContent={hasContent}
emptyStateTitle="Generate Image"
emptyStateSubtitle="Add a prompt, then generate"
emptyStateTitle="Create Image"
emptyStateSubtitle="Describe anything. See it in seconds."
emptyStateCta={
loggedIn ? undefined : (
<Button
variant="primary"
onClick={openSignupCta}
icon={faSparkles}
className="h-12 px-6 text-base font-semibold rounded-full"
>
Sign up to create
</Button>
)
}
bottomOffset={promptHeight + 24}
modelItems={modelItems}
onModelChange={handleModelChange}
@@ -18,7 +18,12 @@ export interface GenerateImageParams {
export async function enqueueImageGeneration(
params: GenerateImageParams,
): Promise<{ success: boolean; jobToken?: string; error?: string }> {
): Promise<{
success: boolean;
jobToken?: string;
error?: string;
errorCode?: number;
}> {
const body: OmniGenImageRequest = {
model: params.model,
prompt: params.prompt,
@@ -40,10 +45,23 @@ export async function enqueueImageGeneration(
}
return { success: false, error: "Generation failed" };
} catch (err: any) {
return { success: false, error: err.message ?? "Request failed" };
return {
success: false,
error: err.message ?? "Request failed",
errorCode: parseHttpStatusCode(err),
};
}
}
// Pull the HTTP status out of an ApiManager error. ApiManager throws
// `Error("HTTP error! status: 402")` on a non-2xx response (the JSON body is
// not surfaced), so callers can special-case codes like 402 Payment Required.
function parseHttpStatusCode(err: unknown): number | undefined {
const message = err instanceof Error ? err.message : String(err);
const match = /status:\s*(\d+)/.exec(message);
return match ? Number(match[1]) : undefined;
}
// ── Poll for completion ──────────────────────────────────────────────────
export async function pollJobResult(
@@ -1,10 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock, faWaveformLines } from "@fortawesome/pro-solid-svg-icons";
import {
faClock,
faSparkles,
faWaveformLines,
} from "@fortawesome/pro-solid-svg-icons";
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
import type { OmniGenVideoModelInfo } from "@storyteller/api";
import { ToggleButton } from "@storyteller/ui-button";
import { Button, ToggleButton } from "@storyteller/ui-button";
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
import { SliderV2 } from "@storyteller/ui-sliderv2";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -45,6 +49,7 @@ import {
getModelCreatorIconPath,
} from "../../lib/omni-gen-hooks";
import { useSignupCta } from "../../components/signup-cta-modal";
import { useInsufficientCredits } from "../../components/insufficient-credits-modal";
// ── Constants ────────────────────────────────────────────────────────────
@@ -206,6 +211,7 @@ function resolveDurationForModel(
export default function CreateVideo() {
const { user, authChecked } = useAuthCheck();
const { loggedIn, openSignupCta } = useSignupCta();
const openInsufficientCredits = useInsufficientCredits();
const { promptBoxRef, promptHeight } = usePromptHeight();
// Fetch models from API
@@ -323,6 +329,7 @@ export default function CreateVideo() {
const setBatchJobToken = useCreateVideoStore((s) => s.setBatchJobToken);
const completeBatch = useCreateVideoStore((s) => s.completeBatch);
const failBatch = useCreateVideoStore((s) => s.failBatch);
const dismissBatch = useCreateVideoStore((s) => s.dismissBatch);
const pollingCleanupsRef = useRef<Map<string, () => void>>(new Map());
// Derived model capabilities
@@ -879,7 +886,14 @@ export default function CreateVideo() {
if (!result.success || !result.jobToken) {
console.warn("[generate-video] enqueue failed", result.error);
failBatch(batchId, result.error ?? "Failed to start generation");
// 402 Payment Required: the user is out of credits. Drop the pending
// card and surface the upgrade modal instead of a failed-card error.
if (result.errorCode === 402) {
dismissBatch(batchId);
openInsufficientCredits();
} else {
failBatch(batchId, result.error ?? "Failed to start generation");
}
} else {
setBatchJobToken(batchId, result.jobToken);
console.log("[generate-video] polling started", {
@@ -919,6 +933,7 @@ export default function CreateVideo() {
}, [
loggedIn,
openSignupCta,
openInsufficientCredits,
prompt,
needsImage,
isReferenceMode,
@@ -941,6 +956,7 @@ export default function CreateVideo() {
setBatchJobToken,
completeBatch,
failBatch,
dismissBatch,
]);
// ── Render ────────────────────────────────────────────────────────────
@@ -959,8 +975,20 @@ export default function CreateVideo() {
description="Generate stunning AI videos with ArtCraft"
authChecked={authChecked}
hasContent={hasContent}
emptyStateTitle="Generate Video"
emptyStateSubtitle="Add a prompt, then generate"
emptyStateTitle="Create Video"
emptyStateSubtitle="Describe a scene. See it in motion."
emptyStateCta={
loggedIn ? undefined : (
<Button
variant="primary"
onClick={openSignupCta}
icon={faSparkles}
className="h-12 px-6 text-base font-semibold rounded-full"
>
Sign up to create
</Button>
)
}
bottomOffset={promptHeight + 24}
modelItems={modelItems}
onModelChange={handleModelChange}
@@ -24,7 +24,12 @@ export interface GenerateVideoParams {
export async function enqueueVideoGeneration(
params: GenerateVideoParams,
): Promise<{ success: boolean; jobToken?: string; error?: string }> {
): Promise<{
success: boolean;
jobToken?: string;
error?: string;
errorCode?: number;
}> {
const body: OmniGenVideoRequest = {
model: params.model,
prompt: params.prompt,
@@ -60,10 +65,23 @@ export async function enqueueVideoGeneration(
}
return { success: false, error: "Generation failed" };
} catch (err: any) {
return { success: false, error: err.message ?? "Request failed" };
return {
success: false,
error: err.message ?? "Request failed",
errorCode: parseHttpStatusCode(err),
};
}
}
// Pull the HTTP status out of an ApiManager error. ApiManager throws
// `Error("HTTP error! status: 402")` on a non-2xx response (the JSON body is
// not surfaced), so callers can special-case codes like 402 Payment Required.
function parseHttpStatusCode(err: unknown): number | undefined {
const message = err instanceof Error ? err.message : String(err);
const match = /status:\s*(\d+)/.exec(message);
return match ? Number(match[1]) : undefined;
}
// ── Poll for completion ──────────────────────────────────────────────────
export async function pollVideoJobResult(
@@ -3,7 +3,7 @@ import { useSearchParams } from "react-router-dom";
import { faCoins } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { UsersApi } from "@storyteller/api";
import { BillingApi, UsersApi } from "@storyteller/api";
import Seo from "../../components/seo";
import {
PricingTable,
@@ -66,18 +66,45 @@ const SeedanceBanner = () => (
</div>
);
// Compact "top up" CTA shown above the plan table for users who already have a
// plan, so returning subscribers can buy one-time credit packs without scrolling.
const BuyMoreCreditsCta = ({ onClick }: { onClick: () => void }) => (
<div className="mb-8 flex flex-col items-center text-center">
<p className="text-base text-white/65">Need more credits?</p>
<Button
variant="secondary"
className="mt-3 gap-2 rounded-full border border-white/[0.1] bg-white/[0.06] hover:bg-white/[0.1] px-5 py-2 h-11 text-[14px] font-semibold text-white"
onClick={onClick}
>
<FontAwesomeIcon icon={faCoins} className="text-primary text-[13px]" />
Buy more credits
</Button>
</div>
);
const Pricing = () => {
const [searchParams] = useSearchParams();
const isSeedanceRef = searchParams.get("ref") === "sd2fakeyou";
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [hasPlan, setHasPlan] = useState(false);
useEffect(() => {
const check = async () => {
try {
const api = new UsersApi();
const res = await api.GetSession();
setIsLoggedIn(res.success && !!res.data?.loggedIn && !!res.data?.user);
const loggedIn =
res.success && !!res.data?.loggedIn && !!res.data?.user;
setIsLoggedIn(loggedIn);
if (!loggedIn) return;
const subs = await new BillingApi().ListActiveSubscriptions();
setHasPlan(
!!subs.success &&
!!subs.data?.active_subscriptions?.some(
(sub) => sub.namespace === "artcraft",
),
);
} catch {
// not logged in
}
@@ -110,6 +137,9 @@ const Pricing = () => {
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-8 xl:gap-12 items-start">
<SeedanceBanner />
<div className="w-full">
{hasPlan && (
<BuyMoreCreditsCta onClick={() => setCreditsModalOpen(true)} />
)}
<PricingTable
showHeader={false}
unifiedTheme
@@ -135,6 +165,9 @@ const Pricing = () => {
everyone.
</p>
</div>
{hasPlan && (
<BuyMoreCreditsCta onClick={() => setCreditsModalOpen(true)} />
)}
<PricingTable
showHeader={false}
unifiedTheme
@@ -145,8 +178,8 @@ const Pricing = () => {
)}
</main>
{isLoggedIn && (
<div className="relative z-10 flex flex-col items-center px-4 pb-4 sm:px-8">
{isLoggedIn && !hasPlan && (
<div className="relative z-10 flex flex-col items-center px-4 pb-4 sm:px-8 pt-6">
<div className="inline-flex items-center gap-2 text-white/40">
<div className="h-px w-8 bg-white/20" />
<span className="text-sm">Or</span>
@@ -1,4 +1,10 @@
import { faCheck, faStar, faGem } from "@fortawesome/pro-solid-svg-icons";
import {
faCheck,
faStar,
faGem,
faCopy,
faEnvelope,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import {
@@ -6,7 +12,7 @@ import {
SubscriptionPlanDetails,
} from "@storyteller/subscription";
import { twMerge } from "tailwind-merge";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { TabSelector } from "@storyteller/ui-tab-selector";
import { UsersApi, BillingApi, UserInfo } from "@storyteller/api";
import {
@@ -33,6 +39,8 @@ const ENTERPRISE_FEATURES = [
"Custom integrations",
];
const CONTACT_EMAIL = "hello@storyteller.ai";
// Mapping from our plan slugs to API plan slugs
const PLAN_SLUG_MAP: Record<string, string> = {
artcraft_basic: "artcraft_basic",
@@ -516,12 +524,10 @@ const PricingTable = ({
For bespoke solutions
</div>
<a
href="mailto:hello@storyteller.ai"
className="w-full flex items-center justify-center gap-2 rounded-xl border border-white/20 bg-white/10 hover:bg-white/15 text-white px-4 py-2 text-sm font-medium transition-colors mb-6 md:mb-8 h-11"
>
Contact Us
</a>
<ContactUsButton
wrapperClassName="w-full mb-6 md:mb-8"
triggerClassName="w-full flex items-center justify-center gap-2 rounded-xl border border-white/20 bg-white/10 hover:bg-white/15 text-white px-4 py-2 text-sm font-medium transition-colors h-11"
/>
<div className="text-sm text-white/50 mb-3">
Everything in Max, plus:
@@ -561,12 +567,11 @@ const PricingTable = ({
))}
</div>
</div>
<a
href="mailto:hello@storyteller.ai"
className="md:self-center flex-shrink-0 flex items-center justify-center gap-2 rounded-lg border border-white/20 bg-white/10 hover:bg-white/15 text-white px-4 py-2 text-sm font-medium transition-colors"
>
Contact Us
</a>
<ContactUsButton
wrapperClassName="md:self-center flex-shrink-0"
triggerClassName="w-full md:w-auto flex items-center justify-center gap-2 rounded-lg border border-white/20 bg-white/10 hover:bg-white/15 text-white px-4 py-2 text-sm font-medium transition-colors"
menuClassName="right-0 min-w-[12rem]"
/>
</div>
)}
@@ -640,4 +645,88 @@ const Feature = ({
</li>
);
// "Contact Us" with a small popover offering copy-to-clipboard or a mailto
// link, so users without a configured mail client can still grab the address.
const ContactUsButton = ({
triggerClassName,
wrapperClassName,
menuClassName = "left-0 right-0",
}: {
triggerClassName: string;
wrapperClassName?: string;
menuClassName?: string;
}) => {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close the popover on an outside click or Escape.
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent) => {
if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(CONTACT_EMAIL);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard unavailable (e.g. insecure context); silently ignore.
}
};
return (
<div ref={containerRef} className={twMerge("relative", wrapperClassName)}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className={triggerClassName}
>
Contact Us
</button>
{open && (
<div
className={twMerge(
"absolute top-full z-20 mt-2 overflow-hidden rounded-xl border border-white/15 bg-[#1b1b20] shadow-[0_20px_60px_-15px_rgba(0,0,0,0.7)]",
menuClassName,
)}
>
<button
type="button"
onClick={handleCopy}
className="flex w-full items-center gap-2.5 px-4 py-3 text-left text-sm text-white/80 transition-colors hover:bg-white/[0.06]"
>
<FontAwesomeIcon
icon={copied ? faCheck : faCopy}
className="w-4 text-primary"
/>
{copied ? "Copied!" : "Copy email"}
</button>
<a
href={`mailto:${CONTACT_EMAIL}`}
onClick={() => setOpen(false)}
className="flex w-full items-center gap-2.5 border-t border-white/10 px-4 py-3 text-left text-sm text-white/80 transition-colors hover:bg-white/[0.06]"
>
<FontAwesomeIcon icon={faEnvelope} className="w-4 text-primary" />
Email us
</a>
</div>
)}
</div>
);
};
export default PricingTable;