diff --git a/.env.local b/.env.local index 48a1161d..bece3d72 100644 --- a/.env.local +++ b/.env.local @@ -2,6 +2,8 @@ # Public (used by client-side dropdown fetches) # ───────────────────────────────────────────── NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net +APP_ORIGIN=https://beta.makearmy.io +KOFI_VERIFY_TOKEN=baa0aa53-7269-4119-a94f-e1380383e05e BG_BYE_UPSTREAM=http://bgbye:7001/remove_background/ FILES_ROOT=/files @@ -10,6 +12,7 @@ FILES_ROOT=/files # ───────────────────────────────────────────── DIRECTUS_URL=https://forms.lasereverything.net DIRECTUS_TOKEN_ADMIN_REGISTER=l_QqNXKpi--Dt-hHDncHyBX0eiHNYZr7 +DIRECTUS_TOKEN_ADMIN_SUPPORTER=tvd64Ex5OWLEdH8EEM0rjH-gM1p-ZwfY DIRECTUS_DEFAULT_ROLE=296a28bc-60ab-4251-8bef-27f6dfb67948 DIRECTUS_ROLE_MEMBER_NAME=Users @@ -42,3 +45,14 @@ DX_FOLDER_UV_SCREENS=a84f54b1-0e92-4ea6-8fbe-37a3a74bd49c DX_FOLDER_PROJECTS_FILES=f264f066-5b38-4335-bb10-5b014bfa62cb DX_FOLDER_PROJECTS_IMAGES=da11b876-2ede-4e19-ad3a-76fc9db449a8 DX_FOLDER_PROJECTS_INSTRUCTIONS=905a4259-0c8e-489b-b810-c27186a2f266 + +# ───────────────────────────────────────────── +# Mail Server Settings (SMTP) +# ───────────────────────────────────────────── +SMTP_HOST=mail.arrmail.net +SMTP_PORT=465 +SMTP_USER=noreply@makearmy.io +SMTP_PASS=TZhXn4yQQ92XEf +SMTP_SECURE=true +EMAIL_FROM=MakeArmy Support + diff --git a/app/api/support/badges/route.ts b/app/api/support/badges/route.ts new file mode 100644 index 00000000..bc658e20 --- /dev/null +++ b/app/api/support/badges/route.ts @@ -0,0 +1,36 @@ +// /app/api/support/badges/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { fetchMembershipBadges } from "lib/support/memberships"; + +// Replace this with your real auth lookup if/when you wire it in +async function getCurrentUser(req: NextRequest): Promise<{ id?: string; email?: string } | null> { + const uid = req.headers.get("x-user-id") || undefined; + const email = req.headers.get("x-user-email") || undefined; + return uid || email ? { id: uid, email } : null; +} + +export async function GET(req: NextRequest) { + try { + const me = await getCurrentUser(req); + + // Accept explicit query params so the component can work without special headers + const emailParam = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase() || undefined; + const userIdParam = (req.nextUrl.searchParams.get("userId") || "").trim() || undefined; + + if (!emailParam && !userIdParam && !me?.id && !me?.email) { + return NextResponse.json({ error: "Provide email or userId" }, { status: 400 }); + } + + const badges = await fetchMembershipBadges({ + userId: userIdParam || me?.id, + email: emailParam || me?.email, + }); + + return NextResponse.json({ badges }); + } catch (e: any) { + return NextResponse.json( + { error: "badges_fetch_failed", detail: String(e?.message || e) }, + { status: 500 } + ); + } +} diff --git a/app/api/support/kofi/claim/start/route.ts b/app/api/support/kofi/claim/start/route.ts new file mode 100644 index 00000000..91cc7778 --- /dev/null +++ b/app/api/support/kofi/claim/start/route.ts @@ -0,0 +1,126 @@ +// app/api/support/kofi/claim/start/route.ts +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import nodemailer from "nodemailer"; + +const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; +const COLLECTION = "user_memberships"; + +const APP_ORIGIN = process.env.APP_ORIGIN || "https://makearmy.io"; // your site base URL + +// SMTP envs +const SMTP_HOST = process.env.SMTP_HOST || ""; +const SMTP_PORT = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 587; +const SMTP_USER = process.env.SMTP_USER || ""; +const SMTP_PASS = process.env.SMTP_PASS || ""; +const SMTP_SECURE = process.env.SMTP_SECURE === "true"; +const EMAIL_FROM = process.env.EMAIL_FROM || "MakeArmy Support "; + +// TODO: replace with your actual auth/session resolver +async function getCurrentUserId(req: NextRequest): Promise { + const uid = req.headers.get("x-user-id"); + return uid && uid.trim() ? uid : null; +} + +export async function POST(req: NextRequest) { + // Basic SMTP sanity check (fail fast) + if (!SMTP_HOST || !SMTP_PORT || !EMAIL_FROM) { + return NextResponse.json( + { ok: false, error: "email_not_configured", detail: "SMTP_HOST/PORT/EMAIL_FROM must be set" }, + { status: 500 } + ); + } + + const userId = await getCurrentUserId(req); + if (!userId) { + return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); + } + + let email = ""; + try { + const body = await req.json(); + email = String(body?.email || "").trim().toLowerCase(); + } catch { + return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 }); + } + if (!email) { + return NextResponse.json({ ok: false, error: "missing_email" }, { status: 400 }); + } + + // 1) find Ko-fi membership by email + const filter = encodeURIComponent( + JSON.stringify({ + _and: [{ provider: { _eq: "kofi" } }, { email: { _eq: email } }], + }) + ); + + const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, { + headers: { Authorization: `Bearer ${BOT_TOKEN}` }, + cache: "no-store", + }); + if (!res.ok) { + const t = await res.text().catch(() => ""); + return NextResponse.json({ ok: false, error: "directus_read_failed", detail: t }, { status: 500 }); + } + const json = await res.json(); + const rec = json?.data?.[0]; + if (!rec) { + return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 }); + } + if (rec.app_user) { + // Already linked + return NextResponse.json({ ok: true, alreadyLinked: true }); + } + + // 2) create a one-time token + const token = crypto.randomBytes(24).toString("base64url"); + const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes + + const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${BOT_TOKEN}`, + }, + body: JSON.stringify({ + claim_token: token, + claim_expires_at: expires.toISOString(), + claim_user_id: userId, + }), + }); + if (!patch.ok) { + const t = await patch.text().catch(() => ""); + return NextResponse.json({ ok: false, error: "directus_write_failed", detail: t }, { status: 500 }); + } + + // 3) send verification email + const verifyUrl = `${APP_ORIGIN}/api/support/kofi/claim/verify?token=${encodeURIComponent(token)}`; + + try { + const transporter = nodemailer.createTransport({ + host: SMTP_HOST, // e.g. "mail.arrmail.net" + port: SMTP_PORT, // 465 in your case + secure: SMTP_SECURE, // true for 465, false for 587 STARTTLS + auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, + // If your server uses a self-signed certificate, uncomment the next line. + // Prefer installing a valid cert instead. + // tls: { rejectUnauthorized: false }, + }); + + await transporter.sendMail({ + from: EMAIL_FROM, // "MakeArmy Support " recommended + to: email, + subject: "Verify Ko-fi link to your MakeArmy account", + text: `Tap to verify your Ko-fi link: ${verifyUrl}\nThis link expires in 15 minutes.`, + html: `

