Skip to content

Commit c0c135a

Browse files
sktbrdclaude
andauthored
feat(stake): painel "Suas posições" abaixo do orbit (#233)
Adiciona um card read-only em /stake (logo abaixo do StakeOrbit) que lê as posições da carteira conectada na Morpheus Capital (stETH + USDC) e mostra, por ativo: depósito, MOR rendido, quando o claim-lock do MOR abre (o power lock escolhido no depósito) e quando o lock de 7 dias do principal abre. - use-morpheus-position: passa a retornar `morUnlockAt` (claimLockEnd, idx 5 do usersData) além do unlock do principal. - StakePositions: novo componente (shadcn Card + i18n en/pt-br), badges "dá pra sacar" / "travado até <data>" pro MOR, e uma nota explicando que um unlock distante = power-lock maior (APR/power factor mais alto). Deixa claro por que um reward pode estar "earned" mas não sacável ainda. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8ddb422 commit c0c135a

5 files changed

Lines changed: 161 additions & 0 deletions

File tree

messages/en/stake.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,5 +192,20 @@
192192
"deployedToast": "{id} vault deployed",
193193
"deployedToastDesc": "Approve the batch in the SOPA Safe to activate.",
194194
"deployFail": "{id} deploy failed"
195+
},
196+
"positions": {
197+
"title": "Your positions",
198+
"subtitle": "Your Morpheus Capital stakes — deposit, MOR earned, and when each lock lifts.",
199+
"connect": "Connect your wallet to see your positions.",
200+
"loading": "Reading your positions…",
201+
"none": "No Morpheus stake yet. Back a rider above to start earning MOR.",
202+
"earned": "MOR earned",
203+
"morLock": "MOR unlock",
204+
"principalLock": "Deposit unlock",
205+
"claimable": "claimable now",
206+
"lockedUntil": "locked until {date}",
207+
"unlocked": "unlocked",
208+
"claimHint": "To claim, open the reward box (bottom-right) once the MOR unlock date has passed — it bridges your MOR to Arbitrum and splits it to your wallet.",
209+
"powerLockNote": "A far-off MOR unlock means a longer power-lock was chosen at deposit (higher APR / power factor) — the MOR stays locked until that date."
195210
}
196211
}

messages/pt-br/stake.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,5 +192,20 @@
192192
"deployedToast": "Vault de {id} deployado",
193193
"deployedToastDesc": "Aprove o batch no Safe da SOPA pra ativar.",
194194
"deployFail": "Falha no deploy de {id}"
195+
},
196+
"positions": {
197+
"title": "Suas posições",
198+
"subtitle": "Seus stakes na Morpheus Capital — depósito, MOR rendido e quando cada lock abre.",
199+
"connect": "Conecte a carteira pra ver suas posições.",
200+
"loading": "Lendo suas posições…",
201+
"none": "Nenhum stake na Morpheus ainda. Apoie um rider acima pra começar a render MOR.",
202+
"earned": "MOR rendido",
203+
"morLock": "Unlock do MOR",
204+
"principalLock": "Unlock do depósito",
205+
"claimable": "dá pra sacar",
206+
"lockedUntil": "travado até {date}",
207+
"unlocked": "liberado",
208+
"claimHint": "Pra sacar, abra a caixa de recompensa (canto inferior direito) depois que a data de unlock do MOR passar — ela leva o MOR pra Arbitrum e divide na sua carteira.",
209+
"powerLockNote": "Um unlock do MOR distante significa que um power-lock maior foi escolhido no depósito (APR / power factor mais alto) — o MOR fica travado até essa data."
195210
}
196211
}

src/app/[locale]/stake/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from "next-intl/server";
33
import { CharacterSelector } from "@/components/stake/CharacterSelector";
44
import { StakeAdminPanel } from "@/components/stake/StakeAdminPanel";
55
import { StakeOrbit } from "@/components/stake/StakeOrbit";
6+
import { StakePositions } from "@/components/stake/StakePositions";
67
import { MorLootbox } from "@/components/stake/MorLootbox";
78
import { MorpheusStakeWidget } from "@/components/stake/MorpheusStakeWidget";
89
import { STAKE_MINIAPP_EMBED_CONFIG } from "@/lib/miniapp-config";
@@ -67,6 +68,7 @@ export default async function StakePage({ params }: { params: Promise<{ locale:
6768
<CharacterSelector />
6869
</div>
6970
<StakeOrbit />
71+
<StakePositions />
7072
<StakeAdminPanel />
7173
</div>
7274
<MorLootbox />
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"use client";
2+
3+
// "Your positions" panel on /stake — reads the connected wallet's Morpheus
4+
// Capital stakes (stETH + USDC) and shows, per asset: deposit, MOR earned, when
5+
// the MOR claim-lock lifts (the power lock chosen at deposit) and when the 7-day
6+
// principal lock lifts. Read-only; the actual claim happens in the reward box.
7+
8+
import { useState } from "react";
9+
import { useTranslations } from "next-intl";
10+
import { useActiveAccount } from "thirdweb/react";
11+
import { cn } from "@/lib/utils";
12+
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
13+
import { useMorpheusPosition, type MorpheusPoolPosition } from "@/hooks/use-morpheus-position";
14+
15+
const fmt = (n: number, d = 4) => n.toLocaleString(undefined, { maximumFractionDigits: d });
16+
const fmtDate = (ts: number) =>
17+
new Date(ts * 1000).toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
18+
19+
type T = ReturnType<typeof useTranslations>;
20+
21+
function Badge({ tone, children }: { tone: "ok" | "warn"; children: React.ReactNode }) {
22+
return (
23+
<span
24+
className={cn(
25+
"inline-block rounded-full px-2 py-0.5 text-[11px] font-semibold",
26+
tone === "ok"
27+
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
28+
: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
29+
)}
30+
>
31+
{children}
32+
</span>
33+
);
34+
}
35+
36+
function Metric({ label, children }: { label: string; children: React.ReactNode }) {
37+
return (
38+
<div className="flex flex-col gap-0.5">
39+
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
40+
<span className="text-sm tabular-nums">{children}</span>
41+
</div>
42+
);
43+
}
44+
45+
function PositionRow({ p, now, t }: { p: MorpheusPoolPosition; now: number; t: T }) {
46+
const morUnlocked = p.morUnlockAt > 0 && p.morUnlockAt <= now;
47+
const depUnlocked = p.unlockAt > 0 && p.unlockAt <= now;
48+
return (
49+
<div className="rounded-xl border p-3">
50+
<div className="flex items-center justify-between gap-2">
51+
<span className="flex items-center gap-2 font-semibold">
52+
<span className={cn("size-2 shrink-0 rounded-full", p.asset === "usdc" ? "bg-sky-500" : "bg-emerald-500")} />
53+
{p.symbol}
54+
</span>
55+
<span className="font-mono text-sm tabular-nums">
56+
{fmt(p.staked)} <span className="text-xs font-normal text-muted-foreground">{p.symbol}</span>
57+
</span>
58+
</div>
59+
60+
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2.5 sm:grid-cols-3">
61+
<Metric label={t("earned")}>
62+
<span className="font-mono font-semibold">{fmt(p.pendingMor)}</span>{" "}
63+
<span className="text-xs text-muted-foreground">MOR</span>
64+
</Metric>
65+
<Metric label={t("morLock")}>
66+
{p.morUnlockAt <= 0 ? (
67+
<span className="text-muted-foreground"></span>
68+
) : morUnlocked ? (
69+
<Badge tone="ok">{t("claimable")}</Badge>
70+
) : (
71+
<Badge tone="warn">{t("lockedUntil", { date: fmtDate(p.morUnlockAt) })}</Badge>
72+
)}
73+
</Metric>
74+
<Metric label={t("principalLock")}>
75+
{p.unlockAt <= 0 ? (
76+
<span className="text-muted-foreground"></span>
77+
) : depUnlocked ? (
78+
<span className="text-emerald-600 dark:text-emerald-400">{t("unlocked")}</span>
79+
) : (
80+
<span>{fmtDate(p.unlockAt)}</span>
81+
)}
82+
</Metric>
83+
</div>
84+
</div>
85+
);
86+
}
87+
88+
export function StakePositions() {
89+
const t = useTranslations("stake.positions");
90+
const you = useActiveAccount()?.address;
91+
const pos = useMorpheusPosition(you);
92+
// Lazy initializer keeps render pure — the 7-day / power locks don't need a
93+
// live tick; a refresh re-reads the clock.
94+
const [now] = useState(() => Math.floor(Date.now() / 1000));
95+
96+
const staked = pos?.pools.filter((p) => p.staked > 0) ?? [];
97+
const anyLocked = staked.some((p) => p.morUnlockAt > now);
98+
99+
let body: React.ReactNode;
100+
if (!you) body = <p className="text-sm text-muted-foreground">{t("connect")}</p>;
101+
else if (!pos) body = <p className="text-sm text-muted-foreground">{t("loading")}</p>;
102+
else if (staked.length === 0) body = <p className="text-sm text-muted-foreground">{t("none")}</p>;
103+
else
104+
body = (
105+
<div className="space-y-3">
106+
{staked.map((p) => (
107+
<PositionRow key={p.asset} p={p} now={now} t={t} />
108+
))}
109+
<p className="pt-1 text-xs leading-relaxed text-muted-foreground">{t("claimHint")}</p>
110+
{anyLocked && <p className="text-xs leading-relaxed text-muted-foreground">{t("powerLockNote")}</p>}
111+
</div>
112+
);
113+
114+
return (
115+
<Card>
116+
<CardHeader>
117+
<CardTitle>{t("title")}</CardTitle>
118+
<CardDescription>{t("subtitle")}</CardDescription>
119+
</CardHeader>
120+
<CardContent>{body}</CardContent>
121+
</Card>
122+
);
123+
}

src/hooks/use-morpheus-position.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export type MorpheusPoolPosition = {
3131
pendingMor: number;
3232
/** Unix seconds when the withdraw lock lifts (0 if nothing staked). */
3333
unlockAt: number;
34+
/** Unix seconds when the MOR reward claim-lock lifts (claimLockEnd) — until
35+
* then the accrued MOR can't be claimed. 0 if none. This is the "power lock"
36+
* chosen at deposit: a longer lock buys a higher power factor / APR. */
37+
morUnlockAt: number;
3438
/** The athlete credited as referrer for this position (drives the 3-way split). */
3539
referrer: Address;
3640
};
@@ -62,12 +66,14 @@ export function useMorpheusPosition(wallet?: string, nonce = 0): MorpheusPositio
6266
]);
6367
const lastStake = Number(data[0]); // uint128 lastStake
6468
const deposited = data[1]; // uint256 deposited
69+
const claimLockEnd = Number(data[5]); // uint128 claimLockEnd (MOR power lock)
6570
const referrer = data[8] as Address; // stored referrer (athlete)
6671
return {
6772
asset, symbol,
6873
staked: Number(formatUnits(deposited, decimals)),
6974
pendingMor: Number(formatUnits(reward, MOR_DECIMALS)),
7075
unlockAt: deposited > BigInt(0) ? lastStake + WITHDRAW_LOCK_SECONDS : 0,
76+
morUnlockAt: deposited > BigInt(0) ? claimLockEnd : 0,
7177
referrer,
7278
} satisfies MorpheusPoolPosition;
7379
}),

0 commit comments

Comments
 (0)