makearmy-app/components/account/ConnectKofi.tsx

138 lines
4.9 KiB
TypeScript
Raw Normal View History

feat(support): Ko-fi end-to-end linking + badges (derive-on-read, no cron) - Add Ko-fi webhook (/api/webhooks/kofi) with upsert by (provider, external_user_id) • Computes renews_at = timestamp + 1 calendar month + 1 day • Preserves first started_at; stores raw payload; canonicalizes by email when available - Add Ko-fi claim flow • POST /api/support/kofi/claim/start — sends verification email via SMTP • GET /api/support/kofi/claim/verify — finalizes link (sets app_user), redirects to /portal/account • POST /api/support/kofi/unlink — clears app_user on Ko-fi rows - Add derive-on-read membership logic • /lib/memberships.ts — single source of truth for badges & “active” state • /api/support/badges — thin wrapper that returns per-provider badges - Account UI • components/account/SupporterBadges.tsx — renders provider badges (Ko-fi now; extensible) • components/account/ConnectKofi.tsx — “Link Ko-fi” form (email → verify link) • components/account/LinkStatus.tsx — success/error banner on return • app/portal/account/AccountPanel.tsx — integrates badges, link panel, and banner - Config/env • Requires: DIRECTUS_URL, DIRECTUS_TOKEN_ADMIN_SUPPORTER, KOFI_VERIFY_TOKEN • SMTP: SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, EMAIL_FROM • APP_ORIGIN used to build absolute verify URLs - Misc • Fixed import to use @/lib/memberships • No cron required; UI derives active state via status === active && renews_at >= now Refs: beta readiness for Ko-fi supporters
2025-10-19 17:51:04 -04:00
// components/account/ConnectKofi.tsx
"use client";
import { useCallback, useMemo, useState } from "react";
export default function ConnectKofi({
email,
userId,
}: {
email?: string | null;
userId?: string | null;
}) {
const [value, setValue] = useState(email || "");
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const canSubmit = useMemo(
() => !!value && /\S+@\S+\.\S+/.test(value),
[value]
);
const startClaim = useCallback(async () => {
setErr(null);
setMsg(null);
setBusy(true);
try {
const res = await fetch("/api/support/kofi/claim/start", {
method: "POST",
headers: {
"Content-Type": "application/json",
// if your auth middleware expects anything, set it here; otherwise cookies suffice
"x-user-id": userId ?? "",
"x-user-email": email ?? "",
},
credentials: "include",
body: JSON.stringify({ email: value }),
});
const j = await res.json().catch(() => ({} as any));
if (!res.ok) {
// Show specific errors where helpful
const detail = j?.detail || j?.error || res.statusText;
throw new Error(
j?.error === "not_found"
? "We dont have any Ko-fi records for that email yet. If youre sure its correct, try again after your next Ko-fi payment or after we run the backfill."
: String(detail || "Failed to start verification")
);
}
if (j?.alreadyLinked) {
setMsg("This Ko-fi email is already linked to your account.");
} else {
setMsg(
"Verification email sent! Check your inbox and click the link to finish linking Ko-fi."
);
}
} catch (e: any) {
setErr(e?.message || "Something went wrong.");
} finally {
setBusy(false);
}
}, [value, userId, email]);
const unlink = useCallback(async () => {
setErr(null);
setMsg(null);
setBusy(true);
try {
const res = await fetch("/api/support/kofi/unlink", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-user-id": userId ?? "",
},
credentials: "include",
});
if (!res.ok) {
const t = await res.text().catch(() => "");
throw new Error(t || "Unlink failed");
}
setMsg("Ko-fi has been unlinked from your account.");
} catch (e: any) {
setErr(e?.message || "Unlink failed.");
} finally {
setBusy(false);
}
}, [userId]);
return (
<div className="rounded-md border p-4">
<h3 className="mb-2 text-base font-semibold">Link Ko-fi</h3>
<p className="mb-3 text-sm opacity-80">
Enter the email you use on Ko-fi. Well send a one-time verification link to confirm its you.
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="email"
className="w-full rounded border px-3 py-2 text-sm"
placeholder="you@kofi-email.com"
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={busy}
/>
<button
type="button"
onClick={startClaim}
disabled={!canSubmit || busy}
className="inline-flex items-center justify-center rounded bg-black px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black"
>
{busy ? "Sending…" : "Send Verify Link"}
</button>
<button
type="button"
onClick={unlink}
disabled={busy}
className="inline-flex items-center justify-center rounded border px-4 py-2 text-sm"
title="Remove the Ko-fi link from your account"
>
Unlink
</button>
</div>
{msg && (
<div className="mt-3 rounded-md border border-emerald-300/50 bg-emerald-50 p-2 text-sm text-emerald-900">
{msg}
</div>
)}
{err && (
<div className="mt-3 rounded-md border border-red-300/50 bg-red-50 p-2 text-sm text-red-900">
{err}
</div>
)}
<p className="mt-3 text-xs opacity-70">
Tip: after you verify, badges update automatically. If you dont see a badge yet, itll appear the next time a Ko-fi payment webhook arrives (or after backfill).
</p>
</div>
);
}