Tap to verify your Ko-fi link:

${verifyUrl}

This link expires in 15 minutes.

`, + }); + } catch (e: any) { + return NextResponse.json( + { ok: false, error: "email_send_failed", detail: String(e?.message || e) }, + { status: 500 } + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/support/kofi/claim/verify/route.ts b/app/api/support/kofi/claim/verify/route.ts new file mode 100644 index 00000000..39f1a6f7 --- /dev/null +++ b/app/api/support/kofi/claim/verify/route.ts @@ -0,0 +1,53 @@ +// /app/api/support/kofi/claim/verify/route.ts +import { NextRequest, NextResponse } from "next/server"; + +const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; +const COLLECTION = "user_memberships"; + +// Default redirects; can be overridden by env +const SUCCESS_REDIRECT = process.env.KOFI_LINK_SUCCESS_URL || "/portal/account?linked=kofi&ok=1"; +const FAIL_REDIRECT = process.env.KOFI_LINK_FAIL_URL || "/portal/account?linked=kofi&error=1"; + +export async function GET(req: NextRequest) { + const token = req.nextUrl.searchParams.get("token") || ""; + const to = (path: string) => NextResponse.redirect(new URL(path, req.url)); + + if (!token) return to(FAIL_REDIRECT); + + // Find the claim row by token + const filter = encodeURIComponent(JSON.stringify({ claim_token: { _eq: token } })); + const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, { + headers: { Authorization: `Bearer ${BOT_TOKEN}` }, + cache: "no-store", + }); + if (!res.ok) return to(FAIL_REDIRECT); + + const json = await res.json().catch(() => ({} as any)); + const rec = json?.data?.[0]; + if (!rec) return to(FAIL_REDIRECT); + + // Validate expiry and sanity + const exp = rec.claim_expires_at ? new Date(rec.claim_expires_at).getTime() : 0; + if (!exp || Date.now() > exp) return to(FAIL_REDIRECT); + if (!rec.claim_user_id) return to(FAIL_REDIRECT); + + // Finalize: set app_user to claim_user_id; clear claim fields + const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${BOT_TOKEN}`, + }, + body: JSON.stringify({ + app_user: rec.claim_user_id, + claim_token: null, + claim_expires_at: null, + claim_user_id: null, + last_event_at: new Date().toISOString(), + }), + }); + + if (!patch.ok) return to(FAIL_REDIRECT); + return to(SUCCESS_REDIRECT); +} diff --git a/app/api/support/kofi/unlink/route.ts b/app/api/support/kofi/unlink/route.ts new file mode 100644 index 00000000..7625b984 --- /dev/null +++ b/app/api/support/kofi/unlink/route.ts @@ -0,0 +1,61 @@ +// app/api/support/kofi/unlink/route.ts +import { NextRequest, NextResponse } from "next/server"; + +const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; +const COLLECTION = "user_memberships"; + +// Replace with your real auth/session +async function getCurrentUserId(req: NextRequest): Promise { + const uid = req.headers.get("x-user-id"); + return uid && uid.trim() ? uid : null; +} + +export async function POST(req: NextRequest) { + const userId = await getCurrentUserId(req); + if (!userId) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + // Find linked Ko-fi rows + const filter = encodeURIComponent( + JSON.stringify({ + _and: [{ provider: { _eq: "kofi" } }, { app_user: { _eq: userId } }], + }) + ); + + const list = await fetch( + `${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=500`, + { headers: { Authorization: `Bearer ${BOT_TOKEN}` }, cache: "no-store" } + ); + if (!list.ok) { + const t = await list.text().catch(() => ""); + return NextResponse.json({ error: "directus_read_failed", detail: t }, { status: 500 }); + } + + const rows = (await list.json()).data || []; + if (!rows.length) return NextResponse.json({ ok: true, changed: 0 }); + + // Batch PATCH: clear app_user + const body = rows.map((r: any) => ({ + id: r.id, + app_user: null, + last_event_at: new Date().toISOString(), + })); + + const res = await fetch(`${DIRECTUS}/items/${COLLECTION}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${BOT_TOKEN}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const t = await res.text().catch(() => ""); + return NextResponse.json({ error: "directus_write_failed", detail: t }, { status: 500 }); + } + + return NextResponse.json({ ok: true, changed: rows.length }); +} diff --git a/app/api/webhooks/kofi/route.ts b/app/api/webhooks/kofi/route.ts new file mode 100644 index 00000000..4c9a61e5 --- /dev/null +++ b/app/api/webhooks/kofi/route.ts @@ -0,0 +1,194 @@ +// app/api/webhooks/kofi/route.ts +import { NextRequest, NextResponse } from "next/server"; + +/** + * ENV required: + * - KOFI_VERIFY_TOKEN + * - DIRECTUS_URL (e.g. https://forms.lasereverything.net) + * - DIRECTUS_TOKEN_ADMIN_SUPPORTER (supporter-bot service token) + * + * Notes: + * - renews_at is computed as (timestamp + 1 calendar month + 1 day) + * - started_at is the first subscription payment timestamp seen and is preserved + */ +const VERIFY = process.env.KOFI_VERIFY_TOKEN!; +const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; +const COLLECTION = "user_memberships"; + +type KofiPayload = { + verification_token: string; + message_id: string; + timestamp: string; // ISO + type: "Donation" | "Subscription" | "Commission" | "Shop Order" | string; + is_public?: boolean; + from_name?: string; + message?: string | null; + amount?: string; + url?: string; + email?: string | null; + currency?: string; + is_subscription_payment?: boolean; + is_first_subscription_payment?: boolean; + kofi_transaction_id?: string; + shop_items?: Array<{ direct_link_code: string; variation_name?: string; quantity?: number }> | null; + tier_name?: string | null; + shipping?: Record | null; + discord_username?: string | null; + discord_userid?: string | null; +}; + +export async function POST(req: NextRequest) { + // 1) Expect application/x-www-form-urlencoded with "data" JSON string + let data: KofiPayload | null = null; + + try { + const ctype = req.headers.get("content-type") || ""; + if (ctype.includes("application/x-www-form-urlencoded")) { + const form = await req.formData(); + const raw = form.get("data"); + if (typeof raw !== "string") throw new Error("missing data"); + data = JSON.parse(raw) as KofiPayload; + } else { + // Try to be forgiving if Ko-fi ever sends JSON directly + data = (await req.json()) as KofiPayload; + } + } catch { + return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 }); + } + + if (!data) { + return NextResponse.json({ ok: false, error: "empty_payload" }, { status: 400 }); + } + + // 2) Verify token (Ko-fi puts it inside the data JSON) + if (!data.verification_token || data.verification_token !== VERIFY) { + // Ko-fi retries on non-200, so 401 is fine here + return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); + } + + // 3) Normalize → membership record + const t = (data.type || "").toLowerCase(); + const isRecurring = Boolean(data.is_subscription_payment) || t.includes("subscription"); + const status = isRecurring ? "active" : "one_time"; + + // Choose a stable external id (prefer email; fallback to discord id, then a name) + const external_user_id = + (data.email && data.email.trim()) || + (data.discord_userid && data.discord_userid.trim()) || + (data.from_name && data.from_name.trim()) || + "unknown"; + + // Fetch existing record for started_at preservation + const filter = encodeURIComponent( + JSON.stringify({ + _and: [{ provider: { _eq: "kofi" } }, { external_user_id: { _eq: external_user_id } }], + }) + ); + const existingRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, { + headers: { Authorization: `Bearer ${BOT_TOKEN}` }, + cache: "no-store", + }); + if (!existingRes.ok) { + return NextResponse.json( + { ok: false, error: "directus_error", detail: `read failed ${existingRes.status}` }, + { status: 500 } + ); + } + const existingJson = await existingRes.json().catch(() => ({} as any)); + const existing = existingJson?.data?.[0]; + + const ts = data.timestamp ? new Date(data.timestamp) : null; + + // Helper: add 1 calendar month + 1 day, clamping end-of-month correctly + function addOneMonthPlusOneDay(d: Date) { + const nd = new Date(d); + const origDay = nd.getDate(); + const origMonth = nd.getMonth(); + nd.setMonth(origMonth + 1); + + // If month rolled over (e.g., Jan 31 -> Mar 02/03), clamp to end of intended next month. + if (nd.getMonth() !== ((origMonth + 1) % 12)) { + nd.setDate(0); // last day of previous month (i.e., end of the intended next month) + } else { + // Keep the original day if possible (handles 28/29/30-day months naturally) + // Already set by setMonth above. + } + + // Plus one extra day buffer + nd.setDate(nd.getDate() + 1); + return nd; + } + + // Determine started_at: first subscription payment time; keep existing if present + const started_at = + isRecurring && ts + ? existing?.started_at + ? new Date(existing.started_at) + : ts + : existing?.started_at + ? new Date(existing.started_at) + : null; + + // Determine renews_at: for subscription payments, slide to 1 month + 1 day past ts + const renews_at = isRecurring && ts ? addOneMonthPlusOneDay(ts) : null; + + const record = { + provider: "kofi", + external_user_id, + email: data.email || null, + username: data.from_name || null, // Ko-fi often provides from_name rather than a handle + status, // "active" for subs; "one_time" for donations/shop/commission + tier: data.tier_name || null, // present for membership tiers + started_at, + renews_at, + last_event_at: new Date(), + raw: safeStringify(data), // Long Text column with JSON editor interface + }; + + // 4) Upsert by (provider, external_user_id) + try { + const url = existing?.id + ? `${DIRECTUS}/items/${COLLECTION}/${existing.id}` + : `${DIRECTUS}/items/${COLLECTION}`; + const method = existing?.id ? "PATCH" : "POST"; + + // If we're patching and this event wasn't a subscription, avoid clobbering existing started_at + if (existing?.id && (!isRecurring || !ts)) { + // don't send started_at; preserve what's in DB + delete (record as any).started_at; + delete (record as any).renews_at; // keep existing renews_at if not a sub event + } + + const writeRes = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${BOT_TOKEN}`, + }, + body: JSON.stringify(record), + }); + + if (!writeRes.ok) { + const body = await writeRes.text().catch(() => ""); + throw new Error(`${method} failed ${writeRes.status} ${body}`); + } + } catch (err: any) { + // Non-200 makes Ko-fi retry, which is usually what we want on DB error + return NextResponse.json( + { ok: false, error: "directus_error", detail: String(err?.message || err) }, + { status: 500 } + ); + } + + // 5) Return 200 per Ko-fi requirement (stops retries) + return NextResponse.json({ ok: true }, { status: 200 }); +} + +function safeStringify(v: unknown) { + try { + return JSON.stringify(v); + } catch { + return JSON.stringify({ _unstringifiable: true }); + } +} diff --git a/app/portal/account/AccountPanel.tsx b/app/portal/account/AccountPanel.tsx index a3d5c1e2..188b1f93 100644 --- a/app/portal/account/AccountPanel.tsx +++ b/app/portal/account/AccountPanel.tsx @@ -5,6 +5,9 @@ import { useEffect, useMemo, useState, useCallback } from "react"; import ProfileEditor from "@/components/account/ProfileEditor"; import PasswordChange from "@/components/account/PasswordChange"; import AvatarUploader from "@/components/account/AvatarUploader"; +import SupporterBadges from "@/components/account/SupporterBadges"; +import ConnectKofi from "@/components/account/ConnectKofi"; +import LinkStatus from "@/components/account/LinkStatus"; // ← ADDED type Avatar = { id: string; filename_download?: string } | null; type Me = { @@ -69,6 +72,7 @@ export default function AccountPanel() {

