mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-09 03:21:23 +00:00
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:
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>
|
||||
|
||||
+26
@@ -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;
|
||||
Reference in new issue
Block a user