- 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
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
// /lib/support/memberships.ts
|
|
|
|
export type MembershipRow = {
|
|
id: string | number;
|
|
provider: "kofi" | "patreon" | "mighty" | string;
|
|
status: "active" | "one_time" | "canceled" | "inactive" | string;
|
|
tier?: string | null;
|
|
started_at?: string | null;
|
|
renews_at?: string | null;
|
|
email?: string | null;
|
|
username?: string | null;
|
|
app_user?: string | null;
|
|
};
|
|
|
|
export type SupportBadge = {
|
|
provider: "kofi" | "patreon" | "mighty" | string;
|
|
active: boolean; // true if 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;
|
|
};
|
|
|
|
const DIRECTUS = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
|
|
|
|
/** Active if status==="active" and renews_at >= now */
|
|
function computeActive(row: MembershipRow): boolean {
|
|
if (row.status !== "active") return false;
|
|
if (!row.renews_at) return false;
|
|
const due = new Date(row.renews_at).getTime();
|
|
return Number.isFinite(due) && due >= Date.now();
|
|
}
|
|
|
|
function providerLabel(p: string) {
|
|
if (p === "kofi") return "Ko-fi";
|
|
if (p === "patreon") return "Patreon";
|
|
if (p === "mighty") return "Mighty";
|
|
return p[0]?.toUpperCase() + p.slice(1);
|
|
}
|
|
|
|
/** Convert a membership row → displayable badge */
|
|
function rowToBadge(row: MembershipRow): SupportBadge {
|
|
const active = computeActive(row);
|
|
const prov = providerLabel(row.provider);
|
|
let kind: SupportBadge["kind"] = "inactive";
|
|
let label = prov;
|
|
|
|
if (active) {
|
|
kind = "member";
|
|
label = row.tier ? `${prov} • ${row.tier}` : `${prov} Member`;
|
|
} else if (row.status === "one_time") {
|
|
kind = "one_time";
|
|
label = `${prov} Supporter`;
|
|
} else {
|
|
kind = "inactive";
|
|
label = `${prov} (inactive)`;
|
|
}
|
|
|
|
return {
|
|
provider: row.provider,
|
|
active,
|
|
kind,
|
|
label,
|
|
tier: row.tier ?? null,
|
|
renews_at: row.renews_at ?? null,
|
|
started_at: row.started_at ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch all memberships for a user by app_user (preferred) or by email.
|
|
* Pass either/both: { userId?, email? }
|
|
*/
|
|
export async function fetchMembershipBadges(opts: { userId?: string; email?: string }): Promise<SupportBadge[]> {
|
|
const clauses: any[] = [{ provider: { _in: ["kofi", "patreon", "mighty"] } }];
|
|
if (opts.userId) clauses.push({ app_user: { _eq: opts.userId } });
|
|
if (opts.email) clauses.push({ email: { _eq: opts.email.toLowerCase() } });
|
|
|
|
// Build filter: provider IN (...) AND (app_user = userId OR email = email)
|
|
const filter = encodeURIComponent(
|
|
JSON.stringify({
|
|
_and: [
|
|
clauses[0],
|
|
{ _or: clauses.slice(1).length ? clauses.slice(1) : [{ id: { _neq: null } }] },
|
|
],
|
|
})
|
|
);
|
|
|
|
const res = await fetch(`${DIRECTUS}/items/user_memberships?filter=${filter}&limit=500`, { cache: "no-store" });
|
|
const json = await res.json().catch(() => ({} as any));
|
|
const rows: MembershipRow[] = json?.data || [];
|
|
|
|
const badges = rows.map(rowToBadge);
|
|
|
|
// Sort: active first, then provider name asc
|
|
badges.sort((a, b) => {
|
|
if (a.active !== b.active) return a.active ? -1 : 1;
|
|
return a.provider.localeCompare(b.provider);
|
|
});
|
|
|
|
return badges;
|
|
}
|
|
|
|
/** Quick boolean if any active membership exists */
|
|
export async function hasActiveMembership(opts: { userId?: string; email?: string }): Promise<boolean> {
|
|
const list = await fetchMembershipBadges(opts);
|
|
return list.some(b => b.kind === "member" && b.active);
|
|
}
|