makearmy-app/components/account/ConnectKofi.tsx
makearmy 912cf71bb9 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

137 lines
4.9 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.

// 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>
);
}