diff --git a/.env.local b/.env.local index 94a2c0f6..bece3d72 100644 --- a/.env.local +++ b/.env.local @@ -2,14 +2,17 @@ # 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=/app/files +FILES_ROOT=/files # ───────────────────────────────────────────── # Server-side Directus # ───────────────────────────────────────────── 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 + diff --git a/.gitignore b/.gitignore index 46e49f09..352ef05a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ !public/svgnest/** -# build & deps +# Next.js build output .next/ + +# Node +node_modules/ + +# dependencies node_modules/ diff --git a/app/api/support/badges/route.ts b/app/api/support/badges/route.ts new file mode 100644 index 00000000..f47c16fd --- /dev/null +++ b/app/api/support/badges/route.ts @@ -0,0 +1,36 @@ +// /app/api/support/badges/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { fetchMembershipBadges } from "@/lib/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 } + ); + } +} diff --git a/app/api/support/kofi/claim/start/route.ts b/app/api/support/kofi/claim/start/route.ts new file mode 100644 index 00000000..91cc7778 --- /dev/null +++ b/app/api/support/kofi/claim/start/route.ts @@ -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 "; + +// TODO: replace with your actual auth/session resolver +async function getCurrentUserId(req: NextRequest): Promise { + 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 " 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: `

Tap to verify your Ko-fi link:

${verifyUrl}

This link expires in 15 minutes.

`, + }); + } catch (e: any) { + return NextResponse.json( + { ok: false, error: "email_send_failed", detail: String(e?.message || e) }, + { status: 500 } + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/support/kofi/claim/verify/route.ts b/app/api/support/kofi/claim/verify/route.ts new file mode 100644 index 00000000..39f1a6f7 --- /dev/null +++ b/app/api/support/kofi/claim/verify/route.ts @@ -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); +} diff --git a/app/api/support/kofi/unlink/route.ts b/app/api/support/kofi/unlink/route.ts new file mode 100644 index 00000000..7625b984 --- /dev/null +++ b/app/api/support/kofi/unlink/route.ts @@ -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 { + 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 }); +} diff --git a/app/api/webhooks/kofi/route.ts b/app/api/webhooks/kofi/route.ts new file mode 100644 index 00000000..d95a156b --- /dev/null +++ b/app/api/webhooks/kofi/route.ts @@ -0,0 +1,186 @@ +// app/api/webhooks/kofi/route.ts +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; + +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"; + +// TEMP healthcheck: prove code + envs are live (remove after testing) +export async function GET() { + return NextResponse.json({ + ok: true, + env: { + hasVerifyToken: Boolean(VERIFY), + hasDirectusUrl: Boolean(DIRECTUS), + hasBotToken: Boolean(BOT_TOKEN), + collection: COLLECTION, + }, + }); +} + +type KofiPayload = { + verification_token: string; + message_id: string; + timestamp?: string; + 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 | null; + discord_username?: string | null; + discord_userid?: string | null; +}; + +function log(o: unknown) { + process.stdout.write(`[kofi] ${JSON.stringify(o)}\n`); +} +function json(res: unknown, status = 200) { + return NextResponse.json(res, { status }); +} +function addOneMonthPlusOneDay(d: Date) { + const nd = new Date(d); + const m = nd.getMonth(); + nd.setMonth(m + 1); + if (nd.getMonth() !== ((m + 1) % 12)) nd.setDate(0); + nd.setDate(nd.getDate() + 1); + return nd; +} +function safeStringify(v: unknown) { + try { + return JSON.stringify(v); + } catch { + return JSON.stringify({ _unstringifiable: true }); + } +} + +export async function POST(req: NextRequest) { + const ct = req.headers.get("content-type") || ""; + log({ step: "recv", ct }); + + if (!VERIFY || !DIRECTUS || !BOT_TOKEN) { + log({ step: "env-missing", hasVerify: !!VERIFY, hasDirectus: !!DIRECTUS, hasBot: !!BOT_TOKEN }); + return json({ ok: false, error: "server_misconfigured" }, 500); + } + + let data: KofiPayload | null = null; + try { + if (ct.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 field"); + data = JSON.parse(raw) as KofiPayload; + log({ step: "parsed-formdata" }); + } else { + // allow JSON for local tests + data = (await req.json()) as KofiPayload; + log({ step: "parsed-json" }); + } + } catch (e: any) { + log({ step: "parse-error", err: String(e?.message || e) }); + return json({ ok: false, error: "invalid_payload" }, 400); + } + + if (!data?.verification_token || data.verification_token !== VERIFY) { + log({ step: "verify-fail", got: data?.verification_token, expectSet: Boolean(VERIFY) }); + return json({ ok: false, error: "unauthorized" }, 401); + } + log({ step: "verify-ok", type: data.type, sub: data.is_subscription_payment }); + + // Compute values + const t = (data.type || "").toLowerCase(); + const isRecurring = Boolean(data.is_subscription_payment) || t.includes("subscription"); + const status = isRecurring ? "active" : "one_time"; + + const ts = data.timestamp ? new Date(data.timestamp) : null; + const emailId = (data.email && data.email.trim().toLowerCase()) || null; + const fallbackId = + (data.discord_userid && data.discord_userid.trim()) || + (data.from_name && data.from_name.trim()) || + "unknown"; + + const filter = encodeURIComponent( + JSON.stringify({ + _and: [{ provider: { _eq: "kofi" } }], + _or: [ + ...(emailId ? [{ email: { _eq: emailId } }, { external_user_id: { _eq: emailId } }] : []), + { external_user_id: { _eq: fallbackId } }, + ], + }) + ); + + // Read existing + const existingRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, { + headers: { Authorization: `Bearer ${BOT_TOKEN}` }, + cache: "no-store", + }); + if (!existingRes.ok) { + const body = await existingRes.text().catch(() => ""); + log({ step: "directus-read-fail", status: existingRes.status, body }); + return json({ ok: false, error: "directus_error_read" }, 500); + } + const existingJson = await existingRes.json().catch(() => ({} as any)); + const existing = existingJson?.data?.[0] ?? null; + log({ step: "directus-read-ok", hasExisting: Boolean(existing) }); + + const external_user_id = emailId || (existing?.external_user_id ?? fallbackId); + const started_at = + isRecurring && ts + ? existing?.started_at + ? existing.started_at + : ts.toISOString() + : existing?.started_at ?? null; + const renews_at = isRecurring && ts ? addOneMonthPlusOneDay(ts).toISOString() : null; + + const record: Record = { + provider: "kofi", + external_user_id, + email: emailId || data.email || null, + username: data.from_name || null, + status, + tier: data.tier_name || null, + started_at, + renews_at, + last_event_at: new Date().toISOString(), + raw: safeStringify(data), + }; + + const url = existing?.id + ? `${DIRECTUS}/items/${COLLECTION}/${existing.id}` + : `${DIRECTUS}/items/${COLLECTION}`; + const method = existing?.id ? "PATCH" : "POST"; + + // Avoid clobbering start/renews when payload has no sub timestamp + if (existing?.id && (!isRecurring || !ts)) { + delete record.started_at; + delete record.renews_at; + } + + 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(() => ""); + log({ step: "directus-write-fail", status: writeRes.status, body }); + return json({ ok: false, error: "directus_error_write" }, 500); + } + const written = await writeRes.json().catch(() => ({} as any)); + log({ step: "directus-write-ok", method, id: written?.data?.id || existing?.id || "?" }); + + return json({ ok: true }); +} diff --git a/app/page.tsx b/app/page.tsx index 1266e30a..739763a2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -41,7 +41,7 @@ export default async function HomePage({
-

This is the production build v0.1.1 - this site is an active BETA. Some features may not be available or work as intended. If you're experiencing issues please report them here: https://forge.makearmy.io/makearmy/makearmy-app/issues

+

This is the beta build v0.1.1 - this site is an active BETA. Some features may not be available or work as intended. If you're experiencing issues please report them here: https://forge.makearmy.io/makearmy/makearmy-app/issues

PRIVACY: We only use cookies strictly necessary to operate the site (e.g., your sign-in session). We do not store user data or telemetry other than what you provide and never share or sell data to third parties. Ever.

diff --git a/app/portal/account/AccountClient.tsx b/app/portal/account/AccountClient.tsx deleted file mode 100644 index e9d19b28..00000000 --- a/app/portal/account/AccountClient.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// app/portal/account/AccountPanel.tsx -"use client"; - -import { useEffect, useState } from "react"; - -type Me = { - id: string; - username: string; - first_name?: string | null; - last_name?: string | null; - email?: string | null; - location?: string | null; - avatar?: { id: string; filename_download: string } | null; -}; - -export default function AccountPanel() { - const [me, setMe] = useState(null); - const [err, setErr] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let alive = true; - (async () => { - try { - setErr(null); - 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(); - if (alive) setMe(j); - } catch (e: any) { - if (alive) setErr(e?.message || "Failed to load account"); - } finally { - if (alive) setLoading(false); - } - })(); - return () => { alive = false; }; - }, []); - - if (loading) return
Loading…
; - if (err) return
Error: {err}
; - if (!me) return null; - - return ( -
-
-

Account

-
-
Username
{me.username}
-
First Name
{me.first_name || "—"}
-
Last Name
{me.last_name || "—"}
-
Email
{me.email || "—"}
-
Location
{me.location || "—"}
-
-

Usernames can’t be changed.

-
- - {/* Place your edit forms here; they should only call the update APIs on submit, - never during initial render. For password/avatar we can prompt for password - inline or send to /auth/sign-in?reauth=1&next=/portal/account#security. */} -
- ); -} diff --git a/app/portal/account/AccountPanel.tsx b/app/portal/account/AccountPanel.tsx index a3d5c1e2..ab673e10 100644 --- a/app/portal/account/AccountPanel.tsx +++ b/app/portal/account/AccountPanel.tsx @@ -2,9 +2,14 @@ "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"; + +// Use relative paths to avoid alias resolution issues in this route +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"; type Avatar = { id: string; filename_download?: string } | null; type Me = { @@ -69,6 +74,8 @@ export default function AccountPanel() {

Account

+ {/* Shows success/failure after Ko-fi verify redirect */} +
@@ -104,19 +111,17 @@ export default function AccountPanel() {
{me.location || "—"}
+ + {/* Badges (derive-on-read, no cron) */} +
+ {/* Link Ko-fi */} + + {/* Editable sections */} - - - - + +
); diff --git a/components/account.zip b/components/account.zip new file mode 100644 index 00000000..a5a5e184 Binary files /dev/null and b/components/account.zip differ diff --git a/components/account/ConnectKofi.tsx b/components/account/ConnectKofi.tsx new file mode 100644 index 00000000..d2d45621 --- /dev/null +++ b/components/account/ConnectKofi.tsx @@ -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(null); + const [err, setErr] = useState(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 ( +
+

Link Ko-fi

+

+ Enter the email you use on Ko-fi. We’ll send a one-time verification link to confirm it’s you. +

+ +
+ setValue(e.target.value)} + disabled={busy} + /> + + +
+ + {msg && ( +
+ {msg} +
+ )} + {err && ( +
+ {err} +
+ )} +

+ 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). +

+
+ ); +} diff --git a/components/account/LinkStatus.tsx b/components/account/LinkStatus.tsx new file mode 100644 index 00000000..96237a54 --- /dev/null +++ b/components/account/LinkStatus.tsx @@ -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 ( +
+ {isOk ? "Ko-fi successfully linked to your account." : "Couldn’t verify that Ko-fi link. Please try again."} +
+ ); +} diff --git a/components/account/SupporterBadges.tsx b/components/account/SupporterBadges.tsx new file mode 100644 index 00000000..b10923e9 --- /dev/null +++ b/components/account/SupporterBadges.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+ Couldn’t load supporter badges. +
+ ); + } + + if (!badges) { + return ( +
+ Loading supporter badges… +
+ ); + } + + if (badges.length === 0) { + return ( +
+ No supporter badges yet. +
+ ); + } + + return ( +
+
Supporter Badges
+
+ {badges.map((b, i) => ( + + {providerIcon(b.provider)} + {b.label} + + ))} +
+
+ ); +} diff --git a/components/portal/UtilitySwitcher.tsx b/components/portal/UtilitySwitcher.tsx index 086058e0..36faf0e1 100644 --- a/components/portal/UtilitySwitcher.tsx +++ b/components/portal/UtilitySwitcher.tsx @@ -6,11 +6,11 @@ import { useRouter, useSearchParams } from "next/navigation"; import { cn } from "@/lib/utils"; type Item = { - key: string; // used in ?t= + key: string; // used in ?t= label: string; note?: string; - icon?: string; // optional icon (public/images/utils/) - href?: string; // optional absolute URL (used if no component) + icon?: string; // optional icon (public/images/utils/) + href?: string; // optional absolute URL (used if no component) component?: React.ComponentType<{ embedded?: boolean }>; }; @@ -19,10 +19,9 @@ const BackgroundRemoverPanel = dynamic( () => import("@/components/utilities/BackgroundRemoverPanel"), { ssr: false } ); -const SVGNestPanel = dynamic( - () => import("@/components/utilities/SVGNestPanel"), - { ssr: false } -); +const SVGNestPanel = dynamic(() => import("@/components/utilities/SVGNestPanel"), { + ssr: false, +}); const LaserToolkitSwitcher = dynamic( () => import("@/components/portal/LaserToolkitSwitcher"), { ssr: false } @@ -34,33 +33,99 @@ const FileBrowserPanel = dynamic( ); const ITEMS: Item[] = [ - { key: "laser-toolkit", label: "Laser Toolkit", note: "convert laser settings, interval and more", icon: "toolkit.png", component: LaserToolkitSwitcher, href: "https://makearmy.io/laser-toolkit" }, -{ key: "files", label: "File Server", note: "download from our file explorer", icon: "fs.png", component: FileBrowserPanel, href: "https://makearmy.io/files" }, -{ key: "svgnest", label: "SVGnest", note: "automatically nests parts and exports svg", icon: "nest.png", component: SVGNestPanel, href: "https://makearmy.io/svgnest" }, -{ key: "background-remover", label: "BG Remover", note: "open source background remover", icon: "bgrm.png", component: BackgroundRemoverPanel, href: "https://makearmy.io/background-remover" }, -{ key: "picsur", label: "Picsur", note: "Simple Image Host", icon: "picsur.png", href: "https://images.makearmy.io" }, -{ key: "privatebin", label: "PrivateBin", note: "Encrypted internet clipboard", icon: "privatebin.png", href: "https://paste.makearmy.io/" }, -{ key: "forgejo", label: "Forgejo", note: "git for our community members", icon: "forge.png", href: "https://forge.makearmy.io" }, + { + key: "laser-toolkit", + label: "Laser Toolkit", + note: "convert laser settings, interval and more", + icon: "toolkit.png", + component: LaserToolkitSwitcher, + href: "/laser-toolkit", + }, +{ + key: "files", + label: "File Server", + note: "download from our file explorer", + icon: "fs.png", + component: FileBrowserPanel, + href: "/files", +}, +{ + key: "svgnest", + label: "SVGnest", + note: "automatically nests parts and exports svg", + icon: "nest.png", + component: SVGNestPanel, + href: "/svgnest", +}, +{ + key: "background-remover", + label: "BG Remover", + note: "open source background remover", + icon: "bgrm.png", + component: BackgroundRemoverPanel, + href: "/background-remover", +}, + +// These stay on makearmy (external services) +{ + key: "picsur", + label: "Picsur", + note: "Simple Image Host", + icon: "picsur.png", + href: "https://images.makearmy.io", +}, +{ + key: "privatebin", + label: "PrivateBin", + note: "Encrypted internet clipboard", + icon: "privatebin.png", + href: "https://paste.makearmy.io/", +}, +{ + key: "forgejo", + label: "Forgejo", + note: "git for our community members", + icon: "forge.png", + href: "https://forge.makearmy.io", +}, ]; -function isExternal(urlStr: string | undefined) { - if (!urlStr) return false; +function isAbsoluteUrl(href: string) { + return /^https?:\/\//i.test(href); +} + +/** + * External if it's an absolute URL and NOT same-origin as the current site. + * Relative paths are always internal. + */ +function isExternalHref(href?: string) { + if (!href) return false; + if (!isAbsoluteUrl(href)) return false; + try { - const u = new URL(urlStr); - return u.hostname !== "makearmy.io"; + const u = new URL(href); + if (typeof window === "undefined") return true; + return u.origin !== window.location.origin; } catch { return true; } } -function toOnsitePath(urlStr: string): string { +/** + * If href is absolute AND same-origin, convert it to a site-relative path. + * Otherwise return as-is. + */ +function toSameOriginPath(href: string) { + if (!isAbsoluteUrl(href)) return href; + try { - const u = new URL(urlStr); - if (u.hostname === "makearmy.io") { + const u = new URL(href); + if (typeof window === "undefined") return href; + if (u.origin === window.location.origin) { return `${u.pathname}${u.search}${u.hash}`; } } catch {} - return urlStr; + return href; } function Panel({ item }: { item: Item }) { @@ -74,18 +139,25 @@ function Panel({ item }: { item: Item }) { ); } - const external = isExternal(item.href); + const href = item.href || "/"; + const external = isExternalHref(href); + if (external) { return ( ); } - const src = toOnsitePath(item.href || "/"); + const src = toSameOriginPath(href); return (