From 963dde1a3e6a701dff9ace0721429c3fb1e2aa5d Mon Sep 17 00:00:00 2001 From: anonymousratwastaken Date: Tue, 1 Sep 2026 13:04:11 +0100 Subject: [PATCH] More changes --- public/logo.svg | 3 + public/zombie.png | Bin 0 -> 609 bytes src/app/api/donations/checkout/route.ts | 80 +++ src/app/api/donations/status/route.ts | 21 + src/app/api/donations/verify/route.ts | 31 ++ src/app/api/geyser-skin/route.ts | 39 ++ src/app/api/webhooks/geyser/route.ts | 23 + src/app/api/webhooks/stripe/route.ts | 102 ++++ src/app/donate/page.tsx | 679 ++++++++++++++++++++++++ src/app/donate/success/page.tsx | 64 +++ src/components/ChibiAvatar.tsx | 33 ++ src/components/campfire-viewer.tsx | 122 +++++ src/components/logo.tsx | 11 + src/components/play-dialog.tsx | 37 ++ src/components/top-bar.tsx | 20 + src/components/ui/questionnaire.tsx | 327 ++++++++++++ src/components/ui/tabs.tsx | 90 ++++ src/lib/donations.ts | 33 ++ src/lib/stripe.ts | 6 + 19 files changed, 1721 insertions(+) create mode 100644 public/logo.svg create mode 100755 public/zombie.png create mode 100644 src/app/api/donations/checkout/route.ts create mode 100644 src/app/api/donations/status/route.ts create mode 100644 src/app/api/donations/verify/route.ts create mode 100644 src/app/api/geyser-skin/route.ts create mode 100644 src/app/api/webhooks/geyser/route.ts create mode 100644 src/app/api/webhooks/stripe/route.ts create mode 100644 src/app/donate/page.tsx create mode 100644 src/app/donate/success/page.tsx create mode 100644 src/components/ChibiAvatar.tsx create mode 100644 src/components/campfire-viewer.tsx create mode 100644 src/components/logo.tsx create mode 100644 src/components/play-dialog.tsx create mode 100644 src/components/top-bar.tsx create mode 100644 src/components/ui/questionnaire.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/lib/donations.ts create mode 100644 src/lib/stripe.ts diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..b726b13 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/zombie.png b/public/zombie.png new file mode 100755 index 0000000000000000000000000000000000000000..eee325ba9cc6176b562f2cc17e7df184485160af GIT binary patch literal 609 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!7>k44ofy`glX(f`u%tWsIx;Y9 z?C1WI$O`032l#}znnubw=c@#^=?O}5g_hT|5}cn_Ql40p$`Fv4nOCCc=Nh75s%NNY zXxPOeb`Gd$M{0y;ny0500|$`9${@wa%D@O@c>%FBlnwHV1|u_AoC(M_WMpCx1kzDJ zoY~F-7S92)L7=yck>Lf<$1ob*Rt5&3t>)Kc6gA8nQ<;6NGLiW1~ z@>uD!xbs!NtWMge@Zg_*MxoY+M_P&;jY3TV8el4#Lr(oy=8=T;1t8Bcc)I$ztaD0e F0s!RJu0{X= literal 0 HcmV?d00001 diff --git a/src/app/api/donations/checkout/route.ts b/src/app/api/donations/checkout/route.ts new file mode 100644 index 0000000..f7ba1e9 --- /dev/null +++ b/src/app/api/donations/checkout/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getStripe } from "@/lib/stripe"; +import { cfg } from "@/lib/cfg"; +import { TIERS, tierForAmount } from "@/lib/donations"; +import { minecraftApi } from "@/lib/minecraft-api"; +import { minecraft } from "@/lib/servertap"; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { platform, username, tier, customAmount, customRecurring, giftTo, upgrade } = body as { + platform: "java" | "bedrock"; username: string; tier: string; customAmount?: number; customRecurring?: boolean; giftTo?: string; upgrade?: boolean; + }; + if (!username || !platform || !tier) return NextResponse.json({ error: "Missing fields" }, { status: 400 }); + + // resolve recipient first — validates the account exists and gives us + // uuid/xuid so the webhook can grant by UUID instead of name + const recipientName = (giftTo?.trim() || username).trim(); + const recipientPlatform = platform; + const resolved = await minecraftApi.resolvePlayerIdentifier({ type: recipientPlatform, username: recipientName }).catch((e: any) => { + return { error: e?.message || "Could not resolve that player" } as const; + }); + if ("error" in resolved!) return NextResponse.json({ error: resolved!.error }, { status: 400 }); + const recipient = resolved!; + + let price = 0; let tierId = tier; let isSub = false; let label = ""; let verifiedFrom: string | null = null; + if (tier === "custom") { + if (!customAmount || customAmount < 100) return NextResponse.json({ error: "Custom amount min £1" }, { status: 400 }); + price = customAmount; isSub = !!customRecurring; + // >=£5/mo recurring custom grants Patron + const unlocked = isSub && price >= TIERS.patron.price ? "patron" : tierForAmount(price); + label = unlocked ? `Custom — unlocks ${unlocked}` : "Custom donation"; + tierId = unlocked ?? "custom"; + } else { + const t = (TIERS as any)[tier]; + if (!t) return NextResponse.json({ error: "Invalid tier" }, { status: 400 }); + if (upgrade) { + const current = await minecraft.getHighestTierFor({ platform: recipientPlatform, username: recipient.username, uuid: recipient.uuid, xuid: recipient.xuid }).catch(() => null); + if (!current) return NextResponse.json({ error: "No existing rank found for that username — can't upgrade" }, { status: 400 }); + const from = (TIERS as any)[current]; + const diff = t.price - from.price; + if (diff <= 0) return NextResponse.json({ error: `Already has ${from.label} or higher — can't upgrade to ${t.label}` }, { status: 400 }); + verifiedFrom = current; + price = diff; + label = `${from.label} → ${t.label} Upgrade`; + } else { + price = t.price; isSub = !!(t as any).subscription; label = t.label; + } + } + + const stripe = getStripe(); + const returnUrl = `${cfg.stripe.appUrl}/donate/success?session_id={CHECKOUT_SESSION_ID}`; + + const metadata: Record = { + tier: tierId, platform: recipientPlatform, username: recipient.username, + uuid: recipient.uuid ?? "", xuid: recipient.xuid ?? "", + donor: username, gift: giftTo ? "1" : "0", + custom: tier === "custom" ? "1" : "0", customAmount: String(price), isSub: isSub ? "1" : "0", + upgradeFrom: verifiedFrom || "", upgrade: verifiedFrom ? "1" : "0", + }; + + const session = await stripe.checkout.sessions.create({ + ui_mode: "elements", + integration_identifier: "frg_donate_embedded_qmfzkvbt", + mode: isSub ? "subscription" : "payment", + return_url: returnUrl, + metadata, + // copy metadata onto the subscription so webhook handlers can revoke later + ...(isSub ? { subscription_data: { metadata } } : {}), + ...(isSub + ? { line_items: [{ price_data: { currency: "gbp", product_data: { name: tier === "custom" ? "Custom Patron" : label }, unit_amount: price, recurring: { interval: "month" } }, quantity: 1 }] } + : { line_items: [{ price_data: { currency: "gbp", product_data: { name: label }, unit_amount: price }, quantity: 1 }] }), + }); + + return NextResponse.json({ clientSecret: session.client_secret }); + } catch (e: any) { + console.error("checkout error", e); + return NextResponse.json({ error: e?.message || "Checkout failed — check STRIPE_SECRET_KEY" }, { status: 500 }); + } +} diff --git a/src/app/api/donations/status/route.ts b/src/app/api/donations/status/route.ts new file mode 100644 index 0000000..cf35daf --- /dev/null +++ b/src/app/api/donations/status/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getStripe } from "@/lib/stripe"; + +export async function GET(req: NextRequest) { + const sessionId = req.nextUrl.searchParams.get("session_id"); + if (!sessionId) return NextResponse.json({ error: "session_id required" }, { status: 400 }); + try { + const session = await getStripe().checkout.sessions.retrieve(sessionId); + return NextResponse.json({ + status: session.status, + paymentStatus: session.payment_status, + tier: session.metadata?.tier, + username: session.metadata?.username, + gift: session.metadata?.gift === "1", + amountTotal: session.amount_total, + currency: session.currency, + }); + } catch (e: any) { + return NextResponse.json({ error: e?.message || "Failed to retrieve session" }, { status: 500 }); + } +} diff --git a/src/app/api/donations/verify/route.ts b/src/app/api/donations/verify/route.ts new file mode 100644 index 0000000..04fa523 --- /dev/null +++ b/src/app/api/donations/verify/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { minecraftApi } from "@/lib/minecraft-api"; +import { minecraft } from "@/lib/servertap"; +import { TIERS } from "@/lib/donations"; + +type Platform = "java" | "bedrock"; + +export async function GET(req: NextRequest) { + const username = req.nextUrl.searchParams.get("username")?.trim(); + if (!username || username.length < 3) return NextResponse.json({ error: "username required" }, { status: 400 }); + const platformParam = req.nextUrl.searchParams.get("platform"); + const platforms: Platform[] = platformParam === "java" || platformParam === "bedrock" ? [platformParam] : ["java", "bedrock"]; + try { + for (const platform of platforms) { + const resolved = await minecraftApi.resolvePlayerIdentifier({ type: platform, username }).catch(() => null); + if (!resolved) continue; + const current = await minecraft.getHighestTierFor({ + platform, username: resolved.username, uuid: resolved.uuid, xuid: resolved.xuid, + }).catch(() => null); + if (!current) continue; + const from = (TIERS as any)[current]; + return NextResponse.json({ + current, label: from?.label ?? current, price: from?.price ?? null, + platform, resolvedName: resolved.username, + }); + } + return NextResponse.json({ current: null, price: null }); + } catch (e: any) { + return NextResponse.json({ error: e?.message || "lookup failed" }, { status: 500 }); + } +} diff --git a/src/app/api/geyser-skin/route.ts b/src/app/api/geyser-skin/route.ts new file mode 100644 index 0000000..12500be --- /dev/null +++ b/src/app/api/geyser-skin/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(req: NextRequest) { + const xuid = req.nextUrl.searchParams.get("xuid"); + if (!xuid) return NextResponse.json({ error: "missing xuid" }, { status: 400 }); + try { + const r = await fetch(`https://api.geysermc.org/v2/skin/${encodeURIComponent(xuid)}`, { + headers: { "User-Agent": "frg-web/1.0" }, + next: { revalidate: 3600 }, + }); + if (r.ok) { + const j = await r.json(); + if (j.texture_id) { + return NextResponse.json({ + textureId: j.texture_id as string, + textureUrl: `https://textures.minecraft.net/texture/${j.texture_id}`, + source: "geyser", + }); + } + } + // 503 / no skin cached — fall through to Floodgate UUID fallback + // Floodgate UUID is 00000000-0000-0000- + const hex = BigInt(xuid).toString(16).padStart(16, "0"); + const floodgateUuid = `00000000-0000-0000-${hex.slice(0, 4)}-${hex.slice(4)}`; + return NextResponse.json({ + textureId: null, + textureUrl: null, + fallbackUuid: floodgateUuid, + // mc-heads and crafatar both serve skins by UUID (Floodgate skins via Geyser's upload) + fallbackUrls: [ + `https://mc-heads.net/skin/${floodgateUuid}`, + `https://crafatar.com/skins/${floodgateUuid}`, + ], + geyserStatus: r.status, + }); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} diff --git a/src/app/api/webhooks/geyser/route.ts b/src/app/api/webhooks/geyser/route.ts new file mode 100644 index 0000000..ba0a7d2 --- /dev/null +++ b/src/app/api/webhooks/geyser/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { minecraft } from "@/lib/servertap"; +import { cfg } from "@/lib/cfg"; + +export async function POST(req: NextRequest) { + const key = req.headers.get("authorization") ?? ""; + if (cfg.minecraftApi.key && key !== `Bearer ${cfg.minecraftApi.key}`) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + const { event, javaUuid, bedrockXuid, bedrockName } = await req.json(); + // Transfer entitlement: query current LP groups via RCON and re-apply + if (event === "link" && javaUuid && bedrockXuid) { + const r: any = await minecraft.executeCommand(`lp user ${bedrockName ?? bedrockXuid} parent info`); + const m = String(r?.data?.response ?? ""); + const group = m.match(/(\b(?:iron|gold|diamond|patron)\b)/)?.[1]; + if (group) { + await minecraft.grantTier(javaUuid, group); + await minecraft.removeTier(`.${bedrockName ?? ""}`.replace("..", "."), group).catch(() => {}); + } + } + if (event === "unlink" && javaUuid && bedrockXuid) { + // optionally revoke from bedrock counterpart + } + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..ffecaa0 --- /dev/null +++ b/src/app/api/webhooks/stripe/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getStripe } from "@/lib/stripe"; +import { cfg } from "@/lib/cfg"; +import { tierForAmount, TIERS, TierId } from "@/lib/donations"; +import { minecraft } from "@/lib/servertap"; + +// LuckPerms keys users by uuid. Linked bedrock players share their java uuid +// (LP use-server-uuid-cache), unlinked bedrock players live under their +// deterministic Floodgate uuid: 00000000-0000-0000-0009- +function floodgateUuid(xuid: string): string { + return `00000000-0000-0000-0009-${BigInt(xuid).toString(16).padStart(12, "0")}`; +} + +type Meta = Record; +const metaOf = (o: any): Meta => o?.metadata ?? {}; + +async function lpGroups(id: string): Promise { + return minecraft.getLpGroups({ uuid: id }).catch(() => []); +} + +// Pick the LP id + mail name for a recipient +async function targetsFor(m: Meta): Promise<{ grant: string; mail: string } | null> { + const username = m.username; + if (!username) return null; + const platform = m.platform ?? "java"; + if (platform === "bedrock" && m.xuid) { + const linked = await minecraft.getLinkedAccount({ xuid: m.xuid }).catch(() => null); + if (linked?.success && linked.data?.linked && linked.data.javaUuid) { + return { grant: linked.data.javaUuid, mail: linked.data.javaName ?? username }; + } + return { grant: floodgateUuid(m.xuid), mail: `.${username}` }; + } + if (m.uuid) return { grant: m.uuid, mail: username }; + return { grant: username, mail: username }; +} + +function groupForTier(m: Meta, pence: number): string | null { + const tierId = m.tier as TierId | "custom"; + if (tierId === "custom" || !(TIERS as any)[tierId]) { + const t = tierForAmount(pence); + return t ? (TIERS as any)[t].group : null; + } + return (TIERS as any)[tierId].group; +} + +async function grantTier(m: Meta, pence: number, giftMsg?: string): Promise<{ granted: boolean; group?: string; reason?: string }> { + const targets = await targetsFor(m); + if (!targets) return { granted: false, reason: "no recipient" }; + const group = groupForTier(m, pence); + if (!group) return { granted: false, reason: "below iron" }; + const groups = await lpGroups(targets.grant); + if (groups.includes(group)) return { granted: false, group, reason: "already has tier (idempotent skip)" }; + await minecraft.grantTier(targets.grant, group); + const mailMsg = giftMsg ?? `Thanks for supporting FRG! Your ${group} rank is now active. <3`; + await minecraft.mailPlayer(targets.mail, mailMsg).catch(() => {}); + return { granted: true, group }; +} + +export async function POST(req: NextRequest) { + const sig = req.headers.get("stripe-signature") ?? ""; + const body = await req.text(); + let event: any; + try { + event = getStripe().webhooks.constructEvent(body, sig, cfg.stripe.webhookSecret); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: 400 }); + } + + if (event.type === "checkout.session.completed") { + const s = event.data.object; + if (s.payment_status && !["paid", "no_payment_required"].includes(s.payment_status)) { + return NextResponse.json({ received: true, skipped: "payment not complete" }); + } + const m = metaOf(s); + const amount = s.amount_total ?? Number(m.customAmount || 0); + const isGift = m.gift === "1"; + const donor = m.donor || "someone"; + const result = await grantTier(m, amount, isGift ? `You've been gifted a rank by ${donor}! Enjoy <3` : undefined).catch((e: any) => ({ granted: false, reason: e?.message })); + return NextResponse.json({ received: true, result }); + } + + if (event.type === "customer.subscription.deleted" || event.type === "invoice.payment_failed") { + const obj: any = event.data.object; + let sub = obj; + if (event.type === "invoice.payment_failed" && obj.subscription) { + sub = await getStripe().subscriptions.retrieve(obj.subscription).catch(() => null); + } + const m = metaOf(sub); + const targets = await targetsFor(m); + if (targets) { + const groups = await lpGroups(targets.grant); + if (groups.includes("patron")) { + await minecraft.removeTier(targets.grant, "patron").catch(() => {}); + await minecraft.mailPlayer(targets.mail, "Your FRG Patron subscription has ended — thanks for the support while it lasted! <3").catch(() => {}); + return NextResponse.json({ received: true, revoked: "patron" }); + } + } + return NextResponse.json({ received: true, revoked: null }); + } + + return NextResponse.json({ received: true }); +} diff --git a/src/app/donate/page.tsx b/src/app/donate/page.tsx new file mode 100644 index 0000000..22b35d6 --- /dev/null +++ b/src/app/donate/page.tsx @@ -0,0 +1,679 @@ +"use client"; +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { loadStripe, type Appearance } from "@stripe/stripe-js"; +import { + CheckoutElementsProvider, + ContactDetailsElement, + PaymentElement, + useCheckoutElements, +} from "@stripe/react-stripe-js/checkout"; +import { toast } from "sonner"; +import { ArrowLeft, Loader2, Leaf } from "lucide-react"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { TIERS, tierForAmount, upgradePrice } from "@/lib/donations"; +import { TopBar } from "@/components/top-bar"; + +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ""); + +const DRAFT_KEY = "frg-donate-draft-v1"; + +type Answers = { + intent: string; + rank: string; + upgradeTo: string; + gift: string; + platform: "java" | "bedrock"; + player: string; + giftTo: string; + customPounds: string; + customRecurring: boolean; +}; + +const EMPTY_ANSWERS: Answers = { + intent: "", + rank: "", + upgradeTo: "", + gift: "", + platform: "java", + player: "", + giftTo: "", + customPounds: "", + customRecurring: false, +}; + +function Choice({ title, description, checked, disabled, onClick }: { + title: React.ReactNode; + description?: React.ReactNode; + checked: boolean; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function Field({ className, ...props }: React.InputHTMLAttributes) { + return ( + + ); +} + +function UpgradeTargetPicker({ value, onChange, player }: { value: string; onChange: (v: string)=>void; player: string }) { + const [verified, setVerified] = useState<{current:string|null;label?:string}|null>(null); + useEffect(()=>{ + const name=(player||"").trim(); + if(name.length<3){setVerified(null);return;} + let cancelled=false; + const t=setTimeout(async()=>{ + try{ + const r=await fetch(`/api/donations/verify?username=${encodeURIComponent(name)}`); + const j=await r.json(); + if(!cancelled) setVerified(j.error?null:j); + }catch{ if(!cancelled) setVerified(null); } + },500); + return()=>{cancelled=true;clearTimeout(t);}; + },[player]); + return (<> + {verified?.current ?

