Skip to content

Commit 97e15d5

Browse files
sktbrdclaude
andcommitted
feat(stake): amplification ladder and roadmap on /stake
The subnet milestone ladder said what a staker EARNS (featured wall / community drop / IRL event) while the stake dialog's own disclaimer said "rewards accrue to the subnet, not to individual stakers". It also did not match the campaign brief. Replace it with the axis the campaign actually commits to: what Gnars delivers to amplify Morpheus as the subnet grows. Only the 10k anchor is locked in, so milestones now carry a firmness label (committed / proposed) rather than reading as six firm promises. The two ungated milestones (subnet live, first 10 backers) are dropped. Adds the four-phase roadmap as its own card. Phase 4 ships with an explicit "planned / not guaranteed / subject to change" hedge in both locales — that hedge is what makes an airdrop tease publishable. - stake-milestones.ts: 6 amount-gated tiers + MilestoneFirmness - stake-roadmap.ts + RoadmapSection.tsx: new - progressToEvent no longer names the IRL event; interpolates the goal - copy in EN and PT-BR, pt-BR following the repo's "a subnet" / "MOR em stake" conventions Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 735c1f2 commit 97e15d5

8 files changed

Lines changed: 249 additions & 43 deletions

File tree

messages/en/stake.json

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -276,24 +276,56 @@
276276
}
277277
},
278278
"subnet": {
279-
"title": "Stake MOR behind the Gnars subnet",
279+
"title": "Co-sponsor the Gnars Builder subnet",
280280
"desc": "This one pays you nothing. Staked MOR powers the Gnars Builder subnet, which funds what the DAO ships. Your MOR stays yours and unlocks 7 days after you stake.",
281281
"morStaked": "MOR staked",
282282
"goal": "goal {n, number}",
283283
"cta": "Back the subnet",
284+
"milestonesTitle": "Amplification milestones",
285+
"milestonesNote": "As staked MOR grows, Gnars amplifies Morpheus — Gnars's own commitment, building on Morpheus's open protocol.",
286+
"firmness": {
287+
"committed": "committed",
288+
"proposed": "proposed"
289+
},
284290
"milestones": {
285-
"live": "Subnet live: Gnars is a Morpheus Builder",
286-
"backers10": "First 10 backers on board",
287-
"10k": "{n, number} MOR staked: featured stakers wall",
288-
"50k": "{n, number} MOR staked: community drop",
289-
"100k": "{n, number} MOR staked: IRL Gnars event"
291+
"10k": "{n, number} MOR staked: Morpheus ad on Gnars TV",
292+
"15k": "{n, number} MOR staked: Morpheus on the Gnars banner + a pinned post",
293+
"25k": "{n, number} MOR staked: Morpheus featured in a Gnars edit",
294+
"30k": "{n, number} MOR staked: Morpheus swag in the Skater Vaults",
295+
"50k": "{n, number} MOR staked: Morpheus at an IRL Gnars activation",
296+
"100k": "{n, number} MOR staked: Morpheus flagship spotlight"
297+
},
298+
"roadmap": {
299+
"title": "What comes next",
300+
"status": {
301+
"live": "live",
302+
"upcoming": "upcoming"
303+
},
304+
"phases": {
305+
"staking": {
306+
"title": "Staking frontend",
307+
"desc": "Stake MOR behind the Gnars Builder subnet. Live now."
308+
},
309+
"agent": {
310+
"title": "BobGnarly on Morpheus",
311+
"desc": "Gnars's AI agent moves onto Morpheus; the DAO becomes a real user of the network."
312+
},
313+
"inference": {
314+
"title": "Gnarly inference",
315+
"desc": "A private, uncensored inference platform built on Morpheus rails."
316+
},
317+
"airdrop": {
318+
"title": "$gnars airdrop",
319+
"desc": "Planned: a $gnars airdrop for subnet stakers. Not guaranteed and subject to change; if it happens, early stakers are first in line."
320+
}
321+
}
290322
},
291323
"dialog": {
292324
"title": "Back Gnars on Morpheus",
293325
"badge": "MOR · Base",
294326
"desc": "Lock MOR into the Gnars Builder subnet to back the crew with real capital. Your principal stays yours — withdrawable after a 7-day lock. Self-custody on Base.",
295-
"milestonesLabel": "Staking milestones",
296-
"progressToEvent": "{pct}% of the way to the IRL event",
327+
"milestonesLabel": "Amplification milestones",
328+
"progressToEvent": "{pct}% of the way to {n, number} MOR staked",
297329
"totalStaked": "Total staked",
298330
"yourStake": "Your stake",
299331
"balance": "Balance:",

messages/pt-br/stake.json

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -276,24 +276,56 @@
276276
}
277277
},
278278
"subnet": {
279-
"title": "Faça stake de MOR pra bancar a subnet Gnars",
279+
"title": "Co-patrocine a subnet Gnars Builder",
280280
"desc": "Esse aqui não te paga nada. O MOR em stake sustenta a subnet Gnars Builder, que financia o que a DAO entrega. Seu MOR continua seu e libera 7 dias depois do stake.",
281281
"morStaked": "MOR em stake",
282282
"goal": "meta {n, number}",
283283
"cta": "Apoiar a subnet",
284+
"milestonesTitle": "Metas de amplificação",
285+
"milestonesNote": "Conforme o MOR em stake cresce, a Gnars amplifica a Morpheus — compromisso unilateral da Gnars, construindo sobre o protocolo aberto da Morpheus.",
286+
"firmness": {
287+
"committed": "confirmado",
288+
"proposed": "proposto"
289+
},
284290
"milestones": {
285-
"live": "Subnet no ar: a Gnars virou Builder na Morpheus",
286-
"backers10": "Primeiros 10 apoiadores a bordo",
287-
"10k": "{n, number} MOR em stake: mural dos apoiadores",
288-
"50k": "{n, number} MOR em stake: drop pra comunidade",
289-
"100k": "{n, number} MOR em stake: evento Gnars presencial"
291+
"10k": "{n, number} MOR em stake: anúncio da Morpheus na Gnars TV",
292+
"15k": "{n, number} MOR em stake: Morpheus no banner da Gnars + post fixado",
293+
"25k": "{n, number} MOR em stake: Morpheus em destaque num edit da Gnars",
294+
"30k": "{n, number} MOR em stake: swag da Morpheus nos Skater Vaults",
295+
"50k": "{n, number} MOR em stake: Morpheus numa ativação IRL da Gnars",
296+
"100k": "{n, number} MOR em stake: spotlight principal da Morpheus"
297+
},
298+
"roadmap": {
299+
"title": "O que vem depois",
300+
"status": {
301+
"live": "no ar",
302+
"upcoming": "a seguir"
303+
},
304+
"phases": {
305+
"staking": {
306+
"title": "Frontend de staking",
307+
"desc": "Faça stake de MOR na subnet Gnars Builder. No ar agora."
308+
},
309+
"agent": {
310+
"title": "BobGnarly na Morpheus",
311+
"desc": "O agente de IA da Gnars migra pra Morpheus; a DAO vira usuária de verdade da rede."
312+
},
313+
"inference": {
314+
"title": "Inference Gnarly",
315+
"desc": "Uma plataforma de inference privada e sem censura sobre os trilhos da Morpheus."
316+
},
317+
"airdrop": {
318+
"title": "Airdrop de $gnars",
319+
"desc": "Planejado: um airdrop de $gnars pros stakers da subnet. Não garantido e sujeito a mudança; se acontecer, quem stakou cedo tem prioridade."
320+
}
321+
}
290322
},
291323
"dialog": {
292324
"title": "Apoie os Gnars na Morpheus",
293325
"badge": "MOR · Base",
294326
"desc": "Trave MOR na subnet Gnars Builder para apoiar a crew com capital de verdade. Seu principal continua seu — pode ser sacado depois de um bloqueio de 7 dias. Autocustódia na Base.",
295-
"milestonesLabel": "Metas de stake",
296-
"progressToEvent": "{pct}% do caminho até o evento IRL",
327+
"milestonesLabel": "Metas de amplificação",
328+
"progressToEvent": "{pct}% do caminho até {n, number} MOR em stake",
297329
"totalStaked": "Total em stake",
298330
"yourStake": "Seu stake",
299331
"balance": "Saldo:",

src/components/stake/GnarsStakeDialog.tsx

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,7 @@ import { useGnarsSubnetStake } from "@/hooks/use-gnars-subnet-stake";
2626
import { useUserAddress } from "@/hooks/use-user-address";
2727
import { toIntlLocale } from "@/lib/i18n/format";
2828
import { SUBNET_MIN_DEPOSIT_MOR } from "@/lib/morpheus-builder";
29-
import {
30-
isMilestoneDone,
31-
SUBNET_GOAL_MOR,
32-
SUBNET_MILESTONES,
33-
type StakeMilestone,
34-
} from "@/lib/stake-milestones";
29+
import { isMilestoneDone, SUBNET_GOAL_MOR, SUBNET_MILESTONES } from "@/lib/stake-milestones";
3530
import { cn } from "@/lib/utils";
3631

3732
// Locale-aware on purpose: these MOR figures sit next to a CountUp and a section
@@ -41,11 +36,10 @@ const fmt = (n: number, locale: string, d = 4) =>
4136
n.toLocaleString(locale, { maximumFractionDigits: d });
4237
const shortMor = (n: number) => (n >= 1000 ? `${n / 1000}k` : String(n));
4338

44-
// MOR-denominated milestones become tick marks on the progress bar.
45-
const MOR_TICKS = SUBNET_MILESTONES.filter(
46-
(m): m is StakeMilestone & { amountMor: number } =>
47-
typeof m.amountMor === "number" && m.amountMor > 0,
48-
);
39+
// Milestones become tick marks on the progress bar. Every milestone is
40+
// MOR-denominated since the ladder became the amplification ladder — the two
41+
// ungated entries this used to filter out (subnet live, first 10 backers) are gone.
42+
const MOR_TICKS = SUBNET_MILESTONES.filter((m) => m.amountMor > 0);
4943

5044
type Props = { open: boolean; onOpenChange: (open: boolean) => void };
5145

@@ -162,7 +156,7 @@ export function GnarsStakeDialog({ open, onOpenChange }: Props) {
162156
</div>
163157
</div>
164158
<span className="rounded-full bg-emerald-500/15 px-3 py-1 text-xs font-semibold text-emerald-600 dark:text-emerald-300">
165-
{t("dialog.progressToEvent", { pct: pctLabel })}
159+
{t("dialog.progressToEvent", { pct: pctLabel, n: SUBNET_GOAL_MOR })}
166160
</span>
167161
</div>
168162

@@ -227,7 +221,13 @@ export function GnarsStakeDialog({ open, onOpenChange }: Props) {
227221
done ? "font-semibold text-foreground" : "text-muted-foreground",
228222
)}
229223
>
230-
{t("milestones." + m.id, { n: m.amountMor ?? 0 })}
224+
{t("milestones." + m.id, { n: m.amountMor })}{" "}
225+
{/* Same committed/proposed label the page ladder carries.
226+
Dropping it here would make the dialog read as six firm
227+
promises while the section behind it says five are not. */}
228+
<span className="text-[10px] font-semibold tracking-wide whitespace-nowrap text-muted-foreground uppercase">
229+
· {t("firmness." + m.firmness)}
230+
</span>
231231
</span>
232232
</li>
233233
);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"use client";
2+
3+
// The four-phase roadmap, sitting directly under SubnetSection because it is the
4+
// same story continued: the ladder above says what Gnars ships as the subnet
5+
// grows, this says where the subnet itself is going.
6+
//
7+
// Its own card rather than a block inside SubnetSection — that card already
8+
// carries a progress bar, a six-item ladder and the page's stake CTA, and a
9+
// roadmap appended below the CTA would read as fine print under the button.
10+
//
11+
// No dates. A roadmap that prints quarters ages into a liability the first time
12+
// one slips, so `status` (live / upcoming) is the only state a phase carries.
13+
// Status and copy both live in messages/{en,pt-br}/stake.json; STAKE_ROADMAP is
14+
// data only, the same split SUBNET_MILESTONES uses.
15+
import { useTranslations } from "next-intl";
16+
import { RevealItem, RevealSection } from "@/components/stake/Reveal";
17+
import { SectionHeader } from "@/components/stake/SectionHeader";
18+
import { CARD, CARD_PAD, MICRO, MUTED } from "@/components/stake/stake-ui";
19+
import { STAKE_ROADMAP } from "@/lib/stake-roadmap";
20+
import { cn } from "@/lib/utils";
21+
22+
export function RoadmapSection() {
23+
const t = useTranslations("stake.page.subnet.roadmap");
24+
25+
return (
26+
<RevealSection className={cn(CARD, CARD_PAD, "space-y-4")}>
27+
<RevealItem>
28+
<SectionHeader title={t("title")} />
29+
</RevealItem>
30+
31+
<RevealItem delay={50}>
32+
<ol className="space-y-4">
33+
{STAKE_ROADMAP.map((p) => {
34+
const live = p.status === "live";
35+
return (
36+
<li key={p.id} className="flex items-start gap-2.5">
37+
{/* Same dot language as the milestone ladder above: filled = real
38+
now, hollow = not yet. Colourless for the same reason — green
39+
belongs to the progress fill and the MOR total, nowhere else. */}
40+
<span
41+
aria-hidden
42+
className={cn(
43+
"mt-1.5 size-1.5 shrink-0 rounded-full",
44+
live ? "bg-foreground/70" : "border border-foreground/30",
45+
)}
46+
/>
47+
<div className="min-w-0">
48+
<p className="text-sm font-semibold">
49+
{t(`phases.${p.id}.title`)}{" "}
50+
<span
51+
className={cn(
52+
"text-[10px] font-medium tracking-wide whitespace-nowrap uppercase",
53+
MICRO,
54+
)}
55+
>
56+
· {t(`status.${p.status}`)}
57+
</span>
58+
</p>
59+
{/* Phase 4 carries an explicit "planned / not guaranteed"
60+
hedge in both locales. It is what makes an airdrop tease
61+
publishable — see src/lib/stake-roadmap.ts. */}
62+
<p className={cn("mt-0.5 max-w-prose text-sm", MUTED)}>
63+
{t(`phases.${p.id}.desc`)}
64+
</p>
65+
</div>
66+
</li>
67+
);
68+
})}
69+
</ol>
70+
</RevealItem>
71+
</RevealSection>
72+
);
73+
}

src/components/stake/StakePageContent.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { CharacterSelector } from "@/components/stake/CharacterSelector";
2727
import { MorLootbox } from "@/components/stake/MorLootbox";
2828
import { PositionsHub } from "@/components/stake/PositionsHub";
2929
import { RevealItem, RevealSection } from "@/components/stake/Reveal";
30+
import { RoadmapSection } from "@/components/stake/RoadmapSection";
3031
import { SectionHeader } from "@/components/stake/SectionHeader";
3132
import {
3233
CARD,
@@ -311,6 +312,8 @@ export function StakePageContent({ initialRider }: { initialRider?: RiderId }) {
311312
</RevealSection>
312313

313314
<SubnetSection />
315+
316+
<RoadmapSection />
314317
</div>
315318

316319
{/* One floater, and only when the wallet actually has something to

src/components/stake/SubnetSection.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515
// dots" rule because they encode real state — done vs pending — rather than
1616
// decorating a value that already carries its own unit.
1717
//
18-
// SUBNET_MILESTONES is campaign DATA only (id + threshold). The copy lives in
18+
// The milestone ladder is what Gnars DELIVERS to amplify Morpheus as the subnet
19+
// grows — not what a staker earns. See src/lib/stake-milestones.ts for why the
20+
// distinction matters and why only the 10k anchor reads as `committed`.
21+
//
22+
// SUBNET_MILESTONES is campaign DATA only (id + threshold + firmness). The copy lives in
1923
// `stake.page.subnet.milestones.<id>` so the checklist speaks the page's language
2024
// — it used to print English in the middle of a Portuguese section — and the
2125
// thresholds inside those labels are interpolated as `{n, number}`, so pt-BR gets
@@ -99,7 +103,13 @@ export function SubnetSection() {
99103
/>
100104
</div>
101105

102-
<ul className="mt-5 space-y-2">
106+
{/* The ladder needs naming and framing before it is read: without these
107+
two lines it looks like a reward schedule, which is the other axis
108+
entirely and the one the stake dialog's disclaimer contradicts. */}
109+
<h3 className="mt-6 text-sm font-semibold">{t("milestonesTitle")}</h3>
110+
<p className={cn("mt-1 max-w-prose text-xs", MUTED)}>{t("milestonesNote")}</p>
111+
112+
<ul className="mt-3 space-y-2">
103113
{SUBNET_MILESTONES.map((m) => {
104114
const done = isMilestoneDone(m, totalStaked);
105115
return (
@@ -115,7 +125,19 @@ export function SubnetSection() {
115125
)}
116126
/>
117127
<span className={done ? "" : MUTED}>
118-
{t(`milestones.${m.id}`, { n: m.amountMor ?? 0 })}
128+
{t(`milestones.${m.id}`, { n: m.amountMor })}{" "}
129+
{/* Inline rather than a right-aligned column: these labels are
130+
long enough to wrap on a phone, and a pinned column would
131+
squeeze them to two words a line. Not a PILL either — a pill
132+
is a badge about the page, never the state a row is in. */}
133+
<span
134+
className={cn(
135+
"whitespace-nowrap text-[10px] font-semibold tracking-wide uppercase",
136+
MICRO,
137+
)}
138+
>
139+
· {t(`firmness.${m.firmness}`)}
140+
</span>
119141
</span>
120142
</li>
121143
);

src/lib/stake-milestones.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,48 @@
11
// Staking milestones for the Gnars Builder subnet campaign. `amountMor` is the
2-
// MOR-staked threshold that unlocks the milestone; `null` means the milestone is
3-
// not amount-gated (tracked manually via `done`). Targets are the current
2+
// MOR-staked threshold that unlocks the milestone. Targets are the current
43
// program — swap here if the campaign changes.
54
//
5+
// WHAT THIS LADDER MEANS. It is what Gnars DELIVERS to amplify Morpheus as the
6+
// subnet grows, not what a staker earns. The two are easy to confuse and the page
7+
// used to carry the other axis (featured wall / community drop / IRL event), which
8+
// contradicted the stake dialog's own disclaimer that "rewards accrue to the
9+
// subnet, not to individual stakers". This ladder is a unilateral Gnars
10+
// commitment, built on Morpheus's open protocol — it is not a partnership and
11+
// implies nothing agreed or returned by the Morpheus side.
12+
//
13+
// `firmness` is why the ladder can be published before it is fully locked in: only
14+
// the 10k anchor is a real commitment, so the other five ship labelled `proposed`
15+
// rather than silently reading as promises.
16+
//
617
// This file carries NO copy. Milestone labels live in
718
// `stake.page.subnet.milestones.<id>` in messages/{en,pt-br}/stake.json and are
819
// resolved by whoever renders the list, so the checklist follows the page locale
920
// instead of printing English inside a Portuguese section. The thresholds that
1021
// appear inside those labels are interpolated as `{n, number}`, which is also
11-
// what makes "10,000" read as "10.000" in pt-BR.
22+
// what makes "10,000" read as "10.000" in pt-BR. Firmness labels live alongside
23+
// them under `stake.page.subnet.firmness.<firmness>`.
1224

1325
export const SUBNET_GOAL_MOR = 100_000;
1426

27+
/** Committed = Gnars has locked this in. Proposed = direction, not a promise. */
28+
export type MilestoneFirmness = "committed" | "proposed";
29+
1530
export type StakeMilestone = {
1631
/** Also the message key under `stake.page.subnet.milestones`. */
1732
id: string;
18-
amountMor: number | null;
19-
done?: boolean;
33+
amountMor: number;
34+
firmness: MilestoneFirmness;
2035
};
2136

2237
export const SUBNET_MILESTONES: StakeMilestone[] = [
23-
{ id: "live", amountMor: 0 },
24-
{ id: "backers10", amountMor: null, done: false },
25-
{ id: "10k", amountMor: 10_000 },
26-
{ id: "50k", amountMor: 50_000 },
27-
{ id: "100k", amountMor: 100_000 },
38+
{ id: "10k", amountMor: 10_000, firmness: "committed" },
39+
{ id: "15k", amountMor: 15_000, firmness: "proposed" },
40+
{ id: "25k", amountMor: 25_000, firmness: "proposed" },
41+
{ id: "30k", amountMor: 30_000, firmness: "proposed" },
42+
{ id: "50k", amountMor: 50_000, firmness: "proposed" },
43+
{ id: "100k", amountMor: 100_000, firmness: "proposed" },
2844
];
2945

3046
export function isMilestoneDone(m: StakeMilestone, totalStaked: number) {
31-
return m.amountMor === null ? Boolean(m.done) : totalStaked >= m.amountMor;
47+
return totalStaked >= m.amountMor;
3248
}

0 commit comments

Comments
 (0)