Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Oga Market — Creator Studio</title> | |
| <link rel="stylesheet" href="styles.css"> | |
| </head> | |
| <body> | |
| <!-- ═══ SETUP ═══ --> | |
| <div id="setup" class="om-setup"> | |
| <div class="om-setup-card"> | |
| <h2>Creator studio.</h2> | |
| <p class="sub">List your product on Good Online — AI writes the copy, you own it on-chain as an iNFT.</p> | |
| <div class="om-field"> | |
| <label>Creator name</label> | |
| <input id="cfg-name" type="text" placeholder="Your brand name (e.g. Arami, Jacqué Lagos)" spellcheck="false" /> | |
| </div> | |
| <div class="om-field"> | |
| <label>0G Chat API Key</label> | |
| <input id="cfg-chat-key" type="password" placeholder="app-sk-..." spellcheck="false" /> | |
| <div class="om-field-hint">Used for vision + copywriting via 0G Compute</div> | |
| </div> | |
| <div class="om-field"> | |
| <label>0G Chain Private Key</label> | |
| <input id="cfg-chain-key" type="password" placeholder="0x... (required to mint iNFTs)" /> | |
| <div class="om-field-hint">Free testnet tokens at <a href="https://faucet.0g.ai" target="_blank">faucet.0g.ai</a></div> | |
| </div> | |
| <div id="cfg-err" class="om-err"></div> | |
| <button class="om-cta" onclick="startCreator()">Enter studio</button> | |
| </div> | |
| </div> | |
| <!-- ═══ TOPBAR ═══ --> | |
| <div class="om-topbar"> | |
| <div class="om-brand"> | |
| <svg width="24" height="24" viewBox="0 0 96 96" fill="none"> | |
| <rect x="14" y="30" width="68" height="56" rx="6" fill="#1F3B2D"/> | |
| <circle cx="36" cy="58" r="6" fill="#F5EFE3"/> | |
| <circle cx="60" cy="58" r="6" fill="#F5EFE3"/> | |
| <line x1="34" y1="30" x2="34" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/> | |
| <line x1="62" y1="30" x2="62" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/> | |
| </svg> | |
| <a href="index.html">Oga Market</a> | |
| <span class="om-brand-tag">Creator</span> | |
| </div> | |
| <div class="om-topbar-actions"> | |
| <span id="creator-badge" style="font-size: 13px; color: var(--fg-2);"></span> | |
| <div class="om-services"> | |
| <span class="om-chip compute" id="chip-compute">Compute</span> | |
| <span class="om-chip storage" id="chip-storage">Storage</span> | |
| <span class="om-chip chain" id="chip-chain">Chain</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ═══ APP ═══ --> | |
| <main class="om-app"> | |
| <div class="om-app-grid-creator"> | |
| <!-- LEFT: Upload form --> | |
| <section class="om-upload"> | |
| <h3>1. Upload your product</h3> | |
| <label for="file-input" class="om-dropzone" id="dropzone"> | |
| <div id="dropzone-empty"> | |
| <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--fg-3);"> | |
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |
| <polyline points="17 8 12 3 7 8"/> | |
| <line x1="12" y1="3" x2="12" y2="15"/> | |
| </svg> | |
| <p style="margin-top: 12px;">Click to upload photo<br><small style="color:var(--fg-3);">PNG, JPG, WebP · max 4MB</small></p> | |
| </div> | |
| <img id="dropzone-img" style="display:none;" /> | |
| </label> | |
| <input id="file-input" type="file" accept="image/*" style="display:none;" /> | |
| <div class="om-field"> | |
| <label>Product name (working title)</label> | |
| <input id="prod-name" type="text" placeholder="e.g. Hibiscus body oil" /> | |
| </div> | |
| <div class="om-field"> | |
| <label>Price (NGN)</label> | |
| <input id="prod-price" type="number" placeholder="13600" /> | |
| </div> | |
| <button class="om-cta" id="generate-btn" onclick="generateListing()" disabled> | |
| Generate listing with AI | |
| </button> | |
| <div id="gen-status" style="font-size: 12px; color: var(--fg-3); text-align: center; min-height: 16px;"></div> | |
| </section> | |
| <!-- RIGHT: Preview + mint --> | |
| <section class="om-listing-preview"> | |
| <h3>2. Preview & mint</h3> | |
| <div id="listing-empty" class="om-listing-preview-empty">Upload a photo and click Generate to see the AI listing here.</div> | |
| <div id="listing-card" class="om-listing-card" style="display:none;"> | |
| <span class="om-listing-eyebrow" id="lp-eyebrow">Category</span> | |
| <h4 class="om-listing-title" id="lp-title"></h4> | |
| <p class="om-listing-tagline" id="lp-tagline"></p> | |
| <p class="om-listing-desc" id="lp-desc"></p> | |
| <div class="om-listing-meta" id="lp-meta"></div> | |
| <div class="om-listing-price" id="lp-price"></div> | |
| <div class="om-listing-feedback" id="lp-feedback"></div> | |
| <div class="om-listing-actions"> | |
| <button class="om-cta" id="mint-btn" onclick="mintListing()">Mint as iNFT on 0G Chain</button> | |
| <button class="om-cta om-cta-secondary" onclick="regenerate()">Regenerate</button> | |
| </div> | |
| </div> | |
| <div id="mint-result" style="margin-top: 16px;"></div> | |
| <div style="margin-top: var(--sp-8); padding-top: var(--sp-6); border-top: 1px solid var(--border-1);"> | |
| <span class="om-side-eyebrow">Your minted listings · on 0G Chain</span> | |
| <div id="minted-grid" class="om-minted-grid"> | |
| <div style="font-size: 12px; color: var(--fg-3); padding: 12px 0;">None yet.</div> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| </main> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/ethers/6.13.4/ethers.umd.min.js"></script> | |
| <script src="shared.js"></script> | |
| <script> | |
| // ════════════════════════════════════════════════════════════════ | |
| // STATE | |
| // ════════════════════════════════════════════════════════════════ | |
| let CFG = {}; | |
| let chainProvider = null, chainWallet = null, listingContract = null; | |
| let currentImage = null; // data URL of uploaded photo | |
| let currentListing = null; // generated listing JSON | |
| let creatorVault = window.OG_STORAGE.load('omk-creator') || {}; | |
| // Restore previous session | |
| (function () { | |
| const saved = sessionStorage.getItem('omk-creator-cfg'); | |
| if (!saved) return; | |
| try { | |
| const s = JSON.parse(saved); | |
| if (s.name) document.getElementById('cfg-name').value = s.name; | |
| if (s.chatKey) document.getElementById('cfg-chat-key').value = s.chatKey; | |
| if (s.chainKey) document.getElementById('cfg-chain-key').value = s.chainKey; | |
| } catch (_) {} | |
| })(); | |
| // ════════════════════════════════════════════════════════════════ | |
| // START | |
| // ════════════════════════════════════════════════════════════════ | |
| window.startCreator = async function () { | |
| const name = document.getElementById('cfg-name').value.trim(); | |
| const chatKey = document.getElementById('cfg-chat-key').value.trim(); | |
| const chainKey = document.getElementById('cfg-chain-key').value.trim(); | |
| if (!name) return showErr('Creator name is required.'); | |
| if (!chatKey) return showErr('0G Chat API key is required.'); | |
| if (!chainKey) return showErr('0G Chain key is required to mint iNFTs.'); | |
| CFG = { name, chatKey, chainKey, model: 'zai-org/GLM-5-FP8', visionModel: 'zai-org/GLM-5-FP8' }; | |
| // Verify chat key | |
| try { | |
| await window.askAI(chatKey, CFG.model, [{ role: 'user', content: 'hi' }], { maxTokens: 3 }); | |
| } catch (e) { | |
| return showErr('Chat key failed: ' + e.message); | |
| } | |
| // Init chain | |
| try { | |
| chainProvider = new ethers.JsonRpcProvider(window.OG_CHAIN.rpc); | |
| chainWallet = new ethers.Wallet(chainKey, chainProvider); | |
| const balance = await chainProvider.getBalance(chainWallet.address); | |
| if (balance === 0n) { | |
| return showErr('Chain wallet has 0 balance. Visit faucet.0g.ai for testnet tokens.'); | |
| } | |
| if (window.OG_CHAIN.listingNFT) { | |
| listingContract = new ethers.Contract(window.OG_CHAIN.listingNFT, window.LISTING_NFT_ABI, chainWallet); | |
| } | |
| } catch (e) { | |
| return showErr('Chain setup failed: ' + e.message); | |
| } | |
| sessionStorage.setItem('omk-creator-cfg', JSON.stringify(CFG)); | |
| document.getElementById('setup').classList.add('hidden'); | |
| document.getElementById('chip-compute').classList.add('active'); | |
| document.getElementById('chip-chain').classList.add('active'); | |
| document.getElementById('creator-badge').textContent = name + ' · ' + chainWallet.address.slice(0, 6) + '…' + chainWallet.address.slice(-4); | |
| // Load creator's minted listings | |
| if (!creatorVault[name]) creatorVault[name] = { listings: [] }; | |
| renderMinted(); | |
| if (creatorVault[name].listings.length > 0) { | |
| document.getElementById('chip-storage').classList.add('active'); | |
| } | |
| }; | |
| function showErr(msg) { | |
| const el = document.getElementById('cfg-err'); | |
| el.textContent = msg; | |
| el.style.display = 'block'; | |
| } | |
| // ════════════════════════════════════════════════════════════════ | |
| // IMAGE UPLOAD | |
| // ════════════════════════════════════════════════════════════════ | |
| const fileInput = document.getElementById('file-input'); | |
| const dropzone = document.getElementById('dropzone'); | |
| const dropzoneImg = document.getElementById('dropzone-img'); | |
| const dropzoneEmpty = document.getElementById('dropzone-empty'); | |
| fileInput.addEventListener('change', (e) => { | |
| const f = e.target.files[0]; | |
| if (!f) return; | |
| if (f.size > 4 * 1024 * 1024) { alert('Max 4MB please.'); return; } | |
| const reader = new FileReader(); | |
| reader.onload = (ev) => { | |
| currentImage = ev.target.result; | |
| dropzoneImg.src = currentImage; | |
| dropzoneImg.style.display = 'block'; | |
| dropzoneEmpty.style.display = 'none'; | |
| dropzone.classList.add('has-image'); | |
| document.getElementById('generate-btn').disabled = false; | |
| }; | |
| reader.readAsDataURL(f); | |
| }); | |
| // ════════════════════════════════════════════════════════════════ | |
| // GENERATE LISTING | |
| // ════════════════════════════════════════════════════════════════ | |
| let generating = false; | |
| window.generateListing = async function () { | |
| if (generating) return; | |
| if (!currentImage) { alert('Upload a photo first.'); return; } | |
| const name = document.getElementById('prod-name').value.trim(); | |
| const price = document.getElementById('prod-price').value.trim(); | |
| if (!name) { alert('Working name is required.'); return; } | |
| if (!price) { alert('Price is required.'); return; } | |
| generating = true; | |
| const btn = document.getElementById('generate-btn'); | |
| const status = document.getElementById('gen-status'); | |
| btn.disabled = true; | |
| btn.textContent = 'Generating…'; | |
| try { | |
| // 1. Vision: analyse the photo | |
| status.textContent = 'Analysing photo via 0G Compute (Vision)…'; | |
| let visionDesc; | |
| try { | |
| visionDesc = await window.askVision(CFG.chatKey, CFG.visionModel, currentImage, | |
| 'Describe this product photo in 2 sentences. Focus on: what the item is, colour, texture, mood. Keep it factual.'); | |
| } catch (e) { | |
| // Vision may not be available on the chat provider — fall back to a stub | |
| console.warn('Vision failed, falling back:', e); | |
| visionDesc = `Product photo for "${name}".`; | |
| } | |
| // 2. LLM: generate the listing | |
| status.textContent = 'Writing listing with GLM-5 on 0G Compute…'; | |
| const prompt = window.buildCreatorPrompt(visionDesc, name, Number(price), CFG.name); | |
| const raw = await window.askAI(CFG.chatKey, CFG.model, [ | |
| { role: 'system', content: 'You are a Nigerian e-commerce copywriter. Always respond with valid JSON only.' }, | |
| { role: 'user', content: prompt }, | |
| ], { maxTokens: 600 }); | |
| // Extract JSON | |
| let json; | |
| try { | |
| const jsonMatch = raw.match(/\{[\s\S]*\}/); | |
| json = JSON.parse(jsonMatch ? jsonMatch[0] : raw); | |
| } catch (e) { | |
| throw new Error('AI returned invalid JSON. Try again.'); | |
| } | |
| currentListing = { | |
| ...json, | |
| creator: CFG.name, | |
| price: Number(price), | |
| workingName: name, | |
| image: currentImage, | |
| visionDesc, | |
| createdAt: Date.now(), | |
| }; | |
| renderListing(currentListing); | |
| status.textContent = ''; | |
| } catch (e) { | |
| status.textContent = 'Error: ' + e.message; | |
| } | |
| generating = false; | |
| btn.textContent = 'Generate listing with AI'; | |
| btn.disabled = false; | |
| }; | |
| window.regenerate = () => generateListing(); | |
| function renderListing(L) { | |
| document.getElementById('listing-empty').style.display = 'none'; | |
| document.getElementById('listing-card').style.display = ''; | |
| document.getElementById('lp-eyebrow').textContent = (L.category || 'Uncategorised').toUpperCase(); | |
| document.getElementById('lp-title').textContent = L.title || ''; | |
| document.getElementById('lp-tagline').textContent = L.tagline || ''; | |
| document.getElementById('lp-desc').textContent = L.description || ''; | |
| const meta = document.getElementById('lp-meta'); | |
| meta.innerHTML = ''; | |
| if (L.category) { | |
| const pill = document.createElement('span'); | |
| pill.className = 'pill'; | |
| pill.textContent = L.category; | |
| meta.appendChild(pill); | |
| } | |
| if (L.curatedList && L.curatedList !== 'null' && L.curatedList) { | |
| const pill = document.createElement('span'); | |
| pill.className = 'pill'; | |
| pill.textContent = 'List: ' + L.curatedList; | |
| meta.appendChild(pill); | |
| } | |
| document.getElementById('lp-price').textContent = 'N' + L.price.toLocaleString(); | |
| document.getElementById('lp-feedback').textContent = L.priceFeedback || ''; | |
| } | |
| // ════════════════════════════════════════════════════════════════ | |
| // MINT iNFT | |
| // ════════════════════════════════════════════════════════════════ | |
| window.mintListing = async function () { | |
| if (!currentListing) return; | |
| if (!chainWallet) { alert('Chain not connected.'); return; } | |
| const mintBtn = document.getElementById('mint-btn'); | |
| mintBtn.disabled = true; | |
| mintBtn.textContent = 'Minting…'; | |
| const resultEl = document.getElementById('mint-result'); | |
| resultEl.innerHTML = ''; | |
| try { | |
| // Build dataDescription (compact JSON) | |
| const dataDescription = JSON.stringify({ | |
| title: currentListing.title, | |
| tagline: currentListing.tagline, | |
| description: currentListing.description, | |
| category: currentListing.category, | |
| curatedList: currentListing.curatedList, | |
| creator: currentListing.creator, | |
| price: currentListing.price, | |
| }); | |
| // dataHash = keccak256(image bytes + listing text) | |
| const imageHash = ethers.keccak256(ethers.toUtf8Bytes(currentListing.image.slice(0, 4000))); | |
| const dataHash = ethers.keccak256(ethers.toUtf8Bytes(dataDescription + imageHash)); | |
| let txHash, tokenId; | |
| if (listingContract) { | |
| // Use deployed contract | |
| const tx = await listingContract.mint(dataDescription, dataHash); | |
| const receipt = await tx.wait(); | |
| txHash = receipt.hash; | |
| // Try to parse the ListingMinted event for tokenId | |
| const event = receipt.logs.find(l => { | |
| try { return listingContract.interface.parseLog(l)?.name === 'ListingMinted'; } catch (_) { return false; } | |
| }); | |
| if (event) { | |
| tokenId = listingContract.interface.parseLog(event).args.tokenId.toString(); | |
| } | |
| } else { | |
| // Fallback: simple calldata transaction if contract not deployed yet | |
| const data = ethers.AbiCoder.defaultAbiCoder().encode( | |
| ['string', 'bytes32', 'address'], | |
| [dataDescription, dataHash, chainWallet.address] | |
| ); | |
| const tx = await chainWallet.sendTransaction({ to: chainWallet.address, value: 0n, data }); | |
| const receipt = await tx.wait(); | |
| txHash = receipt.hash; | |
| tokenId = 'pending-deploy'; | |
| } | |
| // Save to creator vault | |
| const record = { | |
| tokenId, | |
| txHash, | |
| title: currentListing.title, | |
| price: currentListing.price, | |
| category: currentListing.category, | |
| mintedAt: Date.now(), | |
| }; | |
| creatorVault[CFG.name].listings.unshift(record); | |
| window.OG_STORAGE.save('omk-creator', creatorVault); | |
| document.getElementById('chip-storage').classList.add('active'); | |
| // Show result | |
| resultEl.innerHTML = ''; | |
| const success = document.createElement('div'); | |
| success.style.cssText = 'background: rgba(31,59,45,0.08); border-left: 3px solid var(--forest); padding: 12px 16px;'; | |
| const title = document.createElement('div'); | |
| title.style.cssText = 'font-family: var(--font-serif); font-style: italic; font-size: 18px; color: var(--forest);'; | |
| title.textContent = listingContract ? `Minted as iNFT #${tokenId}` : 'Logged on-chain (deploy iNFT contract for full ERC-7857)'; | |
| success.appendChild(title); | |
| const link = document.createElement('a'); | |
| link.href = window.OG_CHAIN.explorerTx(txHash); | |
| link.target = '_blank'; | |
| link.style.cssText = 'font-family: var(--font-mono); font-size: 11px; color: var(--fg-2); display: block; margin-top: 4px;'; | |
| link.textContent = window.shortHash(txHash) + ' →'; | |
| success.appendChild(link); | |
| resultEl.appendChild(success); | |
| renderMinted(); | |
| } catch (e) { | |
| resultEl.innerHTML = ''; | |
| const err = document.createElement('div'); | |
| err.style.cssText = 'color: var(--terracotta); font-size: 13px;'; | |
| err.textContent = 'Mint failed: ' + e.message; | |
| resultEl.appendChild(err); | |
| } | |
| mintBtn.disabled = false; | |
| mintBtn.textContent = 'Mint another'; | |
| }; | |
| function renderMinted() { | |
| const grid = document.getElementById('minted-grid'); | |
| const list = creatorVault[CFG.name]?.listings || []; | |
| grid.innerHTML = ''; | |
| if (list.length === 0) { | |
| const empty = document.createElement('div'); | |
| empty.style.cssText = 'font-size: 12px; color: var(--fg-3); padding: 12px 0;'; | |
| empty.textContent = 'None yet.'; | |
| grid.appendChild(empty); | |
| return; | |
| } | |
| for (const item of list) { | |
| const a = document.createElement('a'); | |
| a.className = 'om-minted-card'; | |
| a.href = window.OG_CHAIN.explorerTx(item.txHash); | |
| a.target = '_blank'; | |
| const id = document.createElement('div'); | |
| id.className = 'id'; | |
| id.textContent = '#' + item.tokenId; | |
| a.appendChild(id); | |
| const n = document.createElement('div'); | |
| n.className = 'name'; | |
| n.textContent = item.title; | |
| a.appendChild(n); | |
| const p = document.createElement('div'); | |
| p.style.cssText = 'font-family: var(--font-mono); font-size: 11px; color: var(--ink);'; | |
| p.textContent = 'N' + item.price.toLocaleString(); | |
| a.appendChild(p); | |
| const tx = document.createElement('div'); | |
| tx.className = 'tx'; | |
| tx.textContent = window.shortHash(item.txHash); | |
| a.appendChild(tx); | |
| grid.appendChild(a); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |