makearmy-app/app/portal/account/AccountPanel.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

125 lines
4.3 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.

// app/portal/account/AccountPanel.tsx
"use client";
import { useEffect, useMemo, useState, useCallback } from "react";
import ProfileEditor from "@/components/account/ProfileEditor";
import PasswordChange from "@/components/account/PasswordChange";
import AvatarUploader from "@/components/account/AvatarUploader";
import SupporterBadges from "@/components/account/SupporterBadges";
import ConnectKofi from "@/components/account/ConnectKofi";
import LinkStatus from "@/components/account/LinkStatus"; // ← ADDED
type Avatar = { id: string; filename_download?: string } | null;
type Me = {
id: string;
username: string;
first_name?: string | null;
last_name?: string | null;
email?: string | null;
location?: string | null;
avatar?: Avatar;
};
export default function AccountPanel() {
const [me, setMe] = useState<Me | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Precompute API base once on the client
const API_BASE = useMemo(
() => (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, ""),
[]
);
const refetchMe = useCallback(async () => {
try {
const r = await fetch("/api/account", {
credentials: "include",
cache: "no-store",
});
if (!r.ok) throw new Error(`Load failed (${r.status})`);
const j = await r.json();
const user: Me | undefined = j?.user ?? j?.data ?? undefined;
if (!user) throw new Error("Malformed response");
setMe(user);
} catch (e: any) {
setErr(e?.message || "Failed to load account");
}
}, []);
useEffect(() => {
let alive = true;
(async () => {
try {
setErr(null);
await refetchMe();
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, [refetchMe]);
if (loading) return <div className="rounded-md border p-6 text-sm opacity-70">Loading</div>;
if (err) return <div className="rounded-md border p-6 text-red-600">Error: {err}</div>;
if (!me) return null;
const avatarUrl = me.avatar?.id ? `${API_BASE}/assets/${me.avatar.id}` : null;
return (
<div className="space-y-6">
<div className="rounded-md border p-4">
<h2 className="text-lg font-semibold mb-2">Account</h2>
<LinkStatus /> {/* ← ADDED: shows success/failure after Ko-fi verify redirect */}
<div className="flex items-center gap-4 mb-4">
<div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={avatarUrl} alt="Avatar" className="h-full w-full object-cover" />
) : (
<span className="text-xs opacity-60">No Avatar</span>
)}
</div>
<div className="text-xs text-muted-foreground">Usernames cant be changed.</div>
</div>
<div className="grid sm:grid-cols-2 gap-3 text-sm">
<div>
<div className="opacity-60">Username</div>
<div className="font-medium">{me.username}</div>
</div>
<div>
<div className="opacity-60">First Name</div>
<div>{me.first_name || "—"}</div>
</div>
<div>
<div className="opacity-60">Last Name</div>
<div>{me.last_name || "—"}</div>
</div>
<div>
<div className="opacity-60">Email</div>
<div>{me.email || "—"}</div>
</div>
<div>
<div className="opacity-60">Location</div>
<div>{me.location || "—"}</div>
</div>
</div>
{/* Badges (derive-on-read, no cron) */}
<SupporterBadges email={me.email ?? undefined} userId={me.id} />
</div>
{/* Link Ko-fi */}
<ConnectKofi email={me.email} userId={me.id} />
{/* Editable sections */}
<AvatarUploader avatarId={me.avatar?.id || null} onUpdated={refetchMe} />
<ProfileEditor me={me} onUpdated={refetchMe} />
<PasswordChange />
</div>
);
}