purchase credits only for logged in users

This commit is contained in:
bflatastic committed 2026-04-16 11:32:37 +07:00
1 parent 6d9401c1f1
commit faaba09623
4 files changed
+206 -4

No files matched your search

@@ -0,0 +1,116 @@
import { useState } from "react";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { faCoins } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { BillingApi } from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
interface CreditPack {
id: string;
total: number;
priceUsd: number;
badge?: string;
}
const CREDIT_PACKS: CreditPack[] = [
{ id: "artcraft_1000", total: 1000, priceUsd: 10 },
{ id: "artcraft_2500", total: 2500, priceUsd: 25 },
{ id: "artcraft_5000", total: 5000, priceUsd: 50, badge: "Popular" },
{ id: "artcraft_10000", total: 10000, priceUsd: 100 },
];
interface CreditsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
const [purchasingId, setPurchasingId] = useState<string | null>(null);
const handlePurchase = async (pack: CreditPack) => {
setPurchasingId(pack.id);
try {
const api = new BillingApi();
const response = await api.CreditsPackCheckout({
creditsPack: pack.id,
});
if (response.success && response.data?.stripeCheckoutRedirectUrl) {
window.location.href = response.data.stripeCheckoutRedirectUrl;
} else {
toast.error(response.errorMessage ?? "Failed to start checkout");
}
} catch {
toast.error("Something went wrong. Please try again.");
} finally {
setPurchasingId(null);
}
};
return (
<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"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
resizable={false}
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
</h2>
<p className="text-white/60">
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="pointer-events-none rounded-lg px-5 py-2 text-sm font-semibold"
disabled={purchasingId !== null}
>
{purchasingId === pack.id ? "Loading..." : "Purchase"}
</Button>
</div>
</button>
))}
</div>
</div>
</Modal>
);
}
@@ -497,7 +497,7 @@ const PricingTable = ({
{/* Manage Plan Button - Only shown if user has active subscription */}
{activePlanSlug && activePlanSlug !== "free" && (
<div className="mt-2 text-center">
<div className="mt-8 text-center">
<button
onClick={handleCancelPlan}
disabled={isCancelling}
@@ -1,7 +1,13 @@
import { useEffect, useState } from "react";
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 Footer from "../../components/footer";
import Seo from "../../components/seo";
import { PricingTable } from "../../components/pricing-table";
import { useSearchParams } from "react-router-dom";
import { CreditsModal } from "../../components/credits-modal";
const SeedanceBanner = () => (
<div className="flex flex-col gap-5">
@@ -64,6 +70,23 @@ const SeedanceBanner = () => (
const Pricing = () => {
const [searchParams] = useSearchParams();
const isSeedanceRef = searchParams.get("ref") === "sd2fakeyou";
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
const [isLoggedIn, setIsLoggedIn] = 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,
);
} catch {
// not logged in
}
};
check();
}, []);
return (
<div className="relative min-h-screen bg-[#101014] text-white bg-dots">
@@ -77,7 +100,7 @@ const Pricing = () => {
</div>
{isSeedanceRef ? (
<main className="relative z-10 pt-32 pb-16 px-4 sm:px-6 lg:px-8">
<main className="relative z-10 pt-32 pb-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-[1700px] mx-auto grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-8 xl:gap-12 items-start">
<SeedanceBanner />
<div className="w-full">
@@ -92,7 +115,7 @@ const Pricing = () => {
</div>
</main>
) : (
<main className="relative z-10 pt-28 sm:pt-32 pb-20 px-4 sm:px-6 lg:px-8">
<main className="relative z-10 pt-28 sm:pt-32 pb-16 px-4 sm:px-6 lg:px-8">
<PricingTable
title="Invest in Yourself"
subtitle="You'll get a ton of generations and you'll be investing in a tool that you'll always own."
@@ -102,6 +125,27 @@ const Pricing = () => {
</main>
)}
{isLoggedIn && (
<div className="relative z-10 flex flex-col items-center px-4 pb-12 sm:px-6 lg:px-8">
<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>
<div className="h-px w-8 bg-white/20" />
</div>
<p className="mt-3 text-lg text-white/70">
Purchase one-time credit packs
</p>
<Button
variant="secondary"
className="mt-4 gap-2 rounded-xl border border-white/15 bg-white/5 px-6 py-2.5 font-semibold text-white backdrop-blur-sm hover:bg-white/10"
onClick={() => setCreditsModalOpen(true)}
>
<FontAwesomeIcon icon={faCoins} className="text-primary" />
Buy Credits
</Button>
</div>
)}
{/* Footnotes */}
<div className="relative z-10 max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 text-center">
<p className="text-md text-white/50 leading-relaxed">
@@ -111,6 +155,13 @@ const Pricing = () => {
</p>
</div>
{isLoggedIn && (
<CreditsModal
isOpen={creditsModalOpen}
onClose={() => setCreditsModalOpen(false)}
/>
)}
<Footer />
</div>
);
+35
View File
@@ -151,6 +151,41 @@ export class BillingApi extends ApiManager {
});
}
public async CreditsPackCheckout({
creditsPack,
}: {
creditsPack: string;
}): Promise<ApiResponse<{ stripeCheckoutRedirectUrl: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/stripe_artcraft/checkout/credits_pack`;
return await this.post<
{ credits_pack: string },
{
success: boolean;
stripe_checkout_redirect_url?: string;
error_message?: string;
}
>({
endpoint: endpoint,
body: {
credits_pack: creditsPack,
},
})
.then((response) => ({
success: response.success,
data: response.stripe_checkout_redirect_url
? { stripeCheckoutRedirectUrl: response.stripe_checkout_redirect_url }
: undefined,
errorMessage: response.error_message,
}))
.catch((err) => {
return {
success: false,
errorMessage: err.message,
};
});
}
public async GetPortalUrl(): Promise<
ApiResponse<{ stripePortalUrl: string }>
> {