[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6FXGGTRA74TNTYPNTD2VVJ6":3,"skill-same:01M6FXGGTRA74TNTYPNTD2VVJ6":58},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":15,"categories":16,"keywords":22,"publishedAt":32,"linkOnly":33,"markdownContent":34,"markdownBody":35,"frontMatter":36,"refName":37,"commitSha":38,"skillDirectory":39,"forksCount":40,"repositoryHtmlUrl":41,"repositoryHomepageUrl":6,"ownerHtmlUrl":42,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":43,"install":44,"resources":57},"01M6FXGGTRA74TNTYPNTD2VVJ6","jb-nft-gallery-ui",null,"Interactive gallery for browsing and managing NFTs from Juicebox 721 hooks. Displays tier information, owned NFTs, and minting interfaces.","kbarbel640-del\u002Fskills","kbarbel640-del","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F255315490?v=4","01M6CJPPQGCCESXMG3K7BBGQ5J","01M6CJPPX2PB2BCZERFVM2C2B3",1,"MIT","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fmejango\u002Fjuicy\u002Fjb-nft-gallery-ui\u002FSKILL.md",[17],{"categoryNo":18,"categoryName":19,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000032","设计师","PROFESSION","适用职业",[23,24,25,26,27,28,29,30,31],"ui","nft","gallery","jb nft gallery ui","interactive","minting","hooks","nfts","information","2026-11-10 10:12:30",false,"---\nname: jb-nft-gallery-ui\ndescription: Interactive gallery for browsing and managing NFTs from Juicebox 721 hooks. Displays tier information, owned NFTs, and minting interfaces.\n---\n\n# Juicebox V5 NFT Gallery UI\n\nInteractive gallery for browsing and managing NFTs from Juicebox 721 hooks. Displays tier information, owned NFTs, and minting interfaces.\n\n## Overview\n\nThis skill generates vanilla JS\u002FHTML interfaces for:\n- Browsing all NFT tiers for a project\n- Viewing owned NFTs by wallet\n- Minting NFTs from available tiers\n- Tier metadata and supply tracking\n- NFT transfer functionality\n\n## NFT Gallery UI Template\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"en\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  \u003Ctitle>Juicebox NFT Gallery\u003C\u002Ftitle>\n  \u003Clink rel=\"stylesheet\" href=\"\u002Fshared\u002Fstyles.css\">\n  \u003Cstyle>\n    \u002F* NFT-specific styles *\u002F\n    .gallery-grid {\n      display: grid;\n      grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n      gap: 20px;\n    }\n    .nft-card {\n      background: var(--bg-secondary);\n      border-radius: 12px;\n      overflow: hidden;\n      cursor: pointer;\n      transition: transform 0.2s, box-shadow 0.2s;\n    }\n    .nft-card:hover {\n      transform: translateY(-5px);\n      box-shadow: 0 10px 30px rgba(0,0,0,0.3);\n    }\n    .nft-image {\n      width: 100%;\n      aspect-ratio: 1;\n      background: var(--bg-tertiary);\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      position: relative;\n    }\n    .nft-image img { width: 100%; height: 100%; object-fit: cover; }\n    .nft-image .placeholder { font-size: 48px; color: #444; }\n    .tier-badge, .supply-badge, .owned-badge {\n      position: absolute;\n      padding: 4px 10px;\n      border-radius: 20px;\n      font-size: 12px;\n      font-weight: 600;\n    }\n    .tier-badge { top: 10px; left: 10px; background: rgba(0,0,0,0.7); color: var(--jb-yellow); }\n    .supply-badge { top: 10px; right: 10px; background: rgba(0,0,0,0.7); color: var(--success); }\n    .supply-badge.sold-out { color: var(--error); }\n    .owned-badge { bottom: 10px; left: 10px; background: var(--success); color: #000; }\n    .nft-info { padding: 15px; }\n    .nft-name { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); margin-bottom: 5px; }\n    .nft-description {\n      font-size: 13px; color: var(--text-muted); margin-bottom: 10px;\n      line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2;\n      -webkit-box-orient: vertical; overflow: hidden;\n    }\n    .nft-meta {\n      display: flex; justify-content: space-between; align-items: center;\n      padding-top: 10px; border-top: 1px solid var(--border-color);\n    }\n    .nft-price { font-size: 1.1rem; font-weight: 700; color: var(--jb-yellow); }\n    .nft-votes { font-size: 12px; color: var(--text-muted); }\n    .mint-btn {\n      width: 100%; padding: 12px; background: var(--jb-yellow); color: #000;\n      border: none; border-radius: 8px; font-weight: 600; cursor: pointer; margin-top: 10px;\n    }\n    .mint-btn:hover { background: var(--jb-yellow-hover); }\n    .mint-btn:disabled { background: var(--bg-tertiary); color: var(--text-muted); cursor: not-allowed; }\n    .category-filter { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }\n    .category-chip {\n      padding: 6px 14px; background: var(--bg-tertiary); border-radius: 20px;\n      font-size: 13px; cursor: pointer; color: var(--text-muted);\n    }\n    .category-chip.active { background: var(--jb-yellow); color: #000; }\n    .modal {\n      display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;\n      background: rgba(0,0,0,0.9); z-index: 1000; padding: 20px; overflow-y: auto;\n    }\n    .modal.visible { display: flex; justify-content: center; align-items: flex-start; }\n    .modal-content { background: var(--bg-secondary); border-radius: 12px; max-width: 600px; width: 100%; margin-top: 40px; }\n    .modal-image { width: 100%; aspect-ratio: 1; background: var(--bg-tertiary); }\n    .modal-image img { width: 100%; height: 100%; object-fit: contain; }\n    .modal-info { padding: 20px; }\n    .modal-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; }\n    .modal-title { font-size: 1.5rem; font-weight: 700; color: var(--text-primary); }\n    .close-btn { background: none; border: none; color: var(--text-muted); font-size: 28px; cursor: pointer; }\n    .modal-description { color: var(--text-muted); line-height: 1.6; margin-bottom: 20px; }\n    .modal-attributes { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 20px; }\n    .attribute { background: var(--bg-tertiary); padding: 12px; border-radius: 8px; }\n    .attribute-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; }\n    .attribute-value { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-top: 4px; }\n    .action-buttons { display: flex; gap: 10px; }\n    .action-btn { flex: 1; padding: 14px; border-radius: 8px; font-weight: 600; cursor: pointer; border: none; }\n    .action-btn.primary { background: var(--jb-yellow); color: #000; }\n    .action-btn.secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color); }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Cdiv class=\"container\">\n    \u003Ch1>NFT Gallery\u003C\u002Fh1>\n\n    \u003Cdiv class=\"card\" style=\"margin-bottom: 20px;\">\n      \u003Cdiv class=\"input-row\">\n        \u003Cinput type=\"text\" id=\"hookAddress\" placeholder=\"721 Hook Address (0x...)\">\n        \u003Cselect id=\"chainSelect\">\n          \u003Coption value=\"1\">Ethereum\u003C\u002Foption>\n          \u003Coption value=\"10\">Optimism\u003C\u002Foption>\n          \u003Coption value=\"8453\">Base\u003C\u002Foption>\n          \u003Coption value=\"42161\">Arbitrum\u003C\u002Foption>\n          \u003Coption value=\"11155111\">Sepolia\u003C\u002Foption>\n        \u003C\u002Fselect>\n        \u003Cbutton class=\"btn\" onclick=\"loadGallery()\">Load Gallery\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"card\" id=\"walletSection\" style=\"display: none; margin-bottom: 20px;\">\n      \u003Cdiv style=\"display: flex; justify-content: space-between; align-items: center;\">\n        \u003Cdiv>\n          \u003Cspan id=\"walletStatus\">Not connected\u003C\u002Fspan>\n          \u003Cspan class=\"code\" id=\"walletAddress\" style=\"margin-left: 10px;\">\u003C\u002Fspan>\n        \u003C\u002Fdiv>\n        \u003Cbutton class=\"btn-secondary\" id=\"connectBtn\" onclick=\"connectWallet()\">Connect Wallet\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"stats\" id=\"statsBar\" style=\"display: none; margin-bottom: 20px;\">\n      \u003Cdiv class=\"stat-card\">\n        \u003Cdiv class=\"stat-value\" id=\"totalTiers\">-\u003C\u002Fdiv>\n        \u003Cdiv class=\"stat-label\">Total Tiers\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"stat-card\">\n        \u003Cdiv class=\"stat-value\" id=\"totalMinted\">-\u003C\u002Fdiv>\n        \u003Cdiv class=\"stat-label\">Total Minted\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"stat-card\">\n        \u003Cdiv class=\"stat-value\" id=\"totalSupply\">-\u003C\u002Fdiv>\n        \u003Cdiv class=\"stat-label\">Max Supply\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"stat-card\">\n        \u003Cdiv class=\"stat-value\" id=\"floorPrice\">-\u003C\u002Fdiv>\n        \u003Cdiv class=\"stat-label\">Floor Price\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"tabs\" id=\"tabsContainer\" style=\"display: none;\">\n      \u003Cdiv class=\"tab active\" data-tab=\"tiers\" onclick=\"switchTab('tiers')\">All Tiers\u003C\u002Fdiv>\n      \u003Cdiv class=\"tab\" data-tab=\"owned\" onclick=\"switchTab('owned')\">My NFTs\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"category-filter\" id=\"categoryFilter\">\u003C\u002Fdiv>\n\n    \u003Cdiv class=\"filter-bar\" id=\"filterBar\" style=\"display: none; margin-bottom: 20px;\">\n      \u003Cbutton class=\"tab active\" data-filter=\"all\" onclick=\"filterTiers('all')\">All\u003C\u002Fbutton>\n      \u003Cbutton class=\"tab\" data-filter=\"available\" onclick=\"filterTiers('available')\">Available\u003C\u002Fbutton>\n      \u003Cbutton class=\"tab\" data-filter=\"sold-out\" onclick=\"filterTiers('sold-out')\">Sold Out\u003C\u002Fbutton>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv id=\"galleryContainer\">\u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"modal\" id=\"nftModal\">\n    \u003Cdiv class=\"modal-content\">\n      \u003Cdiv class=\"modal-image\" id=\"modalImage\">\u003C\u002Fdiv>\n      \u003Cdiv class=\"modal-info\">\n        \u003Cdiv class=\"modal-header\">\n          \u003Cdiv>\n            \u003Cdiv class=\"modal-title\" id=\"modalTitle\">-\u003C\u002Fdiv>\n            \u003Cspan class=\"code\" id=\"modalTokenId\">\u003C\u002Fspan>\n          \u003C\u002Fdiv>\n          \u003Cbutton class=\"close-btn\" onclick=\"closeModal()\">&times;\u003C\u002Fbutton>\n        \u003C\u002Fdiv>\n        \u003Cdiv class=\"modal-description\" id=\"modalDescription\">\u003C\u002Fdiv>\n        \u003Cdiv class=\"modal-attributes\" id=\"modalAttributes\">\u003C\u002Fdiv>\n        \u003Cdiv class=\"action-buttons\" id=\"modalActions\">\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cscript type=\"module\">\n    import { createPublicClient, http, getContract, formatEther, isAddress, createWalletClient, custom } from 'https:\u002F\u002Fesm.sh\u002Fviem';\n    import { mainnet, optimism, base, arbitrum, sepolia } from 'https:\u002F\u002Fesm.sh\u002Fviem\u002Fchains';\n    import { CHAIN_CONFIGS, truncateAddress } from '\u002Fshared\u002Fwallet-utils.js';\n\n    const CHAINS = { 1: mainnet, 10: optimism, 8453: base, 42161: arbitrum, 11155111: sepolia };\n\n    const HOOK_ABI = [\n      { name: 'STORE', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },\n      { name: 'PROJECT_ID', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },\n      { name: 'name', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },\n      { name: 'symbol', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },\n      { name: 'tokenURI', type: 'function', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'string' }] },\n      { name: 'balanceOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ type: 'uint256' }] },\n      { name: 'tokenOfOwnerByIndex', type: 'function', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'index', type: 'uint256' }], outputs: [{ type: 'uint256' }] },\n      { name: 'ownerOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'address' }] },\n      { name: 'totalSupply', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },\n      { name: 'transferFrom', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }], outputs: [] }\n    ];\n\n    const STORE_ABI = [\n      { name: 'tiersOf', type: 'function', stateMutability: 'view',\n        inputs: [{ name: 'hook', type: 'address' }, { name: 'categories', type: 'uint256[]' }, { name: 'includeResolvedUri', type: 'bool' }, { name: 'startingId', type: 'uint256' }, { name: 'size', type: 'uint256' }],\n        outputs: [{ type: 'tuple[]', components: [\n          { name: 'id', type: 'uint256' }, { name: 'price', type: 'uint256' }, { name: 'remainingSupply', type: 'uint256' },\n          { name: 'initialSupply', type: 'uint256' }, { name: 'votingUnits', type: 'uint256' }, { name: 'reserveFrequency', type: 'uint256' },\n          { name: 'reserveBeneficiary', type: 'address' }, { name: 'encodedIPFSUri', type: 'bytes32' }, { name: 'category', type: 'uint256' },\n          { name: 'discountPercent', type: 'uint8' }, { name: 'allowOwnerMint', type: 'bool' }, { name: 'transfersPausable', type: 'bool' },\n          { name: 'useVotingUnits', type: 'bool' }, { name: 'cannotBeRemoved', type: 'bool' }, { name: 'resolvedUri', type: 'string' }\n        ]}]\n      },\n      { name: 'tierIdOfToken', type: 'function', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'uint256' }] }\n    ];\n\n    let publicClient = null;\n    let walletClient = null;\n    let hookContract = null;\n    let storeContract = null;\n    let allTiers = [];\n    let ownedNFTs = [];\n    let currentTab = 'tiers';\n    let currentFilter = 'all';\n    let selectedCategory = null;\n    let hookAddress = '';\n    let chainId = 1;\n    let connectedAddress = null;\n\n    window.loadGallery = async function() {\n      hookAddress = document.getElementById('hookAddress').value;\n      chainId = parseInt(document.getElementById('chainSelect').value);\n\n      if (!hookAddress || !isAddress(hookAddress)) {\n        alert('Please enter a valid hook address');\n        return;\n      }\n\n      const container = document.getElementById('galleryContainer');\n      container.innerHTML = '\u003Cdiv class=\"loading\">\u003Cdiv class=\"spinner\">\u003C\u002Fdiv>Loading NFT collection...\u003C\u002Fdiv>';\n\n      const chain = CHAINS[chainId];\n      const rpc = CHAIN_CONFIGS[chainId]?.rpc || chain.rpcUrls.default.http[0];\n\n      publicClient = createPublicClient({ chain, transport: http(rpc) });\n\n      try {\n        const storeAddr = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'STORE' });\n\n        allTiers = await publicClient.readContract({\n          address: storeAddr, abi: STORE_ABI, functionName: 'tiersOf',\n          args: [hookAddress, [], true, 0n, 100n]\n        });\n\n        let totalMinted = 0, totalMaxSupply = 0, floorPrice = 0n;\n        const categories = new Set();\n\n        allTiers.forEach(tier => {\n          const minted = Number(tier.initialSupply) - Number(tier.remainingSupply);\n          totalMinted += minted;\n          totalMaxSupply += Number(tier.initialSupply);\n          if (tier.category) categories.add(Number(tier.category));\n          if (tier.remainingSupply > 0n && (floorPrice === 0n || tier.price \u003C floorPrice)) {\n            floorPrice = tier.price;\n          }\n        });\n\n        document.getElementById('totalTiers').textContent = allTiers.length;\n        document.getElementById('totalMinted').textContent = totalMinted.toLocaleString();\n        document.getElementById('totalSupply').textContent = totalMaxSupply.toLocaleString();\n        document.getElementById('floorPrice').textContent = floorPrice > 0n ? formatEther(floorPrice) + ' ETH' : 'N\u002FA';\n\n        if (categories.size > 0) {\n          let categoryHtml = '\u003Cdiv class=\"category-chip active\" onclick=\"filterCategory(null)\">All\u003C\u002Fdiv>';\n          [...categories].sort((a, b) => a - b).forEach(cat => {\n            categoryHtml += `\u003Cdiv class=\"category-chip\" onclick=\"filterCategory(${cat})\">Category ${cat}\u003C\u002Fdiv>`;\n          });\n          document.getElementById('categoryFilter').innerHTML = categoryHtml;\n        }\n\n        document.getElementById('statsBar').style.display = 'grid';\n        document.getElementById('tabsContainer').style.display = 'flex';\n        document.getElementById('filterBar').style.display = 'flex';\n        document.getElementById('walletSection').style.display = 'flex';\n\n        renderTiers();\n      } catch (error) {\n        console.error(error);\n        container.innerHTML = `\u003Cdiv class=\"empty\">Error loading collection: ${error.message}\u003C\u002Fdiv>`;\n      }\n    };\n\n    window.connectWallet = async function() {\n      if (!window.ethereum) { alert('Please install a web3 wallet'); return; }\n\n      try {\n        const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' });\n        connectedAddress = address;\n\n        try {\n          await window.ethereum.request({\n            method: 'wallet_switchEthereumChain',\n            params: [{ chainId: '0x' + chainId.toString(16) }]\n          });\n        } catch (e) { console.log('Chain switch failed', e); }\n\n        walletClient = createWalletClient({ chain: CHAINS[chainId], transport: custom(window.ethereum) });\n\n        document.getElementById('walletStatus').textContent = 'Connected:';\n        document.getElementById('walletAddress').textContent = truncateAddress(address);\n        document.getElementById('connectBtn').textContent = 'Disconnect';\n        document.getElementById('connectBtn').onclick = disconnectWallet;\n\n        await loadOwnedNFTs(address);\n      } catch (error) {\n        console.error(error);\n        alert('Failed to connect wallet');\n      }\n    };\n\n    window.disconnectWallet = function() {\n      walletClient = null;\n      connectedAddress = null;\n      ownedNFTs = [];\n      document.getElementById('walletStatus').textContent = 'Not connected';\n      document.getElementById('walletAddress').textContent = '';\n      document.getElementById('connectBtn').textContent = 'Connect Wallet';\n      document.getElementById('connectBtn').onclick = connectWallet;\n      if (currentTab === 'owned') switchTab('tiers'); else renderTiers();\n    };\n\n    async function loadOwnedNFTs(ownerAddress) {\n      try {\n        const storeAddr = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'STORE' });\n        const balance = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'balanceOf', args: [ownerAddress] });\n\n        ownedNFTs = [];\n        for (let i = 0n; i \u003C balance; i++) {\n          const tokenId = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'tokenOfOwnerByIndex', args: [ownerAddress, i] });\n          const tierId = await publicClient.readContract({ address: storeAddr, abi: STORE_ABI, functionName: 'tierIdOfToken', args: [tokenId] });\n          const tier = allTiers.find(t => t.id.toString() === tierId.toString());\n\n          let metadata = {};\n          try {\n            const uri = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'tokenURI', args: [tokenId] });\n            if (uri) {\n              const metadataUrl = uri.startsWith('ipfs:\u002F\u002F') ? `https:\u002F\u002Fipfs.io\u002Fipfs\u002F${uri.slice(7)}` : uri;\n              const response = await fetch(metadataUrl);\n              metadata = await response.json();\n            }\n          } catch (e) { console.log('Failed to load metadata for token', tokenId.toString()); }\n\n          ownedNFTs.push({ tokenId: tokenId.toString(), tierId: tierId.toString(), tier, metadata });\n        }\n\n        if (currentTab === 'owned') renderOwnedNFTs(); else renderTiers();\n      } catch (error) { console.error('Failed to load owned NFTs:', error); }\n    }\n\n    window.switchTab = function(tab) {\n      currentTab = tab;\n      document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));\n      document.querySelector(`.tabs .tab[data-tab=\"${tab}\"]`).classList.add('active');\n\n      if (tab === 'tiers') {\n        document.getElementById('filterBar').style.display = 'flex';\n        renderTiers();\n      } else {\n        document.getElementById('filterBar').style.display = 'none';\n        renderOwnedNFTs();\n      }\n    };\n\n    window.filterTiers = function(filter) {\n      currentFilter = filter;\n      document.querySelectorAll('.filter-bar .tab').forEach(b => b.classList.remove('active'));\n      document.querySelector(`.filter-bar .tab[data-filter=\"${filter}\"]`).classList.add('active');\n      renderTiers();\n    };\n\n    window.filterCategory = function(category) {\n      selectedCategory = category;\n      document.querySelectorAll('.category-chip').forEach(c => c.classList.remove('active'));\n      if (category === null) {\n        document.querySelector('.category-chip').classList.add('active');\n      } else {\n        document.querySelectorAll('.category-chip').forEach(c => {\n          if (c.textContent === `Category ${category}`) c.classList.add('active');\n        });\n      }\n      renderTiers();\n    };\n\n    function renderTiers() {\n      const container = document.getElementById('galleryContainer');\n      let filteredTiers = [...allTiers];\n\n      if (selectedCategory !== null) filteredTiers = filteredTiers.filter(t => Number(t.category) === selectedCategory);\n      if (currentFilter === 'available') filteredTiers = filteredTiers.filter(t => t.remainingSupply > 0n);\n      else if (currentFilter === 'sold-out') filteredTiers = filteredTiers.filter(t => t.remainingSupply === 0n);\n\n      if (filteredTiers.length === 0) {\n        container.innerHTML = '\u003Cdiv class=\"empty\">No tiers match the current filter\u003C\u002Fdiv>';\n        return;\n      }\n\n      let html = '\u003Cdiv class=\"gallery-grid\">';\n      filteredTiers.forEach(tier => {\n        const minted = Number(tier.initialSupply) - Number(tier.remainingSupply);\n        const soldOut = tier.remainingSupply === 0n;\n        const price = formatEther(tier.price);\n        const owned = ownedNFTs.filter(nft => nft.tierId === tier.id.toString());\n\n        html += `\n          \u003Cdiv class=\"nft-card\" onclick=\"openTierModal(${tier.id})\">\n            \u003Cdiv class=\"nft-image\">\n              ${tier.resolvedUri ? `\u003Cimg src=\"${resolveUri(tier.resolvedUri)}\" alt=\"Tier ${tier.id}\" onerror=\"this.style.display='none';this.parentElement.innerHTML='\u003Cdiv class=\\\\'placeholder\\\\'>NFT\u003C\u002Fdiv>'\">` : '\u003Cdiv class=\"placeholder\">NFT\u003C\u002Fdiv>'}\n              \u003Cdiv class=\"tier-badge\">Tier ${tier.id}\u003C\u002Fdiv>\n              \u003Cdiv class=\"supply-badge ${soldOut ? 'sold-out' : ''}\">${minted}\u002F${tier.initialSupply}\u003C\u002Fdiv>\n              ${owned.length > 0 ? `\u003Cdiv class=\"owned-badge\">Owned: ${owned.length}\u003C\u002Fdiv>` : ''}\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"nft-info\">\n              \u003Cdiv class=\"nft-name\">Tier ${tier.id}\u003C\u002Fdiv>\n              \u003Cdiv class=\"nft-description\">Category ${tier.category || 0}\u003C\u002Fdiv>\n              \u003Cdiv class=\"nft-meta\">\n                \u003Cdiv class=\"nft-price\">${price} ETH\u003C\u002Fdiv>\n                ${tier.votingUnits > 0 ? `\u003Cdiv class=\"nft-votes\">${tier.votingUnits} votes\u003C\u002Fdiv>` : ''}\n              \u003C\u002Fdiv>\n              \u003Cbutton class=\"mint-btn\" ${soldOut ? 'disabled' : ''} onclick=\"event.stopPropagation(); mintTier(${tier.id}, '${tier.price}')\">\n                ${soldOut ? 'Sold Out' : 'Mint'}\n              \u003C\u002Fbutton>\n            \u003C\u002Fdiv>\n          \u003C\u002Fdiv>`;\n      });\n      container.innerHTML = html + '\u003C\u002Fdiv>';\n    }\n\n    function renderOwnedNFTs() {\n      const container = document.getElementById('galleryContainer');\n      if (!connectedAddress) {\n        container.innerHTML = '\u003Cdiv class=\"empty\">Connect your wallet to view owned NFTs\u003C\u002Fdiv>';\n        return;\n      }\n      if (ownedNFTs.length === 0) {\n        container.innerHTML = '\u003Cdiv class=\"empty\">You don\\'t own any NFTs from this collection\u003C\u002Fdiv>';\n        return;\n      }\n\n      let html = '\u003Cdiv class=\"gallery-grid\">';\n      ownedNFTs.forEach(nft => {\n        const tier = nft.tier;\n        const name = nft.metadata?.name || `Tier ${nft.tierId} #${nft.tokenId}`;\n        const image = nft.metadata?.image;\n\n        html += `\n          \u003Cdiv class=\"nft-card\" onclick=\"openNFTModal('${nft.tokenId}')\">\n            \u003Cdiv class=\"nft-image\">\n              ${image ? `\u003Cimg src=\"${resolveUri(image)}\" alt=\"${name}\" onerror=\"this.style.display='none';this.parentElement.innerHTML='\u003Cdiv class=\\\\'placeholder\\\\'>NFT\u003C\u002Fdiv>'\">` : '\u003Cdiv class=\"placeholder\">NFT\u003C\u002Fdiv>'}\n              \u003Cdiv class=\"tier-badge\">Tier ${nft.tierId}\u003C\u002Fdiv>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"nft-info\">\n              \u003Cdiv class=\"nft-name\">${name}\u003C\u002Fdiv>\n              \u003Cdiv class=\"nft-description\">Token ID: ${nft.tokenId}\u003C\u002Fdiv>\n              \u003Cdiv class=\"nft-meta\">\n                \u003Cdiv class=\"nft-price\">${tier ? formatEther(tier.price) + ' ETH' : '-'}\u003C\u002Fdiv>\n                ${tier?.votingUnits > 0 ? `\u003Cdiv class=\"nft-votes\">${tier.votingUnits} votes\u003C\u002Fdiv>` : ''}\n              \u003C\u002Fdiv>\n            \u003C\u002Fdiv>\n          \u003C\u002Fdiv>`;\n      });\n      container.innerHTML = html + '\u003C\u002Fdiv>';\n    }\n\n    window.openTierModal = async function(tierId) {\n      const tier = allTiers.find(t => t.id.toString() === tierId.toString());\n      if (!tier) return;\n\n      const minted = Number(tier.initialSupply) - Number(tier.remainingSupply);\n      const soldOut = tier.remainingSupply === 0n;\n      const price = formatEther(tier.price);\n\n      document.getElementById('modalTitle').textContent = `Tier ${tierId}`;\n      document.getElementById('modalTokenId').textContent = `Category ${tier.category || 0}`;\n      document.getElementById('modalDescription').textContent = tier.resolvedUri ? 'Tier metadata loaded from IPFS' : 'No metadata available';\n      document.getElementById('modalImage').innerHTML = tier.resolvedUri\n        ? `\u003Cimg src=\"${resolveUri(tier.resolvedUri)}\" alt=\"Tier ${tierId}\">`\n        : '\u003Cdiv style=\"display:flex;align-items:center;justify-content:center;height:100%;color:#444;font-size:64px;\">NFT\u003C\u002Fdiv>';\n\n      document.getElementById('modalAttributes').innerHTML = `\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Price\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${price} ETH\u003C\u002Fdiv>\u003C\u002Fdiv>\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Supply\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${minted} \u002F ${tier.initialSupply}\u003C\u002Fdiv>\u003C\u002Fdiv>\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Remaining\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${tier.remainingSupply.toString()}\u003C\u002Fdiv>\u003C\u002Fdiv>\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Voting Units\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${tier.votingUnits || 0}\u003C\u002Fdiv>\u003C\u002Fdiv>\n        ${tier.reserveFrequency > 0 ? `\u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Reserve Frequency\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">1 in ${tier.reserveFrequency}\u003C\u002Fdiv>\u003C\u002Fdiv>` : ''}`;\n\n      document.getElementById('modalActions').innerHTML = `\n        \u003Cbutton class=\"action-btn primary\" ${soldOut ? 'disabled' : ''} onclick=\"mintTier(${tierId}, '${tier.price}')\">\n          ${soldOut ? 'Sold Out' : `Mint for ${price} ETH`}\n        \u003C\u002Fbutton>`;\n\n      document.getElementById('nftModal').classList.add('visible');\n    };\n\n    window.openNFTModal = async function(tokenId) {\n      const nft = ownedNFTs.find(n => n.tokenId === tokenId);\n      if (!nft) return;\n\n      const name = nft.metadata?.name || `Tier ${nft.tierId} #${tokenId}`;\n      const description = nft.metadata?.description || 'No description available';\n      const image = nft.metadata?.image;\n\n      document.getElementById('modalTitle').textContent = name;\n      document.getElementById('modalTokenId').textContent = `Token #${tokenId}`;\n      document.getElementById('modalDescription').textContent = description;\n      document.getElementById('modalImage').innerHTML = image\n        ? `\u003Cimg src=\"${resolveUri(image)}\" alt=\"${name}\">`\n        : '\u003Cdiv style=\"display:flex;align-items:center;justify-content:center;height:100%;color:#444;font-size:64px;\">NFT\u003C\u002Fdiv>';\n\n      let attributesHtml = `\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Tier\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${nft.tierId}\u003C\u002Fdiv>\u003C\u002Fdiv>\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Token ID\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${tokenId}\u003C\u002Fdiv>\u003C\u002Fdiv>`;\n\n      if (nft.metadata?.attributes) {\n        nft.metadata.attributes.forEach(attr => {\n          attributesHtml += `\u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">${attr.trait_type}\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${attr.value}\u003C\u002Fdiv>\u003C\u002Fdiv>`;\n        });\n      }\n      document.getElementById('modalAttributes').innerHTML = attributesHtml;\n\n      document.getElementById('modalActions').innerHTML = `\n        \u003Cbutton class=\"action-btn secondary\" onclick=\"transferNFT('${tokenId}')\">Transfer\u003C\u002Fbutton>\n        \u003Cbutton class=\"action-btn secondary\" onclick=\"cashOutNFT('${tokenId}')\">Cash Out\u003C\u002Fbutton>`;\n\n      document.getElementById('nftModal').classList.add('visible');\n    };\n\n    window.closeModal = function() {\n      document.getElementById('nftModal').classList.remove('visible');\n    };\n\n    window.mintTier = async function(tierId, price) {\n      if (!connectedAddress) { alert('Please connect your wallet first'); return; }\n      try {\n        const projectId = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'PROJECT_ID' });\n        alert(`To mint from Tier ${tierId}:\\n\\n1. Go to the Juicebox app\\n2. Pay ${formatEther(BigInt(price))} ETH to Project ${projectId}\\n3. The NFT will be minted to your wallet`);\n      } catch (error) { console.error(error); alert('Minting failed: ' + error.message); }\n    };\n\n    window.transferNFT = async function(tokenId) {\n      if (!connectedAddress) { alert('Please connect your wallet first'); return; }\n      const recipient = prompt('Enter recipient address:');\n      if (!recipient || !isAddress(recipient)) { alert('Invalid address'); return; }\n\n      try {\n        const hash = await walletClient.writeContract({\n          address: hookAddress, abi: HOOK_ABI, functionName: 'transferFrom',\n          args: [connectedAddress, recipient, BigInt(tokenId)], account: connectedAddress\n        });\n        alert('Transaction submitted: ' + hash);\n        await publicClient.waitForTransactionReceipt({ hash });\n        alert('Transfer successful!');\n        await loadOwnedNFTs(connectedAddress);\n        closeModal();\n      } catch (error) { console.error(error); alert('Transfer failed: ' + error.message); }\n    };\n\n    window.cashOutNFT = function(tokenId) {\n      alert(`To cash out NFT #${tokenId}:\\n\\n1. Go to the Juicebox app\\n2. Use the cash out function with this token ID\\n3. The NFT will be burned and you'll receive your share of the treasury`);\n    };\n\n    function resolveUri(uri) {\n      if (!uri) return '';\n      if (uri.startsWith('ipfs:\u002F\u002F')) return `https:\u002F\u002Fipfs.io\u002Fipfs\u002F${uri.slice(7)}`;\n      if (uri.startsWith('data:')) return uri;\n      return uri;\n    }\n\n    document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });\n    document.getElementById('nftModal').addEventListener('click', (e) => { if (e.target.id === 'nftModal') closeModal(); });\n  \u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n```\n\n## Key Features\n\n### Tier Browsing\n- Grid layout with tier cards\n- Supply tracking (minted\u002Ftotal)\n- Price display in ETH\n- Category filtering\n- Availability filtering\n\n### Owned NFTs View\n- Wallet connection via viem\n- Display owned NFTs with metadata\n- Transfer functionality\n- Cash out guidance\n\n### Collection Stats\n- Total tiers\n- Total minted\n- Max supply\n- Floor price\n\n### NFT Details Modal\n- Full metadata display\n- Attribute listing\n- Action buttons (mint\u002Ftransfer\u002Fcash out)\n\n## Data Sources\n\nThe gallery uses on-chain data via:\n- `JB721TiersHook` - NFT contract\n- `JB721TiersHookStore` - Tier data storage\n- Token metadata from IPFS\n\n## Customization Points\n\n1. **Image handling**: Modify `resolveUri()` for different IPFS gateways\n2. **Metadata display**: Extend attributes rendering\n3. **Minting flow**: Integrate directly with terminal\n4. **Styling**: Customize via CSS variables\n\n## Integration Notes\n\n- Works with any V5 721 Hook deployment\n- Uses viem for all blockchain interactions\n- Shared styles from `\u002Fshared\u002Fstyles.css`\n- Chain config from `\u002Fshared\u002Fwallet-utils.js`\n","# Juicebox V5 NFT Gallery UI\n\nInteractive gallery for browsing and managing NFTs from Juicebox 721 hooks. Displays tier information, owned NFTs, and minting interfaces.\n\n## Overview\n\nThis skill generates vanilla JS\u002FHTML interfaces for:\n- Browsing all NFT tiers for a project\n- Viewing owned NFTs by wallet\n- Minting NFTs from available tiers\n- Tier metadata and supply tracking\n- NFT transfer functionality\n\n## NFT Gallery UI Template\n\n```html\n\u003C!DOCTYPE html>\n\u003Chtml lang=\"en\">\n\u003Chead>\n  \u003Cmeta charset=\"UTF-8\">\n  \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  \u003Ctitle>Juicebox NFT Gallery\u003C\u002Ftitle>\n  \u003Clink rel=\"stylesheet\" href=\"\u002Fshared\u002Fstyles.css\">\n  \u003Cstyle>\n    \u002F* NFT-specific styles *\u002F\n    .gallery-grid {\n      display: grid;\n      grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));\n      gap: 20px;\n    }\n    .nft-card {\n      background: var(--bg-secondary);\n      border-radius: 12px;\n      overflow: hidden;\n      cursor: pointer;\n      transition: transform 0.2s, box-shadow 0.2s;\n    }\n    .nft-card:hover {\n      transform: translateY(-5px);\n      box-shadow: 0 10px 30px rgba(0,0,0,0.3);\n    }\n    .nft-image {\n      width: 100%;\n      aspect-ratio: 1;\n      background: var(--bg-tertiary);\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      position: relative;\n    }\n    .nft-image img { width: 100%; height: 100%; object-fit: cover; }\n    .nft-image .placeholder { font-size: 48px; color: #444; }\n    .tier-badge, .supply-badge, .owned-badge {\n      position: absolute;\n      padding: 4px 10px;\n      border-radius: 20px;\n      font-size: 12px;\n      font-weight: 600;\n    }\n    .tier-badge { top: 10px; left: 10px; background: rgba(0,0,0,0.7); color: var(--jb-yellow); }\n    .supply-badge { top: 10px; right: 10px; background: rgba(0,0,0,0.7); color: var(--success); }\n    .supply-badge.sold-out { color: var(--error); }\n    .owned-badge { bottom: 10px; left: 10px; background: var(--success); color: #000; }\n    .nft-info { padding: 15px; }\n    .nft-name { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); margin-bottom: 5px; }\n    .nft-description {\n      font-size: 13px; color: var(--text-muted); margin-bottom: 10px;\n      line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2;\n      -webkit-box-orient: vertical; overflow: hidden;\n    }\n    .nft-meta {\n      display: flex; justify-content: space-between; align-items: center;\n      padding-top: 10px; border-top: 1px solid var(--border-color);\n    }\n    .nft-price { font-size: 1.1rem; font-weight: 700; color: var(--jb-yellow); }\n    .nft-votes { font-size: 12px; color: var(--text-muted); }\n    .mint-btn {\n      width: 100%; padding: 12px; background: var(--jb-yellow); color: #000;\n      border: none; border-radius: 8px; font-weight: 600; cursor: pointer; margin-top: 10px;\n    }\n    .mint-btn:hover { background: var(--jb-yellow-hover); }\n    .mint-btn:disabled { background: var(--bg-tertiary); color: var(--text-muted); cursor: not-allowed; }\n    .category-filter { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }\n    .category-chip {\n      padding: 6px 14px; background: var(--bg-tertiary); border-radius: 20px;\n      font-size: 13px; cursor: pointer; color: var(--text-muted);\n    }\n    .category-chip.active { background: var(--jb-yellow); color: #000; }\n    .modal {\n      display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;\n      background: rgba(0,0,0,0.9); z-index: 1000; padding: 20px; overflow-y: auto;\n    }\n    .modal.visible { display: flex; justify-content: center; align-items: flex-start; }\n    .modal-content { background: var(--bg-secondary); border-radius: 12px; max-width: 600px; width: 100%; margin-top: 40px; }\n    .modal-image { width: 100%; aspect-ratio: 1; background: var(--bg-tertiary); }\n    .modal-image img { width: 100%; height: 100%; object-fit: contain; }\n    .modal-info { padding: 20px; }\n    .modal-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px; }\n    .modal-title { font-size: 1.5rem; font-weight: 700; color: var(--text-primary); }\n    .close-btn { background: none; border: none; color: var(--text-muted); font-size: 28px; cursor: pointer; }\n    .modal-description { color: var(--text-muted); line-height: 1.6; margin-bottom: 20px; }\n    .modal-attributes { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 20px; }\n    .attribute { background: var(--bg-tertiary); padding: 12px; border-radius: 8px; }\n    .attribute-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; }\n    .attribute-value { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-top: 4px; }\n    .action-buttons { display: flex; gap: 10px; }\n    .action-btn { flex: 1; padding: 14px; border-radius: 8px; font-weight: 600; cursor: pointer; border: none; }\n    .action-btn.primary { background: var(--jb-yellow); color: #000; }\n    .action-btn.secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color); }\n  \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\u003Cbody>\n  \u003Cdiv class=\"container\">\n    \u003Ch1>NFT Gallery\u003C\u002Fh1>\n\n    \u003Cdiv class=\"card\" style=\"margin-bottom: 20px;\">\n      \u003Cdiv class=\"input-row\">\n        \u003Cinput type=\"text\" id=\"hookAddress\" placeholder=\"721 Hook Address (0x...)\">\n        \u003Cselect id=\"chainSelect\">\n          \u003Coption value=\"1\">Ethereum\u003C\u002Foption>\n          \u003Coption value=\"10\">Optimism\u003C\u002Foption>\n          \u003Coption value=\"8453\">Base\u003C\u002Foption>\n          \u003Coption value=\"42161\">Arbitrum\u003C\u002Foption>\n          \u003Coption value=\"11155111\">Sepolia\u003C\u002Foption>\n        \u003C\u002Fselect>\n        \u003Cbutton class=\"btn\" onclick=\"loadGallery()\">Load Gallery\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"card\" id=\"walletSection\" style=\"display: none; margin-bottom: 20px;\">\n      \u003Cdiv style=\"display: flex; justify-content: space-between; align-items: center;\">\n        \u003Cdiv>\n          \u003Cspan id=\"walletStatus\">Not connected\u003C\u002Fspan>\n          \u003Cspan class=\"code\" id=\"walletAddress\" style=\"margin-left: 10px;\">\u003C\u002Fspan>\n        \u003C\u002Fdiv>\n        \u003Cbutton class=\"btn-secondary\" id=\"connectBtn\" onclick=\"connectWallet()\">Connect Wallet\u003C\u002Fbutton>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"stats\" id=\"statsBar\" style=\"display: none; margin-bottom: 20px;\">\n      \u003Cdiv class=\"stat-card\">\n        \u003Cdiv class=\"stat-value\" id=\"totalTiers\">-\u003C\u002Fdiv>\n        \u003Cdiv class=\"stat-label\">Total Tiers\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"stat-card\">\n        \u003Cdiv class=\"stat-value\" id=\"totalMinted\">-\u003C\u002Fdiv>\n        \u003Cdiv class=\"stat-label\">Total Minted\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"stat-card\">\n        \u003Cdiv class=\"stat-value\" id=\"totalSupply\">-\u003C\u002Fdiv>\n        \u003Cdiv class=\"stat-label\">Max Supply\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n      \u003Cdiv class=\"stat-card\">\n        \u003Cdiv class=\"stat-value\" id=\"floorPrice\">-\u003C\u002Fdiv>\n        \u003Cdiv class=\"stat-label\">Floor Price\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"tabs\" id=\"tabsContainer\" style=\"display: none;\">\n      \u003Cdiv class=\"tab active\" data-tab=\"tiers\" onclick=\"switchTab('tiers')\">All Tiers\u003C\u002Fdiv>\n      \u003Cdiv class=\"tab\" data-tab=\"owned\" onclick=\"switchTab('owned')\">My NFTs\u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"category-filter\" id=\"categoryFilter\">\u003C\u002Fdiv>\n\n    \u003Cdiv class=\"filter-bar\" id=\"filterBar\" style=\"display: none; margin-bottom: 20px;\">\n      \u003Cbutton class=\"tab active\" data-filter=\"all\" onclick=\"filterTiers('all')\">All\u003C\u002Fbutton>\n      \u003Cbutton class=\"tab\" data-filter=\"available\" onclick=\"filterTiers('available')\">Available\u003C\u002Fbutton>\n      \u003Cbutton class=\"tab\" data-filter=\"sold-out\" onclick=\"filterTiers('sold-out')\">Sold Out\u003C\u002Fbutton>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv id=\"galleryContainer\">\u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cdiv class=\"modal\" id=\"nftModal\">\n    \u003Cdiv class=\"modal-content\">\n      \u003Cdiv class=\"modal-image\" id=\"modalImage\">\u003C\u002Fdiv>\n      \u003Cdiv class=\"modal-info\">\n        \u003Cdiv class=\"modal-header\">\n          \u003Cdiv>\n            \u003Cdiv class=\"modal-title\" id=\"modalTitle\">-\u003C\u002Fdiv>\n            \u003Cspan class=\"code\" id=\"modalTokenId\">\u003C\u002Fspan>\n          \u003C\u002Fdiv>\n          \u003Cbutton class=\"close-btn\" onclick=\"closeModal()\">&times;\u003C\u002Fbutton>\n        \u003C\u002Fdiv>\n        \u003Cdiv class=\"modal-description\" id=\"modalDescription\">\u003C\u002Fdiv>\n        \u003Cdiv class=\"modal-attributes\" id=\"modalAttributes\">\u003C\u002Fdiv>\n        \u003Cdiv class=\"action-buttons\" id=\"modalActions\">\u003C\u002Fdiv>\n      \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n  \u003C\u002Fdiv>\n\n  \u003Cscript type=\"module\">\n    import { createPublicClient, http, getContract, formatEther, isAddress, createWalletClient, custom } from 'https:\u002F\u002Fesm.sh\u002Fviem';\n    import { mainnet, optimism, base, arbitrum, sepolia } from 'https:\u002F\u002Fesm.sh\u002Fviem\u002Fchains';\n    import { CHAIN_CONFIGS, truncateAddress } from '\u002Fshared\u002Fwallet-utils.js';\n\n    const CHAINS = { 1: mainnet, 10: optimism, 8453: base, 42161: arbitrum, 11155111: sepolia };\n\n    const HOOK_ABI = [\n      { name: 'STORE', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },\n      { name: 'PROJECT_ID', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },\n      { name: 'name', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },\n      { name: 'symbol', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },\n      { name: 'tokenURI', type: 'function', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'string' }] },\n      { name: 'balanceOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ type: 'uint256' }] },\n      { name: 'tokenOfOwnerByIndex', type: 'function', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'index', type: 'uint256' }], outputs: [{ type: 'uint256' }] },\n      { name: 'ownerOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'address' }] },\n      { name: 'totalSupply', type: 'function', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },\n      { name: 'transferFrom', type: 'function', stateMutability: 'nonpayable', inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }], outputs: [] }\n    ];\n\n    const STORE_ABI = [\n      { name: 'tiersOf', type: 'function', stateMutability: 'view',\n        inputs: [{ name: 'hook', type: 'address' }, { name: 'categories', type: 'uint256[]' }, { name: 'includeResolvedUri', type: 'bool' }, { name: 'startingId', type: 'uint256' }, { name: 'size', type: 'uint256' }],\n        outputs: [{ type: 'tuple[]', components: [\n          { name: 'id', type: 'uint256' }, { name: 'price', type: 'uint256' }, { name: 'remainingSupply', type: 'uint256' },\n          { name: 'initialSupply', type: 'uint256' }, { name: 'votingUnits', type: 'uint256' }, { name: 'reserveFrequency', type: 'uint256' },\n          { name: 'reserveBeneficiary', type: 'address' }, { name: 'encodedIPFSUri', type: 'bytes32' }, { name: 'category', type: 'uint256' },\n          { name: 'discountPercent', type: 'uint8' }, { name: 'allowOwnerMint', type: 'bool' }, { name: 'transfersPausable', type: 'bool' },\n          { name: 'useVotingUnits', type: 'bool' }, { name: 'cannotBeRemoved', type: 'bool' }, { name: 'resolvedUri', type: 'string' }\n        ]}]\n      },\n      { name: 'tierIdOfToken', type: 'function', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'uint256' }] }\n    ];\n\n    let publicClient = null;\n    let walletClient = null;\n    let hookContract = null;\n    let storeContract = null;\n    let allTiers = [];\n    let ownedNFTs = [];\n    let currentTab = 'tiers';\n    let currentFilter = 'all';\n    let selectedCategory = null;\n    let hookAddress = '';\n    let chainId = 1;\n    let connectedAddress = null;\n\n    window.loadGallery = async function() {\n      hookAddress = document.getElementById('hookAddress').value;\n      chainId = parseInt(document.getElementById('chainSelect').value);\n\n      if (!hookAddress || !isAddress(hookAddress)) {\n        alert('Please enter a valid hook address');\n        return;\n      }\n\n      const container = document.getElementById('galleryContainer');\n      container.innerHTML = '\u003Cdiv class=\"loading\">\u003Cdiv class=\"spinner\">\u003C\u002Fdiv>Loading NFT collection...\u003C\u002Fdiv>';\n\n      const chain = CHAINS[chainId];\n      const rpc = CHAIN_CONFIGS[chainId]?.rpc || chain.rpcUrls.default.http[0];\n\n      publicClient = createPublicClient({ chain, transport: http(rpc) });\n\n      try {\n        const storeAddr = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'STORE' });\n\n        allTiers = await publicClient.readContract({\n          address: storeAddr, abi: STORE_ABI, functionName: 'tiersOf',\n          args: [hookAddress, [], true, 0n, 100n]\n        });\n\n        let totalMinted = 0, totalMaxSupply = 0, floorPrice = 0n;\n        const categories = new Set();\n\n        allTiers.forEach(tier => {\n          const minted = Number(tier.initialSupply) - Number(tier.remainingSupply);\n          totalMinted += minted;\n          totalMaxSupply += Number(tier.initialSupply);\n          if (tier.category) categories.add(Number(tier.category));\n          if (tier.remainingSupply > 0n && (floorPrice === 0n || tier.price \u003C floorPrice)) {\n            floorPrice = tier.price;\n          }\n        });\n\n        document.getElementById('totalTiers').textContent = allTiers.length;\n        document.getElementById('totalMinted').textContent = totalMinted.toLocaleString();\n        document.getElementById('totalSupply').textContent = totalMaxSupply.toLocaleString();\n        document.getElementById('floorPrice').textContent = floorPrice > 0n ? formatEther(floorPrice) + ' ETH' : 'N\u002FA';\n\n        if (categories.size > 0) {\n          let categoryHtml = '\u003Cdiv class=\"category-chip active\" onclick=\"filterCategory(null)\">All\u003C\u002Fdiv>';\n          [...categories].sort((a, b) => a - b).forEach(cat => {\n            categoryHtml += `\u003Cdiv class=\"category-chip\" onclick=\"filterCategory(${cat})\">Category ${cat}\u003C\u002Fdiv>`;\n          });\n          document.getElementById('categoryFilter').innerHTML = categoryHtml;\n        }\n\n        document.getElementById('statsBar').style.display = 'grid';\n        document.getElementById('tabsContainer').style.display = 'flex';\n        document.getElementById('filterBar').style.display = 'flex';\n        document.getElementById('walletSection').style.display = 'flex';\n\n        renderTiers();\n      } catch (error) {\n        console.error(error);\n        container.innerHTML = `\u003Cdiv class=\"empty\">Error loading collection: ${error.message}\u003C\u002Fdiv>`;\n      }\n    };\n\n    window.connectWallet = async function() {\n      if (!window.ethereum) { alert('Please install a web3 wallet'); return; }\n\n      try {\n        const [address] = await window.ethereum.request({ method: 'eth_requestAccounts' });\n        connectedAddress = address;\n\n        try {\n          await window.ethereum.request({\n            method: 'wallet_switchEthereumChain',\n            params: [{ chainId: '0x' + chainId.toString(16) }]\n          });\n        } catch (e) { console.log('Chain switch failed', e); }\n\n        walletClient = createWalletClient({ chain: CHAINS[chainId], transport: custom(window.ethereum) });\n\n        document.getElementById('walletStatus').textContent = 'Connected:';\n        document.getElementById('walletAddress').textContent = truncateAddress(address);\n        document.getElementById('connectBtn').textContent = 'Disconnect';\n        document.getElementById('connectBtn').onclick = disconnectWallet;\n\n        await loadOwnedNFTs(address);\n      } catch (error) {\n        console.error(error);\n        alert('Failed to connect wallet');\n      }\n    };\n\n    window.disconnectWallet = function() {\n      walletClient = null;\n      connectedAddress = null;\n      ownedNFTs = [];\n      document.getElementById('walletStatus').textContent = 'Not connected';\n      document.getElementById('walletAddress').textContent = '';\n      document.getElementById('connectBtn').textContent = 'Connect Wallet';\n      document.getElementById('connectBtn').onclick = connectWallet;\n      if (currentTab === 'owned') switchTab('tiers'); else renderTiers();\n    };\n\n    async function loadOwnedNFTs(ownerAddress) {\n      try {\n        const storeAddr = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'STORE' });\n        const balance = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'balanceOf', args: [ownerAddress] });\n\n        ownedNFTs = [];\n        for (let i = 0n; i \u003C balance; i++) {\n          const tokenId = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'tokenOfOwnerByIndex', args: [ownerAddress, i] });\n          const tierId = await publicClient.readContract({ address: storeAddr, abi: STORE_ABI, functionName: 'tierIdOfToken', args: [tokenId] });\n          const tier = allTiers.find(t => t.id.toString() === tierId.toString());\n\n          let metadata = {};\n          try {\n            const uri = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'tokenURI', args: [tokenId] });\n            if (uri) {\n              const metadataUrl = uri.startsWith('ipfs:\u002F\u002F') ? `https:\u002F\u002Fipfs.io\u002Fipfs\u002F${uri.slice(7)}` : uri;\n              const response = await fetch(metadataUrl);\n              metadata = await response.json();\n            }\n          } catch (e) { console.log('Failed to load metadata for token', tokenId.toString()); }\n\n          ownedNFTs.push({ tokenId: tokenId.toString(), tierId: tierId.toString(), tier, metadata });\n        }\n\n        if (currentTab === 'owned') renderOwnedNFTs(); else renderTiers();\n      } catch (error) { console.error('Failed to load owned NFTs:', error); }\n    }\n\n    window.switchTab = function(tab) {\n      currentTab = tab;\n      document.querySelectorAll('.tabs .tab').forEach(t => t.classList.remove('active'));\n      document.querySelector(`.tabs .tab[data-tab=\"${tab}\"]`).classList.add('active');\n\n      if (tab === 'tiers') {\n        document.getElementById('filterBar').style.display = 'flex';\n        renderTiers();\n      } else {\n        document.getElementById('filterBar').style.display = 'none';\n        renderOwnedNFTs();\n      }\n    };\n\n    window.filterTiers = function(filter) {\n      currentFilter = filter;\n      document.querySelectorAll('.filter-bar .tab').forEach(b => b.classList.remove('active'));\n      document.querySelector(`.filter-bar .tab[data-filter=\"${filter}\"]`).classList.add('active');\n      renderTiers();\n    };\n\n    window.filterCategory = function(category) {\n      selectedCategory = category;\n      document.querySelectorAll('.category-chip').forEach(c => c.classList.remove('active'));\n      if (category === null) {\n        document.querySelector('.category-chip').classList.add('active');\n      } else {\n        document.querySelectorAll('.category-chip').forEach(c => {\n          if (c.textContent === `Category ${category}`) c.classList.add('active');\n        });\n      }\n      renderTiers();\n    };\n\n    function renderTiers() {\n      const container = document.getElementById('galleryContainer');\n      let filteredTiers = [...allTiers];\n\n      if (selectedCategory !== null) filteredTiers = filteredTiers.filter(t => Number(t.category) === selectedCategory);\n      if (currentFilter === 'available') filteredTiers = filteredTiers.filter(t => t.remainingSupply > 0n);\n      else if (currentFilter === 'sold-out') filteredTiers = filteredTiers.filter(t => t.remainingSupply === 0n);\n\n      if (filteredTiers.length === 0) {\n        container.innerHTML = '\u003Cdiv class=\"empty\">No tiers match the current filter\u003C\u002Fdiv>';\n        return;\n      }\n\n      let html = '\u003Cdiv class=\"gallery-grid\">';\n      filteredTiers.forEach(tier => {\n        const minted = Number(tier.initialSupply) - Number(tier.remainingSupply);\n        const soldOut = tier.remainingSupply === 0n;\n        const price = formatEther(tier.price);\n        const owned = ownedNFTs.filter(nft => nft.tierId === tier.id.toString());\n\n        html += `\n          \u003Cdiv class=\"nft-card\" onclick=\"openTierModal(${tier.id})\">\n            \u003Cdiv class=\"nft-image\">\n              ${tier.resolvedUri ? `\u003Cimg src=\"${resolveUri(tier.resolvedUri)}\" alt=\"Tier ${tier.id}\" onerror=\"this.style.display='none';this.parentElement.innerHTML='\u003Cdiv class=\\\\'placeholder\\\\'>NFT\u003C\u002Fdiv>'\">` : '\u003Cdiv class=\"placeholder\">NFT\u003C\u002Fdiv>'}\n              \u003Cdiv class=\"tier-badge\">Tier ${tier.id}\u003C\u002Fdiv>\n              \u003Cdiv class=\"supply-badge ${soldOut ? 'sold-out' : ''}\">${minted}\u002F${tier.initialSupply}\u003C\u002Fdiv>\n              ${owned.length > 0 ? `\u003Cdiv class=\"owned-badge\">Owned: ${owned.length}\u003C\u002Fdiv>` : ''}\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"nft-info\">\n              \u003Cdiv class=\"nft-name\">Tier ${tier.id}\u003C\u002Fdiv>\n              \u003Cdiv class=\"nft-description\">Category ${tier.category || 0}\u003C\u002Fdiv>\n              \u003Cdiv class=\"nft-meta\">\n                \u003Cdiv class=\"nft-price\">${price} ETH\u003C\u002Fdiv>\n                ${tier.votingUnits > 0 ? `\u003Cdiv class=\"nft-votes\">${tier.votingUnits} votes\u003C\u002Fdiv>` : ''}\n              \u003C\u002Fdiv>\n              \u003Cbutton class=\"mint-btn\" ${soldOut ? 'disabled' : ''} onclick=\"event.stopPropagation(); mintTier(${tier.id}, '${tier.price}')\">\n                ${soldOut ? 'Sold Out' : 'Mint'}\n              \u003C\u002Fbutton>\n            \u003C\u002Fdiv>\n          \u003C\u002Fdiv>`;\n      });\n      container.innerHTML = html + '\u003C\u002Fdiv>';\n    }\n\n    function renderOwnedNFTs() {\n      const container = document.getElementById('galleryContainer');\n      if (!connectedAddress) {\n        container.innerHTML = '\u003Cdiv class=\"empty\">Connect your wallet to view owned NFTs\u003C\u002Fdiv>';\n        return;\n      }\n      if (ownedNFTs.length === 0) {\n        container.innerHTML = '\u003Cdiv class=\"empty\">You don\\'t own any NFTs from this collection\u003C\u002Fdiv>';\n        return;\n      }\n\n      let html = '\u003Cdiv class=\"gallery-grid\">';\n      ownedNFTs.forEach(nft => {\n        const tier = nft.tier;\n        const name = nft.metadata?.name || `Tier ${nft.tierId} #${nft.tokenId}`;\n        const image = nft.metadata?.image;\n\n        html += `\n          \u003Cdiv class=\"nft-card\" onclick=\"openNFTModal('${nft.tokenId}')\">\n            \u003Cdiv class=\"nft-image\">\n              ${image ? `\u003Cimg src=\"${resolveUri(image)}\" alt=\"${name}\" onerror=\"this.style.display='none';this.parentElement.innerHTML='\u003Cdiv class=\\\\'placeholder\\\\'>NFT\u003C\u002Fdiv>'\">` : '\u003Cdiv class=\"placeholder\">NFT\u003C\u002Fdiv>'}\n              \u003Cdiv class=\"tier-badge\">Tier ${nft.tierId}\u003C\u002Fdiv>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"nft-info\">\n              \u003Cdiv class=\"nft-name\">${name}\u003C\u002Fdiv>\n              \u003Cdiv class=\"nft-description\">Token ID: ${nft.tokenId}\u003C\u002Fdiv>\n              \u003Cdiv class=\"nft-meta\">\n                \u003Cdiv class=\"nft-price\">${tier ? formatEther(tier.price) + ' ETH' : '-'}\u003C\u002Fdiv>\n                ${tier?.votingUnits > 0 ? `\u003Cdiv class=\"nft-votes\">${tier.votingUnits} votes\u003C\u002Fdiv>` : ''}\n              \u003C\u002Fdiv>\n            \u003C\u002Fdiv>\n          \u003C\u002Fdiv>`;\n      });\n      container.innerHTML = html + '\u003C\u002Fdiv>';\n    }\n\n    window.openTierModal = async function(tierId) {\n      const tier = allTiers.find(t => t.id.toString() === tierId.toString());\n      if (!tier) return;\n\n      const minted = Number(tier.initialSupply) - Number(tier.remainingSupply);\n      const soldOut = tier.remainingSupply === 0n;\n      const price = formatEther(tier.price);\n\n      document.getElementById('modalTitle').textContent = `Tier ${tierId}`;\n      document.getElementById('modalTokenId').textContent = `Category ${tier.category || 0}`;\n      document.getElementById('modalDescription').textContent = tier.resolvedUri ? 'Tier metadata loaded from IPFS' : 'No metadata available';\n      document.getElementById('modalImage').innerHTML = tier.resolvedUri\n        ? `\u003Cimg src=\"${resolveUri(tier.resolvedUri)}\" alt=\"Tier ${tierId}\">`\n        : '\u003Cdiv style=\"display:flex;align-items:center;justify-content:center;height:100%;color:#444;font-size:64px;\">NFT\u003C\u002Fdiv>';\n\n      document.getElementById('modalAttributes').innerHTML = `\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Price\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${price} ETH\u003C\u002Fdiv>\u003C\u002Fdiv>\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Supply\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${minted} \u002F ${tier.initialSupply}\u003C\u002Fdiv>\u003C\u002Fdiv>\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Remaining\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${tier.remainingSupply.toString()}\u003C\u002Fdiv>\u003C\u002Fdiv>\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Voting Units\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${tier.votingUnits || 0}\u003C\u002Fdiv>\u003C\u002Fdiv>\n        ${tier.reserveFrequency > 0 ? `\u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Reserve Frequency\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">1 in ${tier.reserveFrequency}\u003C\u002Fdiv>\u003C\u002Fdiv>` : ''}`;\n\n      document.getElementById('modalActions').innerHTML = `\n        \u003Cbutton class=\"action-btn primary\" ${soldOut ? 'disabled' : ''} onclick=\"mintTier(${tierId}, '${tier.price}')\">\n          ${soldOut ? 'Sold Out' : `Mint for ${price} ETH`}\n        \u003C\u002Fbutton>`;\n\n      document.getElementById('nftModal').classList.add('visible');\n    };\n\n    window.openNFTModal = async function(tokenId) {\n      const nft = ownedNFTs.find(n => n.tokenId === tokenId);\n      if (!nft) return;\n\n      const name = nft.metadata?.name || `Tier ${nft.tierId} #${tokenId}`;\n      const description = nft.metadata?.description || 'No description available';\n      const image = nft.metadata?.image;\n\n      document.getElementById('modalTitle').textContent = name;\n      document.getElementById('modalTokenId').textContent = `Token #${tokenId}`;\n      document.getElementById('modalDescription').textContent = description;\n      document.getElementById('modalImage').innerHTML = image\n        ? `\u003Cimg src=\"${resolveUri(image)}\" alt=\"${name}\">`\n        : '\u003Cdiv style=\"display:flex;align-items:center;justify-content:center;height:100%;color:#444;font-size:64px;\">NFT\u003C\u002Fdiv>';\n\n      let attributesHtml = `\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Tier\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${nft.tierId}\u003C\u002Fdiv>\u003C\u002Fdiv>\n        \u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">Token ID\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${tokenId}\u003C\u002Fdiv>\u003C\u002Fdiv>`;\n\n      if (nft.metadata?.attributes) {\n        nft.metadata.attributes.forEach(attr => {\n          attributesHtml += `\u003Cdiv class=\"attribute\">\u003Cdiv class=\"attribute-label\">${attr.trait_type}\u003C\u002Fdiv>\u003Cdiv class=\"attribute-value\">${attr.value}\u003C\u002Fdiv>\u003C\u002Fdiv>`;\n        });\n      }\n      document.getElementById('modalAttributes').innerHTML = attributesHtml;\n\n      document.getElementById('modalActions').innerHTML = `\n        \u003Cbutton class=\"action-btn secondary\" onclick=\"transferNFT('${tokenId}')\">Transfer\u003C\u002Fbutton>\n        \u003Cbutton class=\"action-btn secondary\" onclick=\"cashOutNFT('${tokenId}')\">Cash Out\u003C\u002Fbutton>`;\n\n      document.getElementById('nftModal').classList.add('visible');\n    };\n\n    window.closeModal = function() {\n      document.getElementById('nftModal').classList.remove('visible');\n    };\n\n    window.mintTier = async function(tierId, price) {\n      if (!connectedAddress) { alert('Please connect your wallet first'); return; }\n      try {\n        const projectId = await publicClient.readContract({ address: hookAddress, abi: HOOK_ABI, functionName: 'PROJECT_ID' });\n        alert(`To mint from Tier ${tierId}:\\n\\n1. Go to the Juicebox app\\n2. Pay ${formatEther(BigInt(price))} ETH to Project ${projectId}\\n3. The NFT will be minted to your wallet`);\n      } catch (error) { console.error(error); alert('Minting failed: ' + error.message); }\n    };\n\n    window.transferNFT = async function(tokenId) {\n      if (!connectedAddress) { alert('Please connect your wallet first'); return; }\n      const recipient = prompt('Enter recipient address:');\n      if (!recipient || !isAddress(recipient)) { alert('Invalid address'); return; }\n\n      try {\n        const hash = await walletClient.writeContract({\n          address: hookAddress, abi: HOOK_ABI, functionName: 'transferFrom',\n          args: [connectedAddress, recipient, BigInt(tokenId)], account: connectedAddress\n        });\n        alert('Transaction submitted: ' + hash);\n        await publicClient.waitForTransactionReceipt({ hash });\n        alert('Transfer successful!');\n        await loadOwnedNFTs(connectedAddress);\n        closeModal();\n      } catch (error) { console.error(error); alert('Transfer failed: ' + error.message); }\n    };\n\n    window.cashOutNFT = function(tokenId) {\n      alert(`To cash out NFT #${tokenId}:\\n\\n1. Go to the Juicebox app\\n2. Use the cash out function with this token ID\\n3. The NFT will be burned and you'll receive your share of the treasury`);\n    };\n\n    function resolveUri(uri) {\n      if (!uri) return '';\n      if (uri.startsWith('ipfs:\u002F\u002F')) return `https:\u002F\u002Fipfs.io\u002Fipfs\u002F${uri.slice(7)}`;\n      if (uri.startsWith('data:')) return uri;\n      return uri;\n    }\n\n    document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });\n    document.getElementById('nftModal').addEventListener('click', (e) => { if (e.target.id === 'nftModal') closeModal(); });\n  \u003C\u002Fscript>\n\u003C\u002Fbody>\n\u003C\u002Fhtml>\n```\n\n## Key Features\n\n### Tier Browsing\n- Grid layout with tier cards\n- Supply tracking (minted\u002Ftotal)\n- Price display in ETH\n- Category filtering\n- Availability filtering\n\n### Owned NFTs View\n- Wallet connection via viem\n- Display owned NFTs with metadata\n- Transfer functionality\n- Cash out guidance\n\n### Collection Stats\n- Total tiers\n- Total minted\n- Max supply\n- Floor price\n\n### NFT Details Modal\n- Full metadata display\n- Attribute listing\n- Action buttons (mint\u002Ftransfer\u002Fcash out)\n\n## Data Sources\n\nThe gallery uses on-chain data via:\n- `JB721TiersHook` - NFT contract\n- `JB721TiersHookStore` - Tier data storage\n- Token metadata from IPFS\n\n## Customization Points\n\n1. **Image handling**: Modify `resolveUri()` for different IPFS gateways\n2. **Metadata display**: Extend attributes rendering\n3. **Minting flow**: Integrate directly with terminal\n4. **Styling**: Customize via CSS variables\n\n## Integration Notes\n\n- Works with any V5 721 Hook deployment\n- Uses viem for all blockchain interactions\n- Shared styles from `\u002Fshared\u002Fstyles.css`\n- Chain config from `\u002Fshared\u002Fwallet-utils.js`\n",{"name":5,"description":7},"main","e7e336ac628c3e3bd771aff1aaa469c93778f94a","skills\u002Fmejango\u002Fjuicy\u002Fjb-nft-gallery-ui",0,"https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del","2026-08-23 22:39:37",{"source":8,"skillName":5,"commands":45,"prompt":55,"zipAvailable":56},[46,49,52],{"tool":47,"command":48},"npx","npx skills add kbarbel640-del\u002Fskills --skill jb-nft-gallery-ui",{"tool":50,"command":51},"bunx","bunx skills add kbarbel640-del\u002Fskills --skill jb-nft-gallery-ui",{"tool":53,"command":54},"pnpm","pnpm dlx skills add kbarbel640-del\u002Fskills --skill jb-nft-gallery-ui","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fmejango\u002Fjuicy\u002Fjb-nft-gallery-ui\u002FSKILL.md",true,[],[59,88,117,138,144,164],{"skillNo":60,"name":61,"nameZh":6,"description":62,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":63,"categories":64,"keywords":68,"publishedAt":32},"01M6G8J55967D6281ACMKAKTEQ","crowd-prompting","A marketplace where AI agents improve prompts, system instructions, tool descriptions, and other text-based content with domain expertise from real-world operations — and earn tokens for valuable contributions.","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzzadrian\u002Fcrowd-prompting\u002FSKILL.md",[65],{"categoryNo":66,"categoryName":67,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000036","内容创作者",[69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87],"crowd prompting","prompting","api","with","tokens","domain","world","prompts","operations","instructions","text","tool","system","improve","productivity","marketplace","https","content","ai agents",{"skillNo":89,"name":90,"nameZh":6,"description":91,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":92,"categories":93,"keywords":99,"publishedAt":32},"01M6G8J0PKPAYVET6BWE8W4C26","dilemm-ai","Compete in DilemmAI, the prisoner's dilemma AI arena at dilemm.ai. Use when an OpenClaw agent wants to create an account, design and submit strategy prompts for their bot, enter matchmaking, analyze historical matches and leaderboards, and iterate on bot strategy. Requires headless browser automation (no API available yet).","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzyori\u002Fdilemm-ai\u002FSKILL.md",[94],{"categoryNo":95,"categoryName":96,"taxonomyType":97,"taxonomyTypeDesc":98},"01JBSEEDCAT000000000000023","Skill与智能体开发","SCENARIO","使用场景",[100,71,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,76,116],"dilemm ai","arena","analyze","browser","openclaw agent","create","automation","browser automation","bot","openclaw","design","strategy","headless","compete","submit","matchmaking","use",{"skillNo":118,"name":119,"nameZh":6,"description":120,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":121,"categories":122,"keywords":126,"publishedAt":32},"01M6G8HWAF3K9SW7NP3Z83CPJ6","arxiv-paper-reviews","Interact with arXiv Crawler API to fetch papers, read reviews, and submit comments. Use when working with arXiv papers, fetching paper lists by date\u002Fcategory\u002Finterest, viewing paper details with comments, or submitting paper reviews via API at http:\u002F\u002F150.158.152.82:8000.","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzxrys\u002Fweak-accept\u002FSKILL.md",[123],{"categoryNo":124,"categoryName":125,"taxonomyType":97,"taxonomyTypeDesc":98},"01JBSEEDCAT000000000000021","资料检索与研究",[127,128,129,130,131,132,133,72,134,114,116,135,71,136,137],"arxiv paper reviews","reviews","arxiv","paper","crawler","papers","comments","fetch","read","http","via",{"skillNo":139,"name":119,"nameZh":6,"description":120,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":140,"categories":141,"keywords":143,"publishedAt":32},"01M6G8HQJ7N7WMY6YV7D7SSJ16","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzxrys\u002Farxiv-paper-reviews\u002FSKILL.md",[142],{"categoryNo":124,"categoryName":125,"taxonomyType":97,"taxonomyTypeDesc":98},[130,129,128,127,137,132,133,72,131,114,116,134,135,71,136],{"skillNo":145,"name":146,"nameZh":6,"description":147,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":148,"categories":149,"keywords":150,"publishedAt":32},"01M6G8HM95Y64JNVFC2HN4E2BG","git-summary","Get a quick summary of the current Git repository including status, recent commits, branches, and contributors.","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fzweack\u002Fgit-summary\u002FSKILL.md",[],[151,152,153,154,155,156,157,158,159,160,161,162,163],"git","summary","git summary","commits","repository","win32","status","linux","contributors","true","quick","get","branches",{"skillNo":165,"name":166,"nameZh":6,"description":167,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":168,"categories":169,"keywords":174,"publishedAt":32},"01M6G8HGEGF05CNEJCGVYFSC4R","agent-constitution","Interact with AgentConstitution governance contracts on Base Sepolia. Check compliance, read rules, log actions, query governance state.","https:\u002F\u002Fgithub.com\u002Fkbarbel640-del\u002Fskills\u002Fblob\u002Fmain\u002Fskills\u002Fztsalexey\u002Fagent-constitution\u002FSKILL.md",[170,171],{"categoryNo":95,"categoryName":96,"taxonomyType":97,"taxonomyTypeDesc":98},{"categoryNo":172,"categoryName":173,"taxonomyType":20,"taxonomyTypeDesc":21},"01JBSEEDCAT000000000000037","律师与法务",[175,176,177,178,72,179,180,37,181,135,182,183,184,185,186,85,187,188,189],"agent constitution","constitution","contracts","tree","query","base","log","check","Rules","projects","compliance","actions","state","governance","github"]