Verified current: {verified.label} — price will be difference

: player.trim().length>=3 ?

No rank found for "{player.trim()}" — upgrade requires an existing rank.

:

Enter your Minecraft name in the next step to verify.

} +
+ {(["gold","diamond"] as const).map(id=>{ + const t=(TIERS as any)[id]; + const disabled = !!verified?.current && !upgradePrice(verified.current as any, id as any); + const price = verified?.current ? upgradePrice(verified.current as any, id as any) : null; + return onChange(id)} title={`${t.label}${price ? ` — £${(price/100).toFixed(2)} upgrade` : ` — £${(t.price/100).toFixed(2)}`}`} description={disabled ? `Already have ${verified?.label} or higher` : t.perks.join(" · ")} />; + })} +
+ ); +} + +type Pending = { + clientSecret: string; + label: string; + perks: string[]; + isSub: boolean; + forUser: string; + gift: boolean; +}; + +function useStripeAppearance() { + return useMemo(() => { + const cs = getComputedStyle(document.documentElement); + const v = (n: string) => cs.getPropertyValue(n).trim(); + const primary = v("--primary"); + const fg = v("--foreground"); + const bg = v("--background"); + const border = v("--border"); + const input = v("--input"); + const mutedFg = v("--muted-foreground"); + const destructive = v("--destructive"); + const accent = v("--accent"); + const radius = v("--radius") || "0.75rem"; + const appearance: Appearance = { + theme: "night", + labels: "above", + variables: { + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Ubuntu, sans-serif", + colorPrimary: primary, + colorPrimaryText: v("--primary-foreground"), + colorBackground: bg, + colorText: fg, + colorTextSecondary: mutedFg, + colorTextPlaceholder: mutedFg, + colorIcon: mutedFg, + colorDanger: destructive, + colorSuccess: primary, + borderRadius: radius, + }, + rules: { + ".Input": { border: `1px solid ${input}`, backgroundColor: "transparent", boxShadow: "none" }, + ".Input:focus": { border: `1px solid ${primary}`, boxShadow: "none" }, + ".Input--invalid": { border: `1px solid ${destructive}`, boxShadow: "none" }, + ".Label": { color: mutedFg, fontWeight: "500", marginBottom: "6px" }, + ".Tab": { border: `1px solid ${border}`, backgroundColor: "transparent", boxShadow: "none" }, + ".Tab:hover": { backgroundColor: accent, boxShadow: "none" }, + ".Tab--selected": { borderColor: primary, boxShadow: "none" }, + ".AccordionItem": { border: `1px solid ${border}`, backgroundColor: "transparent", boxShadow: "none" }, + ".AccordionItem--selected": { borderColor: primary, boxShadow: "none" }, + ".Block": { border: `1px solid ${border}`, backgroundColor: "transparent", boxShadow: "none" }, + ".Divider": { backgroundColor: border }, + ".Error": { color: destructive }, + ".TermsText": { color: mutedFg }, + }, + }; + return { appearance }; + }, []); +} + +function PaymentStage({ pending, onBack }: { pending: Pending; onBack: () => void }) { + const { appearance } = useStripeAppearance(); + return ( + + + + ); +} + +function CheckoutShell({ pending, onBack }: { pending: Pending; onBack: () => void }) { + const state = useCheckoutElements(); + const [submitting, setSubmitting] = useState(false); + + if (state.type === "loading") { + return ( +
+ +
+ ); + } + if (state.type === "error") { + return ( +
+
+

Couldn't load checkout

+

{state.error.message}

+ +
+
+ ); + } + + const checkout = state.checkout; + const currency = checkout.currency; + const divisor = checkout.minorUnitsAmountDivisor || 100; + const total = checkout.total.total.minorUnitsAmount / divisor; + const fmt = (n: number) => + new Intl.NumberFormat("en-GB", { style: "currency", currency }).format(n); + + async function pay(e: React.FormEvent) { + e.preventDefault(); + if (submitting) return; + setSubmitting(true); + try { + const result = await checkout.confirm(); + if (result.type === "error") { + toast.error(result.error?.message || "Payment failed"); + setSubmitting(false); + } + } catch { + toast.error("Payment failed"); + setSubmitting(false); + } + } + + return ( +
+ + +
+
+
+ + FRG Network + + {fmt(total)} + {pending.isSub && /mo} + +
+
+
+
+

{pending.label}

+

+ {fmt(total)} + {pending.isSub && /mo} +

+
+
+

Contact information

+ +
+
+

Payment method

+ +
+ +
+ +

+ FRG Network will contribute{" "} + 1.5% of your purchase to removing + CO₂ from the atmosphere. +

+
+
+ + Powered by + + + + + + + + + + + + Terms + Privacy +
+
+
+
+ ); +} + +function StepShell({ title, description, error, children }: { + title: string; + description?: string; + error?: string | null; + children?: React.ReactNode; +}) { + return ( +
+

{title}

+ {description &&

{description}

} + {children} + {error &&

{error}

} +
+ ); +} + +export default function DonatePage() { + const [ans, setAns] = useState(EMPTY_ANSWERS); + const [restored, setRestored] = useState(false); + const [pending, setPending] = useState(null); + const [started, setStarted] = useState(false); + const [stepIdx, setStepIdx] = useState(0); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const set = (patch: Partial) => { + setError(null); + setAns((a) => ({ ...a, ...patch })); + }; + + useEffect(() => { + try { + const raw = localStorage.getItem(DRAFT_KEY); + if (raw) { + const d = JSON.parse(raw) as Partial; + // autofill fields from storage but always start at the first step + // eslint-disable-next-line react-hooks/set-state-in-effect + setAns((a) => ({ + ...a, + intent: d.intent ?? a.intent, + rank: d.rank ?? a.rank, + upgradeTo: d.upgradeTo ?? a.upgradeTo, + gift: d.gift === "yes" ? "yes" : d.gift === "no" ? "no" : a.gift, + platform: d.platform === "bedrock" ? "bedrock" : d.platform === "java" ? "java" : a.platform, + player: d.player ?? a.player, + giftTo: d.giftTo ?? a.giftTo, + customPounds: d.customPounds ?? a.customPounds, + customRecurring: d.customRecurring ?? a.customRecurring, + })); + } + } catch {} + setRestored(true); + }, []); + + useEffect(() => { + if (!restored) return; + try { + localStorage.setItem(DRAFT_KEY, JSON.stringify(ans)); + } catch {} + }, [restored, ans]); + + // Every question is a step. Conditional steps appear based on answers. + const steps = useMemo(() => { + const s: string[] = ["intent"]; + if (ans.intent === "rank") s.push("rank"); + s.push("username"); + if (ans.intent === "upgrade") s.push("upgrade"); + s.push("gift"); + if (ans.gift === "yes") s.push("giftTo"); + if (ans.intent === "custom") s.push("amount"); + s.push("confirm"); + return s; + }, [ans.intent, ans.gift]); + + const current = Math.min(stepIdx, steps.length - 1); + const step = steps[current]; + const last = step === "confirm"; + + function validateStep(name: string): string | null { + switch (name) { + case "intent": return ans.intent ? null : "Choose an answer to continue."; + case "rank": return ans.rank ? null : "Choose a rank to continue."; + case "upgrade": return ans.upgradeTo ? null : "Choose a target rank to continue."; + case "username": return ans.player.trim().length >= 3 ? null : "Enter your Minecraft name (3+ characters)."; + case "gift": return ans.gift === "yes" || ans.gift === "no" ? null : "Choose an answer to continue."; + case "giftTo": return ans.giftTo.trim().length >= 3 ? null : "Enter the recipient's Minecraft name (3+ characters)."; + case "amount": { + const p = parseFloat(ans.customPounds.trim()); + return !isNaN(p) && p >= 1 ? null : "Enter an amount of at least £1."; + } + default: return null; + } + } + + function goNext() { + const err = validateStep(step); + if (err) { setError(err); return; } + setError(null); + setStepIdx((i) => Math.min(i + 1, steps.length - 1)); + } + + function goBack() { + setError(null); + setStepIdx((i) => Math.max(i - 1, 0)); + } + + // Gift step: pick yes -> "who to?" step, pick no -> move straight on + function chooseGift(choice: "yes" | "no") { + set({ gift: choice }); + const s: string[] = ["intent"]; + if (ans.intent === "rank") s.push("rank"); + s.push("username"); + if (ans.intent === "upgrade") s.push("upgrade"); + s.push("gift"); + if (choice === "yes") s.push("giftTo"); + if (ans.intent === "custom") s.push("amount"); + s.push("confirm"); + setStepIdx(s.indexOf("gift") + 1); + } + + async function handleSubmit() { + if (submitting) return; + const playerVal = ans.player.trim(); + const giftToVal = ans.giftTo.trim(); + const customPoundsVal = ans.customPounds.trim(); + + if (!playerVal || playerVal.length < 3) { toast.error("Enter your Minecraft name"); return; } + if (ans.gift === "yes" && (!giftToVal || giftToVal.length < 3)) { toast.error("Enter gift recipient name"); return; } + + let tier: string; + let customAmount: number | undefined; + let isUpgrade = false; + if (ans.intent === "patron") tier = "patron"; + else if (ans.intent === "rank") { + if (!ans.rank) { toast.error("Pick a rank"); return; } + tier = ans.rank; + } else if (ans.intent === "upgrade") { + if (!ans.upgradeTo) { toast.error("Pick target rank"); return; } + tier = ans.upgradeTo; + isUpgrade = true; + } else if (ans.intent === "custom") { + const pounds = parseFloat(customPoundsVal); + if (isNaN(pounds) || pounds < 1) { toast.error("Min £1 for custom"); return; } + customAmount = Math.round(pounds * 100); + tier = "custom"; + } else { + toast.error("Choose how you want to donate"); + return; + } + + let label: string; + let perks: string[] = []; + let isSub = false; + if (tier === "patron") { + label = TIERS.patron.label; perks = [...TIERS.patron.perks]; isSub = true; + } else if (isUpgrade) { + const to = (TIERS as any)[tier]; + label = `Upgrade → ${to.label}`; perks = [...to.perks]; + } else if (tier === "custom") { + isSub = ans.customRecurring; + const unlocked = isSub && customAmount! >= TIERS.patron.price ? "patron" : tierForAmount(customAmount!); + label = unlocked ? `Custom — unlocks ${unlocked}` : "Custom donation"; + perks = unlocked ? [...(TIERS as any)[unlocked].perks] : []; + if (isSub && customAmount! >= TIERS.patron.price) perks = [...TIERS.patron.perks]; + } else { + const t = (TIERS as any)[tier]; + label = t.label; perks = [...t.perks]; + } + + setSubmitting(true); + try { + const res = await fetch("/api/donations/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ platform: ans.platform, username: playerVal, tier, customAmount, customRecurring: ans.customRecurring || undefined, giftTo: ans.gift==="yes" ? giftToVal || undefined : undefined, upgrade: isUpgrade || undefined }) }); + const text = await res.text(); + let j: any = {}; + try { j = JSON.parse(text); } catch { toast.error(text || "Checkout failed"); return; } + if (j.clientSecret) { + setPending({ + clientSecret: j.clientSecret, + label, perks, isSub, + forUser: ans.gift === "yes" ? giftToVal : playerVal, + gift: ans.gift === "yes", + }); + } else toast.error(j.error || "Checkout failed"); + } catch { + toast.error("Checkout failed"); + } finally { + setSubmitting(false); + } + } + + function onFormSubmit(e: React.FormEvent) { + e.preventDefault(); + if (last) handleSubmit(); + else goNext(); + } + + if (pending) { + return ( +
+ setPending(null)} /> +
+ ); + } + + if (!started) { + return ( +
+ +
+
+

Support FRG

+

Ranks are cosmetic only. Your donation keeps the server running and funds new features.

+ +

Secure via Stripe · 1.5% to Stripe Climate

+
+
+
+ ); + } + + return ( +
+ +
+
+ +

Step {current + 1} of {steps.length}

+ +
+ + {step === "intent" && ( + +
+ set({intent:"rank"})} title="Purchase a rank" description="Iron £3 · Gold £5 · Diamond £7 — one-time" /> + set({intent:"upgrade"})} title="Upgrade rank" description="Already have a rank? Pay the difference" /> + set({intent:"patron"})} title="Become a patron" description="£5/mo — all cosmetics + Patron badge" /> + set({intent:"custom"})} title="Custom donation" description="Any amount — £5+ unlocks Patron" /> +
+
+ )} + + {step === "rank" && ( + +
+ {(["iron","gold","diamond"] as const).map(id=>{ + const t=(TIERS as any)[id]; + return set({rank:id})} title={`${t.label} — £${(t.price/100).toFixed(2)}`} description={t.perks.join(" · ")} />; + })} +
+
+ )} + + {step === "username" && ( + + set({platform: v as any})} className="w-full"> + JavaBedrock + + set({player:e.target.value})} aria-label="Minecraft name" placeholder="Your Minecraft name" /> + + )} + + {step === "upgrade" && ( + + set({upgradeTo:v})} player={ans.player} /> + + )} + + {step === "gift" && ( + +
+ chooseGift("no")} title="For me" description="The donation goes to your account" /> + chooseGift("yes")} title="Gift to a friend" description="We'll mail it to them in-game" /> +
+
+ )} + + {step === "giftTo" && ( + + set({giftTo:e.target.value})} aria-label="Gift recipient" placeholder="Recipient Minecraft name" /> + + )} + + {step === "amount" && ( + + set({customPounds:e.target.value})} aria-label="Custom amount in pounds" placeholder="Amount in £ e.g. 10" /> +
+ set({customRecurring:false})} title="One-time" /> + set({customRecurring:true})} title="Monthly" description="£5+/mo grants Patron" /> +
+
+ )} + + {step === "confirm" && ( + +
+

+ Recipient + {ans.gift==="yes" && ans.giftTo.trim() ? ans.giftTo.trim() : ans.player.trim() || "—"} +

+

+ Platform + {ans.platform} +

+ {(ans.intent === "rank" || ans.intent === "patron" || ans.intent === "upgrade") && ( +

+ Getting + + {ans.intent === "rank" && ans.rank ? (TIERS as any)[ans.rank]?.label : null} + {ans.intent === "patron" ? TIERS.patron.label : null} + {ans.intent === "upgrade" && ans.upgradeTo ? `Upgrade → ${(TIERS as any)[ans.upgradeTo]?.label}` : null} + +

+ )} + {ans.intent === "custom" && ( +

+ Amount + £{ans.customPounds || "—"}{ans.customRecurring ? " /mo" : " one-time"} +

+ )} +
+
+ )} + +
+ {current > 1 ? ( + + ) : } + {last ? ( + + ) : ( + + )} +
+ +
+