Account

+ {/* ← ADDED: shows success/failure after Ko-fi verify redirect */}
@@ -104,19 +108,17 @@ export default function AccountPanel() {
{me.location || "—"}
+ + {/* Badges (derive-on-read, no cron) */} +
+ {/* Link Ko-fi */} + + {/* Editable sections */} - - - - + +
); diff --git a/app/portal/page.tsx b/app/portal/page.tsx index df231159..67f1dffb 100644 --- a/app/portal/page.tsx +++ b/app/portal/page.tsx @@ -1,12 +1,275 @@ -// app/portal/page.tsx -export default function PortalHome() { - return ( -
-

Dashboard

-

- Pick a tab to get started. You can add and manage Rigs, Laser Settings, Sources, Materials, Projects, - or jump into Utilities and Account. -

-
- ); -} +"use client"; + +import Link from "next/link"; +import { + ArrowRight, + CheckCircle2, + CircleX, + HeartHandshake, + HandCoins, + Coffee, + ShieldCheck, + Users, + Video, + Sparkles, +} from "lucide-react"; + +// shadcn/ui +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +/** + * App Dashboard: Support CTA (polished) + * - Stable custom buttons (no hover flicker) + * - Perks on their own lines + * - Copy + outline adjustments per request + */ + +const perks = [ + { icon: