Shop: claim the founder set on the site, with Paddle's overlay (#56)

The shop is the site's only: the buyer types their Minecraft name, the
cosmetics service looks it up and the page shows the account's skin
face to confirm, then Paddle's checkout (0 CHF) opens over the page.
After Paddle's "completed" the page waits for the service to grant it
and says the items are in the launcher's Wardrobe, with the order
number and paddle.net for the receipt. Closing the checkout, errors
and an already claimed account have their own states. ?preview shows
the form before the window opens; the service decides who may claim.

Terms, privacy and /shop/pay now say claims and purchases happen on
the shop page, bound to the account named, and that the skin comes
from textures.minecraft.net.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 authored and GitHub committed 2026-11-04 16:10:14 +01:00
1 parent 28704d4b65
commit 8dcc740829
7 files changed
+479 -57

No files matched your search

+8 -5
View File
@@ -89,9 +89,10 @@ export default function PrivacyPage() {
Contributors&apos; and mod authors&apos; pictures come from avatars.githubusercontent.com, mod icons from horizon-rust.github.io, and every
download from github.com. Those requests go from your browser to GitHub, and{" "}
<Out href="https://docs.github.com/site-policy/privacy-policies/github-general-privacy-statement">GitHub&apos;s privacy statement</Out> covers
them. Nothing else on this site is loaded from another company, except on the checkout page (/shop/pay), which the
launcher opens for a purchase or a claim: it loads Paddle.js from cdn.paddle.com, and Paddle&apos;s checkout runs in it under{" "}
<Out href="https://www.paddle.com/legal/privacy">Paddle&apos;s privacy policy</Out>.
them. Nothing else on this site is loaded from another company, except in the shop (/shop and /shop/pay): once you
start a claim or a purchase there, it loads Paddle.js from cdn.paddle.com, and Paddle&apos;s checkout runs in it under{" "}
<Out href="https://www.paddle.com/legal/privacy">Paddle&apos;s privacy policy</Out>. The face shown to confirm a Minecraft account is that
account&apos;s skin, loaded from textures.minecraft.net (Mojang).
</p>
</Part>
@@ -123,8 +124,10 @@ export default function PrivacyPage() {
<Part title="Cosmetics and purchases">
<p>
Horizon&apos;s cosmetics service is run by {SELLER}, who is responsible for what it keeps; this website only shows its catalogue. You claim
and buy cosmetics in the launcher, never on this site. For each Minecraft account, keyed by its UUID, the service keeps:
Horizon&apos;s cosmetics service is run by {SELLER}, who is responsible for what it keeps. You claim and buy cosmetics on this site&apos;s
shop, without signing in: your browser sends the Minecraft name you type to the service (cosmetics.playhorizon.dev), which looks it up
in Mojang&apos;s public profiles and keeps the answer in memory for ten minutes. For each Minecraft account, keyed by its UUID, the service
keeps:
</p>
<ul className="list-disc space-y-2 pl-5 marker:text-white/30">
<li>the items it owns and since when, what it wears, whether its cosmetics are hidden, and when it claimed the founder set;</li>
+48 -35
View File
@@ -1,19 +1,22 @@
import type { Metadata } from "next";
import Link from "next/link";
import type { ReactNode } from "react";
import { connection } from "next/server";
import { Suspense, type ReactNode } from "react";
import { getReleases, launcherBuild } from "@/components/github-releases";
import { DownloadIcon } from "@/components/icons";
import { LauncherLink } from "@/components/launcher-download";
import { PixelAurora } from "@/components/pixel-aurora";
import { FounderClaim } from "@/components/shop/claim";
import { ClaimDate, Countdown, FounderSet } from "@/components/shop/shop-view";
import { getShopArt, getShopStatus } from "@/components/shop-data";
import { COSMETICS_API, getShopArt, getShopStatus } from "@/components/shop-data";
import { Contact, NOT_OFFICIAL, PADDLE_BUYER_TERMS, SELLER } from "@/components/legal";
import { pageMetadata } from "@/components/site";
import "./shop.css";
// Always there, with every price, before anyone signs in (Paddle's website review, Mojang's guidelines). The claim's
// dates and countdown show only while the cosmetics service says its window is open (components/shop-data.ts); the
// header's link and the sitemap's line too.
// header's link and the sitemap's line too. The shop is here only: the claim (and, later, purchases) run on this page
// with Paddle's overlay, bound to the Minecraft account the buyer names; the launcher's Wardrobe shows what they own.
export async function generateMetadata(): Promise<Metadata> {
const shop = await getShopStatus();
@@ -38,6 +41,13 @@ function Step({ n, title, children }: { n: number; title: string; children: Reac
);
}
// Paddle's client-side token (public by design), read at request time like /shop/pay does.
async function Claim({ open, endsLabel }: { open: boolean; endsLabel: string | null }) {
await connection();
const token = process.env.PADDLE_CLIENT_TOKEN?.trim() ?? "";
return <FounderClaim api={COSMETICS_API} token={/^(live|test)_[A-Za-z0-9]{10,64}$/.test(token) ? token : null} open={open} endsLabel={endsLabel} />;
}
function Fact({ title, children }: { title: string; children: ReactNode }) {
return (
<div>
@@ -66,8 +76,8 @@ export default async function ShopPage() {
<span className="text-accent block pb-1">is free.</span>
</h1>
<p className="mt-6 max-w-2xl text-lg leading-relaxed text-white/70">
Four things for the players who were here first: a cap, a T-shirt, a backpack and a name tag. Claim them in the
Horizon launcher{endsAt === null ? " once the claim opens" : ""}; once claimed, they&apos;re yours to keep.
Four things for the players who were here first: a cap, a T-shirt, a backpack and a name tag. Claim them on this page
{endsAt === null ? " once the claim opens" : ""}, then wear them from the Horizon launcher&apos;s Wardrobe; they&apos;re yours to keep.
</p>
<div className="mt-8 flex flex-col gap-5 rounded-2xl bg-white/[0.06] p-5 backdrop-blur-xl sm:flex-row sm:items-center sm:justify-between sm:p-6">
@@ -89,19 +99,19 @@ export default async function ShopPage() {
</div>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<a
href="#claim"
className="inline-flex h-12 items-center justify-center rounded-[10px] bg-[#6b4bff] px-5 text-[15px] font-medium text-white transition-colors hover:bg-[#7a5dff]"
>
{endsAt !== null ? "Claim the set" : "How to claim"}
</a>
<LauncherLink
build={launcher}
className="inline-flex h-12 items-center justify-center gap-2.5 rounded-[10px] bg-[#6b4bff] px-5 text-[15px] font-medium text-white transition-colors hover:bg-[#7a5dff]"
className="inline-flex h-12 items-center justify-center gap-2.5 rounded-[10px] bg-white/[0.08] px-5 text-[15px] font-medium text-white transition-colors hover:bg-white/[0.14]"
>
<DownloadIcon className="size-[18px]" />
Get the launcher
</LauncherLink>
<a
href="#claim"
className="inline-flex h-12 items-center justify-center rounded-[10px] bg-white/[0.08] px-5 text-[15px] font-medium text-white transition-colors hover:bg-white/[0.14]"
>
How to claim
</a>
</div>
</div>
@@ -115,30 +125,33 @@ export default async function ShopPage() {
<section id="claim" aria-labelledby="claim-title" className="scroll-mt-20">
<div className="mx-auto w-full max-w-6xl px-6 py-16 sm:py-20 lg:px-10">
<p className="text-sm font-medium text-white/60">How to claim</p>
<p className="text-sm font-medium text-white/60">Claim it</p>
<h2 id="claim-title" className="mt-3 font-display text-[clamp(1.75rem,4vw,2.5rem)] leading-tight font-semibold tracking-[-0.03em] text-white">
In the launcher, in three steps.
Here, in three steps.
</h2>
<ol className="mt-8 grid gap-3 md:grid-cols-3">
<Step n={1} title="Get the launcher">
Download Horizon&apos;s launcher for Windows, macOS or Linux, or update the one you have.{" "}
<LauncherLink build={launcher} className={LINK}>
Download it
</LauncherLink>
.
</Step>
<Step n={2} title="Sign in with Microsoft">
Sign in with the Microsoft account that owns Minecraft: Java Edition. Microsoft&apos;s own sign-in opens; Horizon never sees your password.
</Step>
<Step n={3} title="Claim the set">
Open <span className="text-white">Wardrobe</span> in the launcher&apos;s top bar, then its <span className="text-white">Shop</span> tab, and press{" "}
<span className="text-white">Claim the founder set</span>. A free Paddle checkout (0 CHF) opens in your browser to record the claim; once
it&apos;s done, wear what you like from the Wardrobe tab.
</Step>
</ol>
<div className="mt-8 grid gap-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:items-start">
<Suspense fallback={<div className="h-[184px] rounded-2xl bg-white/[0.06]" />}>
<Claim open={endsAt !== null} endsLabel={endsAt !== null ? lastDayShort(endsAt) : null} />
</Suspense>
<ol className="grid gap-3">
<Step n={1} title="Name your account">
Type the Minecraft: Java Edition name the set goes to, and check it&apos;s your skin&apos;s face that shows.
</Step>
<Step n={2} title="A free checkout">
Paddle&apos;s checkout opens on this page: an email for the receipt and your country, nothing to pay (0 CHF).
</Step>
<Step n={3} title="Wear it in the launcher">
Open <span className="text-white">Wardrobe</span> in the{" "}
<LauncherLink build={launcher} className={LINK}>
Horizon launcher
</LauncherLink>
, signed in with that account: the set is there.
</Step>
</ol>
</div>
<p className="mt-6 max-w-3xl rounded-2xl bg-white/[0.03] p-5 text-[15px] leading-relaxed text-white/70 ring-1 ring-white/[0.08]">
<span className="font-medium text-white">This website never signs you in.</span> The founder set costs nothing. If a page, a message or a mod
asks for your password or for money to get it, it isn&apos;t Horizon.
<span className="font-medium text-white">This website never signs you in.</span> It only needs your Minecraft name, and the founder set
costs nothing. If a page, a message or a mod asks for your password or for money to get it, it isn&apos;t Horizon.
</p>
</div>
</section>
@@ -155,7 +168,7 @@ export default async function ShopPage() {
</Fact>
<Fact title="What it changes">How you look. Nothing about how the game plays.</Fact>
<Fact title="Bound to your account">
The set belongs to the Minecraft account you claim it with, on every computer you sign in to. It&apos;s yours to keep.
The set belongs to the Minecraft account you name when you claim it, on every computer you sign in to. It&apos;s yours to keep.
</Fact>
<Fact title="Hide it">
<span className="text-white">Show my cosmetics</span> in the Wardrobe turns yours off for everyone. You can hide other players&apos; cosmetics too,
@@ -172,7 +185,7 @@ export default async function ShopPage() {
</dl>
<div className="mt-14 max-w-3xl rounded-2xl bg-white/[0.03] p-5 text-[15px] leading-relaxed text-white/70 ring-1 ring-white/[0.08]">
<p>
<span className="font-medium text-white">Prices, and who sells.</span> Every price is on this page and in the launcher before you sign in;
<span className="font-medium text-white">Prices, and who sells.</span> Every price is on this page, and nobody signs in to see it;
the founder set is free (0 CHF). Cosmetics are made by {SELLER} and sold through Paddle.com, the merchant of record, under{" "}
<a href={PADDLE_BUYER_TERMS} target="_blank" rel="noreferrer" className={LINK}>
Paddle&apos;s buyer terms
+8 -7
View File
@@ -17,8 +17,8 @@ export default function TermsPage() {
accent="when you get a cosmetic."
intro={
<p>
These terms cover the cosmetics of the Horizon launcher and game, free or paid, from the <Link href="/shop" className={LINK}>shop</Link> or
the launcher&apos;s Wardrobe. By claiming or buying one, you agree to them.
These terms cover the cosmetics of the Horizon launcher and game, free or paid, from the <Link href="/shop" className={LINK}>shop</Link> on
this site. By claiming or buying one, you agree to them.
</p>
}
>
@@ -44,23 +44,24 @@ export default function TermsPage() {
<li>Seen by Horizon players. Other clients and servers see a normal player.</li>
<li>No capes, nothing that looks like one, no random rewards (no loot boxes), no virtual currency, nothing with real-world value.</li>
<li>
Bound to the Minecraft account you were signed in with when you claimed or bought it, on every computer you sign in to. Horizon keeps
it for as long as it runs the cosmetics service.
Bound to the Minecraft account named in the order (you confirm it, by its name and skin, before the checkout), on every computer you
sign in to. An item bought for someone else&apos;s account is theirs. Horizon keeps it for as long as it runs the cosmetics service.
</li>
</ul>
</Part>
<Part title="Prices">
<p>
Every price is on the <Link href="/shop" className={LINK}>shop page</Link> and in the launcher, before you sign in. The founder set is free
Every price is on the <Link href="/shop" className={LINK}>shop page</Link>, and nobody signs in to see it. The founder set is free
(0 CHF). Paddle shows the final price, with any sales tax or VAT for your country, before you confirm, and charges nothing else.
</p>
</Part>
<Part title="Delivery">
<p>
You claim or buy in the launcher, signed in with your Microsoft account. The item is on your account as soon as Paddle confirms the order,
usually within a minute; you can wear it from the Wardrobe. If it doesn&apos;t arrive, write to us with the order number from Paddle&apos;s
You claim or buy on the <Link href="/shop" className={LINK}>shop page</Link>: you type the Minecraft name the item goes to, confirm the
account, and Paddle&apos;s checkout opens on the page. The item is on that account as soon as Paddle confirms the order, usually within a
minute; you wear it from the Wardrobe in the Horizon launcher, signed in with that account. If it doesn&apos;t arrive, write to us with the order number from Paddle&apos;s
receipt.
</p>
</Part>
+5 -3
View File
@@ -1,10 +1,12 @@
import { cacheLife } from "next/cache";
// The founder shop reads Horizon's cosmetics service (horizon-rust/horizon-cosmetics, private), on the server only:
// the browser never calls it, nobody signs in here, and nothing about a visitor is sent. Claiming happens in the
// launcher. The page and its nav entry exist only while the service says the founder set's claim window is open.
// The founder shop reads Horizon's cosmetics service (horizon-rust/horizon-cosmetics, private) on the server. Only the
// claim calls it from the browser (components/shop/claim.tsx): the Minecraft name the buyer types, and the checkout.
// Nobody signs in here. The page and its nav entry exist only while the service says the founder set's claim window is open.
const UPSTREAM = (process.env.COSMETICS_UPSTREAM || "https://cosmetics.playhorizon.dev").replace(/\/+$/, "");
// What the browser calls for the claim (CORS allows this site only): always the production service.
export const COSMETICS_API = "https://cosmetics.playhorizon.dev";
// For previews only: shows the page with a sample window while the service's window isn't open. Ignored on Railway's
// production environment, so a stray variable there can't open the shop early. The sample window is as long as the
+366
View File
@@ -0,0 +1,366 @@
"use client";
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { loadPaddle, type PaddleJs } from "@/components/shop/paddle-js";
// The founder claim, on the site: the buyer names the Minecraft account (the cosmetics service looks it up at Mojang and
// we show its skin's face to confirm), the service starts a free Paddle transaction bound to that account's UUID, and
// Paddle's overlay runs it here. Nothing here grants anything: Paddle's webhook tells the service, which gives the set to
// that account; the launcher's Wardrobe shows it. Nobody signs in on this site.
type Player = { uuid: string; name: string; skin: string | null; founderClaimed: boolean };
type State =
| { at: "name" }
| { at: "finding" }
| { at: "found"; player: Player }
| { at: "starting"; player: Player }
| { at: "checkout"; player: Player; txn: string }
| { at: "waiting"; player: Player; txn: string }
| { at: "delivered"; player: Player; txn: string }
| { at: "slow"; player: Player; txn: string }
| { at: "closed"; player: Player }
| { at: "error"; message: string; player?: Player };
const NAME = /^[A-Za-z0-9_]{1,16}$/;
const SKIN = /^https:\/\/textures\.minecraft\.net\/texture\/[0-9a-f]{1,128}$/;
const TXN = /^txn_[a-z0-9]{26}$/;
// After Paddle's "completed", how long to wait for the service to say the set is on the account.
const POLL_MS = 3000;
const POLL_FOR_MS = 120_000;
const BUTTON =
"inline-flex h-12 items-center justify-center gap-2.5 rounded-[10px] bg-[#6b4bff] px-5 text-[15px] font-medium text-white transition-colors outline-none hover:bg-[#7a5dff] focus-visible:ring-2 focus-visible:ring-[#a98fff] disabled:cursor-not-allowed disabled:opacity-60";
const QUIET =
"inline-flex h-12 items-center justify-center rounded-[10px] bg-white/[0.08] px-5 text-[15px] font-medium text-white transition-colors outline-none hover:bg-white/[0.14] focus-visible:ring-2 focus-visible:ring-[#a98fff] disabled:cursor-not-allowed disabled:opacity-60";
const LINK = "text-[#c4b5ff] underline decoration-[#c4b5ff]/40 underline-offset-4 transition-colors hover:text-white";
const MESSAGES: Record<string, string> = {
player: "No Minecraft: Java Edition account has that name. Check the spelling.",
name: "A Minecraft name has 1 to 16 letters, digits or underscores.",
claimed: "This account already has the founder set. It's in the launcher's Wardrobe.",
claim_not_open: "The claim isn't open yet.",
claim_closed: "The claim has ended: the founder set is retired for good.",
sales_off: "The claim isn't open yet.",
slow_down: "Too many tries in a row. Wait a minute, then try again.",
busy: "The shop is busy. Try again in a moment.",
mojang: "Mojang's profile service didn't answer. Try again in a moment.",
paddle: "Paddle couldn't start the checkout. Nothing was charged; try again in a moment.",
};
const UNREACHABLE = "Horizon's shop couldn't be reached. Check your connection and try again.";
async function call(api: string, path: string, init?: RequestInit): Promise<{ ok: boolean; body: Record<string, unknown> }> {
const res = await fetch(`${api}${path}`, { ...init, cache: "no-store", headers: { accept: "application/json", ...(init?.body ? { "content-type": "application/json" } : {}) } });
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
return { ok: res.ok, body };
}
const errorText = (body: Record<string, unknown>) => (typeof body.error === "string" && MESSAGES[body.error]) || UNREACHABLE;
/** The skin's face and hat layer, drawn pixel for pixel from Mojang's texture; a plain tile without a skin. */
export function SkinFace({ skin, name, size = 56 }: { skin: string | null; name: string; size?: number }) {
if (!skin || !SKIN.test(skin))
return (
<span
aria-hidden
style={{ width: size, height: size }}
className="inline-flex shrink-0 items-center justify-center rounded-lg bg-[#6b4bff]/25 font-display text-[22px] font-semibold text-[#d9ceff]"
>
{name.slice(0, 1).toUpperCase()}
</span>
);
const px = size / 8;
const layer = (x: number) => ({
backgroundImage: `url(${skin})`,
backgroundSize: `${64 * px}px auto`,
backgroundPosition: `-${x * px}px -${8 * px}px`,
});
return (
<span aria-hidden style={{ width: size, height: size }} className="relative inline-block shrink-0 overflow-hidden rounded-lg bg-black/30 [image-rendering:pixelated]">
<span className="absolute inset-0 [image-rendering:pixelated]" style={layer(8)} />
<span className="absolute inset-0 [image-rendering:pixelated]" style={layer(40)} />
</span>
);
}
export function FounderClaim({ api, token, open, endsLabel }: { api: string; token: string | null; open: boolean; endsLabel: string | null }) {
const [state, setState] = useState<State>({ at: "name" });
const [name, setName] = useState("");
const [preview, setPreview] = useState(false);
const paddle = useRef<PaddleJs | null>(null);
// The state as Paddle's events and the wait loop see it (they outlive a render): set with the state, at once, so
// two of Paddle's events in a row (completed, then closed) see the first one's result.
const current = useRef<State>(state);
const go = (next: State) => {
current.current = next;
setState(next);
};
// A tester's link (?preview) shows the form before the claim opens; the service decides who may claim.
useEffect(() => {
const t = window.setTimeout(() => setPreview(new URLSearchParams(window.location.search).has("preview")), 0);
return () => window.clearTimeout(t);
}, []);
// After Paddle's "completed": wait for the service to say the set is on the account.
const txn = state.at === "waiting" ? state.txn : null;
useEffect(() => {
if (!txn) return;
const until = Date.now() + POLL_FOR_MS;
let stop = false;
const tick = async () => {
if (stop) return;
try {
const { ok, body } = await call(api, `/v1/web/checkout/${txn}`);
const s = current.current;
if (ok && body.state === "paid" && s.at === "waiting") return go({ at: "delivered", player: s.player, txn: s.txn });
} catch {
// The next try may work.
}
const s = current.current;
if (Date.now() > until) {
if (s.at === "waiting") go({ at: "slow", player: s.player, txn: s.txn });
return;
}
window.setTimeout(tick, POLL_MS);
};
const first = window.setTimeout(tick, POLL_MS);
return () => {
stop = true;
window.clearTimeout(first);
};
}, [txn, api]);
if (!open && !preview)
return (
<Panel endsLabel={endsLabel}>
<p className="font-display text-[22px] leading-tight font-semibold tracking-[-0.01em] text-white">The claim opens soon.</p>
<p className="mt-2 text-[15px] leading-relaxed text-white/70">
Come back here when it does: you&apos;ll type your Minecraft name, confirm it&apos;s your account, and claim the set for free.
</p>
</Panel>
);
const find = async (e: FormEvent) => {
e.preventDefault();
const wanted = name.trim();
if (!NAME.test(wanted)) return go({ at: "error", message: MESSAGES.name });
go({ at: "finding" });
try {
const { ok, body } = await call(api, `/v1/players/${encodeURIComponent(wanted)}`);
if (!ok || typeof body.uuid !== "string" || typeof body.name !== "string") return go({ at: "error", message: errorText(body) });
go({
at: "found",
player: { uuid: body.uuid, name: body.name, skin: typeof body.skin === "string" ? body.skin : null, founderClaimed: body.founder_claimed === true },
});
} catch {
go({ at: "error", message: UNREACHABLE });
}
};
const claim = async (player: Player) => {
if (!token) return go({ at: "error", message: "Checkout isn't available right now. Nothing was charged.", player });
go({ at: "starting", player });
try {
const js = paddle.current ?? (await loadPaddle(token, (e) => onPaddle(e.name)));
paddle.current = js;
const { ok, body } = await call(api, "/v1/web/checkout", { method: "POST", body: JSON.stringify({ name: player.name, item: "founder_set" }) });
const id = typeof body.transaction_id === "string" && TXN.test(body.transaction_id) ? body.transaction_id : null;
if (!ok || !id || body.uuid !== player.uuid) return go({ at: "error", message: errorText(body), player });
go({ at: "checkout", player, txn: id });
js.Checkout.open({ transactionId: id, settings: { displayMode: "overlay", theme: "dark", locale: "en", allowLogout: false } });
} catch {
go({ at: "error", message: "Paddle's checkout couldn't load. Turn off what blocks cdn.paddle.com, then try again.", player });
}
};
// Paddle.js's events, for whichever checkout is open.
function onPaddle(event: string | undefined) {
const s = current.current;
if (s.at !== "checkout") return;
if (event === "checkout.completed") go({ at: "waiting", player: s.player, txn: s.txn });
if (event === "checkout.closed") go({ at: "closed", player: s.player });
if (event === "checkout.error") go({ at: "error", message: MESSAGES.paddle, player: s.player });
}
const reset = () => {
go({ at: "name" });
};
switch (state.at) {
case "name":
case "finding":
case "error":
if (state.at === "error" && state.player)
return (
<Panel endsLabel={endsLabel}>
<Account player={state.player} />
<Note tone="warn">{state.message}</Note>
<Row>
<button type="button" className={BUTTON} onClick={() => claim(state.player!)}>
Try again
</button>
<button type="button" className={QUIET} onClick={reset}>
Another account
</button>
</Row>
</Panel>
);
return (
<Panel endsLabel={endsLabel}>
<form onSubmit={find} noValidate>
<label htmlFor="mc-name" className="font-display text-[19px] font-semibold text-white">
Your Minecraft name
</label>
<p className="mt-1 text-[15px] text-white/60">The Java Edition name the set goes to. You confirm the account before anything else.</p>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<input
id="mc-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
maxLength={16}
placeholder="e.g. Notch"
aria-invalid={state.at === "error"}
aria-describedby={state.at === "error" ? "mc-name-error" : undefined}
className="h-12 w-full rounded-[10px] bg-white/[0.08] px-4 text-[16px] text-white outline-none placeholder:text-white/35 focus:bg-white/[0.11] focus-visible:ring-2 focus-visible:ring-[#a98fff] sm:max-w-xs"
/>
<button type="submit" className={BUTTON} disabled={state.at === "finding" || !name.trim()}>
{state.at === "finding" ? "Looking it up…" : "Find my account"}
</button>
</div>
{state.at === "error" && (
<p id="mc-name-error" role="alert" className="mt-3 text-[15px] text-[#ffb4a8]">
{state.message}
</p>
)}
</form>
</Panel>
);
case "found":
case "starting":
return (
<Panel endsLabel={endsLabel}>
<Account player={state.player} />
{state.player.founderClaimed ? (
<>
<Note>{MESSAGES.claimed}</Note>
<Row>
<button type="button" className={QUIET} onClick={reset}>
Another account
</button>
</Row>
</>
) : (
<>
<p className="mt-4 text-[15px] leading-relaxed text-white/70">
Is this your account? The founder set goes to it, for good. Paddle&apos;s checkout asks for an email (for the receipt) and your country; the
total is 0 CHF.
</p>
<Row>
<button type="button" className={BUTTON} disabled={state.at === "starting"} onClick={() => claim(state.player)}>
{state.at === "starting" ? "Opening the checkout…" : `Claim for ${state.player.name}`}
</button>
<button type="button" className={QUIET} disabled={state.at === "starting"} onClick={reset}>
Not me
</button>
</Row>
</>
)}
</Panel>
);
case "checkout":
return (
<Panel endsLabel={endsLabel}>
<Account player={state.player} />
<Note>Paddle&apos;s checkout is open over this page. Follow it to the end; it costs nothing.</Note>
</Panel>
);
case "waiting":
return (
<Panel endsLabel={endsLabel}>
<Account player={state.player} />
<Note>Done, thank you. Putting the set on {state.player.name}&apos;s account…</Note>
<Receipt txn={state.txn} />
</Panel>
);
case "delivered":
case "slow":
return (
<Panel endsLabel={endsLabel}>
<Account player={state.player} />
<p className="mt-4 font-display text-[22px] leading-tight font-semibold tracking-[-0.01em] text-white">
{state.at === "delivered" ? "The founder set is yours." : "Claimed. The set is on its way."}
</p>
<p className="mt-2 text-[15px] leading-relaxed text-white/70">
{state.at === "delivered"
? `Your items are now in the launcher's Wardrobe. Sign in there as ${state.player.name} and wear what you like.`
: `Paddle confirmed the claim; the set reaches ${state.player.name}'s Wardrobe in the launcher within a few minutes.`}
</p>
<Receipt txn={state.txn} />
</Panel>
);
case "closed":
return (
<Panel endsLabel={endsLabel}>
<Account player={state.player} />
<Note>The checkout was closed before the end. Nothing was claimed, and nothing was charged.</Note>
<Row>
<button type="button" className={BUTTON} onClick={() => claim(state.player)}>
Open the checkout again
</button>
<button type="button" className={QUIET} onClick={reset}>
Another account
</button>
</Row>
</Panel>
);
}
}
function Panel({ children, endsLabel }: { children: ReactNode; endsLabel: string | null }) {
return (
<div role="region" aria-label="Claim the founder set" aria-live="polite" className="rounded-2xl bg-white/[0.06] p-5 backdrop-blur-xl sm:p-6">
{children}
{endsLabel && <p className="mt-5 text-[13px] text-white/45">Free (0 CHF) until {endsLabel}. One claim per Minecraft account.</p>}
</div>
);
}
function Account({ player }: { player: Player }) {
return (
<div className="flex items-center gap-4">
<SkinFace skin={player.skin} name={player.name} />
<div className="min-w-0">
<p className="truncate font-display text-[22px] leading-tight font-semibold tracking-[-0.01em] text-white">{player.name}</p>
<p className="mt-0.5 truncate font-mono text-[12px] text-white/45">{dashed(player.uuid)}</p>
</div>
</div>
);
}
function Note({ children, tone }: { children: ReactNode; tone?: "warn" }) {
return <p className={`mt-4 text-[15px] leading-relaxed ${tone === "warn" ? "text-[#ffb4a8]" : "text-white/70"}`}>{children}</p>;
}
function Row({ children }: { children: ReactNode }) {
return <div className="mt-5 flex flex-col gap-2 sm:flex-row">{children}</div>;
}
function Receipt({ txn }: { txn: string }) {
return (
<p className="mt-4 text-[14px] leading-relaxed text-white/55">
Order <span className="font-mono text-white/75">{txn}</span>. Paddle sends the receipt to the email you gave; you can find it again at{" "}
<a href="https://paddle.net" target="_blank" rel="noreferrer" className={LINK}>
paddle.net
<span className="sr-only"> (opens in a new tab)</span>
</a>
.
</p>
);
}
const dashed = (u: string) => (u.length === 32 ? `${u.slice(0, 8)}-${u.slice(8, 12)}-${u.slice(12, 16)}-${u.slice(16, 20)}-${u.slice(20)}` : u);
+8 -7
View File
@@ -2,8 +2,9 @@
import { useEffect, useState } from "react";
// Paddle.js on Horizon's default payment link: the launcher opens /shop/pay?_ptxn=txn_… for a checkout the cosmetics
// service created, and Paddle.js opens that transaction's overlay by itself. Nothing here grants anything: Paddle's
// Paddle.js on Horizon's default payment link: Paddle's own links (its emails, an older launcher) open /shop/pay?_ptxn=txn_…
// for a checkout the cosmetics service created, and Paddle.js opens that transaction's overlay by itself. The shop
// itself (/shop) opens its checkouts in place. Nothing here grants anything: Paddle's
// webhook tells the cosmetics service, which grants the items.
type PaddleEvent = { name?: string };
@@ -56,11 +57,11 @@ export function PaddleCheckout({ token, sandbox }: { token: string; sandbox: boo
const text: Record<State, [string, string]> = {
loading: ["Opening Paddle's checkout…", "It opens over this page."],
open: ["Paddle's checkout is open.", "Follow it to the end; your items arrive in the launcher about a minute later."],
done: ["Done, thank you.", "Go back to the launcher: your items appear in the Wardrobe within a minute. Paddle emails you the receipt."],
closed: ["The checkout was closed.", "Nothing was charged. Start again from the launcher's Wardrobe whenever you like."],
none: ["Nothing to check out here.", "Purchases and claims start in the launcher's Wardrobe, which opens this page for you."],
refused: ["Paddle couldn't open this checkout.", "Nothing was charged. Start again from the launcher's Wardrobe; if it happens again, write to us."],
open: ["Paddle's checkout is open.", "Follow it to the end; your items reach the launcher's Wardrobe about a minute later."],
done: ["Done, thank you.", "Your items are now on their way to the launcher's Wardrobe: they appear within a minute. Paddle emails you the receipt."],
closed: ["The checkout was closed.", "Nothing was charged. Start again from the shop whenever you like."],
none: ["Nothing to check out here.", "Claims and purchases start on the shop page: playhorizon.dev/shop."],
refused: ["Paddle couldn't open this checkout.", "Nothing was charged. Start again from the shop; if it happens again, write to us."],
failed: ["Paddle's checkout couldn't load.", "Check your connection or turn off what blocks cdn.paddle.com, then reload this page."],
};
const [title, line] = text[state];
+36
View File
@@ -0,0 +1,36 @@
// Paddle.js v2, loaded once per page from Paddle's CDN and initialised with the account's client-side token (public by
// design). Paddle allows one Initialize per page, so every caller shares it; `onEvent` is the first caller's.
export type PaddleEvent = { name?: string };
export type PaddleJs = {
Environment: { set: (env: string) => void };
Initialize: (options: { token: string; checkout?: { settings?: Record<string, unknown> }; eventCallback?: (e: PaddleEvent) => void }) => void;
Checkout: { open: (options: { transactionId: string; settings?: Record<string, unknown> }) => void };
};
export const PADDLE_JS = "https://cdn.paddle.com/paddle/v2/paddle.js";
let ready: Promise<PaddleJs> | null = null;
export function loadPaddle(token: string, onEvent: (e: PaddleEvent) => void): Promise<PaddleJs> {
ready ??= new Promise<PaddleJs>((resolve, reject) => {
const start = () => {
const paddle = (window as unknown as { Paddle?: PaddleJs }).Paddle;
if (!paddle) return reject(new Error("Paddle.js didn't load"));
if (token.startsWith("test_")) paddle.Environment.set("sandbox");
paddle.Initialize({ token, eventCallback: onEvent });
resolve(paddle);
};
if ((window as unknown as { Paddle?: PaddleJs }).Paddle) return start();
const script = document.createElement("script");
script.src = PADDLE_JS;
script.async = true;
script.onload = start;
script.onerror = () => reject(new Error("Paddle.js didn't load"));
document.head.appendChild(script);
}).catch((e: unknown) => {
ready = null;
throw e;
});
return ready;
}