More changes

This commit is contained in:
anonymousratwastaken 2026-09-01 13:04:11 +01:00
parent 5d5c5dc851
commit 963dde1a3e
19 changed files with 1721 additions and 0 deletions

3
public/logo.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="349" height="447" viewBox="0 0 349 447" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0V446.555H348.093V0H0ZM49.7191 49.6266H298.356V297.711H49.7191V49.6266ZM99.4623 99.235V148.843H149.175V99.235H99.4623ZM149.175 148.843V198.476H198.918V148.843H149.175ZM198.918 148.843H248.637V99.235H198.918V148.843ZM198.918 198.476V248.079H248.637V198.476H198.918ZM149.175 198.476H99.4623V248.079H149.175V198.476ZM49.7191 347.32H149.175V396.928H49.7191V347.32ZM248.637 347.32H298.356V396.928H248.637V347.32Z" fill="#00DA0B"/>
</svg>

After

Width:  |  Height:  |  Size: 545 B

BIN
public/zombie.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 B

View file

@ -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<string, string> = {
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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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-<hex(xuid) padded to 16>
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 });
}
}

View file

@ -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 });
}

View file

@ -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-<xuid as 12 hex chars>
function floodgateUuid(xuid: string): string {
return `00000000-0000-0000-0009-${BigInt(xuid).toString(16).padStart(12, "0")}`;
}
type Meta = Record<string, string>;
const metaOf = (o: any): Meta => o?.metadata ?? {};
async function lpGroups(id: string): Promise<string[]> {
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 });
}

679
src/app/donate/page.tsx Normal file
View file

