Skip to content

Commit b2a7c2a

Browse files
committed
feat: show owned NFTs in app
1 parent 8a9dd70 commit b2a7c2a

3 files changed

Lines changed: 266 additions & 12 deletions

File tree

app/ClientPage.tsx

Lines changed: 238 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ const BADGE_ASSETS: Record<number, string> = {
4747
30: "/badges/30-day-streak.png",
4848
};
4949

50+
type OwnedCollectible = {
51+
tokenId: number;
52+
kind: number | null;
53+
name: string;
54+
imageUrl: string | null;
55+
metadataUri: string | null;
56+
};
57+
58+
const ipfsToHttps = (uri: string) => {
59+
if (uri.startsWith("ipfs://")) {
60+
return `https://ipfs.io/ipfs/${uri.slice("ipfs://".length)}`;
61+
}
62+
return uri;
63+
};
64+
5065
export default function ClientPage() {
5166
const [walletAddress, setWalletAddress] = useState<string>("");
5267
const [status, setStatus] = useState<string>("Not connected");
@@ -73,6 +88,10 @@ export default function ClientPage() {
7388
const [adminBadgeUri, setAdminBadgeUri] = useState<string>("");
7489
const [isLoading, setIsLoading] = useState<boolean>(false);
7590
const [theme, setTheme] = useState<"dark" | "light">("dark");
91+
const [collectibles, setCollectibles] = useState<OwnedCollectible[]>([]);
92+
const [collectiblesStatus, setCollectiblesStatus] = useState<
93+
"idle" | "loading" | "loaded" | "error"
94+
>("idle");
7695

7796
useEffect(() => {
7897
document.documentElement.dataset.theme = theme;
@@ -125,6 +144,148 @@ export default function ClientPage() {
125144
? "https://api.mainnet.hiro.so"
126145
: "https://api.testnet.hiro.so";
127146

147+
const loadOwnedCollectibles = useCallback(
148+
async (principal: string) => {
149+
if (!principal) {
150+
setCollectibles([]);
151+
setCollectiblesStatus("idle");
152+
return;
153+
}
154+
155+
const assetIdentifier = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}::badge`;
156+
setCollectiblesStatus("loading");
157+
158+
try {
159+
const res = await fetch(
160+
`${stacksApiBase}/extended/v1/tokens/nft/holdings?principal=${encodeURIComponent(
161+
principal
162+
)}&limit=200&offset=0`
163+
);
164+
165+
if (!res.ok) throw new Error("holdings fetch failed");
166+
167+
const body: unknown = await res.json();
168+
const results = Array.isArray((body as { results?: unknown }).results)
169+
? ((body as { results: unknown[] }).results as unknown[])
170+
: [];
171+
172+
const tokenIds: number[] = [];
173+
for (const row of results) {
174+
const r = row as {
175+
asset_identifier?: unknown;
176+
value?: unknown;
177+
token_id?: unknown;
178+
};
179+
180+
if (r.asset_identifier !== assetIdentifier) continue;
181+
182+
const v = r.value ?? r.token_id;
183+
let tokenId: number | null = null;
184+
185+
if (typeof v === "string") {
186+
const m = v.match(/^u(\d+)$/);
187+
if (m?.[1]) tokenId = Number(m[1]);
188+
else if (/^\d+$/.test(v)) tokenId = Number(v);
189+
} else if (typeof v === "object" && v !== null && "hex" in v) {
190+
const hex = (v as { hex?: unknown }).hex;
191+
if (typeof hex === "string") {
192+
const cv = hexToCV(hex);
193+
const val = cvToValue(cv) as unknown;
194+
if (typeof val === "bigint") tokenId = Number(val);
195+
else if (typeof val === "number") tokenId = val;
196+
}
197+
}
198+
199+
if (tokenId && Number.isFinite(tokenId)) tokenIds.push(tokenId);
200+
}
201+
202+
if (tokenIds.length === 0) {
203+
setCollectibles([]);
204+
setCollectiblesStatus("loaded");
205+
return;
206+
}
207+
208+
const tokenInfo = await Promise.all(
209+
tokenIds.map(async (tokenId) => {
210+
const [kindCV, uriCV] = await Promise.all([
211+
fetchCallReadOnlyFunction({
212+
contractAddress: CONTRACT_ADDRESS,
213+
contractName: CONTRACT_NAME,
214+
functionName: "get-badge-kind",
215+
functionArgs: [uintCV(tokenId)],
216+
network: STACKS_NETWORK_OBJ,
217+
senderAddress: principal,
218+
}),
219+
fetchCallReadOnlyFunction({
220+
contractAddress: CONTRACT_ADDRESS,
221+
contractName: CONTRACT_NAME,
222+
functionName: "get-token-uri",
223+
functionArgs: [uintCV(tokenId)],
224+
network: STACKS_NETWORK_OBJ,
225+
senderAddress: principal,
226+
}),
227+
]);
228+
229+
const kindVal = cvToValue(kindCV) as unknown;
230+
const kind =
231+
kindVal === null
232+
? null
233+
: typeof kindVal === "bigint"
234+
? Number(kindVal)
235+
: Number(kindVal);
236+
237+
const uriVal = cvToValue(uriCV) as unknown;
238+
const metadataUri = typeof uriVal === "string" ? uriVal : null;
239+
240+
return { tokenId, kind, metadataUri };
241+
})
242+
);
243+
244+
const badgeKinds = new Set<number>(BADGE_MILESTONES.map((m) => m.kind));
245+
246+
const collectibleItems: OwnedCollectible[] = [];
247+
for (const info of tokenInfo) {
248+
if (info.kind !== null && badgeKinds.has(info.kind)) continue;
249+
250+
let name = info.kind === null ? `Token #${info.tokenId}` : `Kind ${info.kind}`;
251+
let imageUrl: string | null = null;
252+
253+
if (info.metadataUri) {
254+
try {
255+
const mRes = await fetch(ipfsToHttps(info.metadataUri));
256+
if (mRes.ok) {
257+
const meta: unknown = await mRes.json();
258+
const metaObj = meta as { name?: unknown; image?: unknown };
259+
if (typeof metaObj.name === "string") name = metaObj.name;
260+
if (typeof metaObj.image === "string") {
261+
imageUrl = ipfsToHttps(metaObj.image);
262+
}
263+
}
264+
} catch {
265+
// ignore metadata failures
266+
}
267+
}
268+
269+
collectibleItems.push({
270+
tokenId: info.tokenId,
271+
kind: info.kind,
272+
name,
273+
imageUrl,
274+
metadataUri: info.metadataUri,
275+
});
276+
}
277+
278+
collectibleItems.sort((a, b) => b.tokenId - a.tokenId);
279+
setCollectibles(collectibleItems);
280+
setCollectiblesStatus("loaded");
281+
} catch {
282+
setCollectibles([]);
283+
setCollectiblesStatus("error");
284+
}
285+
},
286+
[stacksApiBase]
287+
);
288+
128289
useEffect(() => {
129290
let cancelled = false;
130291

@@ -406,13 +567,20 @@ export default function ClientPage() {
406567
setMilestones(null);
407568
}
408569

570+
if (senderOverride || address) {
571+
await loadOwnedCollectibles(sender);
572+
} else {
573+
setCollectibles([]);
574+
setCollectiblesStatus("idle");
575+
}
576+
409577
setStatus("On-chain data refreshed");
410578
} catch {
411579
setError("Failed to fetch on-chain data.");
412580
} finally {
413581
setIsLoading(false);
414582
}
415-
}, [address]);
583+
}, [address, loadOwnedCollectibles]);
416584

417585
const scheduleRefresh = useCallback(
418586
(senderOverride?: string) => {
@@ -887,22 +1055,80 @@ export default function ClientPage() {
8871055
<div className={styles.panelTitleBlock}>
8881056
<h2>NFTs</h2>
8891057
<div className={styles.panelSubtitle}>
890-
Creator drops and paid mints will live here.
1058+
Your owned collectibles.
8911059
</div>
8921060
</div>
893-
<span className={styles.pill}>Soon</span>
1061+
<span className={styles.pill}>Owned</span>
8941062
</div>
8951063
<div className={styles.stack}>
896-
<div className={styles.emptyState}>
897-
<div className={styles.emptyTitle}>No drops yet.</div>
898-
<div className={styles.emptyBody}>
899-
We’ll add paid collectibles in a separate, cleaner flow.
1064+
{!address ? (
1065+
<div className={styles.emptyState}>
1066+
<div className={styles.emptyTitle}>Connect to view NFTs.</div>
1067+
<div className={styles.emptyBody}>
1068+
We’ll show any collectibles you own from this contract.
1069+
</div>
9001070
</div>
901-
</div>
902-
<div className={styles.footnote}>
903-
Tip: the admin panel is hidden behind <strong>7 taps</strong> on the
904-
logo (owner-only).
905-
</div>
1071+
) : collectiblesStatus === "loading" ? (
1072+
<div className={styles.emptyState}>
1073+
<div className={styles.emptyTitle}>Loading NFTs…</div>
1074+
<div className={styles.emptyBody}>Fetching your holdings.</div>
1075+
</div>
1076+
) : collectiblesStatus === "error" ? (
1077+
<div className={styles.emptyState}>
1078+
<div className={styles.emptyTitle}>Couldn’t load NFTs.</div>
1079+
<div className={styles.emptyBody}>
1080+
Try “Refresh On-Chain” again in a moment.
1081+
</div>
1082+
</div>
1083+
) : collectibles.length === 0 ? (
1084+
<div className={styles.emptyState}>
1085+
<div className={styles.emptyTitle}>No NFTs yet.</div>
1086+
<div className={styles.emptyBody}>
1087+
When you mint paid collectibles, they’ll appear here.
1088+
</div>
1089+
</div>
1090+
) : (
1091+
<div className={styles.badgeGrid}>
1092+
{collectibles.map((nft) => (
1093+
<div key={nft.tokenId} className={styles.badgeCard}>
1094+
<div className={styles.badgeThumb}>
1095+
{nft.imageUrl ? (
1096+
<Image
1097+
src={nft.imageUrl}
1098+
alt={nft.name}
1099+
width={112}
1100+
height={112}
1101+
unoptimized
1102+
style={{ width: "100%", height: "100%", objectFit: "contain" }}
1103+
/>
1104+
) : (
1105+
<div className={styles.thumbPlaceholder} />
1106+
)}
1107+
</div>
1108+
<div className={styles.badgeMeta}>
1109+
<div className={styles.badgeTitle}>
1110+
<strong>{nft.name}</strong>
1111+
</div>
1112+
<div className={styles.badgeLine}>
1113+
Token <code>u{nft.tokenId}</code>
1114+
{nft.kind !== null ? (
1115+
<>
1116+
{" · "}Kind <code>u{nft.kind}</code>
1117+
</>
1118+
) : null}
1119+
</div>
1120+
{nft.metadataUri ? (
1121+
<div className={styles.badgeLine}>
1122+
URI <code>{nft.metadataUri}</code>
1123+
</div>
1124+
) : (
1125+
<div className={styles.badgeLine}>URI not set</div>
1126+
)}
1127+
</div>
1128+
</div>
1129+
))}
1130+
</div>
1131+
)}
9061132
</div>
9071133
</div>
9081134
</section>

app/page.module.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,27 @@
494494
overflow: hidden;
495495
}
496496

497+
[data-theme="light"] .badgeThumb {
498+
border-color: rgba(0, 0, 0, 0.12);
499+
background: rgba(0, 0, 0, 0.03);
500+
}
501+
502+
.thumbPlaceholder {
503+
width: 100%;
504+
height: 100%;
505+
background:
506+
radial-gradient(40px 40px at 30% 30%, rgba(122, 255, 214, 0.25), transparent 60%),
507+
radial-gradient(46px 46px at 70% 70%, rgba(122, 255, 214, 0.12), transparent 62%),
508+
linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.02));
509+
}
510+
511+
[data-theme="light"] .thumbPlaceholder {
512+
background:
513+
radial-gradient(40px 40px at 30% 30%, rgba(16, 167, 126, 0.22), transparent 60%),
514+
radial-gradient(46px 46px at 70% 70%, rgba(16, 167, 126, 0.12), transparent 62%),
515+
linear-gradient(135deg, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.01));
516+
}
517+
497518
.badge {
498519
padding: 10px 14px;
499520
border-radius: 16px;

next.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ const nextConfig: NextConfig = {
44
output: "export",
55
images: {
66
unoptimized: true,
7+
remotePatterns: [
8+
{
9+
protocol: "https",
10+
hostname: "ipfs.io",
11+
pathname: "/ipfs/**",
12+
},
13+
],
714
},
815
};
916

0 commit comments

Comments
 (0)