Create monorepo from known-good production state

This commit is contained in:
makearmy 2026-07-09 21:07:34 -04:00
commit c034824338
651 changed files with 120469 additions and 0 deletions

109
app/lib/memberships.ts Normal file
View file

@ -0,0 +1,109 @@
// /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);
}