Secure via Stripe · Refunds 7 days · Cosmetic only · 1.5% of each donation supports Stripe Climate carbon removal

+
+
+
+ ); +} diff --git a/src/app/donate/success/page.tsx b/src/app/donate/success/page.tsx new file mode 100644 index 0000000..3291fdf --- /dev/null +++ b/src/app/donate/success/page.tsx @@ -0,0 +1,64 @@ +"use client"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; +import { ChibiRow } from "@/components/ChibiAvatar"; + +function SuccessInner() { + const params = useSearchParams(); + const sessionId = params.get("session_id"); + const [status, setStatus] = useState<"loading"|"ok"|"pending"|"failed">("loading"); + const [info, setInfo] = useState<{ tier?: string; username?: string; gift?: boolean; amount?: string }>({}); + + useEffect(() => { + if (!sessionId) { setStatus("failed"); return; } + let tries = 0; + const poll = async () => { + try { + const res = await fetch(`/api/donations/status?session_id=${encodeURIComponent(sessionId)}`); + const j = await res.json(); + if (j.paymentStatus === "paid" || j.paymentStatus === "no_payment_required") { + setInfo({ tier: j.tier, username: j.username, gift: j.gift, amount: j.amountTotal != null ? `£${(j.amountTotal/100).toFixed(2)}` : undefined }); + setStatus("ok"); return; + } + if (j.status === "expired" || j.paymentStatus === "unpaid" && j.status === "open") { + // For Checkout: still open + unpaid after redirect means payment failed/cancelled + // Treat expired always as failed; open+unpaid after a few polls is also failed rather than hanging as "processing" + if (j.status === "expired" || tries >= 2) { setStatus("failed"); return; } + } + if (++tries < 10) setTimeout(poll, 2000); else setStatus(j.paymentStatus === "unpaid" ? "failed" : "pending"); + } catch { if (++tries < 10) setTimeout(poll, 2000); else setStatus("failed"); } + }; + poll(); + }, [sessionId]); + + return ( +
+
+ {status === "loading" &&

Confirming payment…

} + {status === "ok" && <> + +

Thank you! <3

+

+ {info.gift ? `Your gift of ${info.tier ?? "a rank"}${info.amount ? ` (${info.amount})` : ""} is on its way to ${info.username ?? "the recipient"}.` : `Your ${info.tier ?? "rank"}${info.amount ? ` (${info.amount})` : ""} has been applied to ${info.username ?? "your account"}.`} +

+

{info.gift ? "They'll receive in-game mail too." : "In-game mail sent — see you online!"}

+ } + {status === "pending" && <> +

Payment processing…

+

Your rank will be applied automatically within a minute or two.

+ } + {status === "failed" && <> +

Payment not completed

+

Nothing was charged. You can try again anytime.

+ } + Back home +
+
+ ); +} + +export default function Success() { + return ; +} diff --git a/src/components/ChibiAvatar.tsx b/src/components/ChibiAvatar.tsx new file mode 100644 index 0000000..0728999 --- /dev/null +++ b/src/components/ChibiAvatar.tsx @@ -0,0 +1,33 @@ +"use client"; +import { useEffect, useRef } from "react"; +const BOXES = {"head":{"x":615,"y":400,"w":80,"h":80},"headSide":{"x":585,"y":400,"w":30,"h":80},"body":{"x":620,"y":480,"w":55,"h":50},"armL":{"x":605,"y":480,"w":15,"h":50},"armR":{"x":675,"y":480,"w":50,"h":25},"armSide":{"x":590,"y":480,"w":15,"h":50},"legL":{"x":625,"y":530,"w":25,"h":30},"legR":{"x":650,"y":530,"w":25,"h":30},"legSide":{"x":605,"y":530,"w":20,"h":30}}; +function draw(c: HTMLCanvasElement, skinUrl: string){ + const ctx=c.getContext("2d"); if(!ctx) return; + const img=new Image(); img.crossOrigin="anonymous"; + img.onload=()=>{ + const W=160,H=160; ctx.imageSmoothingEnabled=false; ctx.clearRect(0,0,W,H); + const s=0.45; + const cx=W/2; + const headY=12; + const d=(id:string, sx:number,sy:number,sw:number,sh:number)=>{ + const b=(BOXES as any)[id]; + const dx=(b.x - BOXES.head.x)*s, dy=(b.y - BOXES.head.y)*s; + const w=b.w*s, h=b.h*s; + const x=cx - BOXES.head.w*s/2 + dx; + const y=headY + dy; + ctx.drawImage(img,sx,sy,sw,sh,x,y,w,h); + }; + d("head",8,8,8,8); d("head",40,8,8,8); + ctx.globalAlpha=0.88; d("headSide",0,8,8,8); ctx.globalAlpha=1; + d("body",20,20,8,12); d("body",20,36,8,12); + d("armL",44,20,4,12); d("armR",36,52,4,12); + ctx.globalAlpha=0.88; d("armSide",40,20,4,12); ctx.globalAlpha=1; + d("legL",4,20,4,12); d("legR",20,52,4,12); + ctx.globalAlpha=0.88; d("legSide",0,20,4,12); ctx.globalAlpha=1; + }; + img.onerror=()=>ctx.clearRect(0,0,c.width,c.height); + img.src=skinUrl; +} +export function Canvas({username, facing, src, skinUrl}:{username:string; facing?:1|-1; src?:string; skinUrl?:string}){ const r=useRef(null); useEffect(()=>{ if(r.current) draw(r.current, skinUrl ?? src ?? `https://mc-heads.net/skin/${encodeURIComponent(username)}`); },[username,src,skinUrl]); return ; } +export function ChibiRow({username, skinUrl}:{username?:string; skinUrl?:string}){ const n=username||"Steve"; return
; } +export function DropsChibi({username, skinUrl}:{username:string; skinUrl?:string}){ return ; } diff --git a/src/components/campfire-viewer.tsx b/src/components/campfire-viewer.tsx new file mode 100644 index 0000000..430d431 --- /dev/null +++ b/src/components/campfire-viewer.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { Canvas, useFrame } from "@react-three/fiber"; +import { useGLTF, useAnimations, Center, OrbitControls } from "@react-three/drei"; +import { useEffect, useRef, useMemo } from "react"; +import * as THREE from "three"; + +function FoxCampfireModel() { + const group = useRef(null); + const { scene, animations } = useGLTF("/campfire.gltf"); + const { actions, mixer } = useAnimations(animations, group); + + const cloned = useMemo(() => scene.clone(true), [scene]); + + // Fix texture filtering - keep crisp pixel art, don't scroll it + useMemo(() => { + cloned.traverse((o) => { + if ((o as THREE.Mesh).isMesh) { + const mesh = o as THREE.Mesh; + mesh.frustumCulled = false; + const mat = mesh.material as THREE.MeshStandardMaterial; + if (mat?.map) { + mat.map.magFilter = THREE.NearestFilter; + mat.map.minFilter = THREE.NearestFilter; + mat.map.colorSpace = THREE.SRGBColorSpace; + } + } + }); + }, [cloned]); + + // Use vanilla Bedrock fox.sleep values (ZtechNetwork/MCBVanillaResourcePack) + // animation.fox.sleep: body pos [0,-4.8,0] rot [0,0,-90], head pos [1.8,-0.4,-2] rot [0,-115, cos(t*slow)+90], tail pos [0,0,1.5] rot [-125,0,0] + // We apply statically and animate head Z breathing slowly (vanilla uses 160* which is too fast for web) + useEffect(() => { + Object.values(actions).forEach((a) => a?.stop()); + if (mixer) mixer.stopAllAction(); + // Hide awake head meshes, keep head_sleeping (closed eyes) + cloned.traverse((o) => { + if ((o as THREE.Mesh).isMesh && o.name === "head") { + let cur: THREE.Object3D | null = o; + let isSleep = false; + while (cur) { if (cur.name === "head_sleeping") isSleep = true; cur = cur.parent; } + if (!isSleep) (o as THREE.Mesh).visible = false; + } + }); + if (mixer) mixer.timeScale = 0; + }, [actions, mixer, cloned]); + + const lightRef = useRef(null); + + const foxRef = useRef(null); + useFrame((state) => { + const t = state.clock.elapsedTime; + if (lightRef.current) { + lightRef.current.intensity = 3 + Math.sin(t * 3) * 0.5 + Math.sin(t * 7) * 0.25; + } + // Vanilla sleep pose - applied each frame (overrides any glTF animation) + const body = cloned.getObjectByName("body") as THREE.Group | null; + const head = cloned.getObjectByName("head") as THREE.Group | null; // pivot containing sleep head + const tail = cloned.getObjectByName("tail") as THREE.Group | null; + const toRad = (d: number) => (d * Math.PI) / 180; + if (body) { + body.rotation.set(0, 0, toRad(-90)); + // keep original body position y offset from glTF, just lower slightly for center + body.position.set(0, -0.35, 0); + } + if (head) { + // Head is child of body - keep its original pos [0,-0.5,0.1875] + vanilla delta scaled + head.position.set(0.1125, -0.525, 0.0625); + const breath = Math.cos(t * 1.1) * toRad(1.5); + head.rotation.set(0, toRad(-115), toRad(90) + breath); + } + if (tail) { + tail.rotation.set(toRad(-125), 0, 0); + tail.position.set(0, 0, 0.09); + } + // Gentle overall bob + if (foxRef.current) { + foxRef.current.position.y = Math.sin(t * 0.7) * 0.02; + } + }); + + return ( + + +
+ +
+
+ +
+ ); +} + +export default function CampfireViewer() { + return ( +
+ { + gl.toneMapping = THREE.ACESFilmicToneMapping; + gl.outputColorSpace = THREE.SRGBColorSpace; + }} + > + + + + { + const t = e?.target as unknown as { object: { position: { x: number; y: number; z: number } }; target: { x: number; y: number; z: number } }; + if (t?.object) console.log(`camera position={[${t.object.position.x.toFixed(2)}, ${t.object.position.y.toFixed(2)}, ${t.object.position.z.toFixed(2)}]} target={[${t.target.x.toFixed(2)}, ${t.target.y.toFixed(2)}, ${t.target.z.toFixed(2)}]}`); + }} + /> + +
+ ); +} + +useGLTF.preload("/campfire.gltf"); diff --git a/src/components/logo.tsx b/src/components/logo.tsx new file mode 100644 index 0000000..aed4efa --- /dev/null +++ b/src/components/logo.tsx @@ -0,0 +1,11 @@ +type LogoProps = { + className?: string; +}; + +export default function Logo({ className }: LogoProps) { + return ( + + + + ); +} diff --git a/src/components/play-dialog.tsx b/src/components/play-dialog.tsx new file mode 100644 index 0000000..4ae1f4d --- /dev/null +++ b/src/components/play-dialog.tsx @@ -0,0 +1,37 @@ +"use client"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; + +export function PlayDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) { + const copy = async (v: string) => { await navigator.clipboard.writeText(v); }; + return ( + + + How to playJoin on Java or Bedrock — same world via Geyser. + + JavaBedrock + +

Multiplayer → Add Server → frg.network

+ +
+ +

Add FRG Servers as an Xbox Live friend, then join via Friends.

+ +
+
+
+
+ ); +} + +export function PlayButton({ size = "sm" }: { size?: "sm" | "default" | "lg" }) { + const [open, setOpen] = useState(false); + return ( + <> + + + + ); +} diff --git a/src/components/top-bar.tsx b/src/components/top-bar.tsx new file mode 100644 index 0000000..b9a4d9e --- /dev/null +++ b/src/components/top-bar.tsx @@ -0,0 +1,20 @@ +"use client"; +import localFont from "next/font/local"; +import Link from "next/link"; +import { PlayButton } from "@/components/play-dialog"; + +const makeSans = localFont({ src: "../../public/font/makesans.ttf" }); + +export function TopBar() { + return ( +
+
+ FRG Network + +
+
+ ); +} diff --git a/src/components/ui/questionnaire.tsx b/src/components/ui/questionnaire.tsx new file mode 100644 index 0000000..ab3bc78 --- /dev/null +++ b/src/components/ui/questionnaire.tsx @@ -0,0 +1,327 @@ +"use client" + +import * as React from "react" +import { Questionnaire as QuestionnairePrimitive } from "@shadcn/react/questionnaire" + +import { cn } from "@/lib/utils" +import { buttonVariants, type Button } from "@/components/ui/button" +import { CheckIcon } from "lucide-react" + +function Questionnaire({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireProgress({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireChoices({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireChoice({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + input:focus-visible]:border-ring has-[>input:focus-visible]:ring-2 has-[>input:focus-visible]:ring-ring/30 data-invalid:border-destructive data-checked:border-primary/40 data-checked:bg-primary/10", + "data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50", + className + )} + {...props} + > + + + ) +} + +function QuestionnaireChoiceDescription({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function QuestionnaireInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ +
+ ) +} + +function QuestionnaireError({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function QuestionnaireActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function QuestionnairePrevious({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Previous"} + + ) +} + +function QuestionnaireSkip({ + children, + className, + size = "default", + variant = "outline", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Skip"} + + ) +} + +function QuestionnaireNext({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Next"} + + ) +} + +function QuestionnaireSubmit({ + children, + className, + size = "default", + variant = "default", + ...props +}: React.ComponentProps & + Pick, "size" | "variant">) { + return ( + + {children ?? "Submit"} + + ) +} + +export { + Questionnaire, + QuestionnaireActions, + QuestionnaireChoice, + QuestionnaireChoiceDescription, + QuestionnaireChoices, + QuestionnaireDescription, + QuestionnaireError, + QuestionnaireInput, + QuestionnaireItem, + QuestionnaireNext, + QuestionnairePrevious, + QuestionnaireProgress, + QuestionnaireSkip, + QuestionnaireSubmit, + QuestionnaireTitle, +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..e87659e --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,90 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: React.ComponentProps & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/src/lib/donations.ts b/src/lib/donations.ts new file mode 100644 index 0000000..569a30d --- /dev/null +++ b/src/lib/donations.ts @@ -0,0 +1,33 @@ +export type TierId = "iron" | "gold" | "diamond" | "patron" | "custom"; +export const TIERS = { + iron: { id: "iron" as const, label: "Iron", price: 300, group: "iron", weight: 10, perks: ["Iron prefix & grey name", "/hat cosmetics", "Supporter role"], popular: false }, + gold: { id: "gold" as const, label: "Gold", price: 500, group: "gold", weight: 20, perks: ["All Iron perks", "Gold prefix & particles", "/nick + join effects"], popular: true }, + diamond: { id: "diamond" as const, label: "Diamond", price: 700, group: "diamond", weight: 30, perks: ["All Gold perks", "Diamond prefix & aqua glow", "All pets, hats & morphs"], popular: false }, + patron: { id: "patron" as const, label: "Patron", price: 500, group: "patron", weight: 40, subscription: true, perks: ["All Diamond perks", "Patron badge & extras", "Ongoing support"], popular: false }, +} as const; + +export const TIER_ORDER: TierId[] = ["iron", "gold", "diamond"]; + +export function upgradePrice(from: TierId, to: TierId): number | null { + const f = (TIERS as any)[from]?.price; + const t = (TIERS as any)[to]?.price; + if (f == null || t == null) return null; + const diff = t - f; + return diff > 0 ? diff : null; +} + +// One-time custom: highest tier affordable in that payment. +// Recurring custom: >=£5/mo acts as Patron, <£5 cumulative unlocks tiers. +export function tierForAmount(pence: number): TierId | null { + if (pence >= TIERS.diamond.price) return "diamond"; + if (pence >= TIERS.gold.price) return "gold"; + if (pence >= TIERS.iron.price) return "iron"; + return null; +} +export function tierForCumulative(pence: number): TierId | null { + return tierForAmount(pence); +} +export function tierForCustom(pence: number, recurring?: boolean): TierId | null { + if (recurring && pence >= TIERS.patron.price) return "patron"; + return tierForAmount(pence); +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..9b2d524 --- /dev/null +++ b/src/lib/stripe.ts @@ -0,0 +1,6 @@ +import Stripe from "stripe"; +import { cfg } from "./cfg"; +export function getStripe() { + if (!cfg.stripe.secretKey) throw new Error("STRIPE_SECRET_KEY not set"); + return new Stripe(cfg.stripe.secretKey); +}