Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2bda66e5a1 | |||
| 514b34dc88 | |||
| 683cdeb86b | |||
| 380a0e6d1c | |||
| 8ac8e7a635 | |||
| 84950dcc6f | |||
| 7b0a35f8ec | |||
| fa26f5abe5 | |||
| e504f1742d | |||
| 59a9e03e61 | |||
| 60e86414b4 | |||
| 952bddc8db |
25 changed files with 355 additions and 22479 deletions
16
.env.local
16
.env.local
|
|
@ -2,17 +2,14 @@
|
||||||
# Public (used by client-side dropdown fetches)
|
# Public (used by client-side dropdown fetches)
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net
|
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/
|
BG_BYE_UPSTREAM=http://bgbye:7001/remove_background/
|
||||||
FILES_ROOT=/files
|
FILES_ROOT=/app/files
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Server-side Directus
|
# Server-side Directus
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
DIRECTUS_URL=https://forms.lasereverything.net
|
DIRECTUS_URL=https://forms.lasereverything.net
|
||||||
DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7
|
DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7
|
||||||
DIRECTUS_TOKEN_ADMIN_SUPPORTER=tvd64Ex5OWLEdH8EEM0rjH-gM1p-ZwfY
|
|
||||||
DIRECTUS_DEFAULT_ROLE=296a28bc-60ab-4251-8bef-27f6dfb67948
|
DIRECTUS_DEFAULT_ROLE=296a28bc-60ab-4251-8bef-27f6dfb67948
|
||||||
DIRECTUS_ROLE_MEMBER_NAME=Users
|
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_FILES=f264f066-5b38-4335-bb10-5b014bfa62cb
|
||||||
DX_FOLDER_PROJECTS_IMAGES=da11b876-2ede-4e19-ad3a-76fc9db449a8
|
DX_FOLDER_PROJECTS_IMAGES=da11b876-2ede-4e19-ad3a-76fc9db449a8
|
||||||
DX_FOLDER_PROJECTS_INSTRUCTIONS=905a4259-0c8e-489b-b810-c27186a2f266
|
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>
|
|
||||||
|
|
||||||
|
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,10 +1,5 @@
|
||||||
!public/svgnest/**
|
!public/svgnest/**
|
||||||
|
|
||||||
# Next.js build output
|
# build & deps
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
# Node
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# dependencies
|
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 <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 });
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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<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 });
|
|
||||||
}
|
|
||||||
|
|
@ -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<string, unknown> | 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<string, any> = {
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
|
|
@ -41,7 +41,7 @@ export default async function HomePage({
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-8 text-center text-xs text-muted-foreground">
|
<section className="mt-8 text-center text-xs text-muted-foreground">
|
||||||
<p>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 </p>
|
<p>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 </p>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
62
app/portal/account/AccountClient.tsx
Normal file
62
app/portal/account/AccountClient.tsx
Normal file
|
|
@ -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<Me | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(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 <div className="rounded-md border p-6 text-sm opacity-70">Loading…</div>;
|
||||||
|
if (err) return <div className="rounded-md border p-6 text-red-600">Error: {err}</div>;
|
||||||
|
if (!me) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="rounded-md border p-4">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">Account</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3 text-sm">
|
||||||
|
<div><div className="opacity-60">Username</div><div className="font-medium">{me.username}</div></div>
|
||||||
|
<div><div className="opacity-60">First Name</div><div>{me.first_name || "—"}</div></div>
|
||||||
|
<div><div className="opacity-60">Last Name</div><div>{me.last_name || "—"}</div></div>
|
||||||
|
<div><div className="opacity-60">Email</div><div>{me.email || "—"}</div></div>
|
||||||
|
<div><div className="opacity-60">Location</div><div>{me.location || "—"}</div></div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs opacity-70">Usernames can’t be changed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
|
import ProfileEditor from "@/components/account/ProfileEditor";
|
||||||
// Use relative paths to avoid alias resolution issues in this route
|
import PasswordChange from "@/components/account/PasswordChange";
|
||||||
import ProfileEditor from "../../../components/account/ProfileEditor";
|
import AvatarUploader from "@/components/account/AvatarUploader";
|
||||||
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 Avatar = { id: string; filename_download?: string } | null;
|
||||||
type Me = {
|
type Me = {
|
||||||
|
|
@ -74,8 +69,6 @@ export default function AccountPanel() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-md border p-4">
|
<div className="rounded-md border p-4">
|
||||||
<h2 className="text-lg font-semibold mb-2">Account</h2>
|
<h2 className="text-lg font-semibold mb-2">Account</h2>
|
||||||
{/* Shows success/failure after Ko-fi verify redirect */}
|
|
||||||
<LinkStatus />
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<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">
|
<div className="h-16 w-16 rounded-full overflow-hidden border bg-muted flex items-center justify-center">
|
||||||
|
|
@ -111,17 +104,19 @@ export default function AccountPanel() {
|
||||||
<div>{me.location || "—"}</div>
|
<div>{me.location || "—"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badges (derive-on-read, no cron) */}
|
|
||||||
<SupporterBadges email={me.email ?? undefined} userId={me.id} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Link Ko-fi */}
|
|
||||||
<ConnectKofi email={me.email} userId={me.id} />
|
|
||||||
|
|
||||||
{/* Editable sections */}
|
{/* Editable sections */}
|
||||||
<AvatarUploader avatarId={me.avatar?.id || null} onUpdated={refetchMe} />
|
<AvatarUploader
|
||||||
<ProfileEditor me={me} onUpdated={refetchMe} />
|
avatarId={me.avatar?.id || null}
|
||||||
|
onUpdated={refetchMe}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProfileEditor
|
||||||
|
me={me}
|
||||||
|
onUpdated={refetchMe}
|
||||||
|
/>
|
||||||
|
|
||||||
<PasswordChange />
|
<PasswordChange />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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<string | null>(null);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
|
||||||
const canSubmit = useMemo(
|
|
||||||
() => !!value && /\S+@\S+\.\S+/.test(value),
|
|
||||||
[value]
|
|
||||||
);
|
|
||||||
|
|
||||||
const startClaim = useCallback(async () => {
|
|
||||||
setErr(null);
|
|
||||||
setMsg(null);
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/support/kofi/claim/start", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
// if your auth middleware expects anything, set it here; otherwise cookies suffice
|
|
||||||
"x-user-id": userId ?? "",
|
|
||||||
"x-user-email": email ?? "",
|
|
||||||
},
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ email: value }),
|
|
||||||
});
|
|
||||||
const j = await res.json().catch(() => ({} as any));
|
|
||||||
if (!res.ok) {
|
|
||||||
// Show specific errors where helpful
|
|
||||||
const detail = j?.detail || j?.error || res.statusText;
|
|
||||||
throw new Error(
|
|
||||||
j?.error === "not_found"
|
|
||||||
? "We don’t have any Ko-fi records for that email yet. If you’re sure it’s correct, try again after your next Ko-fi payment or after we run the backfill."
|
|
||||||
: String(detail || "Failed to start verification")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (j?.alreadyLinked) {
|
|
||||||
setMsg("This Ko-fi email is already linked to your account.");
|
|
||||||
} else {
|
|
||||||
setMsg(
|
|
||||||
"Verification email sent! Check your inbox and click the link to finish linking Ko-fi."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
setErr(e?.message || "Something went wrong.");
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}, [value, userId, email]);
|
|
||||||
|
|
||||||
const unlink = useCallback(async () => {
|
|
||||||
setErr(null);
|
|
||||||
setMsg(null);
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/support/kofi/unlink", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-user-id": userId ?? "",
|
|
||||||
},
|
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const t = await res.text().catch(() => "");
|
|
||||||
throw new Error(t || "Unlink failed");
|
|
||||||
}
|
|
||||||
setMsg("Ko-fi has been unlinked from your account.");
|
|
||||||
} catch (e: any) {
|
|
||||||
setErr(e?.message || "Unlink failed.");
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border p-4">
|
|
||||||
<h3 className="mb-2 text-base font-semibold">Link Ko-fi</h3>
|
|
||||||
<p className="mb-3 text-sm opacity-80">
|
|
||||||
Enter the email you use on Ko-fi. We’ll send a one-time verification link to confirm it’s you.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
className="w-full rounded border px-3 py-2 text-sm"
|
|
||||||
placeholder="you@kofi-email.com"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={startClaim}
|
|
||||||
disabled={!canSubmit || busy}
|
|
||||||
className="inline-flex items-center justify-center rounded bg-black px-4 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-black"
|
|
||||||
>
|
|
||||||
{busy ? "Sending…" : "Send Verify Link"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={unlink}
|
|
||||||
disabled={busy}
|
|
||||||
className="inline-flex items-center justify-center rounded border px-4 py-2 text-sm"
|
|
||||||
title="Remove the Ko-fi link from your account"
|
|
||||||
>
|
|
||||||
Unlink
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{msg && (
|
|
||||||
<div className="mt-3 rounded-md border border-emerald-300/50 bg-emerald-50 p-2 text-sm text-emerald-900">
|
|
||||||
{msg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{err && (
|
|
||||||
<div className="mt-3 rounded-md border border-red-300/50 bg-red-50 p-2 text-sm text-red-900">
|
|
||||||
{err}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="mt-3 text-xs opacity-70">
|
|
||||||
Tip: after you verify, badges update automatically. If you don’t see a badge yet, it’ll appear the next time a Ko-fi payment webhook arrives (or after backfill).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 (
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"mb-3 rounded-md border p-3 text-sm",
|
|
||||||
isOk
|
|
||||||
? "border-emerald-300/50 bg-emerald-50 text-emerald-900"
|
|
||||||
: "border-red-300/50 bg-red-50 text-red-900",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{isOk ? "Ko-fi successfully linked to your account." : "Couldn’t verify that Ko-fi link. Please try again."}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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<SupportBadge[] | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let url = "/api/support/badges";
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (email) params.set("email", String(email));
|
|
||||||
if (userId) params.set("userId", String(userId));
|
|
||||||
const qs = params.toString();
|
|
||||||
if (qs) url += `?${qs}`;
|
|
||||||
|
|
||||||
fetch(url, { cache: "no-store" })
|
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject(r)))
|
|
||||||
.then((json) => setBadges(Array.isArray(json?.badges) ? json.badges : []))
|
|
||||||
.catch(async (e) => {
|
|
||||||
try {
|
|
||||||
const t = await e.text();
|
|
||||||
setError(t || String(e));
|
|
||||||
} catch {
|
|
||||||
setError(String(e));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [email, userId]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="mt-4 rounded-lg border border-red-300/50 bg-red-50 p-3 text-sm text-red-800">
|
|
||||||
Couldn’t load supporter badges.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!badges) {
|
|
||||||
return (
|
|
||||||
<div className="mt-4 animate-pulse rounded-lg border p-3 text-sm opacity-60">
|
|
||||||
Loading supporter badges…
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (badges.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="mt-4 rounded-lg border p-3 text-sm opacity-70">
|
|
||||||
No supporter badges yet.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<div className="text-sm font-medium opacity-80">Supporter Badges</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{badges.map((b, i) => (
|
|
||||||
<span
|
|
||||||
key={`${b.provider}-${i}`}
|
|
||||||
className={[
|
|
||||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm",
|
|
||||||
b.active
|
|
||||||
? "border-green-300 bg-green-50 text-green-900"
|
|
||||||
: b.kind === "one_time"
|
|
||||||
? "border-amber-300 bg-amber-50 text-amber-900"
|
|
||||||
: "border-slate-300 bg-slate-50 text-slate-700",
|
|
||||||
].join(" ")}
|
|
||||||
title={
|
|
||||||
b.active
|
|
||||||
? b.renews_at
|
|
||||||
? `Active • renews by ${new Date(b.renews_at).toLocaleDateString()}`
|
|
||||||
: "Active"
|
|
||||||
: b.kind === "one_time"
|
|
||||||
? "One-time support"
|
|
||||||
: "Inactive"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span>{providerIcon(b.provider)}</span>
|
|
||||||
<span>{b.label}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -19,9 +19,10 @@ const BackgroundRemoverPanel = dynamic(
|
||||||
() => import("@/components/utilities/BackgroundRemoverPanel"),
|
() => import("@/components/utilities/BackgroundRemoverPanel"),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
const SVGNestPanel = dynamic(() => import("@/components/utilities/SVGNestPanel"), {
|
const SVGNestPanel = dynamic(
|
||||||
ssr: false,
|
() => import("@/components/utilities/SVGNestPanel"),
|
||||||
});
|
{ ssr: false }
|
||||||
|
);
|
||||||
const LaserToolkitSwitcher = dynamic(
|
const LaserToolkitSwitcher = dynamic(
|
||||||
() => import("@/components/portal/LaserToolkitSwitcher"),
|
() => import("@/components/portal/LaserToolkitSwitcher"),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
|
|
@ -33,99 +34,33 @@ const FileBrowserPanel = dynamic(
|
||||||
);
|
);
|
||||||
|
|
||||||
const ITEMS: Item[] = [
|
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: "laser-toolkit",
|
{ key: "files", label: "File Server", note: "download from our file explorer", icon: "fs.png", component: FileBrowserPanel, href: "https://makearmy.io/files" },
|
||||||
label: "Laser Toolkit",
|
{ key: "svgnest", label: "SVGnest", note: "automatically nests parts and exports svg", icon: "nest.png", component: SVGNestPanel, href: "https://makearmy.io/svgnest" },
|
||||||
note: "convert laser settings, interval and more",
|
{ key: "background-remover", label: "BG Remover", note: "open source background remover", icon: "bgrm.png", component: BackgroundRemoverPanel, href: "https://makearmy.io/background-remover" },
|
||||||
icon: "toolkit.png",
|
{ key: "picsur", label: "Picsur", note: "Simple Image Host", icon: "picsur.png", href: "https://images.makearmy.io" },
|
||||||
component: LaserToolkitSwitcher,
|
{ key: "privatebin", label: "PrivateBin", note: "Encrypted internet clipboard", icon: "privatebin.png", href: "https://paste.makearmy.io/" },
|
||||||
href: "/laser-toolkit",
|
{ key: "forgejo", label: "Forgejo", note: "git for our community members", icon: "forge.png", href: "https://forge.makearmy.io" },
|
||||||
},
|
|
||||||
{
|
|
||||||
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 isAbsoluteUrl(href: string) {
|
function isExternal(urlStr: string | undefined) {
|
||||||
return /^https?:\/\//i.test(href);
|
if (!urlStr) return false;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
try {
|
||||||
const u = new URL(href);
|
const u = new URL(urlStr);
|
||||||
if (typeof window === "undefined") return true;
|
return u.hostname !== "makearmy.io";
|
||||||
return u.origin !== window.location.origin;
|
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
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 {
|
try {
|
||||||
const u = new URL(href);
|
const u = new URL(urlStr);
|
||||||
if (typeof window === "undefined") return href;
|
if (u.hostname === "makearmy.io") {
|
||||||
if (u.origin === window.location.origin) {
|
|
||||||
return `${u.pathname}${u.search}${u.hash}`;
|
return `${u.pathname}${u.search}${u.hash}`;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
return href;
|
return urlStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Panel({ item }: { item: Item }) {
|
function Panel({ item }: { item: Item }) {
|
||||||
|
|
@ -139,25 +74,18 @@ function Panel({ item }: { item: Item }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const href = item.href || "/";
|
const external = isExternal(item.href);
|
||||||
const external = isExternalHref(href);
|
|
||||||
|
|
||||||
if (external) {
|
if (external) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<a
|
<a href={item.href} target="_blank" rel="noopener noreferrer" className="underline">
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
Open {item.label}
|
Open {item.label}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const src = toSameOriginPath(href);
|
const src = toOnsitePath(item.href || "/");
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
key={src}
|
key={src}
|
||||||
|
|
@ -177,11 +105,11 @@ export default function UtilitySwitcher() {
|
||||||
|
|
||||||
const activeKey = useMemo(() => {
|
const activeKey = useMemo(() => {
|
||||||
const t = (sp.get("t") || ITEMS[0].key).toLowerCase();
|
const t = (sp.get("t") || ITEMS[0].key).toLowerCase();
|
||||||
return ITEMS.some((i) => i.key === t) ? t : ITEMS[0].key;
|
return ITEMS.some(i => i.key === t) ? t : ITEMS[0].key;
|
||||||
}, [sp]);
|
}, [sp]);
|
||||||
|
|
||||||
const activeItem = useMemo(
|
const activeItem = useMemo(
|
||||||
() => ITEMS.find((i) => i.key === activeKey) || ITEMS[0],
|
() => ITEMS.find(i => i.key === activeKey) || ITEMS[0],
|
||||||
[activeKey]
|
[activeKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -191,25 +119,22 @@ export default function UtilitySwitcher() {
|
||||||
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
|
router.replace(`/portal/utilities?${q.toString()}`, { scroll: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional auto-open behavior for external tools when selected via URL (?t=...).
|
|
||||||
* RECOMMENDED: keep this off to avoid popup blockers / surprise tabs.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const item = activeItem;
|
const item = activeItem;
|
||||||
if (!item?.href) return;
|
if (!item) return;
|
||||||
if (item.component) return;
|
if (item.component) return;
|
||||||
if (!isExternalHref(item.href)) return;
|
const external = isExternal(item.href);
|
||||||
|
if (!external) return;
|
||||||
|
|
||||||
if (openedRef.current === item.key) return;
|
if (openedRef.current === item.key) return;
|
||||||
openedRef.current = item.key;
|
openedRef.current = item.key;
|
||||||
|
|
||||||
const AUTO_OPEN_ON_FIRST_PAINT = false;
|
const AUTO_OPEN_ON_FIRST_PAINT = true;
|
||||||
if (AUTO_OPEN_ON_FIRST_PAINT || !firstPaint) {
|
if (AUTO_OPEN_ON_FIRST_PAINT || !firstPaint) {
|
||||||
window.open(item.href, "_blank", "noopener,noreferrer");
|
window.open(item.href!, "_blank", "noopener,noreferrer");
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeItem?.key]);
|
}, [activeItem?.key, activeItem?.href]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFirstPaint(false);
|
setFirstPaint(false);
|
||||||
|
|
@ -221,10 +146,9 @@ export default function UtilitySwitcher() {
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
{ITEMS.map((it) => {
|
{ITEMS.map((it) => {
|
||||||
const isInline = Boolean(it.component);
|
const isInline = Boolean(it.component);
|
||||||
const external = !isInline && isExternalHref(it.href);
|
const external = !isInline && isExternal(it.href);
|
||||||
const iconSrc = it.icon ? `/images/utils/${it.icon}` : null;
|
const iconSrc = it.icon ? `/images/utils/${it.icon}` : null;
|
||||||
const isActive = it.key === activeKey;
|
const isActive = it.key === activeKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={it.key}
|
key={it.key}
|
||||||
|
|
@ -236,9 +160,7 @@ export default function UtilitySwitcher() {
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition",
|
"flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition",
|
||||||
isActive
|
isActive ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "hover:bg-muted"
|
|
||||||
)}
|
)}
|
||||||
title={it.note || it.label}
|
title={it.note || it.label}
|
||||||
>
|
>
|
||||||
|
|
@ -255,9 +177,7 @@ export default function UtilitySwitcher() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<span className="truncate">{it.label}</span>
|
<span className="truncate">{it.label}</span>
|
||||||
|
|
||||||
{!isInline && external && (
|
{!isInline && external && (
|
||||||
<span className="rounded bg-muted px-1 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
<span className="rounded bg-muted px-1 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
new tab
|
new tab
|
||||||
|
|
|
||||||
28
components/ui/progress.tsx
Normal file
28
components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||||
|
>(({ className, value, ...props }, ref) => (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
className="h-full w-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
))
|
||||||
|
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
|
|
@ -78,13 +78,9 @@ export default function BackgroundRemoverPage() {
|
||||||
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
const [sourceUrl, setSourceUrl] = useState<string | null>(null);
|
||||||
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||||
|
|
||||||
const [status, setStatus] = useState<Record<Canonical, Status>>(() => {
|
const [status, setStatus] = useState<Record<Canonical, Status>>(
|
||||||
return Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as Record<
|
() => Object.fromEntries(METHODS.map((m) => [m.key, "idle"])) as Record<Canonical, Status>
|
||||||
Canonical,
|
);
|
||||||
Status
|
|
||||||
>;
|
|
||||||
});
|
|
||||||
|
|
||||||
const [results, setResults] = useState<ResultMap>({});
|
const [results, setResults] = useState<ResultMap>({});
|
||||||
const resultsRef = useRef<ResultMap>({});
|
const resultsRef = useRef<ResultMap>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -186,9 +182,7 @@ export default function BackgroundRemoverPage() {
|
||||||
|
|
||||||
const runOne = async (key: Canonical) => {
|
const runOne = async (key: Canonical) => {
|
||||||
// When GPU-safe is on, try progressively smaller long-edge previews.
|
// When GPU-safe is on, try progressively smaller long-edge previews.
|
||||||
const sizes = gpuSafe
|
const sizes = gpuSafe ? BATCH_SIZES : [Math.max(natural?.w || 0, natural?.h || 0) || 4096];
|
||||||
? BATCH_SIZES
|
|
||||||
: [Math.max(natural?.w || 0, natural?.h || 0) || 4096];
|
|
||||||
|
|
||||||
let lastErr: string | null = null;
|
let lastErr: string | null = null;
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
|
|
@ -201,15 +195,10 @@ export default function BackgroundRemoverPage() {
|
||||||
fd.append("method", key);
|
fd.append("method", key);
|
||||||
|
|
||||||
// CHANGED: call local proxy instead of a hardcoded service
|
// CHANGED: call local proxy instead of a hardcoded service
|
||||||
const res = await fetch("/api/bgbye/process", {
|
const res = await fetch("/api/bgbye/process", { method: "POST", body: fd });
|
||||||
method: "POST",
|
|
||||||
body: fd,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const txt = await res.text().catch(() => "");
|
const txt = await res.text().catch(() => "");
|
||||||
const retryable =
|
const retryable = /out of memory|onnxruntime|cuda|allocate|500/i.test(txt);
|
||||||
/out of memory|onnxruntime|cuda|allocate|500/i.test(txt);
|
|
||||||
if (gpuSafe && retryable) {
|
if (gpuSafe && retryable) {
|
||||||
lastErr = txt || `HTTP ${res.status}`;
|
lastErr = txt || `HTTP ${res.status}`;
|
||||||
continue; // try next smaller size
|
continue; // try next smaller size
|
||||||
|
|
@ -272,16 +261,12 @@ export default function BackgroundRemoverPage() {
|
||||||
const f = e.dataTransfer.files?.[0];
|
const f = e.dataTransfer.files?.[0];
|
||||||
if (f) onPick(f);
|
if (f) onPick(f);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const f = e.target.files?.[0] ?? null;
|
const f = e.target.files?.[0] ?? null;
|
||||||
onPick(f);
|
onPick(f);
|
||||||
};
|
};
|
||||||
|
|
||||||
const aspect = useMemo(
|
const aspect = useMemo(() => (!natural ? 16 / 9 : natural.w / natural.h || 16 / 9), [natural]);
|
||||||
() => (!natural ? 16 / 9 : natural.w / natural.h || 16 / 9),
|
|
||||||
[natural]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateByClientX = useCallback((clientX: number) => {
|
const updateByClientX = useCallback((clientX: number) => {
|
||||||
const el = frameRef.current;
|
const el = frameRef.current;
|
||||||
|
|
@ -290,7 +275,6 @@ export default function BackgroundRemoverPage() {
|
||||||
const pct = ((clientX - rect.left) / rect.width) * 100;
|
const pct = ((clientX - rect.left) / rect.width) * 100;
|
||||||
setReveal(Math.min(100, Math.max(0, pct)));
|
setReveal(Math.min(100, Math.max(0, pct)));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onMouseDown = (e: React.MouseEvent) => {
|
const onMouseDown = (e: React.MouseEvent) => {
|
||||||
draggingRef.current = true;
|
draggingRef.current = true;
|
||||||
updateByClientX(e.clientX);
|
updateByClientX(e.clientX);
|
||||||
|
|
@ -300,7 +284,6 @@ export default function BackgroundRemoverPage() {
|
||||||
updateByClientX(e.clientX);
|
updateByClientX(e.clientX);
|
||||||
};
|
};
|
||||||
const onMouseUp = () => (draggingRef.current = false);
|
const onMouseUp = () => (draggingRef.current = false);
|
||||||
|
|
||||||
const onTouchStart = (e: React.TouchEvent) => {
|
const onTouchStart = (e: React.TouchEvent) => {
|
||||||
draggingRef.current = true;
|
draggingRef.current = true;
|
||||||
updateByClientX(e.touches[0].clientX);
|
updateByClientX(e.touches[0].clientX);
|
||||||
|
|
@ -338,36 +321,24 @@ export default function BackgroundRemoverPage() {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
fd.append("file", file);
|
||||||
fd.append("method", active);
|
fd.append("method", active);
|
||||||
|
|
||||||
// CHANGED: call local proxy instead of a hardcoded service
|
// CHANGED: call local proxy instead of a hardcoded service
|
||||||
const res = await fetch("/api/bgbye/process", { method: "POST", body: fd });
|
const res = await fetch("/api/bgbye/process", { method: "POST", body: fd });
|
||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
|
||||||
const outBlob = await res.blob();
|
const outBlob = await res.blob();
|
||||||
const ms = performance.now() - t0;
|
const ms = performance.now() - t0;
|
||||||
const previewBlob = await makePreview(outBlob);
|
const previewBlob = await makePreview(outBlob);
|
||||||
const previewUrl = URL.createObjectURL(previewBlob);
|
const previewUrl = URL.createObjectURL(previewBlob);
|
||||||
const prev = resultsRef.current[active];
|
const prev = resultsRef.current[active];
|
||||||
if (prev) revoke(prev.previewUrl);
|
if (prev) revoke(prev.previewUrl);
|
||||||
|
setResults((r) => ({ ...r, [active]: { fullBlob: outBlob, previewUrl, bytes: outBlob.size, ms } }));
|
||||||
setResults((r) => ({
|
|
||||||
...r,
|
|
||||||
[active]: { fullBlob: outBlob, previewUrl, bytes: outBlob.size, ms },
|
|
||||||
}));
|
|
||||||
setStatus((s) => ({ ...s, [active]: "ok" }));
|
setStatus((s) => ({ ...s, [active]: "ok" }));
|
||||||
} catch {
|
} catch {
|
||||||
setStatus((s) => ({ ...s, [active]: "error" }));
|
setStatus((s) => ({ ...s, [active]: "error" }));
|
||||||
}
|
}
|
||||||
}, [file, active]);
|
}, [file, active]);
|
||||||
|
|
||||||
const doneCount = useMemo(
|
const doneCount = useMemo(() => METHODS.filter((m) => status[m.key] === "ok").length, [status]);
|
||||||
() => METHODS.filter((m) => status[m.key] === "ok").length,
|
const pendingCount = useMemo(() => METHODS.filter((m) => status[m.key] === "pending").length, [status]);
|
||||||
[status]
|
|
||||||
);
|
|
||||||
const pendingCount = useMemo(
|
|
||||||
() => METHODS.filter((m) => status[m.key] === "pending").length,
|
|
||||||
[status]
|
|
||||||
);
|
|
||||||
|
|
||||||
function StatusDot({ s }: { s: Status }) {
|
function StatusDot({ s }: { s: Status }) {
|
||||||
const cls =
|
const cls =
|
||||||
|
|
@ -391,7 +362,7 @@ export default function BackgroundRemoverPage() {
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
<h1 className="text-2xl font-semibold">Background Remover</h1>
|
<h1 className="text-2xl font-semibold">Background Remover</h1>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="https://makearmy.io"
|
||||||
className="px-3 py-1 rounded-md border border-zinc-700 hover:bg-zinc-800/60 text-sm"
|
className="px-3 py-1 rounded-md border border-zinc-700 hover:bg-zinc-800/60 text-sm"
|
||||||
>
|
>
|
||||||
Back to main
|
Back to main
|
||||||
|
|
@ -408,12 +379,7 @@ export default function BackgroundRemoverPage() {
|
||||||
<div
|
<div
|
||||||
ref={frameRef}
|
ref={frameRef}
|
||||||
className="app-frame checkerboard relative w-full rounded-2xl shadow-inner"
|
className="app-frame checkerboard relative w-full rounded-2xl shadow-inner"
|
||||||
style={{
|
style={{ aspectRatio: `${aspect}`, maxWidth: "1200px", maxHeight: "80vh", marginInline: "auto" }}
|
||||||
aspectRatio: `${aspect}`,
|
|
||||||
maxWidth: "1200px",
|
|
||||||
maxHeight: "80vh",
|
|
||||||
marginInline: "auto",
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
|
|
@ -471,9 +437,7 @@ export default function BackgroundRemoverPage() {
|
||||||
{/* Divider & Thumb */}
|
{/* Divider & Thumb */}
|
||||||
<div
|
<div
|
||||||
className="slider-handle"
|
className="slider-handle"
|
||||||
style={
|
style={{ "--reveal": `${Math.min(100, Math.max(0, reveal))}` } as React.CSSProperties}
|
||||||
{ "--reveal": `${Math.min(100, Math.max(0, reveal))}` } as React.CSSProperties
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="slider-thumb">
|
<div className="slider-thumb">
|
||||||
<div className="w-1.5 h-4 bg-white/80 rounded" />
|
<div className="w-1.5 h-4 bg-white/80 rounded" />
|
||||||
|
|
@ -489,9 +453,7 @@ export default function BackgroundRemoverPage() {
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
className={`w-full justify-center px-3 py-2 rounded-md border flex items-center gap-2 ${
|
className={`w-full justify-center px-3 py-2 rounded-md border flex items-center gap-2 ${
|
||||||
active === key
|
active === key ? "border-blue-400 bg-blue-500/20" : "border-zinc-700 hover:bg-zinc-800/60"
|
||||||
? "border-blue-400 bg-blue-500/20"
|
|
||||||
: "border-zinc-700 hover:bg-zinc-800/60"
|
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setActive(key)}
|
onClick={() => setActive(key)}
|
||||||
disabled={!file}
|
disabled={!file}
|
||||||
|
|
@ -517,20 +479,13 @@ export default function BackgroundRemoverPage() {
|
||||||
|
|
||||||
{/* GPU-safe toggle */}
|
{/* GPU-safe toggle */}
|
||||||
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer select-none order-1">
|
<label className="flex items-center gap-2 text-sm text-zinc-300 cursor-pointer select-none order-1">
|
||||||
<input
|
<input type="checkbox" checked={gpuSafe} onChange={(e) => setGpuSafe(e.target.checked)} /> GPU-safe mode
|
||||||
type="checkbox"
|
|
||||||
checked={gpuSafe}
|
|
||||||
onChange={(e) => setGpuSafe(e.target.checked)}
|
|
||||||
/>{" "}
|
|
||||||
GPU-safe mode
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="text-zinc-400 text-sm order-2">
|
<div className="text-zinc-400 text-sm order-2">
|
||||||
{file ? (
|
{file ? (
|
||||||
pendingCount > 0 ? (
|
pendingCount > 0 ? (
|
||||||
<span>
|
<span>Processing… {doneCount}/{METHODS.length} finished</span>
|
||||||
Processing… {doneCount}/{METHODS.length} finished
|
|
||||||
</span>
|
|
||||||
) : doneCount > 0 ? (
|
) : doneCount > 0 ? (
|
||||||
<span>Done: {doneCount} methods succeeded</span>
|
<span>Done: {doneCount} methods succeeded</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -561,13 +516,7 @@ export default function BackgroundRemoverPage() {
|
||||||
? "border-sky-600 bg-sky-600/20 hover:bg-sky-600/30"
|
? "border-sky-600 bg-sky-600/20 hover:bg-sky-600/30"
|
||||||
: "border-zinc-700 text-zinc-400 cursor-not-allowed"
|
: "border-zinc-700 text-zinc-400 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={!file ? "Select a file first" : !active ? "Choose a method" : "Render selected method at full resolution"}
|
||||||
!file
|
|
||||||
? "Select a file first"
|
|
||||||
: !active
|
|
||||||
? "Choose a method"
|
|
||||||
: "Render selected method at full resolution"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Full-res render
|
Full-res render
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
"Name","Email","IsActive","SinceDateUTC","CurrentPledge","Total","PaymentProvider","Tier","DiscordUsername"
|
|
||||||
"Rhett Peterson","todaviaice213@hotmail.com","True","2025-04-30 04:17","3.00","18.00","PayPal","MakeArmy Recruit","stillice213_76057#0"
|
|
||||||
"lordkoka","lordkoka@gmail.com","True","2025-04-27 09:17","3.00","18.00","PayPal","MakeArmy Recruit","Not connected"
|
|
||||||
"RicK Porter","rickport73@gmail.com","True","2025-03-11 05:36","3.00","24.00","PayPal","MakeArmy Recruit","Not connected"
|
|
||||||
|
|
|
@ -1,109 +0,0 @@
|
||||||
// /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);
|
|
||||||
}
|
|
||||||
|
|
@ -6,29 +6,25 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
* Everything else is considered protected (including most /api/*).
|
* Everything else is considered protected (including most /api/*).
|
||||||
*/
|
*/
|
||||||
const PUBLIC_PAGES = new Set<string>([
|
const PUBLIC_PAGES = new Set<string>([
|
||||||
"/", // splash page is public
|
"/", // ← splash page is public
|
||||||
"/auth/sign-in",
|
"/auth/sign-in",
|
||||||
"/auth/sign-up",
|
"/auth/sign-up",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API paths that are explicitly allowed without auth.
|
* API paths that are explicitly allowed without auth.
|
||||||
* Keep this list tiny; add broad /api/webhooks to allow ALL webhook endpoints.
|
* Keep this list tiny. If you don't need any public APIs, leave it empty.
|
||||||
*/
|
*/
|
||||||
const PUBLIC_API_PREFIXES: string[] = [
|
const PUBLIC_API_PREFIXES: string[] = [
|
||||||
"/api/auth", // login/refresh/callback endpoints
|
"/api/auth", // login/refresh/callback endpoints
|
||||||
"/api/files/list", // read-only file endpoints
|
// 🔹 Allow the file server endpoints (read-only)
|
||||||
|
"/api/files/list",
|
||||||
"/api/files/raw",
|
"/api/files/raw",
|
||||||
"/api/files/download",
|
"/api/files/download",
|
||||||
"/api/webhooks", // allow ALL webhook endpoints
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Directus base (used to remotely validate the token after restarts). */
|
/** Directus base (used to remotely validate the token after restarts). */
|
||||||
const DIRECTUS = (
|
const DIRECTUS = (process.env.NEXT_PUBLIC_API_BASE_URL || process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ||
|
|
||||||
process.env.DIRECTUS_URL ||
|
|
||||||
""
|
|
||||||
).replace(/\/$/, "");
|
|
||||||
|
|
||||||
type MapResult = { pathname: string; query?: Record<string, string> };
|
type MapResult = { pathname: string; query?: Record<string, string> };
|
||||||
|
|
||||||
|
|
@ -77,9 +73,11 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
|
|
||||||
const res = NextResponse.redirect(url);
|
const res = NextResponse.redirect(url);
|
||||||
|
|
||||||
|
// Only clear auth markers in true re-auth scenarios
|
||||||
if (wantReauth) {
|
if (wantReauth) {
|
||||||
res.cookies.set("ma_at", "", { maxAge: 0, path: "/" });
|
res.cookies.set("ma_at", "", { maxAge: 0, path: "/" });
|
||||||
res.cookies.set("ma_v", "", { maxAge: 0, path: "/" });
|
res.cookies.set("ma_v", "", { maxAge: 0, path: "/" }); // throttle marker
|
||||||
|
// res.cookies.set("ma_rt", "", { maxAge: 0, path: "/" }); // if you use refresh tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
|
|
@ -89,12 +87,7 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
const url = req.nextUrl.clone();
|
const url = req.nextUrl.clone();
|
||||||
const { pathname } = url;
|
const { pathname } = url;
|
||||||
|
|
||||||
// ── -1) Always allow ALL webhook endpoints
|
// ── 0) Root must never redirect (no mapping, no gating).
|
||||||
if (pathname === "/api/webhooks" || pathname.startsWith("/api/webhooks/")) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 0) Root must never redirect
|
|
||||||
if (pathname === "/") return NextResponse.next();
|
if (pathname === "/") return NextResponse.next();
|
||||||
|
|
||||||
// ── 1) Legacy → Portal mapping (before auth gating)
|
// ── 1) Legacy → Portal mapping (before auth gating)
|
||||||
|
|
@ -114,8 +107,7 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
|
|
||||||
const forceAuth =
|
const forceAuth =
|
||||||
isAuthRoute &&
|
isAuthRoute &&
|
||||||
(url.searchParams.get("reauth") === "1" ||
|
(url.searchParams.get("reauth") === "1" || url.searchParams.get("force") === "1");
|
||||||
url.searchParams.get("force") === "1");
|
|
||||||
|
|
||||||
if (!token && isProtected) {
|
if (!token && isProtected) {
|
||||||
return kickToSignIn(req, { reauth: false });
|
return kickToSignIn(req, { reauth: false });
|
||||||
|
|
@ -176,9 +168,10 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
function legacyMap(pathname: string): MapResult | null {
|
function legacyMap(pathname: string): MapResult | null {
|
||||||
if (pathname === "/" || pathname.startsWith("/portal")) return null;
|
if (pathname === "/" || pathname.startsWith("/portal")) return null;
|
||||||
|
|
||||||
|
// detail mappings elided for brevity…
|
||||||
|
|
||||||
const listRules: Array<[RegExp, MapResult]> = [
|
const listRules: Array<[RegExp, MapResult]> = [
|
||||||
[/^\/background-remover\/?$/i, { pathname: "/portal/utilities", query: { t: "background-remover" } }],
|
[/^\/background-remover\/?$/i, { pathname: "/portal/utilities", query: { t: "background-remover" } }],
|
||||||
[/^\/svgnest\/?$/i, { pathname: "/portal/utilities", query: { t: "svgnest" } }],
|
|
||||||
[/^\/laser-toolkit\/?$/i, { pathname: "/portal/utilities", query: { t: "laser-toolkit" } }],
|
[/^\/laser-toolkit\/?$/i, { pathname: "/portal/utilities", query: { t: "laser-toolkit" } }],
|
||||||
[/^\/files\/?$/i, { pathname: "/portal/utilities", query: { t: "files" } }],
|
[/^\/files\/?$/i, { pathname: "/portal/utilities", query: { t: "files" } }],
|
||||||
[/^\/buying-guide\/?$/i, { pathname: "/portal/buying-guide" }],
|
[/^\/buying-guide\/?$/i, { pathname: "/portal/buying-guide" }],
|
||||||
|
|
@ -186,11 +179,9 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
[/^\/projects\/?$/i, { pathname: "/portal/projects" }],
|
[/^\/projects\/?$/i, { pathname: "/portal/projects" }],
|
||||||
[/^\/my\/rigs\/?$/i, { pathname: "/portal/rigs", query: { t: "my" } }],
|
[/^\/my\/rigs\/?$/i, { pathname: "/portal/rigs", query: { t: "my" } }],
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [re, dest] of listRules) {
|
for (const [re, dest] of listRules) {
|
||||||
if (re.test(pathname)) return dest;
|
if (re.test(pathname)) return dest;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,11 +202,9 @@ import { NextResponse, NextRequest } from "next/server";
|
||||||
if (pathname.startsWith("/api/")) {
|
if (pathname.startsWith("/api/")) {
|
||||||
return startsWithAny(pathname, PUBLIC_API_PREFIXES);
|
return startsWithAny(pathname, PUBLIC_API_PREFIXES);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match all except the usual static assets
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"],
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|images|static).*)"],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
161
package-lock.json
generated
161
package-lock.json
generated
|
|
@ -11,14 +11,15 @@
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.10",
|
"@radix-ui/react-accordion": "^1.2.10",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"csv-parse": "^6.1.0",
|
"framer-motion": "12.23.24",
|
||||||
"lucide-react": "^0.507.0",
|
"lucide-react": "^0.507.0",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"next": "^15.3.2",
|
"next": "^15.3.2",
|
||||||
"nodemailer": "^7.0.9",
|
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
|
|
@ -1311,6 +1312,53 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-select": {
|
"node_modules/@radix-ui/react-select": {
|
||||||
"version": "2.2.6",
|
"version": "2.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||||
|
|
@ -1409,6 +1457,52 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-separator": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
|
@ -1875,6 +1969,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.2.tgz",
|
||||||
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
|
"integrity": "sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -2068,6 +2163,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001716",
|
"caniuse-lite": "^1.0.30001716",
|
||||||
"electron-to-chromium": "^1.5.149",
|
"electron-to-chromium": "^1.5.149",
|
||||||
|
|
@ -2350,12 +2446,6 @@
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/csv-parse": {
|
|
||||||
"version": "6.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.1.0.tgz",
|
|
||||||
"integrity": "sha512-CEE+jwpgLn+MmtCpVcPtiCZpVtB6Z2OKPTr34pycYYoL7sxdOkXDdQ4lRiw6ioC0q6BLqhc6cKweCVvral8yhw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
|
@ -2721,6 +2811,33 @@
|
||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.23.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
|
||||||
|
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.23.23",
|
||||||
|
"motion-utils": "^12.23.6",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|
@ -3765,6 +3882,21 @@
|
||||||
"node": ">=16 || 14 >=14.17"
|
"node": ">=16 || 14 >=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.23.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||||
|
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.23.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.23.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||||
|
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -3890,15 +4022,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
|
||||||
"version": "7.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
|
|
||||||
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
|
|
||||||
"license": "MIT-0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
|
|
@ -4063,6 +4186,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.8",
|
"nanoid": "^3.3.8",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -4210,6 +4334,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -4219,6 +4344,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.26.0"
|
"scheduler": "^0.26.0"
|
||||||
},
|
},
|
||||||
|
|
@ -4231,6 +4357,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz",
|
||||||
"integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==",
|
"integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,15 @@
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.10",
|
"@radix-ui/react-accordion": "^1.2.10",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"csv-parse": "^6.1.0",
|
"framer-motion": "12.23.24",
|
||||||
"lucide-react": "^0.507.0",
|
"lucide-react": "^0.507.0",
|
||||||
"mime": "^4.0.7",
|
"mime": "^4.0.7",
|
||||||
"next": "^15.3.2",
|
"next": "^15.3.2",
|
||||||
"nodemailer": "^7.0.9",
|
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.63.0",
|
"react-hook-form": "^7.63.0",
|
||||||
|
|
|
||||||
21132
schema.yaml
21132
schema.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,224 +0,0 @@
|
||||||
// scripts/backfill-kofi.mjs
|
|
||||||
// Usage (inside container):
|
|
||||||
// node scripts/backfill-kofi.mjs --file /data/kofi.csv [--dry] [--concurrency=5]
|
|
||||||
//
|
|
||||||
// CSV expected headers (case-insensitive; unknown columns ignored):
|
|
||||||
// email, from_name, type, timestamp, is_subscription_payment, is_first_subscription_payment,
|
|
||||||
// tier_name, discord_userid, discord_username, amount, currency, kofi_transaction_id
|
|
||||||
//
|
|
||||||
// Upsert strategy: find existing where provider="kofi" and (email == email || external_user_id == email || external_user_id == discord_userid || external_user_id == from_name)
|
|
||||||
|
|
||||||
import fs from "node:fs";
|
|
||||||
import { parse } from "csv-parse/sync";
|
|
||||||
|
|
||||||
// --- envs from the running container ---
|
|
||||||
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
|
|
||||||
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER || "";
|
|
||||||
const COLLECTION = "user_memberships";
|
|
||||||
|
|
||||||
if (!DIRECTUS || !BOT_TOKEN) {
|
|
||||||
console.error("[backfill] Missing DIRECTUS_URL or DIRECTUS_TOKEN_ADMIN_SUPPORTER in env.");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- args ----
|
|
||||||
const args = new Map(
|
|
||||||
process.argv.slice(2).flatMap((a) => {
|
|
||||||
if (!a.startsWith("--")) return [];
|
|
||||||
const [k, v = "true"] = a.replace(/^--/, "").split("=");
|
|
||||||
return [[k, v]];
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const file = args.get("file");
|
|
||||||
const dry = args.get("dry") === "true" || args.has("dry");
|
|
||||||
const concurrency = Number(args.get("concurrency") || 5);
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
console.error("Usage: node scripts/backfill-kofi.mjs --file /path/to/kofi.csv [--dry] [--concurrency=5]");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- helpers ----
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
function addOneMonthPlusOneDay(d) {
|
|
||||||
const nd = new Date(d);
|
|
||||||
const m = nd.getMonth();
|
|
||||||
nd.setMonth(m + 1);
|
|
||||||
// handle month overflow (e.g., Jan 31 -> Mar 2, fix by going last day of prev month)
|
|
||||||
if (nd.getMonth() !== ((m + 1) % 12)) nd.setDate(0);
|
|
||||||
nd.setDate(nd.getDate() + 1);
|
|
||||||
return nd;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normBool(v) {
|
|
||||||
if (typeof v === "boolean") return v;
|
|
||||||
if (v == null) return false;
|
|
||||||
const s = String(v).trim().toLowerCase();
|
|
||||||
return s === "true" || s === "1" || s === "yes" || s === "y";
|
|
||||||
}
|
|
||||||
|
|
||||||
function lowerOrNull(v) {
|
|
||||||
const s = (v ?? "").toString().trim();
|
|
||||||
return s ? s.toLowerCase() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function strOrNull(v) {
|
|
||||||
const s = (v ?? "").toString().trim();
|
|
||||||
return s || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertOne(rec) {
|
|
||||||
const email = lowerOrNull(rec.email);
|
|
||||||
const from_name = strOrNull(rec.from_name);
|
|
||||||
const discord_userid = strOrNull(rec.discord_userid);
|
|
||||||
const type = strOrNull(rec.type) || "";
|
|
||||||
const isSub = normBool(rec.is_subscription_payment) || type.toLowerCase().includes("subscription");
|
|
||||||
const ts = rec.timestamp ? new Date(rec.timestamp) : null;
|
|
||||||
|
|
||||||
const extId =
|
|
||||||
email ||
|
|
||||||
discord_userid ||
|
|
||||||
from_name ||
|
|
||||||
"unknown";
|
|
||||||
|
|
||||||
const started_at =
|
|
||||||
isSub && ts ? ts.toISOString() : null;
|
|
||||||
|
|
||||||
const renews_at =
|
|
||||||
isSub && ts ? addOneMonthPlusOneDay(ts).toISOString() : null;
|
|
||||||
|
|
||||||
const status = isSub ? "active" : "one_time";
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
provider: "kofi",
|
|
||||||
external_user_id: extId,
|
|
||||||
email,
|
|
||||||
username: from_name,
|
|
||||||
status,
|
|
||||||
tier: strOrNull(rec.tier_name),
|
|
||||||
started_at,
|
|
||||||
renews_at,
|
|
||||||
last_event_at: new Date().toISOString(),
|
|
||||||
// Keep a compact raw for backfill; webhook now stores full JSON in TEXT
|
|
||||||
raw: JSON.stringify({
|
|
||||||
src: "backfill",
|
|
||||||
type,
|
|
||||||
email,
|
|
||||||
from_name,
|
|
||||||
is_subscription_payment: isSub,
|
|
||||||
is_first_subscription_payment: normBool(rec.is_first_subscription_payment),
|
|
||||||
timestamp: rec.timestamp || null,
|
|
||||||
discord_userid: discord_userid || null,
|
|
||||||
discord_username: strOrNull(rec.discord_username),
|
|
||||||
amount: strOrNull(rec.amount),
|
|
||||||
currency: strOrNull(rec.currency),
|
|
||||||
kofi_transaction_id: strOrNull(rec.kofi_transaction_id),
|
|
||||||
tier_name: strOrNull(rec.tier_name),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find existing (same criteria as webhook)
|
|
||||||
const filter = encodeURIComponent(
|
|
||||||
JSON.stringify({
|
|
||||||
_and: [{ provider: { _eq: "kofi" } }],
|
|
||||||
_or: [
|
|
||||||
...(email ? [{ email: { _eq: email } }, { external_user_id: { _eq: email } }] : []),
|
|
||||||
...(discord_userid ? [{ external_user_id: { _eq: discord_userid } }] : []),
|
|
||||||
...(from_name ? [{ external_user_id: { _eq: from_name } }] : []),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dry) {
|
|
||||||
return { dry: true, will: payload };
|
|
||||||
}
|
|
||||||
|
|
||||||
// read existing
|
|
||||||
const exRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
|
|
||||||
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
|
|
||||||
cache: "no-store",
|
|
||||||
});
|
|
||||||
if (!exRes.ok) {
|
|
||||||
const errText = await exRes.text().catch(() => "");
|
|
||||||
throw new Error(`directus_read_failed(${exRes.status}): ${errText}`);
|
|
||||||
}
|
|
||||||
const exJson = await exRes.json().catch(() => ({}));
|
|
||||||
const existing = exJson?.data?.[0] || null;
|
|
||||||
|
|
||||||
// For updates with no ts/isSub we won't clobber start/renew — mirror webhook behavior.
|
|
||||||
const body = { ...payload };
|
|
||||||
if (existing && (!isSub || !ts)) {
|
|
||||||
delete body.started_at;
|
|
||||||
delete body.renews_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = existing
|
|
||||||
? `${DIRECTUS}/items/${COLLECTION}/${existing.id}`
|
|
||||||
: `${DIRECTUS}/items/${COLLECTION}`;
|
|
||||||
const method = existing ? "PATCH" : "POST";
|
|
||||||
|
|
||||||
const wr = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${BOT_TOKEN}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!wr.ok) {
|
|
||||||
const t = await wr.text().catch(() => "");
|
|
||||||
throw new Error(`directus_write_failed(${wr.status}): ${t}`);
|
|
||||||
}
|
|
||||||
const wj = await wr.json().catch(() => ({}));
|
|
||||||
return { ok: true, method, id: wj?.data?.id || existing?.id || null };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
const csvBuf = fs.readFileSync(file);
|
|
||||||
const records = parse(csvBuf, {
|
|
||||||
columns: true,
|
|
||||||
skip_empty_lines: true,
|
|
||||||
bom: true,
|
|
||||||
relax_column_count: true,
|
|
||||||
trim: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`[backfill] Loaded ${records.length} rows from ${file}`);
|
|
||||||
console.log(`[backfill] Directus=${DIRECTUS} collection=${COLLECTION} dry=${dry} concurrency=${concurrency}`);
|
|
||||||
|
|
||||||
let ok = 0, fail = 0;
|
|
||||||
let idx = 0;
|
|
||||||
|
|
||||||
const pool = new Array(concurrency).fill(0).map(async () => {
|
|
||||||
while (idx < records.length) {
|
|
||||||
const i = idx++;
|
|
||||||
const rec = records[i];
|
|
||||||
try {
|
|
||||||
const res = await upsertOne(rec);
|
|
||||||
if (res.dry) {
|
|
||||||
console.log(`[dry] row#${i + 1}: would upsert ext="${(res.will.external_user_id)}" email="${res.will.email}" status=${res.will.status}`);
|
|
||||||
} else {
|
|
||||||
console.log(`[ok] row#${i + 1}: ${res.method} id=${res.id}`);
|
|
||||||
}
|
|
||||||
ok++;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[err] row#${i + 1}: ${(e && e.message) || e}`);
|
|
||||||
fail++;
|
|
||||||
// small backoff to avoid hammering if there's a transient issue
|
|
||||||
await sleep(150);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await Promise.all(pool);
|
|
||||||
console.log(`[backfill] done. ok=${ok} fail=${fail}`);
|
|
||||||
if (fail > 0) process.exitCode = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
run().catch((e) => {
|
|
||||||
console.error("[backfill] fatal:", e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue