diff --git a/frontend/apps/artcraft-webapp/src/components/generation-gallery/CreateMediaPageShell.tsx b/frontend/apps/artcraft-webapp/src/components/generation-gallery/CreateMediaPageShell.tsx
index 4c13d1f581..abb3c89f72 100644
--- a/frontend/apps/artcraft-webapp/src/components/generation-gallery/CreateMediaPageShell.tsx
+++ b/frontend/apps/artcraft-webapp/src/components/generation-gallery/CreateMediaPageShell.tsx
@@ -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({
))}
+ {/* Subtle truchet pattern, only on the empty state so it never sits
+ behind the gallery grid. Masked to a soft center vignette. */}
+ {!hasContent && (
+
)}
diff --git a/frontend/apps/artcraft-webapp/src/components/insufficient-credits-modal.tsx b/frontend/apps/artcraft-webapp/src/components/insufficient-credits-modal.tsx
new file mode 100644
index 0000000000..f1900d95e3
--- /dev/null
+++ b/frontend/apps/artcraft-webapp/src/components/insufficient-credits-modal.tsx
@@ -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((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 credits.
+ >
+ ),
+ 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 credits.
+ >
+ ),
+ 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 {
+ 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 (
+ <>
+
+
{progressPercent}%
diff --git a/frontend/apps/artcraft-webapp/src/pages/create-image/create-image.tsx b/frontend/apps/artcraft-webapp/src/pages/create-image/create-image.tsx
index 4876f2cadb..237a8c4c5d 100644
--- a/frontend/apps/artcraft-webapp/src/pages/create-image/create-image.tsx
+++ b/frontend/apps/artcraft-webapp/src/pages/create-image/create-image.tsx
@@ -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
);
+// 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 }) => (
+