Files
songs2vid/components/PlanBillingActions.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

413 lines
14 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { QUOTA_REQUEST_EMAIL } from "@/lib/plans";
type ExtensionRequest = {
id: string;
status: string;
message: string;
requestedAt: string;
processedAt: string | null;
adminNote: string | null;
};
type ExtensionUsage = {
used: number;
limit: number;
remaining: number;
requests: ExtensionRequest[];
};
type Props = {
initialUsage: ExtensionUsage;
};
function openQuotaRequestMailto(reason?: string) {
const subject = encodeURIComponent("Songs2VID Quota reset / extension request");
const body = encodeURIComponent(
[
"Hi,",
"",
"I'd like to request a Pro quota reset or temporary extension.",
"",
reason?.trim() ? `Reason:\n${reason.trim()}` : "Reason: (optional add details here)",
"",
"Account email: (please keep the address you use to sign in)",
"",
"Thanks,",
].join("\n"),
);
window.location.href = `mailto:${QUOTA_REQUEST_EMAIL}?subject=${subject}&body=${body}`;
}
function formatEndDate(iso: string | null) {
if (!iso) return null;
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
export function PlanBillingActions({ initialUsage }: Props) {
const router = useRouter();
const [usage, setUsage] = useState(initialUsage);
const [cancelling, setCancelling] = useState(false);
const [requesting, setRequesting] = useState(false);
const [openingPortal, setOpeningPortal] = useState(false);
const [showCancelChoices, setShowCancelChoices] = useState(false);
const [cancelAtPeriodEnd, setCancelAtPeriodEnd] = useState(false);
const [endsAt, setEndsAt] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch("/api/account/cancel-subscription");
if (!res.ok) return;
const data = await res.json();
if (cancelled) return;
setCancelAtPeriodEnd(Boolean(data.cancelAtPeriodEnd));
setEndsAt(data.endsAt ?? null);
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, []);
async function handleOpenBillingPortal() {
setOpeningPortal(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/billing-portal", { method: "POST" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Could not open billing portal");
if (!data.url) throw new Error("No portal URL returned");
window.location.href = data.url;
} catch (err) {
setError(err instanceof Error ? err.message : "Could not open billing portal");
setOpeningPortal(false);
}
}
async function cancelWithMode(when: "immediate" | "period_end") {
setCancelling(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/cancel-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ when }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to cancel subscription");
setShowCancelChoices(false);
if (when === "period_end") {
setCancelAtPeriodEnd(true);
setEndsAt(data.endsAt ?? null);
const label = formatEndDate(data.endsAt ?? null);
setSuccess(
label
? `Cancellation scheduled. You keep Pro until ${label}; no further charges after that.`
: "Cancellation scheduled at the end of your current billing period. You keep Pro until then.",
);
} else {
setCancelAtPeriodEnd(false);
setEndsAt(null);
setSuccess("Subscription canceled immediately. You are now on the Free plan.");
}
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to cancel subscription");
} finally {
setCancelling(false);
}
}
async function handleResumeSubscription() {
setCancelling(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/cancel-subscription", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "resume" }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to resume subscription");
setCancelAtPeriodEnd(false);
setEndsAt(data.currentPeriodEnd ?? null);
setSuccess("Subscription kept. It will renew as usual.");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to resume subscription");
} finally {
setCancelling(false);
}
}
async function handleQuotaRequest() {
if (usage.remaining <= 0) return;
const reason = prompt(
"Optional: tell us why you need a quota reset or extension (leave blank to skip).",
);
if (reason === null) return;
setRequesting(true);
setError(null);
setSuccess(null);
try {
const res = await fetch("/api/account/quota-extension-request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: reason }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to submit request");
setUsage({
used: data.used,
limit: data.limit,
remaining: data.remaining,
requests: data.requests ?? usage.requests,
});
setSuccess(
`Request recorded (${data.used} of ${data.limit} this year). Opening your email client to contact support…`,
);
openQuotaRequestMailto(reason);
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to submit request");
} finally {
setRequesting(false);
}
}
const hasPending = usage.requests.some((r) => r.status === "PENDING");
const endLabel = formatEndDate(endsAt);
return (
<div className="mt-4 space-y-4">
<div className="rounded border border-gray-700 bg-surface px-4 py-3 text-sm">
<p className="text-gray-300">
Extension requests this year:{" "}
<span className="font-medium text-white">
{usage.used} / {usage.limit}
</span>
{usage.remaining > 0 ? (
<span className="text-gray-500"> · {usage.remaining} remaining</span>
) : (
<span className="text-yellow-400"> · limit reached</span>
)}
</p>
</div>
{cancelAtPeriodEnd && (
<div className="rounded border border-yellow-600/40 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-100">
<p>
Cancellation scheduled
{endLabel ? (
<>
{" "}
Pro stays active until <strong>{endLabel}</strong>
</>
) : (
<> at the end of your current billing period</>
)}
. You will not be charged again.
</p>
<button
type="button"
onClick={() => void handleResumeSubscription()}
disabled={cancelling}
className="mt-2 text-sm font-medium text-accent underline hover:text-white disabled:opacity-50"
>
{cancelling ? "Working…" : "Keep my Pro subscription"}
</button>
</div>
)}
{error && (
<div className="rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-300">
{success}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap">
<button
type="button"
onClick={() => void handleOpenBillingPortal()}
disabled={openingPortal}
className="inline-flex items-center justify-center rounded border border-gray-600 px-4 py-2 text-sm font-medium text-gray-200 transition-colors hover:bg-gray-800 disabled:opacity-50"
>
{openingPortal ? "Opening…" : "Manage billing & card"}
</button>
{!cancelAtPeriodEnd && (
<button
type="button"
onClick={() => {
setShowCancelChoices(true);
setError(null);
setSuccess(null);
}}
disabled={cancelling}
className="inline-flex items-center justify-center rounded border border-yellow-600/50 px-4 py-2 text-sm font-medium text-yellow-300 transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
Cancel your plan
</button>
)}
<a
href={`mailto:${QUOTA_REQUEST_EMAIL}?subject=${encodeURIComponent("Songs2VID Quota reset / extension request")}`}
onClick={(e) => {
if (requesting || usage.remaining <= 0 || hasPending) {
e.preventDefault();
return;
}
e.preventDefault();
void handleQuotaRequest();
}}
aria-disabled={requesting || usage.remaining <= 0 || hasPending}
className={`inline-flex items-center justify-center rounded border border-accent/40 px-4 py-2 text-sm font-medium text-accent transition-colors hover:bg-accent/10 ${
requesting || usage.remaining <= 0 || hasPending
? "pointer-events-none cursor-not-allowed opacity-50"
: ""
}`}
>
{requesting
? "Submitting…"
: hasPending
? "Request pending…"
: "Request quota reset & extension"}
</a>
</div>
{showCancelChoices && (
<div
role="dialog"
aria-labelledby="cancel-plan-title"
className="rounded border border-yellow-600/40 bg-surface px-4 py-4"
>
<h3 id="cancel-plan-title" className="text-sm font-semibold text-white">
When should Pro end?
</h3>
<p className="mt-1 text-xs text-gray-400">
This is sent to Stripe. Choose how you want to cancel your subscription.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<button
type="button"
disabled={cancelling}
onClick={() => void cancelWithMode("period_end")}
className="rounded border border-gray-600 bg-surface-light px-3 py-3 text-left transition-colors hover:border-accent/50 hover:bg-accent/5 disabled:opacity-50"
>
<span className="block text-sm font-medium text-white">
At end of billing period
</span>
<span className="mt-1 block text-xs text-gray-400">
Keep Pro until your paid period ends. No more renewals after that.
</span>
</button>
<button
type="button"
disabled={cancelling}
onClick={() => void cancelWithMode("immediate")}
className="rounded border border-yellow-600/50 bg-yellow-500/5 px-3 py-3 text-left transition-colors hover:bg-yellow-500/10 disabled:opacity-50"
>
<span className="block text-sm font-medium text-yellow-200">
Cancel immediately
</span>
<span className="mt-1 block text-xs text-gray-400">
End Pro now and switch to Free. Access ends right away.
</span>
</button>
</div>
<button
type="button"
disabled={cancelling}
onClick={() => setShowCancelChoices(false)}
className="mt-3 text-xs text-gray-500 underline hover:text-gray-300 disabled:opacity-50"
>
{cancelling ? "Working…" : "Never mind"}
</button>
</div>
)}
{usage.requests.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-gray-500">
Request history
</p>
<ul className="space-y-2">
{usage.requests.slice(0, 5).map((req) => (
<li
key={req.id}
className="rounded border border-gray-800 bg-surface px-3 py-2 text-xs text-gray-400"
>
<div className="flex items-center justify-between gap-2">
<span className="text-gray-300">
{new Date(req.requestedAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
<span
className={
req.status === "APPROVED"
? "text-green-400"
: req.status === "REJECTED"
? "text-red-400"
: "text-yellow-400"
}
>
{req.status}
</span>
</div>
{req.message && <p className="mt-1 text-gray-500">{req.message}</p>}
<p className="mt-1 font-mono text-[10px] text-gray-600">ID: {req.id}</p>
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-gray-500">
Pro users may request up to 5 manual quota resets or extensions per calendar year by emailing{" "}
<a
href={`mailto:${QUOTA_REQUEST_EMAIL}`}
className="text-gray-400 underline hover:text-white"
>
{QUOTA_REQUEST_EMAIL}
</a>
. See our{" "}
<a href="/terms" className="text-gray-400 underline hover:text-white">
Terms of Service
</a>
.
</p>
</div>
);
}