Skip to content

Commit 84735ca

Browse files
committed
Add Epoch 1000 validator edition page
Introduce /epoch1000/validator with vote account lookup via Trillium, validator survivor cards with logo support, and wallet/validator edition nav.
1 parent 4cab7b6 commit 84735ca

12 files changed

Lines changed: 1177 additions & 71 deletions

File tree

apps/web/src/app/[locale]/epoch1000/page.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Epoch1000Experience from "@/components/epoch1000/Epoch1000Experience";
2+
import Epoch1000EditionNav from "@/components/epoch1000/Epoch1000EditionNav";
23
import type { Metadata } from "next";
3-
import { getAlternates, Link } from "@workspace/i18n/routing";
4+
import { getAlternates } from "@workspace/i18n/routing";
45
import { getTranslations } from "@workspace/i18n/server";
56

67
type Props = { params: Promise<{ locale: string }> };
@@ -14,12 +15,11 @@ export default async function Page() {
1415
return (
1516
<main className="w-full max-w-6xl mx-auto px-5 sm:px-8 pt-6 sm:pt-8 pb-8 sm:pb-12 flex flex-col gap-10 sm:gap-14 min-h-screen">
1617
<header className="flex justify-end border-b border-ep-edge/70 pb-4">
17-
<Link
18-
href="/epoch1000#checker"
19-
className="text-xs font-brand-mono text-ep-dust hover:text-ep-ink transition-colors duration-200 shrink-0 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4"
20-
>
21-
{t("checkerLink")}
22-
</Link>
18+
<Epoch1000EditionNav
19+
active="wallet"
20+
walletLabel={t("navWallet")}
21+
validatorLabel={t("navValidator")}
22+
/>
2323
</header>
2424

2525
<Epoch1000Experience
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import type { Metadata } from "next";
2+
import { getAlternates, Link } from "@workspace/i18n/routing";
3+
import { getTranslations } from "@workspace/i18n/server";
4+
import Reveal from "@/components/epoch1000/Reveal";
5+
import { tierFor } from "@/lib/epoch1000/tiers";
6+
7+
interface Props {
8+
params: Promise<{ locale: string }>;
9+
searchParams: Promise<Record<string, string | string[] | undefined>>;
10+
}
11+
12+
function first(v: string | string[] | undefined): string {
13+
return Array.isArray(v) ? (v[0] ?? "") : (v ?? "");
14+
}
15+
16+
function ogQuery(
17+
sp: Record<string, string | string[] | undefined>,
18+
locale?: string,
19+
): string {
20+
const p = new URLSearchParams();
21+
for (const key of ["s", "fe", "c", "t", "x", "w", "k", "vl"]) {
22+
const v = first(sp[key]);
23+
if (v) p.set(key, v);
24+
}
25+
if (!p.has("k")) p.set("k", "validator");
26+
if (locale) p.set("l", locale);
27+
return p.toString();
28+
}
29+
30+
export async function generateMetadata({
31+
params,
32+
searchParams,
33+
}: Props): Promise<Metadata> {
34+
const { locale } = await params;
35+
const t = await getTranslations({
36+
locale,
37+
namespace: "epoch1000.validator.card",
38+
});
39+
const sp = await searchParams;
40+
const survived = first(sp.s) || "0";
41+
const capped = first(sp.x) === "1";
42+
const firstEpoch = first(sp.fe);
43+
const tier = tierFor(parseInt(survived, 10) || 0);
44+
const survivedLabel = `${survived}${capped ? "+" : ""}`;
45+
const title = t("meta.title", { survived: survivedLabel });
46+
const description = t(
47+
firstEpoch
48+
? "meta.description.withFirstEpoch"
49+
: "meta.description.withoutFirstEpoch",
50+
{
51+
survived: survivedLabel,
52+
firstEpoch,
53+
tierLine: t(`tiers.${tier.id}.line`),
54+
},
55+
);
56+
const image = `/api/epoch1000/og?${ogQuery(sp, locale)}`;
57+
return {
58+
title,
59+
description,
60+
alternates: getAlternates("/epoch1000/validator/card", locale),
61+
openGraph: {
62+
title,
63+
description,
64+
images: [{ url: image, width: 1200, height: 630 }],
65+
},
66+
twitter: {
67+
card: "summary_large_image",
68+
title,
69+
description,
70+
images: [image],
71+
},
72+
};
73+
}
74+
75+
export default async function ValidatorCardPage({
76+
params,
77+
searchParams,
78+
}: Props) {
79+
const { locale } = await params;
80+
const t = await getTranslations({
81+
locale,
82+
namespace: "epoch1000.validator.card",
83+
});
84+
const sp = await searchParams;
85+
const query = ogQuery(sp, locale);
86+
const survived = Math.max(0, parseInt(first(sp.s), 10) || 0);
87+
const capped = first(sp.x) === "1";
88+
const firstEpoch = first(sp.fe);
89+
const voteAccount = first(sp.w);
90+
const tier = tierFor(survived);
91+
const tierName = t(`tiers.${tier.id}.name`);
92+
const survivedLabel = `${survived}${capped ? "+" : ""}`;
93+
const voteAccountLabel = voteAccount || t("summary.defaultVoteAccount");
94+
95+
return (
96+
<main className="w-full max-w-3xl mx-auto px-5 sm:px-8 py-12 sm:py-20 flex flex-col items-center text-center gap-8 sm:gap-10 min-h-screen">
97+
<header className="flex flex-col items-center gap-4 sm:gap-5">
98+
<p className="font-brand-mono text-xs sm:text-sm tracking-[0.2em] text-ep-dust uppercase">
99+
{t("eyebrow")}
100+
</p>
101+
<h1
102+
className="font-black tracking-tight text-4xl sm:text-6xl leading-none"
103+
style={{ color: tier.color }}
104+
>
105+
{t("heading", { survived: survivedLabel })}
106+
</h1>
107+
<p className="text-sm sm:text-base text-ep-dim max-w-xl">
108+
{t.rich(
109+
firstEpoch ? "summary.withFirstEpoch" : "summary.withoutFirstEpoch",
110+
{
111+
voteAccountLabel,
112+
survived: survivedLabel,
113+
firstEpoch,
114+
voteAccount: (chunks) =>
115+
voteAccount ? (
116+
<span className="font-mono text-ep-ink">{chunks}</span>
117+
) : (
118+
<>{chunks}</>
119+
),
120+
strong: (chunks) => (
121+
<span className="font-semibold text-ep-ink">{chunks}</span>
122+
),
123+
},
124+
)}
125+
</p>
126+
</header>
127+
128+
<Reveal className="w-full">
129+
<img
130+
src={`/api/epoch1000/og?${query}`}
131+
alt={t("imageAlt", { tier: tierName, survived: survivedLabel })}
132+
className="w-full rounded-xl border border-ep-edge"
133+
style={{ boxShadow: `0 20px 100px -30px ${tier.color}66` }}
134+
width={1200}
135+
height={630}
136+
/>
137+
</Reveal>
138+
139+
<Reveal className="flex flex-col items-center gap-6 sm:gap-8">
140+
<div className="flex flex-col items-center gap-3">
141+
<div className="flex flex-wrap justify-center gap-3">
142+
<Link
143+
href="/epoch1000/validator#checker"
144+
className="bg-ep-ink text-ep-void font-semibold rounded-full px-7 py-3 text-sm hover:bg-ep-dim transition-colors duration-200"
145+
>
146+
{t("ctaCheck")}
147+
</Link>
148+
<Link
149+
href="/epoch1000/validator"
150+
className="border border-ep-edge rounded-full px-7 py-3 text-sm text-ep-dim hover:text-ep-ink transition-colors duration-200"
151+
>
152+
{t("ctaExplore")}
153+
</Link>
154+
</div>
155+
<p className="text-xs text-ep-dust">{t("privacyNote")}</p>
156+
</div>
157+
</Reveal>
158+
</main>
159+
);
160+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import ValidatorEpoch1000Experience from "@/components/epoch1000/ValidatorEpoch1000Experience";
2+
import Epoch1000EditionNav from "@/components/epoch1000/Epoch1000EditionNav";
3+
import type { Metadata } from "next";
4+
import { getAlternates } from "@workspace/i18n/routing";
5+
import { getTranslations } from "@workspace/i18n/server";
6+
7+
type Props = { params: Promise<{ locale: string }> };
8+
9+
const VALIDATOR_PATH = "/epoch1000/validator";
10+
const VALIDATOR_SOCIAL_IMAGE =
11+
"/api/epoch1000/og?s=1000&fe=0&c=1000&k=validator";
12+
13+
export default async function Page() {
14+
const t = await getTranslations("epoch1000.validator.page");
15+
const nav = await getTranslations("epoch1000.page");
16+
17+
return (
18+
<main className="w-full max-w-6xl mx-auto px-5 sm:px-8 pt-6 sm:pt-8 pb-8 sm:pb-12 flex flex-col gap-10 sm:gap-14 min-h-screen">
19+
<header className="flex justify-end border-b border-ep-edge/70 pb-4">
20+
<Epoch1000EditionNav
21+
active="validator"
22+
walletLabel={nav("navWallet")}
23+
validatorLabel={nav("navValidator")}
24+
/>
25+
</header>
26+
27+
<ValidatorEpoch1000Experience
28+
title={t.rich("title", {
29+
gradient: (chunks) => (
30+
<span className="text-sol-gradient">{chunks}</span>
31+
),
32+
})}
33+
subtitle={t("subtitle")}
34+
description={t("description")}
35+
/>
36+
</main>
37+
);
38+
}
39+
40+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
41+
const { locale } = await params;
42+
const t = await getTranslations("epoch1000.validator.meta");
43+
const title = t("title");
44+
const description = t("description");
45+
const alternates = getAlternates(VALIDATOR_PATH, locale);
46+
47+
return {
48+
title,
49+
description,
50+
alternates,
51+
openGraph: {
52+
title: t("openGraphTitle"),
53+
description: t("openGraphDescription"),
54+
type: "website",
55+
url: alternates.canonical,
56+
images: [
57+
{
58+
url: VALIDATOR_SOCIAL_IMAGE,
59+
width: 1200,
60+
height: 630,
61+
},
62+
],
63+
},
64+
twitter: {
65+
card: "summary_large_image",
66+
title,
67+
description,
68+
images: [VALIDATOR_SOCIAL_IMAGE],
69+
},
70+
};
71+
}

0 commit comments

Comments
 (0)