diff --git a/apps/supercode-cli/client/app/layout.tsx b/apps/supercode-cli/client/app/layout.tsx index 08d4172..834ce74 100644 --- a/apps/supercode-cli/client/app/layout.tsx +++ b/apps/supercode-cli/client/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "sonner"; +import { DodoPaymentsScript } from "@/components/DodoPaymentsScript"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -37,6 +38,7 @@ export default function RootLayout({ > {children} + diff --git a/apps/supercode-cli/client/app/studio/page.tsx b/apps/supercode-cli/client/app/studio/page.tsx new file mode 100644 index 0000000..0d7eac5 --- /dev/null +++ b/apps/supercode-cli/client/app/studio/page.tsx @@ -0,0 +1,782 @@ +"use client" + +import { Suspense, useEffect, useState, useCallback } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { authClient } from "@/lib/auth-client" +import { Spinner } from "@/components/ui/spinner" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { toast } from "sonner" +import { + ArrowUpRight, + Loader2, + Sparkles, + Shield, + RefreshCw, + ExternalLink, + AlertTriangle, + CheckCircle2, + Zap, +} from "lucide-react" + +type Plan = { + id: string + tier: string + name: string + description: string | null + variant: string | null + interval: string | null + priceCents: number + currency: string + requestLimit: number + contextLimit: number + modelAccess: string + creditAmountCents: number + dodoProductId: string | null + sortOrder: number +} + +type SubscriptionInfo = { + id: string + status: string + currentPeriodStart: string | null + currentPeriodEnd: string | null + trialEndsAt: string | null + cancelAtPeriodEnd: boolean + isGrandfathered: boolean +} + +type CreditBalance = { + balanceCents: number + totalCredits: number + resetAt: string | null +} + +const CHECKOUT_BASE = + process.env.NEXT_PUBLIC_DODO_CHECKOUT_BASE ?? "https://checkout.dodopayments.com/buy" + +const PRODUCT_CHECKOUT_URLS: Record = { + // ── test mode ── + pdt_0Nk0u6EggnAdDxGtoLa1W: `${CHECKOUT_BASE}/pdt_0Nk0u6EggnAdDxGtoLa1W?quantity=1`, + pdt_0NkRjph71bwF2z3aBulCG: `${CHECKOUT_BASE}/pdt_0Nk0vwfI1kYrDaRsQzEUQ?quantity=1`, + pdt_0Nk0vS5WMtkT7u2T7P70e: `${CHECKOUT_BASE}/pdt_0Nk0vS5WMtkT7u2T7P70e?quantity=1`, + pdt_0NkRm62YJBP043k9sEDab: `${CHECKOUT_BASE}/pdt_0NkRm62YJBP043k9sEDab?quantity=1`, + pdt_0Nk0vwfI1kYrDaRsQzEUQ: `${CHECKOUT_BASE}/pdt_0Nk0vwfI1kYrDaRsQzEUQ?quantity=1`, + pdt_0NkRmfmsthQToUr41UAnd: `${CHECKOUT_BASE}/pdt_0NkRmfmsthQToUr41UAnd?quantity=1`, + pdt_0NkRmxBRNTOME3FYF9Qkg: `${CHECKOUT_BASE}/pdt_0NkRmxBRNTOME3FYF9Qkg?quantity=1`, + // ── live mode ── + pdt_0NkW4k2cUeO1f7a8yLje5: `${CHECKOUT_BASE}/pdt_0NkW4k2cUeO1f7a8yLje5?quantity=1`, + pdt_0NkW59I3J7uy2m1j0RAOF: `${CHECKOUT_BASE}/pdt_0NkW59I3J7uy2m1j0RAOF?quantity=1`, + pdt_0NkW5Vvq7Uxw55sjMlDFe: `${CHECKOUT_BASE}/pdt_0NkW5Vvq7Uxw55sjMlDFe?quantity=1`, + pdt_0NkW5s9M64jbHMoKrIpsl: `${CHECKOUT_BASE}/pdt_0NkW5s9M64jbHMoKrIpsl?quantity=1`, + pdt_0NkW6PfRqqooJXIMaX4Ci: `${CHECKOUT_BASE}/pdt_0NkW6PfRqqooJXIMaX4Ci?quantity=1`, + pdt_0NkW6cjti170KbFpeolfw: `${CHECKOUT_BASE}/pdt_0NkW6cjti170KbFpeolfw?quantity=1`, + pdt_0NkW6tnjud9Sp1iGr9TXj: `${CHECKOUT_BASE}/pdt_0NkW6tnjud9Sp1iGr9TXj?quantity=1`, +} + +function getCheckoutUrl(plan: Plan): string | null { + if (!plan.dodoProductId) return null + return PRODUCT_CHECKOUT_URLS[plan.dodoProductId] ?? null +} + +const TIER_COLORS: Record = { + spark: "text-emerald-400 border-emerald-500/30", + "spark-premium": "text-cyan-400 border-cyan-500/30", + pro: "text-amber-400 border-amber-500/30", + ultra: "text-purple-400 border-purple-500/30", +} + +const TIER_BG: Record = { + spark: "bg-emerald-500/[0.03]", + "spark-premium": "bg-cyan-500/[0.03]", + pro: "bg-amber-500/[0.04]", + ultra: "bg-purple-500/[0.03]", +} + +function BackgroundGlow() { + return ( + <> +
+
+
+ + ) +} + +function getFeatures(tier: string) { + if (tier === "spark") return [ + { label: "Open models only", included: true }, + { label: "16K context window", included: true }, + { label: "~10K requests/month", included: true }, + { label: "Standard token limits", included: true }, + { label: "$5 monthly credits + deals", included: true }, + { label: "Memory (cross-session)", included: false }, + { label: "Merge.dev Agent Handler", included: false }, + { label: "Priority access", included: false }, + ] + if (tier === "spark-premium") return [ + { label: "Open models only", included: true }, + { label: "32K context window", included: true }, + { label: "~15K requests/month (resets)", included: true }, + { label: "Standard token limits", included: true }, + { label: "$10 monthly credits + deals", included: true }, + { label: "Memory (cross-session)", included: false }, + { label: "Merge.dev Agent Handler", included: false }, + { label: "Priority access", included: false }, + ] + if (tier === "pro") return [ + { label: "Open-source + premium models", included: true }, + { label: "128K context window", included: true }, + { label: "~25K requests/month", included: true }, + { label: "Higher token limits", included: true }, + { label: "Usage analytics", included: true }, + { label: "Memory (cross-session)", included: true }, + { label: "Merge.dev Agent Handler", included: true }, + { label: "Priority access", included: true }, + ] + return [ + { label: "All models (unrestricted)", included: true }, + { label: "1M context window", included: true }, + { label: "~110K requests/month", included: true }, + { label: "Maximum token limits", included: true }, + { label: "Highest rate limits", included: true }, + { label: "Usage analytics", included: true }, + { label: "Priority support", included: true }, + { label: "99.9% availability SLA", included: true }, + ] +} + +function StatBadge({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} + +function PlanLimitBar({ used, limit }: { used: number; limit: number }) { + const pct = Math.min(100, Math.round((used / limit) * 100)) + const color = pct > 90 ? "bg-red-500" : pct > 70 ? "bg-amber-500" : "bg-cyan-500" + return ( +
+
+ Requests used + + {used.toLocaleString()} / {limit.toLocaleString()} ({pct}%) + +
+
+
+
+
+ ) +} + +function CreditMeter({ balance }: { balance: CreditBalance }) { + const pct = Math.min(100, Math.round((balance.balanceCents / Math.max(balance.totalCredits, 1)) * 100)) + return ( +
+
+ Credits remaining + + ${(balance.balanceCents / 100).toFixed(2)} / ${(balance.totalCredits / 100).toFixed(2)} ({pct}%) + +
+
+
+
+
+ ) +} + +function formatPrice(plan: Plan): string { + if (plan.currency === "INR") { + return `₹${(plan.priceCents / 100).toLocaleString("en-IN")}` + } + return `$${(plan.priceCents / 100).toFixed(0)}` +} + +/** Resolve the regional variant (India vs international) from the browser locale. */ +function detectVariant(): "in" | "int" { + if (typeof navigator === "undefined") return "int" + const region = navigator.language?.split("-")[1]?.toUpperCase() + return region === "IN" ? "in" : "int" +} + +function getNextBillingDate(interval: string | null): string { + const d = new Date() + if (interval === "year") { + d.setFullYear(d.getFullYear() + 1) + } else { + d.setMonth(d.getMonth() + 1) + } + return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }) +} + +function StudioPage() { + const { data: session, isPending: sessionLoading } = authClient.useSession() + const router = useRouter() + const searchParams = useSearchParams() + + const [plans, setPlans] = useState([]) + const [subscription, setSubscription] = useState(null) + const [currentPlan, setCurrentPlan] = useState(null) + const [creditBalance, setCreditBalance] = useState(null) + const [requestsUsed, setRequestsUsed] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [cancelOpen, setCancelOpen] = useState(false) + const [cancelling, setCancelling] = useState(false) + const [refunding, setRefunding] = useState(false) + const [confirmingPlan, setConfirmingPlan] = useState(null) + + const userId = session?.user?.id + + useEffect(() => { + if (!sessionLoading && !session?.session) { + router.replace(`/sign-in?redirect=${encodeURIComponent("/studio")}`) + } + }, [session, sessionLoading, router]) + + // Surface success/cancelled from the Dodo return/cancel URL + useEffect(() => { + if (searchParams.get("success")) toast.success("Checkout complete — welcome aboard!") + if (searchParams.get("cancelled")) toast.info("Checkout cancelled") + }, [searchParams, toast]) + + // Auto-scroll to / highlight the plan requested via ?plan= (from /pricing) + useEffect(() => { + const requested = searchParams.get("plan") + if (!requested) return + if (!loading && plans.length > 0) { + const target = document.getElementById(`plan-card-${requested}`) + if (target) { + target.scrollIntoView({ behavior: "smooth", block: "center" }) + target.classList.add("ring-2", "ring-amber-500/60") + setTimeout(() => target.classList.remove("ring-2", "ring-amber-500/60"), 2500) + } + } + }, [searchParams, plans, loading]) + + const fetchData = useCallback(async () => { + if (!userId) return + try { + setError(null) + const variant = detectVariant() + const [plansRes, statusRes] = await Promise.all([ + fetch(`/api/billing/plans?isGrandfathered=true&variant=${variant}`), + fetch(`/api/billing/status?userId=${encodeURIComponent(userId)}`), + ]) + if (!plansRes.ok) throw new Error(`Plans API: ${plansRes.status}`) + if (!statusRes.ok) throw new Error(`Status API: ${statusRes.status}`) + const plansData = await plansRes.json() + const statusData = await statusRes.json() + setPlans(plansData.plans ?? []) + setSubscription(statusData.subscription ?? null) + setCurrentPlan(statusData.plan ?? null) + setCreditBalance(statusData.creditBalance ?? null) + setRequestsUsed(statusData.requestsUsed ?? 0) + } catch (err) { + setError(String(err)) + } finally { + setLoading(false) + } + }, [userId]) + + useEffect(() => { + if (userId) fetchData() + }, [userId, fetchData]) + + const currentTier = currentPlan?.tier ?? "spark" + const isGrandfathered = subscription?.isGrandfathered ?? false + + const handleConfirmPayNow = useCallback(async () => { + if (!confirmingPlan || !userId) return + const url = getCheckoutUrl(confirmingPlan) + if (url) { + window.location.href = url + } else { + setConfirmingPlan(null) + toast.error("Checkout URL not available for this plan") + } + }, [confirmingPlan, userId]) + + const handleCancel = useCallback(async () => { + if (!userId) return + setCancelling(true) + try { + const res = await fetch(`/api/billing/status?userId=${encodeURIComponent(userId)}`, { + method: "DELETE", + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? "Failed to cancel subscription") + toast.success(data.message ?? "Subscription cancelled") + setCancelOpen(false) + await fetchData() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Cancel failed") + } finally { + setCancelling(false) + } + }, [userId, fetchData]) + + const handleRefund = useCallback(async () => { + if (!userId) return + setRefunding(true) + try { + const res = await fetch("/api/billing/refund", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? data.message ?? "Refund request failed") + toast.success(data.message ?? "Refund initiated") + await fetchData() + } catch (err) { + toast.error(err instanceof Error ? err.message : "Refund failed") + } finally { + setRefunding(false) + } + }, [userId, fetchData]) + + const handlePortal = useCallback(async () => { + if (!userId) return + try { + const res = await fetch("/api/billing/status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId, action: "portal" }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error ?? "Failed to get portal link") + if (data.url) window.open(data.url, "_blank", "noopener,noreferrer") + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to open portal") + } + }, [userId]) + + if (sessionLoading || (!session?.session && !loading)) { + return ( +
+ +
+ ) + } + + const upgradePlans = plans.filter( + (p) => p.tier !== currentTier && p.tier !== "spark", + ) + const sparkPremiumPlan = plans.find((p) => p.tier === "spark-premium") + + return ( +
+ + +
+ {/* Header */} +
+
+ + Billing Studio + +

+ Your Plan & Usage +

+
+
+ + +
+
+ + {loading ? ( +
+ +
+ ) : error ? ( + + +
+ +
+

Failed to load billing data

+

{error}

+
+
+ +
+
+ ) : ( +
+ {/* Current plan card */} + + +
+
+
+

+ {currentPlan?.name ?? "No active plan"} +

+ + {subscription?.status ?? "none"} + + {isGrandfathered && ( + + Grandfathered + + )} +
+

+ {currentPlan?.description ?? "Subscribe to a plan to get started."} +

+
+ {subscription && ( + + )} +
+ + {/* Stats grid */} +
+ + + + +
+ + {/* Request usage bar */} + + + {/* Credit balance */} + {creditBalance && ( +
+ + {creditBalance.resetAt && ( +

+ Resets {new Date(creditBalance.resetAt).toLocaleDateString()} +

+ )} +
+ )} + + {/* Features list */} +
+ + Plan features + +
+ {getFeatures(currentTier).map((f) => ( +
+ {f.included ? ( + + ) : ( + + )} + {f.label} +
+ ))} +
+
+ + {/* Cancel button */} + {currentTier !== "spark" && ( +
+ + + + + + + Cancel subscription? + + Your plan will remain active until the end of the billing period. + {isGrandfathered + ? " You will fall back to the free Spark plan (10K requests, 16K context)." + : " Without a paid plan, you will need Spark Premium to keep using the CLI."} + + + + Keep Plan + + {cancelling ? : null} + Confirm Cancel + + + + +
+ )} +
+
+ + {/* Spark Premium upgrade card (Spark users only) */} + {currentTier === "spark" && sparkPremiumPlan && ( + + +
+
+
+ +
+
+
+

Spark Premium

+ + Recommended + +
+

+ Upgrade for $1/month — unlock 15K requests, 32K context, and $10 monthly credits with deal multipliers. +

+
+
+
+
+ $1 + /month + + processing fee +
+ +
+
+ + {/* Mini stats */} +
+
+
15K
+
Requests
+
+
+
32K
+
Context
+
+
+
$10
+
Credits
+
+
+
+
+ )} + + {/* No active plan → show sign-up prompt for new users */} + {!currentPlan && ( + + +
+
+ +
+
+

Get started with Spark Premium

+

+ $1/month + processing fee — 15K requests, 32K context, $10 monthly credits, open models. + Fully refundable if you use less than $5 of credits and 7.5K requests. +

+
+
+
+
+ )} + + {/* Other plan options */} + {upgradePlans.length > 0 && ( +
+

+ Available upgrades +

+
+ {upgradePlans.map((plan) => ( + + +
+
+

{plan.name}

+

{plan.description}

+
+
+ + {formatPrice(plan)} + + /{plan.interval} +
+
+
+
+ {plan.requestLimit.toLocaleString()} + requests +
+
+ {(plan.contextLimit / 1000).toFixed(0)}K + context +
+
+ +
+
+ ))} +
+
+ )} + + {/* Refund section (Spark Premium only) */} + {currentTier === "spark-premium" && ( + + +
+
+ +
+

Request Refund

+

+ Refundable if credits consumed < $5 and requests used < 7.5K. + Otherwise, refunds are handled on a case-by-case basis. +

+
+
+ +
+
+
+ )} +
+ )} +
+ + {/* Confirm checkout dialog */} + { if (!open) setConfirmingPlan(null) }}> + + + + Confirm plan changes + + + + {confirmingPlan && ( +
+ {/* Plan + price */} +
+
+ {confirmingPlan.name} +

+ Billed {confirmingPlan.interval === "year" ? "yearly" : "monthly"}, starting today +

+
+ {formatPrice(confirmingPlan)} +
+ + {/* Dashed divider */} +
+ + {/* Due today */} +
+
+ Due today +

+ Your next billing date will be {getNextBillingDate(confirmingPlan.interval)} +

+
+ {formatPrice(confirmingPlan)} +
+ + {/* UPI note (INR only) */} + {confirmingPlan.currency === "INR" ? ( +

+ UPI auto-pay can't approve this charge on its own. After you click Pay now, you'll be redirected to enter your UPI PIN to authorise it. +

+ ) : ( +

+ You'll be redirected to complete payment securely. +

+ )} +
+ )} + + {/* Footer */} +
+ + + + +
+ +
+
+ ) +} + +export default function StudioPageWrapper() { + return ( + + +
+ } + > + + + ) +} diff --git a/apps/supercode-cli/client/components/DodoPaymentsScript.tsx b/apps/supercode-cli/client/components/DodoPaymentsScript.tsx new file mode 100644 index 0000000..73068db --- /dev/null +++ b/apps/supercode-cli/client/components/DodoPaymentsScript.tsx @@ -0,0 +1,13 @@ +"use client" + +import Script from "next/script" + +export function DodoPaymentsScript() { + return ( +