Files
songs2vid/components/CreditPurchasePanel.tsx
T
Atakan Doğan Özban 9404efd86c Align preview typography with FFmpeg output, add video/song title split, and ship OSS updates.
Separate YouTube video titles from on-video song/artist fields with Pro gating, serve curated fonts and watermark assets for 1:1 preview parity, and include billing/API/docs/deploy stack for self-hosted release.
2026-07-27 16:26:19 +02:00

200 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string | null>(null);
const [message, setMessage] = useState<string | null>(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 (
<div className="mt-6 border-t border-gray-800 pt-6">
<h3 className="mb-1 text-sm font-semibold uppercase tracking-wider text-gray-500">
Extra credits
</h3>
<p className="mb-4 text-sm text-gray-400">
Free plan: 10 monthly videos + up to {FREE_EXTRA_CREDITS_MAX} paid extras (
{unitLabel} each). After both are used, Pro is required.
</p>
<div className="mb-4 rounded border border-gray-800 bg-surface p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Extra credit balance
</p>
<p className="mt-1 text-2xl font-semibold text-white">
{Math.min(creditsBalance, FREE_EXTRA_CREDITS_MAX)}
<span className="text-base font-normal text-gray-400">
{" "}
/ {FREE_EXTRA_CREDITS_MAX} credits
</span>
</p>
{creditsBalance > FREE_EXTRA_CREDITS_MAX && (
<p className="mt-1 text-xs text-amber-400">
You have {creditsBalance} extras from earlier purchases; no further top-ups until
under {FREE_EXTRA_CREDITS_MAX}.
</p>
)}
</div>
{atCap ? (
<div className="space-y-2 rounded border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-amber-100">
<p>
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).
</p>
<UpgradeProButton className="font-medium text-accent underline hover:text-white" />
</div>
) : !stripeConfigured ? (
<p className="text-sm text-amber-400">
Card checkout is not configured yet (missing Stripe keys). In development you can also
use{" "}
<code className="text-gray-300">POST /api/dev/grant-credits</code>.
</p>
) : (
<div className="space-y-4">
<label className="block text-sm text-gray-300">
Credits to buy ({FREE_TOP_UP_MIN}{maxBuyable})
<div className="mt-2 flex items-center gap-3">
<input
type="range"
min={minBuyable || FREE_TOP_UP_MIN}
max={maxBuyable}
value={clampAmount(amount)}
onChange={(e) => setAmount(clampAmount(Number(e.target.value)))}
className="w-full accent-[var(--accent,#3b82f6)]"
/>
<input
type="number"
min={FREE_TOP_UP_MIN}
max={maxBuyable}
value={clampAmount(amount)}
onChange={(e) =>
setAmount(
clampAmount(Math.floor(Number(e.target.value)) || FREE_TOP_UP_MIN),
)
}
className="w-16 rounded border border-gray-700 bg-surface px-2 py-1 text-center text-white"
/>
</div>
</label>
<p className="text-sm text-gray-300">
Total:{" "}
<span className="font-semibold text-white">{totalLabel}</span>
<span className="text-gray-500">
{" "}
({unitLabel} × {clampAmount(amount)})
</span>
</p>
<button
type="button"
disabled={loading}
onClick={handleBuy}
className="rounded bg-accent px-4 py-2 text-sm font-medium text-white hover:bg-accent/90 disabled:opacity-50"
>
{loading
? "Starting checkout…"
: `Buy ${clampAmount(amount)} credits (${totalLabel})`}
</button>
</div>
)}
{message && <p className="mt-3 text-sm text-emerald-400">{message}</p>}
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
{recentPurchases.length > 0 && (
<div className="mt-6">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
Recent purchases
</h4>
<ul className="space-y-1 text-sm text-gray-400">
{recentPurchases.map((p) => (
<li key={p.id}>
+{p.credits} credits · {formatEuroFromCents(p.amountCents)}
{p.completedAt
? ` · ${new Date(p.completedAt).toLocaleDateString()}`
: ""}
</li>
))}
</ul>
</div>
)}
</div>
);
}