Skip to content

Commit ffc0656

Browse files
committed
fix: hide badge tokens from nfts + add mint card
1 parent d6a9333 commit ffc0656

2 files changed

Lines changed: 222 additions & 9 deletions

File tree

app/ClientPage.tsx

Lines changed: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const INFERNO_PULSE = {
6060
imageCid: "bafybeidm2pqh5ty5ltlt4zpl3osy2t27annf5zui5ur6j6uxwk2oco24zm",
6161
localImagePath: "/nfts/inferno-pulse.png",
6262
name: "StackUp: Inferno Pulse",
63+
kind: 101,
6364
} as const;
6465

6566
type NftOverrideEntry = {
@@ -115,6 +116,7 @@ export default function ClientPage() {
115116
Record<number, number | null>
116117
>({});
117118
const [milestones, setMilestones] = useState<number[] | null>(null);
119+
const [badgeUris, setBadgeUris] = useState<Record<number, string | null>>({});
118120
const [adminOpen, setAdminOpen] = useState<boolean>(false);
119121
const [adminUnlocked, setAdminUnlocked] = useState<boolean>(false);
120122
const [adminTapCount, setAdminTapCount] = useState<number>(0);
@@ -131,6 +133,8 @@ export default function ClientPage() {
131133
const [collectiblesStatus, setCollectiblesStatus] = useState<
132134
"idle" | "loading" | "loaded" | "error"
133135
>("idle");
136+
const [infernoFeeUstx, setInfernoFeeUstx] = useState<number | null>(null);
137+
const [infernoUri, setInfernoUri] = useState<string | null>(null);
134138
const [nftOverrides, setNftOverrides] = useState<NftOverrides>({
135139
byTokenId: {},
136140
byKind: {},
@@ -346,10 +350,20 @@ export default function ClientPage() {
346350
);
347351

348352
const badgeKinds = new Set<number>(BADGE_MILESTONES.map((m) => m.kind));
353+
const badgeTokenIdSet = new Set<number>(
354+
Object.values(badgeTokenIds)
355+
.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
356+
);
357+
const badgeUriSet = new Set<string>(
358+
Object.values(badgeUris)
359+
.filter((v): v is string => typeof v === "string" && v.length > 0)
360+
);
349361

350362
const collectibleItems: OwnedCollectible[] = [];
351363
for (const info of tokenInfo) {
364+
if (badgeTokenIdSet.has(info.tokenId)) continue;
352365
if (info.kind !== null && badgeKinds.has(info.kind)) continue;
366+
if (info.metadataUri && badgeUriSet.has(info.metadataUri)) continue;
353367
if (info.kind === null) {
354368
// If we can't resolve kind, treat it as non-badge but keep it safe in UI.
355369
}
@@ -420,7 +434,7 @@ export default function ClientPage() {
420434
setCollectiblesStatus("error");
421435
}
422436
},
423-
[stacksApiBase, getOverrideForToken, resolveLocalNftImage]
437+
[stacksApiBase, getOverrideForToken, resolveLocalNftImage, badgeTokenIds, badgeUris]
424438
);
425439

426440
useEffect(() => {
@@ -678,14 +692,32 @@ export default function ClientPage() {
678692

679693
try {
680694
const senderAddress = sender;
681-
const milestonesCV = await fetchCallReadOnlyFunction({
682-
contractAddress: CONTRACT_ADDRESS,
683-
contractName: CONTRACT_NAME,
684-
functionName: "get-milestones",
685-
functionArgs: [],
686-
network: STACKS_NETWORK_OBJ,
687-
senderAddress,
688-
});
695+
const [milestonesCV, infernoFeeCV, infernoUriCV] = await Promise.all([
696+
fetchCallReadOnlyFunction({
697+
contractAddress: CONTRACT_ADDRESS,
698+
contractName: CONTRACT_NAME,
699+
functionName: "get-milestones",
700+
functionArgs: [],
701+
network: STACKS_NETWORK_OBJ,
702+
senderAddress,
703+
}),
704+
fetchCallReadOnlyFunction({
705+
contractAddress: CONTRACT_ADDRESS,
706+
contractName: CONTRACT_NAME,
707+
functionName: "get-mint-fee-kind",
708+
functionArgs: [uintCV(INFERNO_PULSE.kind)],
709+
network: STACKS_NETWORK_OBJ,
710+
senderAddress,
711+
}),
712+
fetchCallReadOnlyFunction({
713+
contractAddress: CONTRACT_ADDRESS,
714+
contractName: CONTRACT_NAME,
715+
functionName: "get-badge-uri",
716+
functionArgs: [uintCV(INFERNO_PULSE.kind)],
717+
network: STACKS_NETWORK_OBJ,
718+
senderAddress,
719+
}),
720+
]);
689721

690722
const ms = cvToValue(milestonesCV) as unknown;
691723

@@ -699,6 +731,43 @@ export default function ClientPage() {
699731
setMilestones(null);
700732
}
701733

734+
const feeUnwrapped = unwrapCvToValue(cvToValue(infernoFeeCV) as unknown);
735+
const fee =
736+
feeUnwrapped === null
737+
? null
738+
: typeof feeUnwrapped === "bigint"
739+
? Number(feeUnwrapped)
740+
: typeof feeUnwrapped === "number"
741+
? feeUnwrapped
742+
: null;
743+
setInfernoFeeUstx(fee);
744+
745+
const uriUnwrapped = unwrapCvToValue(cvToValue(infernoUriCV) as unknown);
746+
setInfernoUri(typeof uriUnwrapped === "string" ? uriUnwrapped : null);
747+
748+
try {
749+
const kindsToLoad = BADGE_MILESTONES.map((m) => m.kind);
750+
const uriPairs = await Promise.all(
751+
kindsToLoad.map(async (kind) => {
752+
const v = await fetchCallReadOnlyFunction({
753+
contractAddress: CONTRACT_ADDRESS,
754+
contractName: CONTRACT_NAME,
755+
functionName: "get-badge-uri",
756+
functionArgs: [uintCV(kind)],
757+
network: STACKS_NETWORK_OBJ,
758+
senderAddress,
759+
});
760+
const u = unwrapCvToValue(cvToValue(v) as unknown);
761+
return [kind, typeof u === "string" ? u : null] as const;
762+
})
763+
);
764+
const nextMap: Record<number, string | null> = {};
765+
for (const [k, u] of uriPairs) nextMap[k] = u;
766+
setBadgeUris(nextMap);
767+
} catch {
768+
// ignore
769+
}
770+
702771
} catch {
703772
// Older contracts might not expose these admin read-only endpoints.
704773
setMilestones(null);
@@ -1011,6 +1080,38 @@ export default function ClientPage() {
10111080
}
10121081
};
10131082

1083+
const mintInfernoPulse = async () => {
1084+
if (!address) {
1085+
setError("Connect wallet first.");
1086+
return;
1087+
}
1088+
1089+
setError("");
1090+
setStatus("Minting Inferno Pulse...");
1091+
1092+
try {
1093+
openContractCall({
1094+
contractAddress: CONTRACT_ADDRESS,
1095+
contractName: CONTRACT_NAME,
1096+
functionName: "mint-paid-kind",
1097+
functionArgs: [uintCV(INFERNO_PULSE.kind)],
1098+
network: STACKS_NETWORK_OBJ,
1099+
appDetails: {
1100+
name: APP_NAME,
1101+
icon: new URL(APP_ICON_PATH, window.location.origin).toString(),
1102+
},
1103+
onFinish: () => {
1104+
setStatus("Mint submitted");
1105+
scheduleRefresh(address);
1106+
},
1107+
onCancel: () => setStatus("Mint cancelled"),
1108+
});
1109+
} catch {
1110+
setError("Failed to open mint transaction.");
1111+
setStatus("Mint failed");
1112+
}
1113+
};
1114+
10141115
return (
10151116
<div className={styles.page}>
10161117
<div className={styles.shell}>
@@ -1247,6 +1348,47 @@ export default function ClientPage() {
12471348
<span className={styles.pill}>Owned</span>
12481349
</div>
12491350
<div className={styles.stack}>
1351+
<div className={styles.dropCard}>
1352+
<div className={styles.dropLeft}>
1353+
<div className={styles.dropThumb}>
1354+
<Image
1355+
src={INFERNO_PULSE.localImagePath}
1356+
alt={INFERNO_PULSE.name}
1357+
width={96}
1358+
height={96}
1359+
unoptimized
1360+
style={{ width: "100%", height: "100%", objectFit: "contain" }}
1361+
/>
1362+
</div>
1363+
<div className={styles.dropMeta}>
1364+
<div className={styles.dropTitle}>{INFERNO_PULSE.name}</div>
1365+
<div className={styles.dropLine}>
1366+
Kind <code>u{INFERNO_PULSE.kind}</code>
1367+
{" · "}
1368+
{infernoFeeUstx === null
1369+
? "Price: —"
1370+
: `Price: ${(infernoFeeUstx / 1_000_000).toFixed(2)} STX`}
1371+
</div>
1372+
<div className={styles.dropLine}>
1373+
URI{" "}
1374+
<code>
1375+
{infernoUri ? infernoUri : "not set"}
1376+
</code>
1377+
</div>
1378+
</div>
1379+
</div>
1380+
<div className={styles.dropRight}>
1381+
<button
1382+
className={styles.button}
1383+
onClick={mintInfernoPulse}
1384+
disabled={!address || !infernoUri}
1385+
type="button"
1386+
>
1387+
Mint
1388+
</button>
1389+
</div>
1390+
</div>
1391+
12501392
{!address ? (
12511393
<div className={styles.emptyState}>
12521394
<div className={styles.emptyTitle}>Connect to view NFTs.</div>

app/page.module.css

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,77 @@
322322
gap: 12px;
323323
}
324324

325+
.dropCard {
326+
display: flex;
327+
justify-content: space-between;
328+
gap: 12px;
329+
padding: 12px;
330+
border-radius: 18px;
331+
border: 1px solid rgba(255, 255, 255, 0.12);
332+
background: rgba(255, 255, 255, 0.06);
333+
}
334+
335+
[data-theme="light"] .dropCard {
336+
border-color: rgba(0, 0, 0, 0.12);
337+
background: rgba(0, 0, 0, 0.02);
338+
}
339+
340+
.dropLeft {
341+
display: flex;
342+
gap: 12px;
343+
align-items: center;
344+
min-width: 0;
345+
}
346+
347+
.dropRight {
348+
display: flex;
349+
align-items: center;
350+
}
351+
352+
.dropThumb {
353+
width: 72px;
354+
height: 72px;
355+
border-radius: 18px;
356+
border: 1px solid rgba(255, 255, 255, 0.14);
357+
background: rgba(255, 255, 255, 0.06);
358+
overflow: hidden;
359+
display: grid;
360+
place-items: center;
361+
}
362+
363+
[data-theme="light"] .dropThumb {
364+
border-color: rgba(0, 0, 0, 0.12);
365+
background: rgba(0, 0, 0, 0.03);
366+
}
367+
368+
.dropMeta {
369+
display: flex;
370+
flex-direction: column;
371+
gap: 6px;
372+
min-width: 0;
373+
}
374+
375+
.dropTitle {
376+
font-size: 14px;
377+
font-weight: 800;
378+
color: var(--text);
379+
}
380+
381+
.dropLine {
382+
font-size: 12px;
383+
color: var(--muted);
384+
line-height: 1.4;
385+
}
386+
387+
.dropLine code {
388+
white-space: nowrap;
389+
overflow: hidden;
390+
text-overflow: ellipsis;
391+
max-width: 100%;
392+
display: inline-block;
393+
vertical-align: bottom;
394+
}
395+
325396
.badgeCard {
326397
display: flex;
327398
gap: 12px;

0 commit comments

Comments
 (0)