"use client"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; import { UpgradeProButton } from "@/components/UpgradeProButton"; import { CREDIT_PRICE_CENTS, FREE_EXTRA_CREDITS_MAX, FREE_TOP_UP_MAX, FREE_TOP_UP_MIN, formatCreditPrice, formatEuroFromCents, } from "@/lib/credits"; type Purchase = { id: string; credits: number; amountCents: number; completedAt: string | null; }; type Props = { initialCredits: number; stripeConfigured: boolean; recentPurchases?: Purchase[]; }; export function CreditPurchasePanel({ initialCredits, stripeConfigured, recentPurchases = [], }: Props) { const router = useRouter(); const [creditsBalance, setCreditsBalance] = useState(initialCredits); const [amount, setAmount] = useState(FREE_TOP_UP_MIN); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [message, setMessage] = useState(null); const room = Math.max(0, FREE_EXTRA_CREDITS_MAX - creditsBalance); const maxBuyable = Math.min(FREE_TOP_UP_MAX, room); const minBuyable = room < FREE_TOP_UP_MIN ? 0 : FREE_TOP_UP_MIN; const atCap = room === 0; const totalLabel = useMemo(() => formatCreditPrice(amount), [amount]); const unitLabel = formatCreditPrice(1); async function handleBuy() { setLoading(true); setError(null); setMessage(null); try { const res = await fetch("/api/account/credits", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credits: amount }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || "Checkout failed"); if (data.mocked) { setCreditsBalance((c) => c + (data.granted ?? amount)); setMessage( `Granted +${data.granted ?? amount} credits for ${formatEuroFromCents(data.amountCents ?? amount * CREDIT_PRICE_CENTS)} (dev mock).`, ); setLoading(false); router.refresh(); return; } if (!data.url) throw new Error("No checkout URL returned"); window.location.href = data.url; } catch (err) { setError(err instanceof Error ? err.message : "Checkout failed"); setLoading(false); } } function clampAmount(value: number) { if (maxBuyable < FREE_TOP_UP_MIN) return FREE_TOP_UP_MIN; return Math.min(maxBuyable, Math.max(FREE_TOP_UP_MIN, value)); } return (

Extra credits

Free plan: 10 monthly videos + up to {FREE_EXTRA_CREDITS_MAX} paid extras ( {unitLabel} each). After both are used, Pro is required.

Extra credit balance

{Math.min(creditsBalance, FREE_EXTRA_CREDITS_MAX)} {" "} / {FREE_EXTRA_CREDITS_MAX} credits

{creditsBalance > FREE_EXTRA_CREDITS_MAX && (

You have {creditsBalance} extras from earlier purchases; no further top-ups until under {FREE_EXTRA_CREDITS_MAX}.

)}
{atCap ? (

Free extras are capped at {FREE_EXTRA_CREDITS_MAX}. When your monthly 10 and these extras are gone, continue with Pro (€5/mo · 50 videos).

) : !stripeConfigured ? (

Card checkout is not configured yet (missing Stripe keys). In development you can also use{" "} POST /api/dev/grant-credits.

) : (

Total:{" "} {totalLabel} {" "} ({unitLabel} × {clampAmount(amount)})

)} {message &&

{message}

} {error &&

{error}

} {recentPurchases.length > 0 && (

Recent purchases

    {recentPurchases.map((p) => (
  • +{p.credits} credits · {formatEuroFromCents(p.amountCents)} {p.completedAt ? ` · ${new Date(p.completedAt).toLocaleDateString()}` : ""}
  • ))}
)}
); }