oga-market / creator.html
harystyleseze
Oga Market: Day 2 hybrid shopper + creator app for Good Online
dcc955a
Raw
History Blame Contribute Delete
19.5 kB
<!DOCTYPE html>
<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>