- 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
275 lines
12 KiB
TypeScript
275 lines
12 KiB
TypeScript
"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 help—pick 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">We’d 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 problem—here’s 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>
|
||
);
|
||
}
|