diff --git a/.env.local b/.env.local index bece3d72..94a2c0f6 100644 --- a/.env.local +++ b/.env.local @@ -2,17 +2,14 @@ # 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 +FILES_ROOT=/app/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 @@ -45,14 +42,3 @@ 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 352ef05a..46e49f09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,5 @@ !public/svgnest/** -# Next.js build output +# build & deps .next/ - -# Node -node_modules/ - -# dependencies node_modules/ diff --git a/app/api/support/badges/route.ts b/app/api/support/badges/route.ts deleted file mode 100644 index f47c16fd..00000000 --- a/app/api/support/badges/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -// /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 deleted file mode 100644 index 91cc7778..00000000 --- a/app/api/support/kofi/claim/start/route.ts +++ /dev/null @@ -1,126 +0,0 @@ -// 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 deleted file mode 100644 index 39f1a6f7..00000000 --- a/app/api/support/kofi/claim/verify/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -// /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 deleted file mode 100644 index 7625b984..00000000 --- a/app/api/support/kofi/unlink/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -// 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 deleted file mode 100644 index d95a156b..00000000 --- a/app/api/webhooks/kofi/route.ts +++ /dev/null @@ -1,186 +0,0 @@ -// 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 739763a2..1266e30a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -41,7 +41,7 @@ export default async function HomePage({
-

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

+

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

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 new file mode 100644 index 00000000..e9d19b28 --- /dev/null +++ b/app/portal/account/AccountClient.tsx @@ -0,0 +1,62 @@ +// 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 ab673e10..a3d5c1e2 100644 --- a/app/portal/account/AccountPanel.tsx +++ b/app/portal/account/AccountPanel.tsx @@ -2,14 +2,9 @@ "use client"; import { useEffect, useMemo, useState, useCallback } from "react"; - -// 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"; +import ProfileEditor from "@/components/account/ProfileEditor"; +import PasswordChange from "@/components/account/PasswordChange"; +import AvatarUploader from "@/components/account/AvatarUploader"; type Avatar = { id: string; filename_download?: string } | null; type Me = { @@ -74,8 +69,6 @@ export default function AccountPanel() {

Account

- {/* Shows success/failure after Ko-fi verify redirect */} -
@@ -111,17 +104,19 @@ 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 deleted file mode 100644 index a5a5e184..00000000 Binary files a/components/account.zip and /dev/null differ diff --git a/components/account/ConnectKofi.tsx b/components/account/ConnectKofi.tsx deleted file mode 100644 index d2d45621..00000000 --- a/components/account/ConnectKofi.tsx +++ /dev/null @@ -1,137 +0,0 @@ -// 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 deleted file mode 100644 index 96237a54..00000000 --- a/components/account/LinkStatus.tsx +++ /dev/null @@ -1,28 +0,0 @@ -// /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 deleted file mode 100644 index b10923e9..00000000 --- a/components/account/SupporterBadges.tsx +++ /dev/null @@ -1,111 +0,0 @@ -// 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 36faf0e1..086058e0 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,9 +19,10 @@ 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 } @@ -33,99 +34,33 @@ 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: "/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", -}, + { 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" }, ]; -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; - +function isExternal(urlStr: string | undefined) { + if (!urlStr) return false; try { - const u = new URL(href); - if (typeof window === "undefined") return true; - return u.origin !== window.location.origin; + const u = new URL(urlStr); + return u.hostname !== "makearmy.io"; } catch { return true; } } -/** - * 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; - +function toOnsitePath(urlStr: string): string { try { - const u = new URL(href); - if (typeof window === "undefined") return href; - if (u.origin === window.location.origin) { + const u = new URL(urlStr); + if (u.hostname === "makearmy.io") { return `${u.pathname}${u.search}${u.hash}`; } } catch {} - return href; + return urlStr; } function Panel({ item }: { item: Item }) { @@ -139,25 +74,18 @@ function Panel({ item }: { item: Item }) { ); } - const href = item.href || "/"; - const external = isExternalHref(href); - + const external = isExternal(item.href); if (external) { return ( ); } - const src = toSameOriginPath(href); + const src = toOnsitePath(item.href || "/"); return (