@ -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 (
<button
type="button"
role="radio"
aria-checked={checked}
disabled={disabled}
onClick={onClick}
className={cn(
"flex min-h-11 w-full cursor-pointer items-start gap-2.5 rounded-xl border px-3 py-2.5 text-start text-xs/relaxed transition-colors outline-none select-none hover:bg-input/40 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
checked ? "border-primary/40 bg-primary/10" : "border-input"
)}
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border",
checked ? "border-primary bg-primary" : "border-input dark:bg-input/30"
)}
>
{checked && <span className="size-2 rounded-full bg-primary-foreground" />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug">
<span className="font-medium">{title}</span>
{description && <span className="text-muted-foreground">{description}</span>}
</span>
</button>
);
}
function Field({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
{...props}
className={cn(
"h-11 w-full min-w-0 rounded-md border border-input bg-input/20 px-3 text-sm transition-[color,box-shadow,border-color] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 dark:bg-input/30",
className
)}
/>
);
}
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 ? <p className="text-xs text-muted-foreground">Verified current: <span className="font-medium text-foreground">{verified.label}</span> — price will be difference</p> : player.trim().length>=3 ? <p className="text-xs text-amber-600">No rank found for &quot;{player.trim()}&quot; — upgrade requires an existing rank.</p> : <p className="text-xs text-muted-foreground">Enter your Minecraft name in the next step to verify.</p>}
<div className="grid gap-1.5">
{(["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 <Choice key={id} checked={value===id} disabled={disabled} onClick={()=>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(" · ")} />;
})}
</div>
</>);
}
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 (
<CheckoutElementsProvider
stripe={stripePromise}
options={{ clientSecret: pending.clientSecret, elementsOptions: { appearance } }}
>
<CheckoutShell pending={pending} onBack={onBack} />
</CheckoutElementsProvider>
);
}
function CheckoutShell({ pending, onBack }: { pending: Pending; onBack: () => void }) {
const state = useCheckoutElements();
const [submitting, setSubmitting] = useState(false);
if (state.type === "loading") {
return (
<div className="min-h-dvh flex items-center justify-center">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
if (state.type === "error") {
return (
<div className="min-h-dvh flex items-center justify-center px-4">
<div className="text-center space-y-3">
<h1 className="text-xl font-semibold">Couldn&apos;t load checkout</h1>
<p className="text-sm text-muted-foreground">{state.error.message}</p>
<Button variant="outline" onClick={onBack}>Back</Button>
</div>
</div>
);
}
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 (
<div className="min-h-dvh lg:h-dvh lg:overflow-hidden lg:grid lg:grid-cols-2">
<aside className="hidden lg:flex flex-col border-r border-border bg-card/50 p-10">
<div className="flex items-center gap-3">
<Button
onClick={onBack}
aria-label="Back"
variant="outline"
size="icon"
className="size-8 rounded-full"
>
<ArrowLeft className="size-4" />
</Button>
<span className="font-semibold">FRG Network</span>
</div>
<div className="mt-10">
<p className="text-sm text-muted-foreground">{pending.label}</p>
<p className="text-3xl font-semibold tracking-tight mt-1">
{fmt(total)}
{pending.isSub && <span className="text-base font-normal text-muted-foreground"> /mo</span>}
</p>
</div>
<div className="flex-1" />
<Button onClick={onBack} variant="link" size="sm" className="self-start h-auto p-0 text-xs text-muted-foreground">
Change donation details
</Button>
</aside>
<section className="flex flex-col overflow-y-auto">
<header className="lg:hidden sticky top-0 z-10 backdrop-blur bg-background/80 border-b border-border">
<div className="mx-auto max-w-[400px] flex items-center gap-3 px-4 py-2.5">
<Button
onClick={onBack}
aria-label="Back"
variant="outline"
size="icon"
className="size-8 rounded-full"
>
<ArrowLeft className="size-4" />
</Button>
<span className="font-semibold text-sm">FRG Network</span>
<span className="ml-auto text-sm font-semibold">
{fmt(total)}
{pending.isSub && <span className="text-xs font-normal text-muted-foreground"> /mo</span>}
</span>
</div>
</header>
<form onSubmit={pay} className="mx-auto w-full max-w-[400px] min-w-72 px-4 py-10 lg:my-auto space-y-6">
<div className="lg:hidden">
<p className="text-sm text-muted-foreground">{pending.label}</p>
<p className="text-2xl font-semibold tracking-tight mt-1">
{fmt(total)}
{pending.isSub && <span className="text-sm font-normal text-muted-foreground"> /mo</span>}
</p>
</div>
<section className="space-y-2.5">
<h2 className="text-[15px] font-semibold tracking-tight">Contact information</h2>
<ContactDetailsElement />
</section>
<section className="space-y-2.5">
<h2 className="text-[15px] font-semibold tracking-tight">Payment method</h2>
<PaymentElement />
</section>
<Button type="submit" className="w-full h-11" disabled={submitting || !checkout.canConfirm}>
{submitting ? <Loader2 className="size-4 animate-spin" /> : "Pay"}
</Button>
<div className="flex items-start gap-2.5 text-xs text-muted-foreground">
<Leaf className="size-4 text-primary shrink-0 mt-0.5" />
<p>
FRG Network will contribute{" "}
<span className="font-semibold text-foreground">1.5% of your purchase</span> to removing
CO₂ from the atmosphere.
</p>
</div>
<div className="flex items-center justify-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
Powered by
<svg width="360" height="151" viewBox="0 0 360 151" aria-label="Stripe" role="img" className="h-3 w-auto text-foreground">
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M360 78.0002C360 52.4002 347.6 32.2002 323.9 32.2002C300.1 32.2002 285.7 52.4002 285.7 77.8002C285.7 107.9 302.7 123.1 327.1 123.1C339 123.1 348 120.4 354.8 116.6V96.6002C348 100 340.2 102.1 330.3 102.1C320.6 102.1 312 98.7002 310.9 86.9002H359.8C359.8 85.6002 360 80.4002 360 78.0002ZM310.6 68.5002C310.6 57.2002 317.5 52.5002 323.8 52.5002C329.9 52.5002 336.4 57.2002 336.4 68.5002H310.6Z" />
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M247.1 32.2002C237.3 32.2002 231 36.8002 227.5 40.0002L226.2 33.8002H204.2V150.4L229.2 145.1L229.3 116.8C232.9 119.4 238.2 123.1 247 123.1C264.9 123.1 281.2 108.7 281.2 77.0002C281.1 48.0002 264.6 32.2002 247.1 32.2002ZM241.1 101.1C235.2 101.1 231.7 99.0002 229.3 96.4002L229.2 59.3002C231.8 56.4002 235.4 54.4002 241.1 54.4002C250.2 54.4002 256.5 64.6002 256.5 77.7002C256.5 91.1002 250.3 101.1 241.1 101.1Z" />
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M169.8 26.3001L194.9 20.9001V0.600098L169.8 5.9001V26.3001Z" />
<path fill="currentColor" d="M194.9 33.9001H169.8V121.4H194.9V33.9001Z" />
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M142.9 41.3001L141.3 33.9001H119.7V121.4H144.7V62.1001C150.6 54.4001 160.6 55.8001 163.7 56.9001V33.9001C160.5 32.7001 148.8 30.5001 142.9 41.3001Z" />
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M92.8999 12.2002L68.4999 17.4002L68.3999 97.5002C68.3999 112.3 79.4999 123.2 94.2999 123.2C102.5 123.2 108.5 121.7 111.8 119.9V99.6002C108.6 100.9 92.7999 105.5 92.7999 90.7002V55.2002H111.8V33.9002H92.7999L92.8999 12.2002Z" />
<path fill="currentColor" fillRule="evenodd" clipRule="evenodd" d="M25.3 59.3002C25.3 55.4002 28.5 53.9002 33.8 53.9002C41.4 53.9002 51 56.2002 58.6 60.3002V36.8002C50.3 33.5002 42.1 32.2002 33.8 32.2002C13.5 32.2002 0 42.8002 0 60.5002C0 88.1002 38 83.7002 38 95.6002C38 100.2 34 101.7 28.4 101.7C20.1 101.7 9.5 98.3002 1.1 93.7002V117.5C10.4 121.5 19.8 123.2 28.4 123.2C49.2 123.2 63.5 112.9 63.5 95.0002C63.4 65.2002 25.3 70.5002 25.3 59.3002Z" />
</svg>
</span>
<span aria-hidden className="h-3 border-l border-border" />
<a href="https://stripe.com/legal/terms-of-use" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Terms</a>
<a href="https://stripe.com/legal/privacy-policy" target="_blank" rel="noopener noreferrer" className="hover:text-foreground transition-colors">Privacy</a>
</div>
</form>
</section>
</div>
);
}
function StepShell({ title, description, error, children }: {
title: string;
description?: string;
error?: string | null;
children?: React.ReactNode;
}) {
return (
<section className="flex min-w-0 flex-col gap-3">
<h2 className="text-sm font-semibold">{title}</h2>
{description && <p className="text-xs/relaxed text-pretty text-muted-foreground">{description}</p>}
{children}
{error && <p className="text-xs text-destructive" role="alert">{error}</p>}
</section>
);
}
export default function DonatePage() {
const [ans, setAns] = useState<Answers>(EMPTY_ANSWERS);
const [restored, setRestored] = useState(false);
const [pending, setPending] = useState<Pending | null>(null);
const [started, setStarted] = useState(false);
const [stepIdx, setStepIdx] = useState(0);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const set = (patch: Partial<Answers>) => {
setError(null);
setAns((a) => ({ ...a, ...patch }));
};
useEffect(() => {
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (raw) {
const d = JSON.parse(raw) as Partial<Answers>;
// 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 (
<main className="min-h-dvh bg-background text-foreground">
<PaymentStage pending={pending} onBack={() => setPending(null)} />
</main>
);
}
if (!started) {
return (
<main className="min-h-dvh bg-background text-foreground font-mona flex flex-col">
<TopBar />
<div className="flex-1 flex items-center justify-center px-4 py-12">
<div className="max-w-md w-full text-center space-y-6">
<h1 className="text-3xl font-semibold tracking-tight">Support FRG</h1>
<p className="text-sm text-muted-foreground">Ranks are cosmetic only. Your donation keeps the server running and funds new features.</p>
<Button size="lg" onClick={()=>setStarted(true)} className="w-full">Donate</Button>
<p className="text-xs text-muted-foreground">Secure via Stripe · 1.5% to <a href="https://climate.stripe.com" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">Stripe Climate</a></p>
</div>
</div>
</main>
);
}
return (
<main className="min-h-dvh bg-background text-foreground font-mona flex flex-col">
<TopBar />
<div className="flex-1 flex items-center justify-center px-4 py-8">
<div className="w-full max-w-md">
<p className="text-[0.625rem] font-medium text-muted-foreground tabular-nums">Step {current + 1} of {steps.length}</p>
<form onSubmit={onFormSubmit} className="mt-2 flex flex-col gap-4">
{step === "intent" && (
<StepShell title="How do you want to donate?" error={error}>
<div className="grid gap-1.5">
<Choice checked={ans.intent==="rank"} onClick={()=>set({intent:"rank"})} title="Purchase a rank" description="Iron £3 · Gold £5 · Diamond £7 — one-time" />
<Choice checked={ans.intent==="upgrade"} onClick={()=>set({intent:"upgrade"})} title="Upgrade rank" description="Already have a rank? Pay the difference" />
<Choice checked={ans.intent==="patron"} onClick={()=>set({intent:"patron"})} title="Become a patron" description="£5/mo — all cosmetics + Patron badge" />
<Choice checked={ans.intent==="custom"} onClick={()=>set({intent:"custom"})} title="Custom donation" description="Any amount — £5+ unlocks Patron" />
</div>
</StepShell>
)}
{step === "rank" && (
<StepShell title="Which rank?" error={error}>
<div className="grid gap-1.5">
{(["iron","gold","diamond"] as const).map(id=>{
const t=(TIERS as any)[id];
return <Choice key={id} checked={ans.rank===id} onClick={()=>set({rank:id})} title={`${t.label} — £${(t.price/100).toFixed(2)}`} description={t.perks.join(" · ")} />;
})}
</div>
</StepShell>
)}
{step === "username" && (
<StepShell title="Minecraft username" description="We verify your current rank from this." error={error}>
<Tabs value={ans.platform} onValueChange={(v)=>set({platform: v as any})} className="w-full">
<TabsList className="w-full"><TabsTrigger value="java" className="flex-1">Java</TabsTrigger><TabsTrigger value="bedrock" className="flex-1">Bedrock</TabsTrigger></TabsList>
</Tabs>
<Field autoFocus value={ans.player} onChange={(e)=>set({player:e.target.value})} aria-label="Minecraft name" placeholder="Your Minecraft name" />
</StepShell>
)}
{step === "upgrade" && (
<StepShell title="Upgrade your rank" description="We'll verify your current rank from your username and charge only the difference." error={error}>
<UpgradeTargetPicker value={ans.upgradeTo} onChange={(v)=>set({upgradeTo:v})} player={ans.player} />
</StepShell>
)}
{step === "gift" && (
<StepShell title="Is this a gift?" description="Optional — pick “For me” to move on." error={error}>
<div className="grid gap-1.5">
<Choice checked={ans.gift==="no"} onClick={()=>chooseGift("no")} title="For me" description="The donation goes to your account" />
<Choice checked={ans.gift==="yes"} onClick={()=>chooseGift("yes")} title="Gift to a friend" description="We'll mail it to them in-game" />
</div>
</StepShell>
)}
{step === "giftTo" && (
<StepShell title="Who is it for?" description="We'll mail the gift to them in-game." error={error}>
<Field autoFocus value={ans.giftTo} onChange={(e)=>set({giftTo:e.target.value})} aria-label="Gift recipient" placeholder="Recipient Minecraft name" />
</StepShell>
)}
{step === "amount" && (
<StepShell title="How much?" error={error}>
<Field autoFocus type="number" min={1} step="0.01" value={ans.customPounds} onChange={(e)=>set({customPounds:e.target.value})} aria-label="Custom amount in pounds" placeholder="Amount in £ e.g. 10" />
<div className="grid gap-1.5">
<Choice checked={!ans.customRecurring} onClick={()=>set({customRecurring:false})} title="One-time" />
<Choice checked={ans.customRecurring} onClick={()=>set({customRecurring:true})} title="Monthly" description="£5+/mo grants Patron" />
</div>
</StepShell>
)}
{step === "confirm" && (
<StepShell title="Confirm details">
<div className="rounded-xl border border-border bg-card/50 p-4 text-xs/relaxed">
<p className="flex justify-between gap-4">
<span className="text-muted-foreground">Recipient</span>
<span className="font-medium">{ans.gift==="yes" && ans.giftTo.trim() ? ans.giftTo.trim() : ans.player.trim() || "—"}</span>
</p>
<p className="mt-1.5 flex justify-between gap-4">
<span className="text-muted-foreground">Platform</span>
<span className="font-medium capitalize">{ans.platform}</span>
</p>
{(ans.intent === "rank" || ans.intent === "patron" || ans.intent === "upgrade") && (
<p className="mt-1.5 flex justify-between gap-4">
<span className="text-muted-foreground">Getting</span>
<span className="font-medium">
{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}
</span>
</p>
)}
{ans.intent === "custom" && (
<p className="mt-1.5 flex justify-between gap-4">
<span className="text-muted-foreground">Amount</span>
<span className="font-medium">£{ans.customPounds || "—"}{ans.customRecurring ? " /mo" : " one-time"}</span>
</p>
)}
</div>
</StepShell>
)}
<div className="flex items-center justify-between gap-2">
{current > 1 ? (
<Button type="button" variant="outline" onClick={goBack}>Back</Button>
) : <span />}
{last ? (
<Button type="submit" className="min-w-40" disabled={submitting}>
{submitting ? <Loader2 className="size-4 animate-spin" /> : "Continue to Stripe"}
</Button>
) : (
<Button type="submit">Next</Button>
)}
</div>
</form>
<p className="text-xs text-muted-foreground text-center mt-6">Secure via Stripe · Refunds 7 days · Cosmetic only · 1.5% of each donation supports <a href="https://climate.stripe.com" target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">Stripe Climate</a> carbon removal</p>
</div>
</div>
</main>
);
}

View file

@ -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 (
<main className="min-h-screen bg-background text-foreground font-mona flex items-center justify-center px-4">
<div className="text-center space-y-3 max-w-md">
{status === "loading" && <h1 className="text-2xl font-semibold">Confirming payment…</h1>}
{status === "ok" && <>
<ChibiRow username={info.username} />
<h1 className="text-3xl font-bold">Thank you! &lt;3</h1>
<p className="text-sm text-muted-foreground">
{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"}.`}
</p>
<p className="text-xs text-muted-foreground">{info.gift ? "They'll receive in-game mail too." : "In-game mail sent — see you online!"}</p>
</>}
{status === "pending" && <>
<h1 className="text-2xl font-semibold">Payment processing…</h1>
<p className="text-sm text-muted-foreground">Your rank will be applied automatically within a minute or two.</p>
</>}
{status === "failed" && <>
<h1 className="text-2xl font-semibold">Payment not completed</h1>
<p className="text-sm text-muted-foreground">Nothing was charged. You can try again anytime.</p>
</>}
<Link href="/" className="inline-block text-sm underline underline-offset-2">Back home</Link>
</div>
</main>
);
}
export default function Success() {
return <Suspense><SuccessInner /></Suspense>;
}

View file

@ -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<HTMLCanvasElement>(null); useEffect(()=>{ if(r.current) draw(r.current, skinUrl ?? src ?? `https://mc-heads.net/skin/${encodeURIComponent(username)}`); },[username,src,skinUrl]); return <canvas ref={r} width={160} height={160} style={{imageRendering:"pixelated" as const, width:88,height:88, transform: facing===-1 ? "scaleX(-1)" : undefined}} className="bg-transparent"/>; }
export function ChibiRow({username, skinUrl}:{username?:string; skinUrl?:string}){ const n=username||"Steve"; return <div className="flex items-center justify-center gap-0"><Canvas username={n} facing={1} skinUrl={skinUrl}/><img src="/diamond.png" alt="" width={18} height={18} className="-mx-4 -translate-y-2"/><Canvas username={n} facing={-1} src="/zombie.png"/></div>; }
export function DropsChibi({username, skinUrl}:{username:string; skinUrl?:string}){ return <Canvas username={username} skinUrl={skinUrl}/>; }

View file

@ -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<THREE.Group>(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<THREE.PointLight>(null);
const foxRef = useRef<THREE.Group>(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 (
<group ref={group}>
<group ref={foxRef}>
<Center>
<primitive object={cloned} scale={1.2} />
</Center>
</group>
<pointLight ref={lightRef} position={[0, 0.5, 0]} intensity={3} distance={5} color="#ff7a18" decay={2} />
</group>
);
}
export default function CampfireViewer() {
return (
<div className="h-full w-full min-h-[520px] flex items-center justify-center rounded-2xl overflow-hidden bg-card border">
<Canvas
camera={{ position: [-4.5, 2.36, -5.1], fov: 38 }}
dpr={[1, 2]}
gl={{ antialias: true, alpha: true }}
onCreated={({ gl }) => {
gl.toneMapping = THREE.ACESFilmicToneMapping;
gl.outputColorSpace = THREE.SRGBColorSpace;
}}
>
<ambientLight intensity={0.9} />
<directionalLight position={[3, 4, 2]} intensity={1} />
<FoxCampfireModel />
<OrbitControls
enablePan={false}
onEnd={(e) => {
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)}]}`);
}}
/>
</Canvas>
</div>
);
}
useGLTF.preload("/campfire.gltf");

11
src/components/logo.tsx Normal file
View file

@ -0,0 +1,11 @@
type LogoProps = {
className?: string;
};
export default function Logo({ className }: LogoProps) {
return (
<svg width="349" height="447" viewBox="0 0 349 447" xmlns="http://www.w3.org/2000/svg" className={className}>
<path d="M0 0V446.555H348.093V0H0ZM49.7191 49.6266H298.356V297.711H49.7191V49.6266ZM99.4623 99.235V148.843H149.175V99.235H99.4623ZM149.175 148.843V198.476H198.918V148.843H149.175ZM198.918 148.843H248.637V99.235H198.918V148.843ZM198.918 198.476V248.079H248.637V198.476H198.918ZM149.175 198.476H99.4623V248.079H149.175V198.476ZM49.7191 347.32H149.175V396.928H49.7191V347.32ZM248.637 347.32H298.356V396.928H248.637V347.32Z"/>
</svg>
);
}

View file

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-5">
<DialogHeader><DialogTitle>How to play</DialogTitle><DialogDescription>Join on Java or Bedrock — same world via Geyser.</DialogDescription></DialogHeader>
<Tabs defaultValue="java">
<TabsList className="w-full"><TabsTrigger value="java" className="flex-1">Java</TabsTrigger><TabsTrigger value="bedrock" className="flex-1">Bedrock</TabsTrigger></TabsList>
<TabsContent value="java" className="space-y-3 pt-2">
<p className="text-sm text-muted-foreground">Multiplayer → Add Server → <span className="font-mono font-semibold text-foreground">frg.network</span></p>
<Button size="sm" variant="secondary" onClick={() => copy("frg.network")}>Copy address</Button>
</TabsContent>
<TabsContent value="bedrock" className="space-y-3 pt-2">
<p className="text-sm text-muted-foreground">Add <span className="font-semibold text-foreground">FRG Servers</span> as an Xbox Live friend, then join via Friends.</p>
<Button size="sm" variant="secondary" onClick={() => copy("FRG Servers")}>Copy gamertag</Button>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
export function PlayButton({ size = "sm" }: { size?: "sm" | "default" | "lg" }) {
const [open, setOpen] = useState(false);
return (
<>
<Button size={size} onClick={() => setOpen(true)}>Play</Button>
<PlayDialog open={open} onOpenChange={setOpen} />
</>
);
}

View file

@ -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 (
<header className="sticky top-0 z-10 w-full backdrop-blur supports-backdrop-filter:bg-background/80 bg-background/80 border-b border-border">
<div className="mx-auto max-w-5xl flex items-center justify-between px-4 py-2.5 text-sm">
<Link href="/" className={`${makeSans.className} font-extrabold lowercase text-primary text-base tracking-tight`}>FRG Network</Link>
<nav className="flex items-center gap-4">
<Link href="/donate" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">Donate</Link>
<PlayButton />
</nav>
</div>
</header>
);
}

View file

@ -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<typeof QuestionnairePrimitive.Root>) {
return (
<QuestionnairePrimitive.Root
data-slot="questionnaire"
className={cn("flex w-full min-w-0 flex-col gap-4", className)}
{...props}
/>
)
}
function QuestionnaireProgress({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Progress>) {
return (
<QuestionnairePrimitive.Progress
data-slot="questionnaire-progress"
className={cn(
"min-h-[1lh] w-fit min-w-[14ch] text-[0.625rem] font-medium text-muted-foreground tabular-nums",
className
)}
{...props}
/>
)
}
function QuestionnaireItem({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Item>) {
return (
<QuestionnairePrimitive.Item
data-slot="questionnaire-item"
className={cn(
"flex min-w-0 flex-col gap-3 border-0 p-0 outline-none",
className
)}
{...props}
/>
)
}
function QuestionnaireTitle({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Title>) {
return (
<QuestionnairePrimitive.Title
data-slot="questionnaire-title"
className={cn(
"text-sm font-semibold text-pretty [&:not(:has(~[data-slot=questionnaire-description]))]:mb-3",
className
)}
{...props}
/>
)
}
function QuestionnaireDescription({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Description>) {
return (
<QuestionnairePrimitive.Description
data-slot="questionnaire-description"
className={cn(
"text-xs/relaxed text-pretty text-muted-foreground",
className
)}
{...props}
/>
)
}
function QuestionnaireChoices({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choices>) {
return (
<QuestionnairePrimitive.Choices
data-slot="questionnaire-choices"
className={cn(
"group/questionnaire-choices grid min-w-0 gap-1.5",
className
)}
{...props}
/>
)
}
function QuestionnaireChoice({
children,
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Choice>) {
return (
<QuestionnairePrimitive.Choice
data-slot="questionnaire-choice"
className={cn(
"group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start gap-2.5 rounded-xl border border-input px-3 py-2.5 text-start text-xs/relaxed transition-colors outline-none select-none hover:bg-input/40 has-[>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}
>
<QuestionnairePrimitive.ChoiceInput
data-slot="questionnaire-choice-input"
className="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span
aria-hidden="true"
data-slot="questionnaire-choice-indicator"
className="pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-[4px] border border-input group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground dark:bg-input/30 dark:group-data-checked/questionnaire-choice:bg-primary"
>
<span
data-slot="questionnaire-choice-indicator-dot"
className="hidden size-2 rounded-full bg-primary-foreground group-data-[type=checkbox]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block"
/>
<CheckIcon data-slot="questionnaire-choice-indicator-check" className="hidden size-3.5 group-data-[type=radio]/questionnaire-choice:hidden group-data-checked/questionnaire-choice:block" />
</span>
<QuestionnairePrimitive.ChoiceLabel
data-slot="questionnaire-choice-label"
className="flex min-w-0 flex-1 flex-col gap-0.5 leading-snug"
>
{children}
</QuestionnairePrimitive.ChoiceLabel>
<QuestionnairePrimitive.ChoiceShortcut
data-slot="questionnaire-choice-shortcut"
className="pointer-events-none ms-auto hidden size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-sm border border-input bg-background/80 font-mono text-[0.5625rem] leading-none font-medium text-muted-foreground group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[shortcut]/questionnaire-choice:inline-flex"
/>
</QuestionnairePrimitive.Choice>
)
}
function QuestionnaireChoiceDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="questionnaire-choice-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
function QuestionnaireInput({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Input>) {
return (
<div
data-slot="questionnaire-input-wrapper"
className="group/questionnaire-input relative w-full min-w-0"
>
<QuestionnairePrimitive.Input
data-slot="questionnaire-input"
className={cn(
"h-7 min-h-11 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm transition-[color,box-shadow,background-color] outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 sm:min-h-0 md:text-xs/relaxed dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground",
className
)}
{...props}
/>
</div>
)
}
function QuestionnaireError({
className,
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Error>) {
return (
<QuestionnairePrimitive.Error
data-slot="questionnaire-error"
className={cn("mt-2 text-xs/relaxed text-destructive", className)}
{...props}
/>
)
}
function QuestionnaireActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="questionnaire-actions"
className={cn(
"grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-1.5 sm:min-h-7",
className
)}
{...props}
/>
)
}
function QuestionnairePrevious({
children,
className,
size = "default",
variant = "outline",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Previous> &
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
return (
<QuestionnairePrimitive.Previous
data-slot="questionnaire-previous"
data-size={size}
data-variant={variant}
className={cn(
buttonVariants({ size, variant }),
"col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0",
className
)}
{...props}
>
{children ?? "Previous"}
</QuestionnairePrimitive.Previous>
)
}
function QuestionnaireSkip({
children,
className,
size = "default",
variant = "outline",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Skip> &
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
return (
<QuestionnairePrimitive.Skip
data-slot="questionnaire-skip"
data-size={size}
data-variant={variant}
className={cn(
buttonVariants({ size, variant }),
"col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0",
className
)}
{...props}
>
{children ?? "Skip"}
</QuestionnairePrimitive.Skip>
)
}
function QuestionnaireNext({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Next> &
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
return (
<QuestionnairePrimitive.Next
data-slot="questionnaire-next"
data-size={size}
data-variant={variant}
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0",
className
)}
{...props}
>
{children ?? "Next"}
</QuestionnairePrimitive.Next>
)
}
function QuestionnaireSubmit({
children,
className,
size = "default",
variant = "default",
...props
}: React.ComponentProps<typeof QuestionnairePrimitive.Submit> &
Pick<React.ComponentProps<typeof Button>, "size" | "variant">) {
return (
<QuestionnairePrimitive.Submit
data-slot="questionnaire-submit"
data-size={size}
data-variant={variant}
className={cn(
buttonVariants({ size, variant }),
"col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0",
className
)}
{...props}
>
{children ?? "Submit"}
</QuestionnairePrimitive.Submit>
)
}
export {
Questionnaire,
QuestionnaireActions,
QuestionnaireChoice,
QuestionnaireChoiceDescription,
QuestionnaireChoices,
QuestionnaireDescription,
QuestionnaireError,
QuestionnaireInput,
QuestionnaireItem,
QuestionnaireNext,
QuestionnairePrevious,
QuestionnaireProgress,
QuestionnaireSkip,
QuestionnaireSubmit,
QuestionnaireTitle,
}

View file

@ -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<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
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<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:py-[calc(--spacing(1.25))] hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-xs/relaxed outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

33
src/lib/donations.ts Normal file
View file

@ -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);
}

6
src/lib/stripe.ts Normal file
View file

@ -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);
}