- 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
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
// components/account/SupporterBadges.tsx
|
||
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
type SupportBadge = {
|
||
provider: "kofi" | "patreon" | "mighty" | string;
|
||
active: boolean; // currently entitled (status==="active" && renews_at >= now)
|
||
kind: "member" | "one_time" | "inactive";
|
||
label: string; // e.g., "Ko-fi • Bronze" or "Ko-fi Supporter"
|
||
tier?: string | null;
|
||
renews_at?: string | null;
|
||
started_at?: string | null;
|
||
};
|
||
|
||
function providerIcon(p: string) {
|
||
// Swap these for real icons later if you want
|
||
if (p === "kofi") return "☕";
|
||
if (p === "patreon") return "🅿️";
|
||
if (p === "mighty") return "💬";
|
||
return "🎖️";
|
||
}
|
||
|
||
export default function SupporterBadges({
|
||
email,
|
||
userId,
|
||
}: {
|
||
email?: string | null;
|
||
userId?: string | null;
|
||
}) {
|
||
const [badges, setBadges] = useState<SupportBadge[] | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let url = "/api/support/badges";
|
||
const params = new URLSearchParams();
|
||
if (email) params.set("email", String(email));
|
||
if (userId) params.set("userId", String(userId));
|
||
const qs = params.toString();
|
||
if (qs) url += `?${qs}`;
|
||
|
||
fetch(url, { cache: "no-store" })
|
||
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
||
.then((json) => setBadges(Array.isArray(json?.badges) ? json.badges : []))
|
||
.catch(async (e) => {
|
||
try {
|
||
const t = await e.text();
|
||
setError(t || String(e));
|
||
} catch {
|
||
setError(String(e));
|
||
}
|
||
});
|
||
}, [email, userId]);
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="mt-4 rounded-lg border border-red-300/50 bg-red-50 p-3 text-sm text-red-800">
|
||
Couldn’t load supporter badges.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!badges) {
|
||
return (
|
||
<div className="mt-4 animate-pulse rounded-lg border p-3 text-sm opacity-60">
|
||
Loading supporter badges…
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (badges.length === 0) {
|
||
return (
|
||
<div className="mt-4 rounded-lg border p-3 text-sm opacity-70">
|
||
No supporter badges yet.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="mt-4 space-y-2">
|
||
<div className="text-sm font-medium opacity-80">Supporter Badges</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{badges.map((b, i) => (
|
||
<span
|
||
key={`${b.provider}-${i}`}
|
||
className={[
|
||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm",
|
||
b.active
|
||
? "border-green-300 bg-green-50 text-green-900"
|
||
: b.kind === "one_time"
|
||
? "border-amber-300 bg-amber-50 text-amber-900"
|
||
: "border-slate-300 bg-slate-50 text-slate-700",
|
||
].join(" ")}
|
||
title={
|
||
b.active
|
||
? b.renews_at
|
||
? `Active • renews by ${new Date(b.renews_at).toLocaleDateString()}`
|
||
: "Active"
|
||
: b.kind === "one_time"
|
||
? "One-time support"
|
||
: "Inactive"
|
||
}
|
||
>
|
||
<span>{providerIcon(b.provider)}</span>
|
||
<span>{b.label}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|