diff --git a/.env.local b/.env.local index 49edab7d..bece3d72 100644 --- a/.env.local +++ b/.env.local @@ -2,7 +2,9 @@ # Public (used by client-side dropdown fetches) # ───────────────────────────────────────────── NEXT_PUBLIC_API_BASE_URL=https://forms.lasereverything.net -BG_BYE_UPSTREAM=https://makearmy.io/bgbye/process +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..f47c16fd --- /dev/null +++ b/app/api/support/badges/route.ts @@ -0,0 +1,36 @@ +// /app/api/support/badges/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { fetchMembershipBadges } from "@/lib/memberships"; + +// Replace this with your real auth lookup if/when you wire it in +async function getCurrentUser(req: NextRequest): Promise<{ id?: string; email?: string } | null> { + const uid = req.headers.get("x-user-id") || undefined; + const email = req.headers.get("x-user-email") || undefined; + return uid || email ? { id: uid, email } : null; +} + +export async function GET(req: NextRequest) { + try { + const me = await getCurrentUser(req); + + // Accept explicit query params so the component can work without special headers + const emailParam = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase() || undefined; + const userIdParam = (req.nextUrl.searchParams.get("userId") || "").trim() || undefined; + + if (!emailParam && !userIdParam && !me?.id && !me?.email) { + return NextResponse.json({ error: "Provide email or userId" }, { status: 400 }); + } + + const badges = await fetchMembershipBadges({ + userId: userIdParam || me?.id, + email: emailParam || me?.email, + }); + + return NextResponse.json({ badges }); + } catch (e: any) { + return NextResponse.json( + { error: "badges_fetch_failed", detail: String(e?.message || e) }, + { status: 500 } + ); + } +} diff --git a/app/api/support/kofi/claim/start/route.ts b/app/api/support/kofi/claim/start/route.ts new file mode 100644 index 00000000..91cc7778 --- /dev/null +++ b/app/api/support/kofi/claim/start/route.ts @@ -0,0 +1,126 @@ +// app/api/support/kofi/claim/start/route.ts +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import nodemailer from "nodemailer"; + +const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; +const COLLECTION = "user_memberships"; + +const APP_ORIGIN = process.env.APP_ORIGIN || "https://makearmy.io"; // your site base URL + +// SMTP envs +const SMTP_HOST = process.env.SMTP_HOST || ""; +const SMTP_PORT = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 587; +const SMTP_USER = process.env.SMTP_USER || ""; +const SMTP_PASS = process.env.SMTP_PASS || ""; +const SMTP_SECURE = process.env.SMTP_SECURE === "true"; +const EMAIL_FROM = process.env.EMAIL_FROM || "MakeArmy Support "; + +// TODO: replace with your actual auth/session resolver +async function getCurrentUserId(req: NextRequest): Promise { + const uid = req.headers.get("x-user-id"); + return uid && uid.trim() ? uid : null; +} + +export async function POST(req: NextRequest) { + // Basic SMTP sanity check (fail fast) + if (!SMTP_HOST || !SMTP_PORT || !EMAIL_FROM) { + return NextResponse.json( + { ok: false, error: "email_not_configured", detail: "SMTP_HOST/PORT/EMAIL_FROM must be set" }, + { status: 500 } + ); + } + + const userId = await getCurrentUserId(req); + if (!userId) { + return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); + } + + let email = ""; + try { + const body = await req.json(); + email = String(body?.email || "").trim().toLowerCase(); + } catch { + return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 }); + } + if (!email) { + return NextResponse.json({ ok: false, error: "missing_email" }, { status: 400 }); + } + + // 1) find Ko-fi membership by email + const filter = encodeURIComponent( + JSON.stringify({ + _and: [{ provider: { _eq: "kofi" } }, { email: { _eq: email } }], + }) + ); + + const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, { + headers: { Authorization: `Bearer ${BOT_TOKEN}` }, + cache: "no-store", + }); + if (!res.ok) { + const t = await res.text().catch(() => ""); + return NextResponse.json({ ok: false, error: "directus_read_failed", detail: t }, { status: 500 }); + } + const json = await res.json(); + const rec = json?.data?.[0]; + if (!rec) { + return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 }); + } + if (rec.app_user) { + // Already linked + return NextResponse.json({ ok: true, alreadyLinked: true }); + } + + // 2) create a one-time token + const token = crypto.randomBytes(24).toString("base64url"); + const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes + + const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${BOT_TOKEN}`, + }, + body: JSON.stringify({ + claim_token: token, + claim_expires_at: expires.toISOString(), + claim_user_id: userId, + }), + }); + if (!patch.ok) { + const t = await patch.text().catch(() => ""); + return NextResponse.json({ ok: false, error: "directus_write_failed", detail: t }, { status: 500 }); + } + + // 3) send verification email + const verifyUrl = `${APP_ORIGIN}/api/support/kofi/claim/verify?token=${encodeURIComponent(token)}`; + + try { + const transporter = nodemailer.createTransport({ + host: SMTP_HOST, // e.g. "mail.arrmail.net" + port: SMTP_PORT, // 465 in your case + secure: SMTP_SECURE, // true for 465, false for 587 STARTTLS + auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, + // If your server uses a self-signed certificate, uncomment the next line. + // Prefer installing a valid cert instead. + // tls: { rejectUnauthorized: false }, + }); + + await transporter.sendMail({ + from: EMAIL_FROM, // "MakeArmy Support " recommended + to: email, + subject: "Verify Ko-fi link to your MakeArmy account", + text: `Tap to verify your Ko-fi link: ${verifyUrl}\nThis link expires in 15 minutes.`, + html: `

Tap to verify your Ko-fi link:

${verifyUrl}

This link expires in 15 minutes.

`, + }); + } catch (e: any) { + return NextResponse.json( + { ok: false, error: "email_send_failed", detail: String(e?.message || e) }, + { status: 500 } + ); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/support/kofi/claim/verify/route.ts b/app/api/support/kofi/claim/verify/route.ts new file mode 100644 index 00000000..39f1a6f7 --- /dev/null +++ b/app/api/support/kofi/claim/verify/route.ts @@ -0,0 +1,53 @@ +// /app/api/support/kofi/claim/verify/route.ts +import { NextRequest, NextResponse } from "next/server"; + +const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; +const COLLECTION = "user_memberships"; + +// Default redirects; can be overridden by env +const SUCCESS_REDIRECT = process.env.KOFI_LINK_SUCCESS_URL || "/portal/account?linked=kofi&ok=1"; +const FAIL_REDIRECT = process.env.KOFI_LINK_FAIL_URL || "/portal/account?linked=kofi&error=1"; + +export async function GET(req: NextRequest) { + const token = req.nextUrl.searchParams.get("token") || ""; + const to = (path: string) => NextResponse.redirect(new URL(path, req.url)); + + if (!token) return to(FAIL_REDIRECT); + + // Find the claim row by token + const filter = encodeURIComponent(JSON.stringify({ claim_token: { _eq: token } })); + const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, { + headers: { Authorization: `Bearer ${BOT_TOKEN}` }, + cache: "no-store", + }); + if (!res.ok) return to(FAIL_REDIRECT); + + const json = await res.json().catch(() => ({} as any)); + const rec = json?.data?.[0]; + if (!rec) return to(FAIL_REDIRECT); + + // Validate expiry and sanity + const exp = rec.claim_expires_at ? new Date(rec.claim_expires_at).getTime() : 0; + if (!exp || Date.now() > exp) return to(FAIL_REDIRECT); + if (!rec.claim_user_id) return to(FAIL_REDIRECT); + + // Finalize: set app_user to claim_user_id; clear claim fields + const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${BOT_TOKEN}`, + }, + body: JSON.stringify({ + app_user: rec.claim_user_id, + claim_token: null, + claim_expires_at: null, + claim_user_id: null, + last_event_at: new Date().toISOString(), + }), + }); + + if (!patch.ok) return to(FAIL_REDIRECT); + return to(SUCCESS_REDIRECT); +} diff --git a/app/api/support/kofi/unlink/route.ts b/app/api/support/kofi/unlink/route.ts new file mode 100644 index 00000000..7625b984 --- /dev/null +++ b/app/api/support/kofi/unlink/route.ts @@ -0,0 +1,61 @@ +// app/api/support/kofi/unlink/route.ts +import { NextRequest, NextResponse } from "next/server"; + +const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; +const COLLECTION = "user_memberships"; + +// Replace with your real auth/session +async function getCurrentUserId(req: NextRequest): Promise { + const uid = req.headers.get("x-user-id"); + return uid && uid.trim() ? uid : null; +} + +export async function POST(req: NextRequest) { + const userId = await getCurrentUserId(req); + if (!userId) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + // Find linked Ko-fi rows + const filter = encodeURIComponent( + JSON.stringify({ + _and: [{ provider: { _eq: "kofi" } }, { app_user: { _eq: userId } }], + }) + ); + + const list = await fetch( + `${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=500`, + { headers: { Authorization: `Bearer ${BOT_TOKEN}` }, cache: "no-store" } + ); + if (!list.ok) { + const t = await list.text().catch(() => ""); + return NextResponse.json({ error: "directus_read_failed", detail: t }, { status: 500 }); + } + + const rows = (await list.json()).data || []; + if (!rows.length) return NextResponse.json({ ok: true, changed: 0 }); + + // Batch PATCH: clear app_user + const body = rows.map((r: any) => ({ + id: r.id, + app_user: null, + last_event_at: new Date().toISOString(), + })); + + const res = await fetch(`${DIRECTUS}/items/${COLLECTION}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${BOT_TOKEN}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const t = await res.text().catch(() => ""); + return NextResponse.json({ error: "directus_write_failed", detail: t }, { status: 500 }); + } + + return NextResponse.json({ ok: true, changed: rows.length }); +} diff --git a/app/api/webhooks/kofi/route.ts b/app/api/webhooks/kofi/route.ts new file mode 100644 index 00000000..d95a156b --- /dev/null +++ b/app/api/webhooks/kofi/route.ts @@ -0,0 +1,186 @@ +// app/api/webhooks/kofi/route.ts +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; + +const VERIFY = process.env.KOFI_VERIFY_TOKEN || ""; +const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); +const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER || ""; +const COLLECTION = "user_memberships"; + +// TEMP healthcheck: prove code + envs are live (remove after testing) +export async function GET() { + return NextResponse.json({ + ok: true, + env: { + hasVerifyToken: Boolean(VERIFY), + hasDirectusUrl: Boolean(DIRECTUS), + hasBotToken: Boolean(BOT_TOKEN), + collection: COLLECTION, + }, + }); +} + +type KofiPayload = { + verification_token: string; + message_id: string; + timestamp?: string; + type: "Donation" | "Subscription" | "Commission" | "Shop Order" | string; + is_public?: boolean; + from_name?: string; + message?: string | null; + amount?: string; + url?: string; + email?: string | null; + currency?: string; + is_subscription_payment?: boolean; + is_first_subscription_payment?: boolean; + kofi_transaction_id?: string; + shop_items?: Array<{ direct_link_code: string; variation_name?: string; quantity?: number }> | null; + tier_name?: string | null; + shipping?: Record | null; + discord_username?: string | null; + discord_userid?: string | null; +}; + +function log(o: unknown) { + process.stdout.write(`[kofi] ${JSON.stringify(o)}\n`); +} +function json(res: unknown, status = 200) { + return NextResponse.json(res, { status }); +} +function addOneMonthPlusOneDay(d: Date) { + const nd = new Date(d); + const m = nd.getMonth(); + nd.setMonth(m + 1); + if (nd.getMonth() !== ((m + 1) % 12)) nd.setDate(0); + nd.setDate(nd.getDate() + 1); + return nd; +} +function safeStringify(v: unknown) { + try { + return JSON.stringify(v); + } catch { + return JSON.stringify({ _unstringifiable: true }); + } +} + +export async function POST(req: NextRequest) { + const ct = req.headers.get("content-type") || ""; + log({ step: "recv", ct }); + + if (!VERIFY || !DIRECTUS || !BOT_TOKEN) { + log({ step: "env-missing", hasVerify: !!VERIFY, hasDirectus: !!DIRECTUS, hasBot: !!BOT_TOKEN }); + return json({ ok: false, error: "server_misconfigured" }, 500); + } + + let data: KofiPayload | null = null; + try { + if (ct.includes("application/x-www-form-urlencoded")) { + const form = await req.formData(); + const raw = form.get("data"); + if (typeof raw !== "string") throw new Error("missing data field"); + data = JSON.parse(raw) as KofiPayload; + log({ step: "parsed-formdata" }); + } else { + // allow JSON for local tests + data = (await req.json()) as KofiPayload; + log({ step: "parsed-json" }); + } + } catch (e: any) { + log({ step: "parse-error", err: String(e?.message || e) }); + return json({ ok: false, error: "invalid_payload" }, 400); + } + + if (!data?.verification_token || data.verification_token !== VERIFY) { + log({ step: "verify-fail", got: data?.verification_token, expectSet: Boolean(VERIFY) }); + return json({ ok: false, error: "unauthorized" }, 401); + } + log({ step: "verify-ok", type: data.type, sub: data.is_subscription_payment }); + + // Compute values + const t = (data.type || "").toLowerCase(); + const isRecurring = Boolean(data.is_subscription_payment) || t.includes("subscription"); + const status = isRecurring ? "active" : "one_time"; + + const ts = data.timestamp ? new Date(data.timestamp) : null; + const emailId = (data.email && data.email.trim().toLowerCase()) || null; + const fallbackId = + (data.discord_userid && data.discord_userid.trim()) || + (data.from_name && data.from_name.trim()) || + "unknown"; + + const filter = encodeURIComponent( + JSON.stringify({ + _and: [{ provider: { _eq: "kofi" } }], + _or: [ + ...(emailId ? [{ email: { _eq: emailId } }, { external_user_id: { _eq: emailId } }] : []), + { external_user_id: { _eq: fallbackId } }, + ], + }) + ); + + // Read existing + const existingRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, { + headers: { Authorization: `Bearer ${BOT_TOKEN}` }, + cache: "no-store", + }); + if (!existingRes.ok) { + const body = await existingRes.text().catch(() => ""); + log({ step: "directus-read-fail", status: existingRes.status, body }); + return json({ ok: false, error: "directus_error_read" }, 500); + } + const existingJson = await existingRes.json().catch(() => ({} as any)); + const existing = existingJson?.data?.[0] ?? null; + log({ step: "directus-read-ok", hasExisting: Boolean(existing) }); + + const external_user_id = emailId || (existing?.external_user_id ?? fallbackId); + const started_at = + isRecurring && ts + ? existing?.started_at + ? existing.started_at + : ts.toISOString() + : existing?.started_at ?? null; + const renews_at = isRecurring && ts ? addOneMonthPlusOneDay(ts).toISOString() : null; + + const record: Record = { + provider: "kofi", + external_user_id, + email: emailId || data.email || null, + username: data.from_name || null, + status, + tier: data.tier_name || null, + started_at, + renews_at, + last_event_at: new Date().toISOString(), + raw: safeStringify(data), + }; + + const url = existing?.id + ? `${DIRECTUS}/items/${COLLECTION}/${existing.id}` + : `${DIRECTUS}/items/${COLLECTION}`; + const method = existing?.id ? "PATCH" : "POST"; + + // Avoid clobbering start/renews when payload has no sub timestamp + if (existing?.id && (!isRecurring || !ts)) { + delete record.started_at; + delete record.renews_at; + } + + const writeRes = await fetch(url, { + method, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${BOT_TOKEN}` }, + body: JSON.stringify(record), + }); + + if (!writeRes.ok) { + const body = await writeRes.text().catch(() => ""); + log({ step: "directus-write-fail", status: writeRes.status, body }); + return json({ ok: false, error: "directus_error_write" }, 500); + } + const written = await writeRes.json().catch(() => ({} as any)); + log({ step: "directus-write-ok", method, id: written?.data?.id || existing?.id || "?" }); + + return json({ ok: true }); +} diff --git a/app/page.tsx b/app/page.tsx index 6892b510..739763a2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -41,7 +41,7 @@ export default async function HomePage({
-

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

+

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

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

diff --git a/app/portal/account/AccountClient.tsx b/app/portal/account/AccountClient.tsx deleted file mode 100644 index e9d19b28..00000000 --- a/app/portal/account/AccountClient.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// app/portal/account/AccountPanel.tsx -"use client"; - -import { useEffect, useState } from "react"; - -type Me = { - id: string; - username: string; - first_name?: string | null; - last_name?: string | null; - email?: string | null; - location?: string | null; - avatar?: { id: string; filename_download: string } | null; -}; - -export default function AccountPanel() { - const [me, setMe] = useState(null); - const [err, setErr] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let alive = true; - (async () => { - try { - setErr(null); - const r = await fetch("/api/account", { credentials: "include", cache: "no-store" }); - if (!r.ok) throw new Error(`Load failed (${r.status})`); - const j = await r.json(); - if (alive) setMe(j); - } catch (e: any) { - if (alive) setErr(e?.message || "Failed to load account"); - } finally { - if (alive) setLoading(false); - } - })(); - return () => { alive = false; }; - }, []); - - if (loading) return
Loading…
; - if (err) return
Error: {err}
; - if (!me) return null; - - return ( -
-
-

Account

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

Usernames can’t be changed.

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

Account

+ {/* Shows success/failure after Ko-fi verify redirect */} +
@@ -104,19 +111,17 @@ export default function AccountPanel() {
{me.location || "—"}
+ + {/* Badges (derive-on-read, no cron) */} +
+ {/* Link Ko-fi */} + + {/* Editable sections */} - - - - + +
); diff --git a/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: