makearmy-app/app/portal/page.tsx
makearmy 912cf71bb9 feat(support): Ko-fi end-to-end linking + badges (derive-on-read, no cron)
- Add Ko-fi webhook (/api/webhooks/kofi) with upsert by (provider, external_user_id)
  • Computes renews_at = timestamp + 1 calendar month + 1 day
  • Preserves first started_at; stores raw payload; canonicalizes by email when available
- Add Ko-fi claim flow
  • POST /api/support/kofi/claim/start — sends verification email via SMTP
  • GET  /api/support/kofi/claim/verify — finalizes link (sets app_user), redirects to /portal/account
  • POST /api/support/kofi/unlink — clears app_user on Ko-fi rows
- Add derive-on-read membership logic
  • /lib/memberships.ts — single source of truth for badges & “active” state
  • /api/support/badges — thin wrapper that returns per-provider badges
- Account UI
  • components/account/SupporterBadges.tsx — renders provider badges (Ko-fi now; extensible)
  • components/account/ConnectKofi.tsx — “Link Ko-fi” form (email → verify link)
  • components/account/LinkStatus.tsx — success/error banner on return
  • app/portal/account/AccountPanel.tsx — integrates badges, link panel, and banner
- Config/env
  • Requires: DIRECTUS_URL, DIRECTUS_TOKEN_ADMIN_SUPPORTER, KOFI_VERIFY_TOKEN
  • SMTP: SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, EMAIL_FROM
  • APP_ORIGIN used to build absolute verify URLs
- Misc
  • Fixed import to use @/lib/memberships
  • No cron required; UI derives active state via status === active && renews_at >= now

Refs: beta readiness for Ko-fi supporters
2025-10-19 17:51:04 -04:00

275 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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: <ShieldCheck className="h-5 w-5" aria-hidden="true" />, text: "No ads. No sponsors. No influence." },
{ icon: <Users className="h-5 w-5" aria-hidden="true" />, text: "Community-funded, community-first priorities." },
{ icon: <Video className="h-5 w-5" aria-hidden="true" />, text: "Videos, tools, and guides stay open for all." },
];
const supportTiers = [
{
name: "Laser Master Academy",
href: "https://masters.lasereverything.net",
icon: <Sparkles className="h-6 w-6" aria-hidden="true" />,
blurb:
"Our flagship learning community on MightyNetworks: structured courses, AMAs, and deeper mentorship.",
perks: ["Parameter Packs", "Support Forums", "Bonus Content Archives"],
cta: "Join the Academy",
},
{
name: "Patreon",
href: "https://www.patreon.com/c/LaserEverything",
icon: <HeartHandshake className="h-6 w-6" aria-hidden="true" />,
blurb:
"Flexible monthly support to underwrite videos, research, and open tools without monetization strings.",
perks: ["Directly Support Us", "Behind-the-Scenes Notes", "Community Polls"],
cta: "Back on Patreon",
},
{
name: "Ko-Fi",
href: "https://ko-fi.com/lasereverything",
icon: <Coffee className="h-6 w-6" aria-hidden="true" />,
blurb:
"One-time tips that go straight to hosting, development, and production costs — no paywall, just fuel.",
perks: ["Say thanks once", "Help cover a bill", "Keep it free for others"],
cta: "Tip on Ko-Fi",
},
];
const adVsCommunity = {
ads: [
"Algorithm-shaped content",
"Sponsor talking points",
"Influencer bias risk",
"Tracking and interruptions",
],
community: [
"Curriculum set by makers",
"Honest reviews & hard truths",
"Open tools without strings",
"Privacy-respecting experience",
],
};
// Reusable button styles (anchors styled as buttons to avoid shadcn hover conflicts)
const btn =
"inline-flex items-center justify-center rounded-2xl px-5 py-3 text-sm font-medium text-white shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2";
const btnTeal = `${btn} bg-teal-600 hover:bg-teal-700 focus-visible:ring-teal-500`;
const btnRose = `${btn} bg-rose-600 hover:bg-rose-700 focus-visible:ring-rose-500`;
const btnSky = `${btn} bg-sky-600 hover:bg-sky-700 focus-visible:ring-sky-500`;
export default function Page() {
return (
<div className="min-h-[calc(100dvh-4rem)] w-full">
{/* Hero */}
<section className="mx-auto w-full max-w-6xl px-6 py-14 sm:py-16">
<div className="text-center">
<Badge variant="secondary" className="mb-4">
Community-Funded Ad-Free Open Resources
</Badge>
<h1 className="text-3xl font-extrabold tracking-tight sm:text-4xl">
Keep Laser Everything & MakeArmy FREE for Everyone
</h1>
<p className="mx-auto mt-3 max-w-3xl text-balance text-sm text-muted-foreground sm:text-base">
The videos, tools, and docs you use today exist because previous supporters paid it forward.
We want to stay independent no ads, no sponsors, no strings and that only works if we fund it together.
</p>
{/* CTA row (custom anchors = stable hover) */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
<Link
href="https://masters.lasereverything.net"
target="_blank"
rel="noopener noreferrer"
aria-label="Join Laser Master Academy"
className={btnTeal}
>
Join the LMA <ArrowRight className="ml-2 h-4 w-4" />
</Link>
<Link
href="https://www.patreon.com/c/LaserEverything"
target="_blank"
rel="noopener noreferrer"
aria-label="Back Laser Everything on Patreon"
className={btnRose}
>
Back on Patreon
</Link>
<Link
href="https://ko-fi.com/lasereverything"
target="_blank"
rel="noopener noreferrer"
aria-label="Contribute on Ko-Fi"
className={btnSky}
>
Tip on Ko-Fi
</Link>
</div>
{/* Perks: each on its own line, centered */}
<ul className="mx-auto mt-5 max-w-xl space-y-2 text-muted-foreground">
{perks.map((p, i) => (
<li key={i} className="flex items-center justify-center gap-2 text-sm">
{p.icon}
<span>{p.text}</span>
</li>
))}
</ul>
</div>
</section>
{/* Support Options */}
<section className="mx-auto -mt-2 max-w-6xl px-6 pb-12">
<div className="rounded-3xl border bg-card p-6 shadow-sm sm:p-8">
<div className="mx-auto max-w-3xl text-center">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">How you can helppick what fits</h2>
<p className="mt-2 text-muted-foreground">
Whether you join the Academy, pledge monthly, or tip once, every contribution sustains the whole community.
</p>
</div>
<div className="mt-8 grid grid-cols-1 gap-6 md:grid-cols-3">
{supportTiers.map((tier) => (
<Card key={tier.name} className="group rounded-2xl transition hover:shadow-lg">
<CardHeader>
<div className="mb-2 flex items-center gap-2 text-teal-600 dark:text-teal-400">
{tier.icon}
<CardTitle className="text-lg">{tier.name}</CardTitle>
</div>
<p className="text-sm text-muted-foreground">{tier.blurb}</p>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{tier.perks.map((perk) => (
<li key={perk} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> {perk}
</li>
))}
</ul>
<Link
href={tier.href}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-flex w-full items-center justify-center rounded-xl bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-colors hover:bg-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground"
>
{tier.cta}
</Link>
</CardContent>
</Card>
))}
</div>
{/* Philosophy: Ads vs Community */}
<div className="mt-10">
<div className="mx-auto max-w-3xl text-center">
<h3 className="text-xl font-semibold">Why not just run ads & sponsors?</h3>
<p className="mt-2 text-sm text-muted-foreground">
Because it quietly changes what we make and who we serve.
</p>
<p className="mt-1 text-sm text-muted-foreground">Wed rather be accountable to you.</p>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Dim outline for Advertising box */}
<Card className="rounded-2xl border border-foreground/10 dark:border-foreground/15">
<CardHeader>
<div className="flex items-center gap-2 text-rose-600 dark:text-rose-400">
<CircleX className="h-5 w-5" aria-hidden="true" />
<CardTitle className="text-base">Advertising / Sponsorship Model</CardTitle>
</div>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-muted-foreground">
{adVsCommunity.ads.map((t) => (
<li key={t} className="flex items-center gap-2">
<CircleX className="h-4 w-4" aria-hidden="true" /> {t}
</li>
))}
</ul>
</CardContent>
</Card>
{/* Brighter outline for Community box */}
<Card className="rounded-2xl border border-teal-500/50 shadow-sm">
<CardHeader>
<div className="flex items-center gap-2 text-teal-700 dark:text-teal-300">
<CheckCircle2 className="h-5 w-5" aria-hidden="true" />
<CardTitle className="text-base">Community-Funded Model</CardTitle>
</div>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{adVsCommunity.community.map((t) => (
<li key={t} className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> {t}
</li>
))}
</ul>
</CardContent>
</Card>
</div>
</div>
{/* Divider */}
<div className="my-10 h-px w-full bg-border" />
{/* Other ways */}
<div className="mt-10">
<h3 className="text-center text-xl font-semibold">No budget? No problemheres how to help for free</h3>
<div className="mx-auto mt-4 max-w-3xl">
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{[
"Share our videos with a friend",
"Star + share our repos/tools",
"Submit laser settings for others",
"Report bugs and suggest features",
].map((item) => (
<li key={item} className="flex items-center gap-2 text-sm text-muted-foreground">
<HandCoins className="h-4 w-4" aria-hidden="true" /> {item}
</li>
))}
</ul>
</div>
</div>
{/* Closing statement */}
<div className="mx-auto mt-10 max-w-3xl text-center">
<p className="text-balance text-sm text-muted-foreground">
Laser Everything exists because makers before you chose to keep the ladder down. If we each do a small part
<span className="font-medium text-foreground"> we never need a paywall</span>.
</p>
</div>
</div>
</section>
</div>
);
}