mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-04 09:28:11 +00:00
purchase credits only for logged in users
This commit is contained in:
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>
|
||||
);
|
||||
|
||||
@@ -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 }>
|
||||
> {
|
||||
|
||||
Reference in new issue
Block a user