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
This commit is contained in:
parent
3d23bbb3f0
commit
912cf71bb9
13 changed files with 1156 additions and 22 deletions
14
.env.local
14
.env.local
|
|
@ -2,6 +2,8 @@
|
|||
# Public (used by client-side dropdown fetches)
|
||||
# ─────────────────────────────────────────────
|
||||
NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net
|
||||
APP_ORIGIN=https://beta.makearmy.io
|
||||
KOFI_VERIFY_TOKEN=baa0aa53-7269-4119-a94f-e1380383e05e
|
||||
BG_BYE_UPSTREAM=http://bgbye:7001/remove_background/
|
||||
FILES_ROOT=/files
|
||||
|
||||
|
|
@ -10,6 +12,7 @@ FILES_ROOT=/files
|
|||
# ─────────────────────────────────────────────
|
||||
DIRECTUS_URL=https://forms.lasereverything.net
|
||||
DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7
|
||||
DIRECTUS_TOKEN_ADMIN_SUPPORTER=tvd64Ex5OWLEdH8EEM0rjH-gM1p-ZwfY
|
||||
DIRECTUS_DEFAULT_ROLE=296a28bc-60ab-4251-8bef-27f6dfb67948
|
||||
DIRECTUS_ROLE_MEMBER_NAME=Users
|
||||
|
||||
|
|
@ -42,3 +45,14 @@ DX_FOLDER_UV_SCREENS=a84f54b1-0e92-4ea6-8fbe-37a3a74bd49c
|
|||
DX_FOLDER_PROJECTS_FILES=f264f066-5b38-4335-bb10-5b014bfa62cb
|
||||
DX_FOLDER_PROJECTS_IMAGES=da11b876-2ede-4e19-ad3a-76fc9db449a8
|
||||
DX_FOLDER_PROJECTS_INSTRUCTIONS=905a4259-0c8e-489b-b810-c27186a2f266
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Mail Server Settings (SMTP)
|
||||
# ─────────────────────────────────────────────
|
||||
SMTP_HOST=mail.arrmail.net
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=noreply@makearmy.io
|
||||
SMTP_PASS=TZhXn4yQQ92XEf
|
||||
SMTP_SECURE=true
|
||||
EMAIL_FROM=MakeArmy Support <noreply@makearmy.io>
|
||||
|
||||
|
|
|
|||
36
app/api/support/badges/route.ts
Normal file
36
app/api/support/badges/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// /app/api/support/badges/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { fetchMembershipBadges } from "lib/support/memberships";
|
||||
|
||||
// Replace this with your real auth lookup if/when you wire it in
|
||||
async function getCurrentUser(req: NextRequest): Promise<{ id?: string; email?: string } | null> {
|
||||
const uid = req.headers.get("x-user-id") || undefined;
|
||||
const email = req.headers.get("x-user-email") || undefined;
|
||||
return uid || email ? { id: uid, email } : null;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const me = await getCurrentUser(req);
|
||||
|
||||
// Accept explicit query params so the component can work without special headers
|
||||
const emailParam = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase() || undefined;
|
||||
const userIdParam = (req.nextUrl.searchParams.get("userId") || "").trim() || undefined;
|
||||
|
||||
if (!emailParam && !userIdParam && !me?.id && !me?.email) {
|
||||
return NextResponse.json({ error: "Provide email or userId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const badges = await fetchMembershipBadges({
|
||||
userId: userIdParam || me?.id,
|
||||
email: emailParam || me?.email,
|
||||
});
|
||||
|
||||
return NextResponse.json({ badges });
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ error: "badges_fetch_failed", detail: String(e?.message || e) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
app/api/support/kofi/claim/start/route.ts
Normal file
126
app/api/support/kofi/claim/start/route.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// app/api/support/kofi/claim/start/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
|
||||
const COLLECTION = "user_memberships";
|
||||
|
||||
const APP_ORIGIN = process.env.APP_ORIGIN || "https://makearmy.io"; // your site base URL
|
||||
|
||||
// SMTP envs
|
||||
const SMTP_HOST = process.env.SMTP_HOST || "";
|
||||
const SMTP_PORT = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 587;
|
||||
const SMTP_USER = process.env.SMTP_USER || "";
|
||||
const SMTP_PASS = process.env.SMTP_PASS || "";
|
||||
const SMTP_SECURE = process.env.SMTP_SECURE === "true";
|
||||
const EMAIL_FROM = process.env.EMAIL_FROM || "MakeArmy Support <no-reply@makearmy.io>";
|
||||
|
||||
// TODO: replace with your actual auth/session resolver
|
||||
async function getCurrentUserId(req: NextRequest): Promise<string | null> {
|
||||
const uid = req.headers.get("x-user-id");
|
||||
return uid && uid.trim() ? uid : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Basic SMTP sanity check (fail fast)
|
||||
if (!SMTP_HOST || !SMTP_PORT || !EMAIL_FROM) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "email_not_configured", detail: "SMTP_HOST/PORT/EMAIL_FROM must be set" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const userId = await getCurrentUserId(req);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let email = "";
|
||||
try {
|
||||
const body = await req.json();
|
||||
email = String(body?.email || "").trim().toLowerCase();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 });
|
||||
}
|
||||
if (!email) {
|
||||
return NextResponse.json({ ok: false, error: "missing_email" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1) find Ko-fi membership by email
|
||||
const filter = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
_and: [{ provider: { _eq: "kofi" } }, { email: { _eq: email } }],
|
||||
})
|
||||
);
|
||||
|
||||
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
|
||||
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
return NextResponse.json({ ok: false, error: "directus_read_failed", detail: t }, { status: 500 });
|
||||
}
|
||||
const json = await res.json();
|
||||
const rec = json?.data?.[0];
|
||||
if (!rec) {
|
||||
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
|
||||
}
|
||||
if (rec.app_user) {
|
||||
// Already linked
|
||||
return NextResponse.json({ ok: true, alreadyLinked: true });
|
||||
}
|
||||
|
||||
// 2) create a one-time token
|
||||
const token = crypto.randomBytes(24).toString("base64url");
|
||||
const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
|
||||
|
||||
const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BOT_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
claim_token: token,
|
||||
claim_expires_at: expires.toISOString(),
|
||||
claim_user_id: userId,
|
||||
}),
|
||||
});
|
||||
if (!patch.ok) {
|
||||
const t = await patch.text().catch(() => "");
|
||||
return NextResponse.json({ ok: false, error: "directus_write_failed", detail: t }, { status: 500 });
|
||||
}
|
||||
|
||||
// 3) send verification email
|
||||
const verifyUrl = `${APP_ORIGIN}/api/support/kofi/claim/verify?token=${encodeURIComponent(token)}`;
|
||||
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: SMTP_HOST, // e.g. "mail.arrmail.net"
|
||||
port: SMTP_PORT, // 465 in your case
|
||||
secure: SMTP_SECURE, // true for 465, false for 587 STARTTLS
|
||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
||||
// If your server uses a self-signed certificate, uncomment the next line.
|
||||
// Prefer installing a valid cert instead.
|
||||
// tls: { rejectUnauthorized: false },
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: EMAIL_FROM, // "MakeArmy Support <noreply@makearmy.io>" recommended
|
||||
to: email,
|
||||
subject: "Verify Ko-fi link to your MakeArmy account",
|
||||
text: `Tap to verify your Ko-fi link: ${verifyUrl}\nThis link expires in 15 minutes.`,
|
||||
html: `<p>Tap to verify your Ko-fi link:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>This link expires in 15 minutes.</p>`,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "email_send_failed", detail: String(e?.message || e) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
53
app/api/support/kofi/claim/verify/route.ts
Normal file
53
app/api/support/kofi/claim/verify/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// /app/api/support/kofi/claim/verify/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
|
||||
const COLLECTION = "user_memberships";
|
||||
|
||||
// Default redirects; can be overridden by env
|
||||
const SUCCESS_REDIRECT = process.env.KOFI_LINK_SUCCESS_URL || "/portal/account?linked=kofi&ok=1";
|
||||
const FAIL_REDIRECT = process.env.KOFI_LINK_FAIL_URL || "/portal/account?linked=kofi&error=1";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const token = req.nextUrl.searchParams.get("token") || "";
|
||||
const to = (path: string) => NextResponse.redirect(new URL(path, req.url));
|
||||
|
||||
if (!token) return to(FAIL_REDIRECT);
|
||||
|
||||
// Find the claim row by token
|
||||
const filter = encodeURIComponent(JSON.stringify({ claim_token: { _eq: token } }));
|
||||
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
|
||||
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) return to(FAIL_REDIRECT);
|
||||
|
||||
const json = await res.json().catch(() => ({} as any));
|
||||
const rec = json?.data?.[0];
|
||||
if (!rec) return to(FAIL_REDIRECT);
|
||||
|
||||
// Validate expiry and sanity
|
||||
const exp = rec.claim_expires_at ? new Date(rec.claim_expires_at).getTime() : 0;
|
||||
if (!exp || Date.now() > exp) return to(FAIL_REDIRECT);
|
||||
if (!rec.claim_user_id) return to(FAIL_REDIRECT);
|
||||
|
||||
// Finalize: set app_user to claim_user_id; clear claim fields
|
||||
const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BOT_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
app_user: rec.claim_user_id,
|
||||
claim_token: null,
|
||||
claim_expires_at: null,
|
||||
claim_user_id: null,
|
||||
last_event_at: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!patch.ok) return to(FAIL_REDIRECT);
|
||||
return to(SUCCESS_REDIRECT);
|
||||
}
|
||||
61
app/api/support/kofi/unlink/route.ts
Normal file
61
app/api/support/kofi/unlink/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// app/api/support/kofi/unlink/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
|
||||
const COLLECTION = "user_memberships";
|
||||
|
||||
// Replace with your real auth/session
|
||||
async function getCurrentUserId(req: NextRequest): Promise<string | null> {
|
||||
const uid = req.headers.get("x-user-id");
|
||||
return uid && uid.trim() ? uid : null;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const userId = await getCurrentUserId(req);
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Find linked Ko-fi rows
|
||||
const filter = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
_and: [{ provider: { _eq: "kofi" } }, { app_user: { _eq: userId } }],
|
||||
})
|
||||
);
|
||||
|
||||
const list = await fetch(
|
||||
`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=500`,
|
||||
{ headers: { Authorization: `Bearer ${BOT_TOKEN}` }, cache: "no-store" }
|
||||
);
|
||||
if (!list.ok) {
|
||||
const t = await list.text().catch(() => "");
|
||||
return NextResponse.json({ error: "directus_read_failed", detail: t }, { status: 500 });
|
||||
}
|
||||
|
||||
const rows = (await list.json()).data || [];
|
||||
if (!rows.length) return NextResponse.json({ ok: true, changed: 0 });
|
||||
|
||||
// Batch PATCH: clear app_user
|
||||
const body = rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
app_user: null,
|
||||
last_event_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BOT_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const t = await res.text().catch(() => "");
|
||||
return NextResponse.json({ error: "directus_write_failed", detail: t }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, changed: rows.length });
|
||||
}
|
||||
194
app/api/webhooks/kofi/route.ts
Normal file
194
app/api/webhooks/kofi/route.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// app/api/webhooks/kofi/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* ENV required:
|
||||
* - KOFI_VERIFY_TOKEN
|
||||
* - DIRECTUS_URL (e.g. https://forms.lasereverything.net)
|
||||
* - DIRECTUS_TOKEN_ADMIN_SUPPORTER (supporter-bot service token)
|
||||
*
|
||||
* Notes:
|
||||
* - renews_at is computed as (timestamp + 1 calendar month + 1 day)
|
||||
* - started_at is the first subscription payment timestamp seen and is preserved
|
||||
*/
|
||||
const VERIFY = process.env.KOFI_VERIFY_TOKEN!;
|
||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
|
||||
const COLLECTION = "user_memberships";
|
||||
|
||||
type KofiPayload = {
|
||||
verification_token: string;
|
||||
message_id: string;
|
||||
timestamp: string; // ISO
|
||||
type: "Donation" | "Subscription" | "Commission" | "Shop Order" | string;
|
||||
is_public?: boolean;
|
||||
from_name?: string;
|
||||
message?: string | null;
|
||||
amount?: string;
|
||||
url?: string;
|
||||
email?: string | null;
|
||||
currency?: string;
|
||||
is_subscription_payment?: boolean;
|
||||
is_first_subscription_payment?: boolean;
|
||||
kofi_transaction_id?: string;
|
||||
shop_items?: Array<{ direct_link_code: string; variation_name?: string; quantity?: number }> | null;
|
||||
tier_name?: string | null;
|
||||
shipping?: Record<string, unknown> | null;
|
||||
discord_username?: string | null;
|
||||
discord_userid?: string | null;
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// 1) Expect application/x-www-form-urlencoded with "data" JSON string
|
||||
let data: KofiPayload | null = null;
|
||||
|
||||
try {
|
||||
const ctype = req.headers.get("content-type") || "";
|
||||
if (ctype.includes("application/x-www-form-urlencoded")) {
|
||||
const form = await req.formData();
|
||||
const raw = form.get("data");
|
||||
if (typeof raw !== "string") throw new Error("missing data");
|
||||
data = JSON.parse(raw) as KofiPayload;
|
||||
} else {
|
||||
// Try to be forgiving if Ko-fi ever sends JSON directly
|
||||
data = (await req.json()) as KofiPayload;
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ ok: false, error: "empty_payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2) Verify token (Ko-fi puts it inside the data JSON)
|
||||
if (!data.verification_token || data.verification_token !== VERIFY) {
|
||||
// Ko-fi retries on non-200, so 401 is fine here
|
||||
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 3) Normalize → membership record
|
||||
const t = (data.type || "").toLowerCase();
|
||||
const isRecurring = Boolean(data.is_subscription_payment) || t.includes("subscription");
|
||||
const status = isRecurring ? "active" : "one_time";
|
||||
|
||||
// Choose a stable external id (prefer email; fallback to discord id, then a name)
|
||||
const external_user_id =
|
||||
(data.email && data.email.trim()) ||
|
||||
(data.discord_userid && data.discord_userid.trim()) ||
|
||||
(data.from_name && data.from_name.trim()) ||
|
||||
"unknown";
|
||||
|
||||
// Fetch existing record for started_at preservation
|
||||
const filter = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
_and: [{ provider: { _eq: "kofi" } }, { external_user_id: { _eq: external_user_id } }],
|
||||
})
|
||||
);
|
||||
const existingRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
|
||||
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!existingRes.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "directus_error", detail: `read failed ${existingRes.status}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const existingJson = await existingRes.json().catch(() => ({} as any));
|
||||
const existing = existingJson?.data?.[0];
|
||||
|
||||
const ts = data.timestamp ? new Date(data.timestamp) : null;
|
||||
|
||||
// Helper: add 1 calendar month + 1 day, clamping end-of-month correctly
|
||||
function addOneMonthPlusOneDay(d: Date) {
|
||||
const nd = new Date(d);
|
||||
const origDay = nd.getDate();
|
||||
const origMonth = nd.getMonth();
|
||||
nd.setMonth(origMonth + 1);
|
||||
|
||||
// If month rolled over (e.g., Jan 31 -> Mar 02/03), clamp to end of intended next month.
|
||||
if (nd.getMonth() !== ((origMonth + 1) % 12)) {
|
||||
nd.setDate(0); // last day of previous month (i.e., end of the intended next month)
|
||||
} else {
|
||||
// Keep the original day if possible (handles 28/29/30-day months naturally)
|
||||
// Already set by setMonth above.
|
||||
}
|
||||
|
||||
// Plus one extra day buffer
|
||||
nd.setDate(nd.getDate() + 1);
|
||||
return nd;
|
||||
}
|
||||
|
||||
// Determine started_at: first subscription payment time; keep existing if present
|
||||
const started_at =
|
||||
isRecurring && ts
|
||||
? existing?.started_at
|
||||
? new Date(existing.started_at)
|
||||
: ts
|
||||
: existing?.started_at
|
||||
? new Date(existing.started_at)
|
||||
: null;
|
||||
|
||||
// Determine renews_at: for subscription payments, slide to 1 month + 1 day past ts
|
||||
const renews_at = isRecurring && ts ? addOneMonthPlusOneDay(ts) : null;
|
||||
|
||||
const record = {
|
||||
provider: "kofi",
|
||||
external_user_id,
|
||||
email: data.email || null,
|
||||
username: data.from_name || null, // Ko-fi often provides from_name rather than a handle
|
||||
status, // "active" for subs; "one_time" for donations/shop/commission
|
||||
tier: data.tier_name || null, // present for membership tiers
|
||||
started_at,
|
||||
renews_at,
|
||||
last_event_at: new Date(),
|
||||
raw: safeStringify(data), // Long Text column with JSON editor interface
|
||||
};
|
||||
|
||||
// 4) Upsert by (provider, external_user_id)
|
||||
try {
|
||||
const url = existing?.id
|
||||
? `${DIRECTUS}/items/${COLLECTION}/${existing.id}`
|
||||
: `${DIRECTUS}/items/${COLLECTION}`;
|
||||
const method = existing?.id ? "PATCH" : "POST";
|
||||
|
||||
// If we're patching and this event wasn't a subscription, avoid clobbering existing started_at
|
||||
if (existing?.id && (!isRecurring || !ts)) {
|
||||
// don't send started_at; preserve what's in DB
|
||||
delete (record as any).started_at;
|
||||
delete (record as any).renews_at; // keep existing renews_at if not a sub event
|
||||
}
|
||||
|
||||
const writeRes = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BOT_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
|
||||
if (!writeRes.ok) {
|
||||
const body = await writeRes.text().catch(() => "");
|
||||
throw new Error(`${method} failed ${writeRes.status} ${body}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// Non-200 makes Ko-fi retry, which is usually what we want on DB error
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "directus_error", detail: String(err?.message || err) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 5) Return 200 per Ko-fi requirement (stops retries)
|
||||
return NextResponse.json({ ok: true }, { status: 200 });
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown) {
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return JSON.stringify({ _unstringifiable: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,9 @@ 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 = {
|
||||
|
|
@ -69,6 +72,7 @@ export default function AccountPanel() {
|
|||
<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">
|
||||
|
|
@ -104,19 +108,17 @@ export default function AccountPanel() {
|
|||
<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}
|
||||
/>
|
||||
|
||||
<AvatarUploader avatarId={me.avatar?.id || null} onUpdated={refetchMe} />
|
||||
<ProfileEditor me={me} onUpdated={refetchMe} />
|
||||
<PasswordChange />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,12 +1,275 @@
|
|||
// app/portal/page.tsx
|
||||
export default function PortalHome() {
|
||||
return (
|
||||
<div className="rounded-lg border p-6">
|
||||
<h2 className="text-xl font-semibold mb-2">Dashboard</h2>
|
||||
<p className="opacity-80">
|
||||
Pick a tab to get started. You can add and manage Rigs, Laser Settings, Sources, Materials, Projects,
|
||||
or jump into Utilities and Account.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
CircleX,
|
||||
HeartHandshake,
|
||||
HandCoins,
|
||||
Coffee,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Video,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
// shadcn/ui
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/**
|
||||
* App Dashboard: Support CTA (polished)
|
||||
* - Stable custom buttons (no hover flicker)
|
||||
* - Perks on their own lines
|
||||
* - Copy + outline adjustments per request
|
||||
*/
|
||||
|
||||
const perks = [
|
||||
{ icon: <ShieldCheck className="h-5 w-5" aria-hidden="true" />, text: "No ads. No sponsors. No influence." },
|
||||
{ icon: <Users className="h-5 w-5" aria-hidden="true" />, text: "Community-funded, community-first priorities." },
|
||||
{ icon: <Video className="h-5 w-5" aria-hidden="true" />, text: "Videos, tools, and guides stay open for all." },
|
||||
];
|
||||
|
||||
const supportTiers = [
|
||||
{
|
||||
name: "Laser Master Academy",
|
||||
href: "https://masters.lasereverything.net",
|
||||
icon: <Sparkles className="h-6 w-6" aria-hidden="true" />,
|
||||
blurb:
|
||||
"Our flagship learning community on MightyNetworks: structured courses, AMAs, and deeper mentorship.",
|
||||
perks: ["Parameter Packs", "Support Forums", "Bonus Content Archives"],
|
||||
cta: "Join the Academy",
|
||||
},
|
||||
{
|
||||
name: "Patreon",
|
||||
href: "https://www.patreon.com/c/LaserEverything",
|
||||
icon: <HeartHandshake className="h-6 w-6" aria-hidden="true" />,
|
||||
blurb:
|
||||
"Flexible monthly support to underwrite videos, research, and open tools without monetization strings.",
|
||||
perks: ["Directly Support Us", "Behind-the-Scenes Notes", "Community Polls"],
|
||||
cta: "Back on Patreon",
|
||||
},
|
||||
{
|
||||
name: "Ko-Fi",
|
||||
href: "https://ko-fi.com/lasereverything",
|
||||
icon: <Coffee className="h-6 w-6" aria-hidden="true" />,
|
||||
blurb:
|
||||
"One-time tips that go straight to hosting, development, and production costs — no paywall, just fuel.",
|
||||
perks: ["Say thanks once", "Help cover a bill", "Keep it free for others"],
|
||||
cta: "Tip on Ko-Fi",
|
||||
},
|
||||
];
|
||||
|
||||
const adVsCommunity = {
|
||||
ads: [
|
||||
"Algorithm-shaped content",
|
||||
"Sponsor talking points",
|
||||
"Influencer bias risk",
|
||||
"Tracking and interruptions",
|
||||
],
|
||||
community: [
|
||||
"Curriculum set by makers",
|
||||
"Honest reviews & hard truths",
|
||||
"Open tools without strings",
|
||||
"Privacy-respecting experience",
|
||||
],
|
||||
};
|
||||
|
||||
// Reusable button styles (anchors styled as buttons to avoid shadcn hover conflicts)
|
||||
const btn =
|
||||
"inline-flex items-center justify-center rounded-2xl px-5 py-3 text-sm font-medium text-white shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2";
|
||||
const btnTeal = `${btn} bg-teal-600 hover:bg-teal-700 focus-visible:ring-teal-500`;
|
||||
const btnRose = `${btn} bg-rose-600 hover:bg-rose-700 focus-visible:ring-rose-500`;
|
||||
const btnSky = `${btn} bg-sky-600 hover:bg-sky-700 focus-visible:ring-sky-500`;
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="min-h-[calc(100dvh-4rem)] w-full">
|
||||
{/* Hero */}
|
||||
<section className="mx-auto w-full max-w-6xl px-6 py-14 sm:py-16">
|
||||
<div className="text-center">
|
||||
<Badge variant="secondary" className="mb-4">
|
||||
Community-Funded • Ad-Free • Open Resources
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-3xl font-extrabold tracking-tight sm:text-4xl">
|
||||
Keep Laser Everything & MakeArmy FREE for Everyone
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-3 max-w-3xl text-balance text-sm text-muted-foreground sm:text-base">
|
||||
The videos, tools, and docs you use today exist because previous supporters paid it forward.
|
||||
We want to stay independent — no ads, no sponsors, no strings — and that only works if we fund it together.
|
||||
</p>
|
||||
|
||||
{/* CTA row (custom anchors = stable hover) */}
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="https://masters.lasereverything.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Join Laser Master Academy"
|
||||
className={btnTeal}
|
||||
>
|
||||
Join the LMA <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://www.patreon.com/c/LaserEverything"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Back Laser Everything on Patreon"
|
||||
className={btnRose}
|
||||
>
|
||||
Back on Patreon
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="https://ko-fi.com/lasereverything"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Contribute on Ko-Fi"
|
||||
className={btnSky}
|
||||
>
|
||||
Tip on Ko-Fi
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Perks: each on its own line, centered */}
|
||||
<ul className="mx-auto mt-5 max-w-xl space-y-2 text-muted-foreground">
|
||||
{perks.map((p, i) => (
|
||||
<li key={i} className="flex items-center justify-center gap-2 text-sm">
|
||||
{p.icon}
|
||||
<span>{p.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Support Options */}
|
||||
<section className="mx-auto -mt-2 max-w-6xl px-6 pb-12">
|
||||
<div className="rounded-3xl border bg-card p-6 shadow-sm sm:p-8">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">How you can help—pick what fits</h2>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Whether you join the Academy, pledge monthly, or tip once, every contribution sustains the whole community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{supportTiers.map((tier) => (
|
||||
<Card key={tier.name} className="group rounded-2xl transition hover:shadow-lg">
|
||||
<CardHeader>
|
||||
<div className="mb-2 flex items-center gap-2 text-teal-600 dark:text-teal-400">
|
||||
{tier.icon}
|
||||
<CardTitle className="text-lg">{tier.name}</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{tier.blurb}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{tier.perks.map((perk) => (
|
||||
<li key={perk} className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> {perk}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={tier.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-4 inline-flex w-full items-center justify-center rounded-xl bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-colors hover:bg-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
|
||||
>
|
||||
{tier.cta}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Philosophy: Ads vs Community */}
|
||||
<div className="mt-10">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h3 className="text-xl font-semibold">Why not just run ads & sponsors?</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Because it quietly changes what we make and who we serve.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">We’d rather be accountable to you.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* Dim outline for Advertising box */}
|
||||
<Card className="rounded-2xl border border-foreground/10 dark:border-foreground/15">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-rose-600 dark:text-rose-400">
|
||||
<CircleX className="h-5 w-5" aria-hidden="true" />
|
||||
<CardTitle className="text-base">Advertising / Sponsorship Model</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{adVsCommunity.ads.map((t) => (
|
||||
<li key={t} className="flex items-center gap-2">
|
||||
<CircleX className="h-4 w-4" aria-hidden="true" /> {t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Brighter outline for Community box */}
|
||||
<Card className="rounded-2xl border border-teal-500/50 shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2 text-teal-700 dark:text-teal-300">
|
||||
<CheckCircle2 className="h-5 w-5" aria-hidden="true" />
|
||||
<CardTitle className="text-base">Community-Funded Model</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{adVsCommunity.community.map((t) => (
|
||||
<li key={t} className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> {t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-10 h-px w-full bg-border" />
|
||||
|
||||
{/* Other ways */}
|
||||
<div className="mt-10">
|
||||
<h3 className="text-center text-xl font-semibold">No budget? No problem—here’s how to help for free</h3>
|
||||
<div className="mx-auto mt-4 max-w-3xl">
|
||||
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{[
|
||||
"Share our videos with a friend",
|
||||
"Star + share our repos/tools",
|
||||
"Submit laser settings for others",
|
||||
"Report bugs and suggest features",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<HandCoins className="h-4 w-4" aria-hidden="true" /> {item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Closing statement */}
|
||||
<div className="mx-auto mt-10 max-w-3xl text-center">
|
||||
<p className="text-balance text-sm text-muted-foreground">
|
||||
Laser Everything exists because makers before you chose to keep the ladder down. If we each do a small part —
|
||||
<span className="font-medium text-foreground"> we never need a paywall</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
BIN
components/account.zip
Normal file
BIN
components/account.zip
Normal file
Binary file not shown.
137
components/account/ConnectKofi.tsx
Normal file
137
components/account/ConnectKofi.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// 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 don’t have any Ko-fi records for that email yet. If you’re sure it’s 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. We’ll send a one-time verification link to confirm it’s 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 don’t see a badge yet, it’ll appear the next time a Ko-fi payment webhook arrives (or after backfill).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
components/account/LinkStatus.tsx
Normal file
28
components/account/LinkStatus.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// /components/account/LinkStatus.tsx
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
export default function LinkStatus() {
|
||||
const sp = useSearchParams();
|
||||
const linked = sp.get("linked");
|
||||
if (linked !== "kofi") return null;
|
||||
|
||||
const isOk = sp.get("ok") === "1";
|
||||
const isErr = sp.get("error") === "1";
|
||||
|
||||
if (!isOk && !isErr) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"mb-3 rounded-md border p-3 text-sm",
|
||||
isOk
|
||||
? "border-emerald-300/50 bg-emerald-50 text-emerald-900"
|
||||
: "border-red-300/50 bg-red-50 text-red-900",
|
||||
].join(" ")}
|
||||
>
|
||||
{isOk ? "Ko-fi successfully linked to your account." : "Couldn’t verify that Ko-fi link. Please try again."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
components/account/SupporterBadges.tsx
Normal file
111
components/account/SupporterBadges.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// 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>
|
||||
);
|
||||
}
|
||||
109
lib/memberships.ts
Normal file
109
lib/memberships.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue