@@ -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+
5065export 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 >
0 commit comments