From ebe2129b4be8872efad444eb07d78aa4b0990057 Mon Sep 17 00:00:00 2001 From: BFlat Date: Tue, 26 May 2026 16:08:50 +0700 Subject: [PATCH] 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 --- frontend/apps/artcraft-webapp/src/app/app.tsx | 2 + .../src/components/credits-modal.tsx | 133 +++++++---- .../CreateMediaPageShell.tsx | 26 ++ .../components/insufficient-credits-modal.tsx | 225 ++++++++++++++++++ .../src/components/signup-cta-modal.tsx | 62 ++++- .../src/components/topbar/task-queue.tsx | 17 -- .../src/pages/create-image/create-image.tsx | 32 ++- .../pages/create-image/generate-image-api.ts | 22 +- .../src/pages/create-video/create-video.tsx | 38 ++- .../pages/create-video/generate-video-api.ts | 22 +- .../src/pages/pricing/pricing.tsx | 41 +++- .../pricing-table/src/lib/pricing-table.tsx | 117 +++++++-- 12 files changed, 634 insertions(+), 103 deletions(-) create mode 100644 frontend/apps/artcraft-webapp/src/components/insufficient-credits-modal.tsx diff --git a/frontend/apps/artcraft-webapp/src/app/app.tsx b/frontend/apps/artcraft-webapp/src/app/app.tsx index 78c016a82d..52d7ab6580 100644 --- a/frontend/apps/artcraft-webapp/src/app/app.tsx +++ b/frontend/apps/artcraft-webapp/src/app/app.tsx @@ -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() { + ); } diff --git a/frontend/apps/artcraft-webapp/src/components/credits-modal.tsx b/frontend/apps/artcraft-webapp/src/components/credits-modal.tsx index a2a6e357ed..3ec22c7c15 100644 --- a/frontend/apps/artcraft-webapp/src/components/credits-modal.tsx +++ b/frontend/apps/artcraft-webapp/src/components/credits-modal.tsx @@ -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) { -
-
-

- Buy Credits +
+ {/* Off-center ambient glow, feels designed, not generic */} +
+
+ +
+

+ Buy credits

-

+

One-time credit packs. No subscription required.

-
-
- {CREDIT_PACKS.map((pack) => ( - -
- - ))} + {pack.badge && ( + + {pack.badge} + + )} + +
+ + + +
+
+ {pack.total.toLocaleString()} +
+
credits
+
+
+ +
+ + ${pack.priceUsd} + + + {isLoading ? ( + + ) : ( + <> + Buy + + + )} + +
+ + ); + })} +
+ +

+ Secure checkout via Stripe. +

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 && ( +
+ +
+ )} +
{!hasContent && ( @@ -77,6 +102,7 @@ export function CreateMediaPageShell({ {emptyStateSubtitle} + {emptyStateCta &&
{emptyStateCta}
}
)} 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 ( + <> + +
+ {/* Off-center ambient glow, feels designed, not generic */} +
+
+ +
+

+ {copy.heading} +

+

+ {copy.subtitle} +

+ +
    + {copy.perks.map((perk) => ( +
  • + + + + {perk} +
  • + ))} +
+ +
+ {hasPlan ? ( + <> + {!isOnBiggestPlan && ( + + + + )} + + + ) : ( + + + + )} + +
+
+
+ + + setCreditsOpen(false)} + /> + + ); +} diff --git a/frontend/apps/artcraft-webapp/src/components/signup-cta-modal.tsx b/frontend/apps/artcraft-webapp/src/components/signup-cta-modal.tsx index eb111ac305..88af032901 100644 --- a/frontend/apps/artcraft-webapp/src/components/signup-cta-modal.tsx +++ b/frontend/apps/artcraft-webapp/src/components/signup-cta-modal.tsx @@ -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(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 (
- {/* Off-center ambient glow — feels designed, not generic */} + {/* Off-center ambient glow, feels designed, not generic */}
crafting in seconds.

- Free to try. Sign up and pick up where you left off. + Sign up and start creating right away.

    @@ -93,19 +113,41 @@ export function SignupCtaModal() { ))}
-
+
+ {error && ( +
+ {error} +
+ )} + + +
+
+
+
+ + or + +
+ + + Already have an account?{" "} Log in diff --git a/frontend/apps/artcraft-webapp/src/components/topbar/task-queue.tsx b/frontend/apps/artcraft-webapp/src/components/topbar/task-queue.tsx index 2b0b694616..b0f2c4f9db 100644 --- a/frontend/apps/artcraft-webapp/src/components/topbar/task-queue.tsx +++ b/frontend/apps/artcraft-webapp/src/components/topbar/task-queue.tsx @@ -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 && ( - - - - )}
{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 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 : ( + + ) + } bottomOffset={promptHeight + 24} modelItems={modelItems} onModelChange={handleModelChange} diff --git a/frontend/apps/artcraft-webapp/src/pages/create-image/generate-image-api.ts b/frontend/apps/artcraft-webapp/src/pages/create-image/generate-image-api.ts index 600008e2a4..6241d29826 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-image/generate-image-api.ts +++ b/frontend/apps/artcraft-webapp/src/pages/create-image/generate-image-api.ts @@ -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( diff --git a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx index 09476a9d41..c4ddf5ca88 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx @@ -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 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 : ( + + ) + } bottomOffset={promptHeight + 24} modelItems={modelItems} onModelChange={handleModelChange} diff --git a/frontend/apps/artcraft-webapp/src/pages/create-video/generate-video-api.ts b/frontend/apps/artcraft-webapp/src/pages/create-video/generate-video-api.ts index ce8e6e1b1e..1bfed46ee9 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-video/generate-video-api.ts +++ b/frontend/apps/artcraft-webapp/src/pages/create-video/generate-video-api.ts @@ -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( diff --git a/frontend/apps/artcraft-webapp/src/pages/pricing/pricing.tsx b/frontend/apps/artcraft-webapp/src/pages/pricing/pricing.tsx index 18ec5b10ea..525c73b13e 100644 --- a/frontend/apps/artcraft-webapp/src/pages/pricing/pricing.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/pricing/pricing.tsx @@ -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 = () => (
); +// 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 }) => ( +
+

Need more credits?

+ +
+); + 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 = () => {
+ {hasPlan && ( + setCreditsModalOpen(true)} /> + )} { everyone.

+ {hasPlan && ( + setCreditsModalOpen(true)} /> + )} { )} - {isLoggedIn && ( -
+ {isLoggedIn && !hasPlan && ( +
Or diff --git a/frontend/libs/components/pricing-table/src/lib/pricing-table.tsx b/frontend/libs/components/pricing-table/src/lib/pricing-table.tsx index 3fef688d38..686dfee672 100644 --- a/frontend/libs/components/pricing-table/src/lib/pricing-table.tsx +++ b/frontend/libs/components/pricing-table/src/lib/pricing-table.tsx @@ -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 = { artcraft_basic: "artcraft_basic", @@ -516,12 +524,10 @@ const PricingTable = ({ For bespoke solutions
- - Contact Us - +
Everything in Max, plus: @@ -561,12 +567,11 @@ const PricingTable = ({ ))}
- - Contact Us - +
)} @@ -640,4 +645,88 @@ const Feature = ({ ); +// "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(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 ( +
+ + + {open && ( + + )} +
+ ); +}; + export default PricingTable;