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:
makearmy 2025-10-19 17:51:04 -04:00
parent 3d23bbb3f0
commit 912cf71bb9
13 changed files with 1156 additions and 22 deletions

View file

@ -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>

View 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 }
);
}
}

View 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 });
}

View 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);
}

View 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 });
}

View 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 });
}
}

View file

@ -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>
);

View file

@ -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 helppick 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">Wed 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 problemheres 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

Binary file not shown.

View 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 dont have any Ko-fi records for that email yet. If youre sure its 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. Well send a one-time verification link to confirm its 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 dont see a badge yet, itll appear the next time a Ko-fi payment webhook arrives (or after backfill).
</p>
</div>
);
}

View 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." : "Couldnt verify that Ko-fi link. Please try again."}
</div>
);
}

View 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">
Couldnt 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
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);
}