Initial OSS scaffold from Songs2VID (pre-strip)

This commit is contained in:
Songs2VID OSS
2026-08-03 06:15:05 +02:00
commit 7d7043b150
195 changed files with 25023 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
"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>
);
}