harystyleseze commited on
Commit
dcc955a
Β·
0 Parent(s):

Oga Market: Day 2 hybrid shopper + creator app for Good Online

Browse files
Files changed (13) hide show
  1. .gitignore +8 -0
  2. AGENTS.md +69 -0
  3. CLAUDE.md +70 -0
  4. README.md +81 -0
  5. contract/ListingNFT.sol +93 -0
  6. contract/README.md +45 -0
  7. contract/deploy.js +111 -0
  8. contract/package.json +14 -0
  9. creator.html +468 -0
  10. index.html +72 -0
  11. shared.js +233 -0
  12. shopper.html +440 -0
  13. styles.css +843 -0
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .DS_Store
3
+ *.log
4
+ contract/.env
5
+ contract/node_modules/
6
+ contract/ListingNFT.abi.json
7
+ contract/ListingNFT.bytecode
8
+ node_modules/
AGENTS.md ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md β€” Oga Market
2
+
3
+ Portable agent guidance for the Oga Market codebase.
4
+
5
+ ## What this is
6
+
7
+ **Day 2** entry for the 0G Onsite Lagos Hackathon. A web-only commerce app for [Good Online](https://good.online) with two surfaces:
8
+
9
+ 1. **Shopper** (`shopper.html`) β€” AI shopping assistant (voice + text), wishlist on 0G Storage, on-chain shopping receipts
10
+ 2. **Creator** (`creator.html`) β€” AI listing generator (vision + LLM), mints listings as iNFTs (ERC-7857-style) on 0G Chain
11
+
12
+ Sibling project to **Day 1 Oga Mi** at `/Users/mac/OG/ogami` (which is the robot version).
13
+
14
+ ## File map
15
+
16
+ | File | Purpose |
17
+ |------|---------|
18
+ | `index.html` | Landing page with mode selector |
19
+ | `shopper.html` | Shopper experience (chat, voice, wishlist) |
20
+ | `creator.html` | Creator studio (upload photo, generate listing, mint iNFT) |
21
+ | `shared.js` | Catalog, system prompts, 0G helpers (askAI, askVision, transcribe, OG_CHAIN config, OG_STORAGE) |
22
+ | `styles.css` | All CSS β€” design tokens from `colors_and_type.css` reference |
23
+ | `contract/ListingNFT.sol` | Solidity source β€” ERC-7857-inspired |
24
+ | `contract/deploy.js` | One-step compile + deploy via solc-js + ethers |
25
+ | `contract/package.json` | Just `solc`, `ethers`, `dotenv` |
26
+
27
+ ## 0G integration
28
+
29
+ **Direct mode endpoints** (browser-CORS-friendly, NOT Router API):
30
+ - Chat: `compute-network-1.integratenetwork.work/v1/proxy/chat/completions`
31
+ - Whisper: `compute-network-16.integratenetwork.work/v1/proxy/audio/transcriptions`
32
+
33
+ Each provider needs a separate `app-sk-...` key minted from **Advanced mode** on pc.0g.ai.
34
+
35
+ **Chain**: 0G testnet (Galileo), Chain ID 16602, RPC `https://evmrpc-testnet.0g.ai`.
36
+
37
+ ## Gotchas (copy-paste from Day 1 / hackathon docs)
38
+
39
+ 1. **CORS** β€” Router API (`router-api.0g.ai`) does NOT support browser preflight. Use Direct endpoints.
40
+ 2. **Key per provider** β€” Chat key won't authorize Whisper. Mint separate `app-sk-...` for each.
41
+ 3. **Whisper format** β€” Browser MediaRecorder gives webm/opus. 0G Whisper rejects raw webm. `webmBlobToWav()` in shared.js converts to PCM WAV first.
42
+ 4. **Vision** β€” May fail on `zai-org/GLM-5-FP8` if that provider's not vision-enabled. The creator flow catches this and falls back to a text stub. To use real vision, deposit to `qwen/qwen3-vl-30b-a3b-instruct` and update `CFG.visionModel` in `creator.html`.
43
+ 5. **iNFT contract** β€” Must be deployed once via `contract/deploy.js`, address pasted into `shared.js`. If empty, creator mode falls back to plain calldata transactions (still on-chain but not real iNFTs).
44
+
45
+ ## Editing conventions
46
+
47
+ - **Single-file pages**: each HTML file is self-contained, scripts inline. Only `shared.js` and `styles.css` are imported.
48
+ - **No build step**: serve with `python3 -m http.server`.
49
+ - **Design tokens**: in `:root` of `styles.css`. Change colors there.
50
+ - **System prompts**: in `shared.js` (`buildShopperPrompt`, `buildCreatorPrompt`). Edit there.
51
+ - **Catalog**: in `shared.js` (`window.GOOD_CATALOG` string).
52
+
53
+ ## Day 1 vs Day 2
54
+
55
+ | | Day 1 (Oga Mi) | Day 2 (Oga Market) |
56
+ |---|---|---|
57
+ | Surface | Robot + 3D sim | Web only, no robot |
58
+ | Audience | In-store at Good House | Customers + creators online |
59
+ | Hardware | Reachy Mini (WebRTC) | None |
60
+ | Chain | Plain interaction calldata | iNFTs (ERC-7857-inspired) |
61
+ | Vision | None | Photo β†’ AI listing |
62
+ | Catalog | Read-only (recommends) | Read + Write (creators add new) |
63
+
64
+ ## References
65
+
66
+ - 0G docs: https://docs.0g.ai/ai-context
67
+ - Hackathon repo: https://github.com/0gfoundation/reachy-mini-hackathon
68
+ - Good Online: https://good.online
69
+ - ERC-7857 spec: https://eips.ethereum.org/EIPS/eip-7857 (we use a minimal version β€” no TEE/ZKP since listings are public)
CLAUDE.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md β€” Oga Market
2
+
3
+ @AGENTS.md
4
+
5
+ ## Claude Code workflow
6
+
7
+ 1. Edit `shopper.html`, `creator.html`, or `shared.js`
8
+ 2. Serve: `python3 -m http.server 8766`
9
+ 3. Test: http://localhost:8766 β€” refresh after edits
10
+ 4. Both modes work without hardware
11
+
12
+ ## Quick nav
13
+
14
+ | Feature | Where |
15
+ |---------|-------|
16
+ | CSS tokens | `:root` block in `styles.css` |
17
+ | Mode selector | `index.html` |
18
+ | Shopper chat loop | `shopper.html` `sendMessage()` |
19
+ | Shopper voice | `shopper.html` mic-btn click handler |
20
+ | Wishlist | `shopper.html` `addToWishlist()`, `renderWishlist()` |
21
+ | Creator upload | `creator.html` `file-input` change handler |
22
+ | Creator AI listing gen | `creator.html` `generateListing()` |
23
+ | Creator iNFT mint | `creator.html` `mintListing()` |
24
+ | 0G Compute helpers | `shared.js` `askAI()`, `askVision()`, `transcribe()` |
25
+ | 0G Chain config | `shared.js` `OG_CHAIN`, `LISTING_NFT_ABI` |
26
+ | System prompts | `shared.js` `buildShopperPrompt()`, `buildCreatorPrompt()` |
27
+ | Catalog | `shared.js` `GOOD_CATALOG` |
28
+ | Contract source | `contract/ListingNFT.sol` |
29
+ | Contract deploy | `contract/deploy.js` |
30
+
31
+ ## Smoke test
32
+
33
+ ```bash
34
+ # Test chat key
35
+ curl -sS https://compute-network-1.integratenetwork.work/v1/proxy/chat/completions \
36
+ -H "Authorization: Bearer YOUR_APP_SK" \
37
+ -H "Content-Type: application/json" \
38
+ -d '{"model":"zai-org/GLM-5-FP8","messages":[{"role":"user","content":"hi"}],"max_tokens":5}'
39
+ ```
40
+
41
+ 200 + JSON = key works.
42
+
43
+ ## Deploy iNFT contract
44
+
45
+ ```bash
46
+ cd contract
47
+ echo "PRIVATE_KEY=0x..." > .env
48
+ npm install
49
+ node deploy.js
50
+ # Copy printed address into shared.js OG_CHAIN.listingNFT
51
+ ```
52
+
53
+ ## Adding features
54
+
55
+ **New shopper feature** β€” edit `shopper.html`. State lives in module-scope `let` vars (CFG, customerMemory, wishlist).
56
+
57
+ **New creator field** β€” add input to `creator.html` form, include in `generateListing()` prompt, persist to `creatorVault[CFG.name].listings[*]`.
58
+
59
+ **New on-chain action** β€” extend `LISTING_NFT_ABI` in `shared.js`, add a function to `ListingNFT.sol`, redeploy contract, update address.
60
+
61
+ ## Publishing
62
+
63
+ ```bash
64
+ git add -A
65
+ git commit -m "your message"
66
+ git push hf main # HuggingFace Space
67
+ git push origin main # GitHub
68
+ ```
69
+
70
+ HF Space lives at https://devharystyles-oga-market.static.hf.space/
README.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Oga Market
3
+ emoji: πŸ›οΈ
4
+ colorFrom: green
5
+ colorTo: yellow
6
+ sdk: static
7
+ pinned: false
8
+ tags:
9
+ - 0g-hackathon
10
+ ---
11
+
12
+ # Oga Market β€” Good Online on the 0G Stack
13
+
14
+ Day 2 entry for the **0G Onsite Lagos Hackathon (Commerce x AI)**.
15
+
16
+ Two surfaces on one app, both powered by 0G:
17
+
18
+ - **Shopper mode** β€” AI shopping assistant (chat + voice) that knows the entire Good Online catalog, remembers what you like, and writes on-chain receipts for every interaction.
19
+ - **Creator mode** β€” Upload a product photo β†’ AI writes the listing (title, story, category, price feedback) β†’ mint it as an **iNFT (ERC-7857-inspired)** on 0G Chain so the creator owns their listing as an on-chain asset.
20
+
21
+ ## Live
22
+
23
+ - HuggingFace Space: https://devharystyles-oga-market.static.hf.space/ *(after deploy)*
24
+ - GitHub: https://github.com/harystyleseze/oga-market *(after push)*
25
+
26
+ ## 0G integration
27
+
28
+ | Service | Used by | What |
29
+ |---------|---------|------|
30
+ | Compute (Chat) | Both | GLM-5 FP8 for conversation + listing copy |
31
+ | Compute (Whisper) | Shopper | Voice product search |
32
+ | Compute (Vision) | Creator | Analyse uploaded product photos |
33
+ | Storage | Both | Customer profiles, wishlists, minted listings (localStorage proxy) |
34
+ | Chain | Both | Shopper receipts + creator iNFTs (ERC-7857-style) on 0G testnet |
35
+
36
+ ## Run locally
37
+
38
+ ```bash
39
+ cd /Users/mac/OG/oga-market
40
+ python3 -m http.server 8766
41
+ ```
42
+
43
+ Open http://localhost:8766 β†’ choose Shopper or Creator.
44
+
45
+ ## Deploy the iNFT contract
46
+
47
+ The creator mode needs a deployed `ListingNFT` contract on 0G testnet. One-time setup:
48
+
49
+ ```bash
50
+ cd contract
51
+ echo "PRIVATE_KEY=0xYOUR_TESTNET_KEY" > .env
52
+ npm install
53
+ node deploy.js
54
+ ```
55
+
56
+ Paste the printed address into `shared.js` β†’ `window.OG_CHAIN.listingNFT`.
57
+
58
+ Get free testnet 0G at https://faucet.0g.ai
59
+
60
+ ## Files
61
+
62
+ ```
63
+ oga-market/
64
+ β”œβ”€β”€ index.html Mode selector (Shopper or Creator)
65
+ β”œβ”€β”€ shopper.html AI shopping assistant
66
+ β”œβ”€β”€ creator.html AI listing generator + iNFT minter
67
+ β”œβ”€β”€ shared.js Catalog + 0G helpers (Compute, Chain, Storage)
68
+ β”œβ”€β”€ styles.css Good Online design system (cream/forest/saffron)
69
+ β”œβ”€β”€ contract/
70
+ β”‚ β”œβ”€β”€ ListingNFT.sol ERC-7857-inspired contract source
71
+ β”‚ β”œβ”€β”€ deploy.js One-step compile + deploy
72
+ β”‚ β”œβ”€β”€ package.json
73
+ β”‚ └── README.md
74
+ β”œβ”€β”€ CLAUDE.md
75
+ β”œβ”€β”€ AGENTS.md
76
+ └── README.md
77
+ ```
78
+
79
+ ## Built for
80
+
81
+ 0G Onsite Lagos Β· Day 2 Β· Commerce x AI Β· 2026-05-28
contract/ListingNFT.sol ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.20;
3
+
4
+ /**
5
+ * @title ListingNFT
6
+ * @notice A minimal ERC-7857-inspired iNFT for Good Online product listings.
7
+ * Each listing is minted by a creator with on-chain metadata description
8
+ * and a content hash. Ownership is transferable.
9
+ *
10
+ * This is a simplified version of ERC-7857 (Intelligent NFTs) that
11
+ * skips the TEE/ZKP verification layer required for full encrypted
12
+ * metadata transfer. Listings here are public by design (it's a
13
+ * marketplace), so we just need: ownership + content commitment.
14
+ */
15
+ contract ListingNFT {
16
+ struct IntelligentData {
17
+ string dataDescription;
18
+ bytes32 dataHash;
19
+ address creator;
20
+ uint256 timestamp;
21
+ }
22
+
23
+ string public constant name = "Good Online Listing";
24
+ string public constant symbol = "GOOD-LIST";
25
+
26
+ uint256 private _nextTokenId = 1;
27
+ mapping(uint256 => address) private _owners;
28
+ mapping(uint256 => IntelligentData) private _data;
29
+ mapping(address => uint256) private _balances;
30
+
31
+ event ListingMinted(
32
+ uint256 indexed tokenId,
33
+ address indexed creator,
34
+ string dataDescription,
35
+ bytes32 dataHash,
36
+ uint256 timestamp
37
+ );
38
+ event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
39
+
40
+ /// @notice Mint a new listing as an iNFT.
41
+ /// @param dataDescription JSON string describing the listing (title, category, price, etc.)
42
+ /// @param dataHash keccak256 commitment to the full content (image + text)
43
+ /// @return tokenId of the new listing
44
+ function mint(string memory dataDescription, bytes32 dataHash) external returns (uint256) {
45
+ uint256 tokenId = _nextTokenId++;
46
+ _owners[tokenId] = msg.sender;
47
+ _balances[msg.sender] += 1;
48
+ _data[tokenId] = IntelligentData({
49
+ dataDescription: dataDescription,
50
+ dataHash: dataHash,
51
+ creator: msg.sender,
52
+ timestamp: block.timestamp
53
+ });
54
+ emit ListingMinted(tokenId, msg.sender, dataDescription, dataHash, block.timestamp);
55
+ emit Transfer(address(0), msg.sender, tokenId);
56
+ return tokenId;
57
+ }
58
+
59
+ /// @notice Get the listing data for a token.
60
+ function getData(uint256 tokenId) external view returns (
61
+ string memory dataDescription,
62
+ bytes32 dataHash,
63
+ address creator,
64
+ uint256 timestamp
65
+ ) {
66
+ IntelligentData memory d = _data[tokenId];
67
+ return (d.dataDescription, d.dataHash, d.creator, d.timestamp);
68
+ }
69
+
70
+ function totalSupply() external view returns (uint256) {
71
+ return _nextTokenId - 1;
72
+ }
73
+
74
+ function ownerOf(uint256 tokenId) external view returns (address) {
75
+ require(_owners[tokenId] != address(0), "Token does not exist");
76
+ return _owners[tokenId];
77
+ }
78
+
79
+ function balanceOf(address owner) external view returns (uint256) {
80
+ return _balances[owner];
81
+ }
82
+
83
+ /// @notice Transfer ownership of a listing.
84
+ function transferFrom(address from, address to, uint256 tokenId) external {
85
+ require(_owners[tokenId] == from, "Not owner");
86
+ require(from == msg.sender, "Only owner can transfer");
87
+ require(to != address(0), "Cannot transfer to zero");
88
+ _balances[from] -= 1;
89
+ _balances[to] += 1;
90
+ _owners[tokenId] = to;
91
+ emit Transfer(from, to, tokenId);
92
+ }
93
+ }
contract/README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ListingNFT β€” Contract Deploy
2
+
3
+ Minimal ERC-7857-inspired iNFT contract for Good Online product listings. Creators mint listings as on-chain owned tokens.
4
+
5
+ ## Deploy in 3 commands
6
+
7
+ ```bash
8
+ cd contract
9
+ echo "PRIVATE_KEY=0xYOUR_TESTNET_PRIVATE_KEY" > .env
10
+ npm install
11
+ node deploy.js
12
+ ```
13
+
14
+ The script compiles `ListingNFT.sol` with `solc-js`, deploys to **0G testnet** (Chain ID 16602), and prints the deployed address.
15
+
16
+ ## After deploy
17
+
18
+ Copy the printed address and paste it into `../shared.js`:
19
+
20
+ ```js
21
+ window.OG_CHAIN = {
22
+ rpc: 'https://evmrpc-testnet.0g.ai',
23
+ chainId: 16602,
24
+ explorerTx: (h) => `https://chainscan-galileo.0g.ai/tx/${h}`,
25
+ listingNFT: '0xYOUR_DEPLOYED_ADDRESS', // ← paste here
26
+ };
27
+ ```
28
+
29
+ ## Get testnet 0G
30
+
31
+ Free from https://faucet.0g.ai (0.1 0G/day, more than enough for deploy)
32
+
33
+ ## Contract API
34
+
35
+ ```solidity
36
+ function mint(string dataDescription, bytes32 dataHash) external returns (uint256 tokenId);
37
+ function getData(uint256 tokenId) external view returns (string, bytes32, address, uint256);
38
+ function ownerOf(uint256 tokenId) external view returns (address);
39
+ function transferFrom(address from, address to, uint256 tokenId) external;
40
+ function totalSupply() external view returns (uint256);
41
+ ```
42
+
43
+ Events:
44
+ - `ListingMinted(uint256 indexed tokenId, address indexed creator, string dataDescription, bytes32 dataHash, uint256 timestamp)`
45
+ - `Transfer(address indexed from, address indexed to, uint256 indexed tokenId)` (ERC-721 compatible)
contract/deploy.js ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // deploy.js β€” compile and deploy ListingNFT.sol to 0G testnet
2
+ //
3
+ // Usage:
4
+ // cd contract
5
+ // echo "PRIVATE_KEY=0x..." > .env
6
+ // npm install
7
+ // node deploy.js
8
+ //
9
+ // Prints the deployed address β€” paste into ../shared.js OG_CHAIN.listingNFT
10
+
11
+ import { readFileSync, writeFileSync } from 'fs';
12
+ import { fileURLToPath } from 'url';
13
+ import { dirname, resolve } from 'path';
14
+ import { ethers } from 'ethers';
15
+ import solc from 'solc';
16
+ import 'dotenv/config';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+
20
+ const OG_RPC = 'https://evmrpc-testnet.0g.ai';
21
+ const OG_CHAIN_ID = 16602;
22
+
23
+ function compile() {
24
+ console.log('Compiling ListingNFT.sol…');
25
+ const source = readFileSync(resolve(__dirname, 'ListingNFT.sol'), 'utf8');
26
+
27
+ const input = {
28
+ language: 'Solidity',
29
+ sources: { 'ListingNFT.sol': { content: source } },
30
+ settings: {
31
+ outputSelection: { '*': { '*': ['abi', 'evm.bytecode.object'] } },
32
+ optimizer: { enabled: true, runs: 200 },
33
+ },
34
+ };
35
+
36
+ const output = JSON.parse(solc.compile(JSON.stringify(input)));
37
+
38
+ if (output.errors) {
39
+ for (const err of output.errors) {
40
+ if (err.severity === 'error') {
41
+ console.error(err.formattedMessage);
42
+ process.exit(1);
43
+ }
44
+ }
45
+ }
46
+
47
+ const contract = output.contracts['ListingNFT.sol']['ListingNFT'];
48
+ const abi = contract.abi;
49
+ const bytecode = contract.evm.bytecode.object;
50
+
51
+ // Save artifacts for the frontend
52
+ writeFileSync(resolve(__dirname, 'ListingNFT.abi.json'), JSON.stringify(abi, null, 2));
53
+ writeFileSync(resolve(__dirname, 'ListingNFT.bytecode'), bytecode);
54
+ console.log('Compiled. ABI + bytecode written to contract/.');
55
+
56
+ return { abi, bytecode };
57
+ }
58
+
59
+ async function deploy() {
60
+ const privateKey = process.env.PRIVATE_KEY;
61
+ if (!privateKey) {
62
+ console.error('Missing PRIVATE_KEY in contract/.env');
63
+ console.error('Create one: echo "PRIVATE_KEY=0x..." > .env');
64
+ process.exit(1);
65
+ }
66
+
67
+ const { abi, bytecode } = compile();
68
+
69
+ console.log('Connecting to 0G testnet…');
70
+ const provider = new ethers.JsonRpcProvider(OG_RPC);
71
+ const wallet = new ethers.Wallet(privateKey, provider);
72
+
73
+ const network = await provider.getNetwork();
74
+ if (Number(network.chainId) !== OG_CHAIN_ID) {
75
+ console.warn(`Warning: chainId is ${network.chainId}, expected ${OG_CHAIN_ID}`);
76
+ }
77
+
78
+ const balance = await provider.getBalance(wallet.address);
79
+ console.log(`Wallet: ${wallet.address}`);
80
+ console.log(`Balance: ${ethers.formatEther(balance)} 0G`);
81
+
82
+ if (balance === 0n) {
83
+ console.error('Wallet has 0 balance. Get testnet tokens at https://faucet.0g.ai');
84
+ process.exit(1);
85
+ }
86
+
87
+ console.log('Deploying ListingNFT…');
88
+ const factory = new ethers.ContractFactory(abi, '0x' + bytecode, wallet);
89
+ const contract = await factory.deploy();
90
+ console.log(`Tx submitted: ${contract.deploymentTransaction().hash}`);
91
+ console.log('Waiting for confirmation…');
92
+ await contract.waitForDeployment();
93
+ const addr = await contract.getAddress();
94
+
95
+ console.log('');
96
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
97
+ console.log('βœ“ Deployed!');
98
+ console.log('');
99
+ console.log(`Address: ${addr}`);
100
+ console.log(`Explorer: https://chainscan-galileo.0g.ai/address/${addr}`);
101
+ console.log('');
102
+ console.log('Next step:');
103
+ console.log(' Edit ../shared.js and set:');
104
+ console.log(` OG_CHAIN.listingNFT = '${addr}'`);
105
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
106
+ }
107
+
108
+ deploy().catch(e => {
109
+ console.error('Deploy failed:', e);
110
+ process.exit(1);
111
+ });
contract/package.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "oga-market-contract",
3
+ "version": "1.0.0",
4
+ "description": "ListingNFT (ERC-7857-inspired) iNFT contract for Oga Market",
5
+ "type": "module",
6
+ "scripts": {
7
+ "deploy": "node deploy.js"
8
+ },
9
+ "dependencies": {
10
+ "dotenv": "^16.4.5",
11
+ "ethers": "^6.13.4",
12
+ "solc": "^0.8.20"
13
+ }
14
+ }
creator.html ADDED
@@ -0,0 +1,468 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Oga Market β€” Creator Studio</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+
11
+ <!-- ═══ SETUP ═══ -->
12
+ <div id="setup" class="om-setup">
13
+ <div class="om-setup-card">
14
+ <h2>Creator studio.</h2>
15
+ <p class="sub">List your product on Good Online β€” AI writes the copy, you own it on-chain as an iNFT.</p>
16
+
17
+ <div class="om-field">
18
+ <label>Creator name</label>
19
+ <input id="cfg-name" type="text" placeholder="Your brand name (e.g. Arami, JacquΓ© Lagos)" spellcheck="false" />
20
+ </div>
21
+
22
+ <div class="om-field">
23
+ <label>0G Chat API Key</label>
24
+ <input id="cfg-chat-key" type="password" placeholder="app-sk-..." spellcheck="false" />
25
+ <div class="om-field-hint">Used for vision + copywriting via 0G Compute</div>
26
+ </div>
27
+
28
+ <div class="om-field">
29
+ <label>0G Chain Private Key</label>
30
+ <input id="cfg-chain-key" type="password" placeholder="0x... (required to mint iNFTs)" />
31
+ <div class="om-field-hint">Free testnet tokens at <a href="https://faucet.0g.ai" target="_blank">faucet.0g.ai</a></div>
32
+ </div>
33
+
34
+ <div id="cfg-err" class="om-err"></div>
35
+ <button class="om-cta" onclick="startCreator()">Enter studio</button>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- ═══ TOPBAR ═══ -->
40
+ <div class="om-topbar">
41
+ <div class="om-brand">
42
+ <svg width="24" height="24" viewBox="0 0 96 96" fill="none">
43
+ <rect x="14" y="30" width="68" height="56" rx="6" fill="#1F3B2D"/>
44
+ <circle cx="36" cy="58" r="6" fill="#F5EFE3"/>
45
+ <circle cx="60" cy="58" r="6" fill="#F5EFE3"/>
46
+ <line x1="34" y1="30" x2="34" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/>
47
+ <line x1="62" y1="30" x2="62" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/>
48
+ </svg>
49
+ <a href="index.html">Oga Market</a>
50
+ <span class="om-brand-tag">Creator</span>
51
+ </div>
52
+ <div class="om-topbar-actions">
53
+ <span id="creator-badge" style="font-size: 13px; color: var(--fg-2);"></span>
54
+ <div class="om-services">
55
+ <span class="om-chip compute" id="chip-compute">Compute</span>
56
+ <span class="om-chip storage" id="chip-storage">Storage</span>
57
+ <span class="om-chip chain" id="chip-chain">Chain</span>
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- ═══ APP ═══ -->
63
+ <main class="om-app">
64
+ <div class="om-app-grid-creator">
65
+
66
+ <!-- LEFT: Upload form -->
67
+ <section class="om-upload">
68
+ <h3>1. Upload your product</h3>
69
+
70
+ <label for="file-input" class="om-dropzone" id="dropzone">
71
+ <div id="dropzone-empty">
72
+ <svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color: var(--fg-3);">
73
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
74
+ <polyline points="17 8 12 3 7 8"/>
75
+ <line x1="12" y1="3" x2="12" y2="15"/>
76
+ </svg>
77
+ <p style="margin-top: 12px;">Click to upload photo<br><small style="color:var(--fg-3);">PNG, JPG, WebP Β· max 4MB</small></p>
78
+ </div>
79
+ <img id="dropzone-img" style="display:none;" />
80
+ </label>
81
+ <input id="file-input" type="file" accept="image/*" style="display:none;" />
82
+
83
+ <div class="om-field">
84
+ <label>Product name (working title)</label>
85
+ <input id="prod-name" type="text" placeholder="e.g. Hibiscus body oil" />
86
+ </div>
87
+
88
+ <div class="om-field">
89
+ <label>Price (NGN)</label>
90
+ <input id="prod-price" type="number" placeholder="13600" />
91
+ </div>
92
+
93
+ <button class="om-cta" id="generate-btn" onclick="generateListing()" disabled>
94
+ Generate listing with AI
95
+ </button>
96
+ <div id="gen-status" style="font-size: 12px; color: var(--fg-3); text-align: center; min-height: 16px;"></div>
97
+ </section>
98
+
99
+ <!-- RIGHT: Preview + mint -->
100
+ <section class="om-listing-preview">
101
+ <h3>2. Preview & mint</h3>
102
+ <div id="listing-empty" class="om-listing-preview-empty">Upload a photo and click Generate to see the AI listing here.</div>
103
+ <div id="listing-card" class="om-listing-card" style="display:none;">
104
+ <span class="om-listing-eyebrow" id="lp-eyebrow">Category</span>
105
+ <h4 class="om-listing-title" id="lp-title"></h4>
106
+ <p class="om-listing-tagline" id="lp-tagline"></p>
107
+ <p class="om-listing-desc" id="lp-desc"></p>
108
+ <div class="om-listing-meta" id="lp-meta"></div>
109
+ <div class="om-listing-price" id="lp-price"></div>
110
+ <div class="om-listing-feedback" id="lp-feedback"></div>
111
+ <div class="om-listing-actions">
112
+ <button class="om-cta" id="mint-btn" onclick="mintListing()">Mint as iNFT on 0G Chain</button>
113
+ <button class="om-cta om-cta-secondary" onclick="regenerate()">Regenerate</button>
114
+ </div>
115
+ </div>
116
+
117
+ <div id="mint-result" style="margin-top: 16px;"></div>
118
+
119
+ <div style="margin-top: var(--sp-8); padding-top: var(--sp-6); border-top: 1px solid var(--border-1);">
120
+ <span class="om-side-eyebrow">Your minted listings Β· on 0G Chain</span>
121
+ <div id="minted-grid" class="om-minted-grid">
122
+ <div style="font-size: 12px; color: var(--fg-3); padding: 12px 0;">None yet.</div>
123
+ </div>
124
+ </div>
125
+ </section>
126
+
127
+ </div>
128
+ </main>
129
+
130
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/ethers/6.13.4/ethers.umd.min.js"></script>
131
+ <script src="shared.js"></script>
132
+ <script>
133
+ // ════════════════════════════════════════════════════════════════
134
+ // STATE
135
+ // ════════════════════════════════════════════════════════════════
136
+ let CFG = {};
137
+ let chainProvider = null, chainWallet = null, listingContract = null;
138
+ let currentImage = null; // data URL of uploaded photo
139
+ let currentListing = null; // generated listing JSON
140
+
141
+ let creatorVault = window.OG_STORAGE.load('omk-creator') || {};
142
+
143
+ // Restore previous session
144
+ (function () {
145
+ const saved = sessionStorage.getItem('omk-creator-cfg');
146
+ if (!saved) return;
147
+ try {
148
+ const s = JSON.parse(saved);
149
+ if (s.name) document.getElementById('cfg-name').value = s.name;
150
+ if (s.chatKey) document.getElementById('cfg-chat-key').value = s.chatKey;
151
+ if (s.chainKey) document.getElementById('cfg-chain-key').value = s.chainKey;
152
+ } catch (_) {}
153
+ })();
154
+
155
+ // ════════════════════════════════════════════════════════════════
156
+ // START
157
+ // ════════════════════════════════════════════════════════════════
158
+ window.startCreator = async function () {
159
+ const name = document.getElementById('cfg-name').value.trim();
160
+ const chatKey = document.getElementById('cfg-chat-key').value.trim();
161
+ const chainKey = document.getElementById('cfg-chain-key').value.trim();
162
+ if (!name) return showErr('Creator name is required.');
163
+ if (!chatKey) return showErr('0G Chat API key is required.');
164
+ if (!chainKey) return showErr('0G Chain key is required to mint iNFTs.');
165
+
166
+ CFG = { name, chatKey, chainKey, model: 'zai-org/GLM-5-FP8', visionModel: 'zai-org/GLM-5-FP8' };
167
+
168
+ // Verify chat key
169
+ try {
170
+ await window.askAI(chatKey, CFG.model, [{ role: 'user', content: 'hi' }], { maxTokens: 3 });
171
+ } catch (e) {
172
+ return showErr('Chat key failed: ' + e.message);
173
+ }
174
+
175
+ // Init chain
176
+ try {
177
+ chainProvider = new ethers.JsonRpcProvider(window.OG_CHAIN.rpc);
178
+ chainWallet = new ethers.Wallet(chainKey, chainProvider);
179
+ const balance = await chainProvider.getBalance(chainWallet.address);
180
+ if (balance === 0n) {
181
+ return showErr('Chain wallet has 0 balance. Visit faucet.0g.ai for testnet tokens.');
182
+ }
183
+ if (window.OG_CHAIN.listingNFT) {
184
+ listingContract = new ethers.Contract(window.OG_CHAIN.listingNFT, window.LISTING_NFT_ABI, chainWallet);
185
+ }
186
+ } catch (e) {
187
+ return showErr('Chain setup failed: ' + e.message);
188
+ }
189
+
190
+ sessionStorage.setItem('omk-creator-cfg', JSON.stringify(CFG));
191
+ document.getElementById('setup').classList.add('hidden');
192
+ document.getElementById('chip-compute').classList.add('active');
193
+ document.getElementById('chip-chain').classList.add('active');
194
+ document.getElementById('creator-badge').textContent = name + ' Β· ' + chainWallet.address.slice(0, 6) + '…' + chainWallet.address.slice(-4);
195
+
196
+ // Load creator's minted listings
197
+ if (!creatorVault[name]) creatorVault[name] = { listings: [] };
198
+ renderMinted();
199
+ if (creatorVault[name].listings.length > 0) {
200
+ document.getElementById('chip-storage').classList.add('active');
201
+ }
202
+ };
203
+
204
+ function showErr(msg) {
205
+ const el = document.getElementById('cfg-err');
206
+ el.textContent = msg;
207
+ el.style.display = 'block';
208
+ }
209
+
210
+ // ════════════════════════════════════════════════════════════════
211
+ // IMAGE UPLOAD
212
+ // ════════════════════════════════════════════════════════════════
213
+ const fileInput = document.getElementById('file-input');
214
+ const dropzone = document.getElementById('dropzone');
215
+ const dropzoneImg = document.getElementById('dropzone-img');
216
+ const dropzoneEmpty = document.getElementById('dropzone-empty');
217
+
218
+ fileInput.addEventListener('change', (e) => {
219
+ const f = e.target.files[0];
220
+ if (!f) return;
221
+ if (f.size > 4 * 1024 * 1024) { alert('Max 4MB please.'); return; }
222
+ const reader = new FileReader();
223
+ reader.onload = (ev) => {
224
+ currentImage = ev.target.result;
225
+ dropzoneImg.src = currentImage;
226
+ dropzoneImg.style.display = 'block';
227
+ dropzoneEmpty.style.display = 'none';
228
+ dropzone.classList.add('has-image');
229
+ document.getElementById('generate-btn').disabled = false;
230
+ };
231
+ reader.readAsDataURL(f);
232
+ });
233
+
234
+ // ════════════════════════════════════════════════════════════════
235
+ // GENERATE LISTING
236
+ // ════════════════════════════════════════════════════════════════
237
+ let generating = false;
238
+
239
+ window.generateListing = async function () {
240
+ if (generating) return;
241
+ if (!currentImage) { alert('Upload a photo first.'); return; }
242
+ const name = document.getElementById('prod-name').value.trim();
243
+ const price = document.getElementById('prod-price').value.trim();
244
+ if (!name) { alert('Working name is required.'); return; }
245
+ if (!price) { alert('Price is required.'); return; }
246
+
247
+ generating = true;
248
+ const btn = document.getElementById('generate-btn');
249
+ const status = document.getElementById('gen-status');
250
+ btn.disabled = true;
251
+ btn.textContent = 'Generating…';
252
+
253
+ try {
254
+ // 1. Vision: analyse the photo
255
+ status.textContent = 'Analysing photo via 0G Compute (Vision)…';
256
+ let visionDesc;
257
+ try {
258
+ visionDesc = await window.askVision(CFG.chatKey, CFG.visionModel, currentImage,
259
+ 'Describe this product photo in 2 sentences. Focus on: what the item is, colour, texture, mood. Keep it factual.');
260
+ } catch (e) {
261
+ // Vision may not be available on the chat provider β€” fall back to a stub
262
+ console.warn('Vision failed, falling back:', e);
263
+ visionDesc = `Product photo for "${name}".`;
264
+ }
265
+
266
+ // 2. LLM: generate the listing
267
+ status.textContent = 'Writing listing with GLM-5 on 0G Compute…';
268
+ const prompt = window.buildCreatorPrompt(visionDesc, name, Number(price), CFG.name);
269
+ const raw = await window.askAI(CFG.chatKey, CFG.model, [
270
+ { role: 'system', content: 'You are a Nigerian e-commerce copywriter. Always respond with valid JSON only.' },
271
+ { role: 'user', content: prompt },
272
+ ], { maxTokens: 600 });
273
+
274
+ // Extract JSON
275
+ let json;
276
+ try {
277
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
278
+ json = JSON.parse(jsonMatch ? jsonMatch[0] : raw);
279
+ } catch (e) {
280
+ throw new Error('AI returned invalid JSON. Try again.');
281
+ }
282
+
283
+ currentListing = {
284
+ ...json,
285
+ creator: CFG.name,
286
+ price: Number(price),
287
+ workingName: name,
288
+ image: currentImage,
289
+ visionDesc,
290
+ createdAt: Date.now(),
291
+ };
292
+ renderListing(currentListing);
293
+ status.textContent = '';
294
+ } catch (e) {
295
+ status.textContent = 'Error: ' + e.message;
296
+ }
297
+
298
+ generating = false;
299
+ btn.textContent = 'Generate listing with AI';
300
+ btn.disabled = false;
301
+ };
302
+
303
+ window.regenerate = () => generateListing();
304
+
305
+ function renderListing(L) {
306
+ document.getElementById('listing-empty').style.display = 'none';
307
+ document.getElementById('listing-card').style.display = '';
308
+ document.getElementById('lp-eyebrow').textContent = (L.category || 'Uncategorised').toUpperCase();
309
+ document.getElementById('lp-title').textContent = L.title || '';
310
+ document.getElementById('lp-tagline').textContent = L.tagline || '';
311
+ document.getElementById('lp-desc').textContent = L.description || '';
312
+ const meta = document.getElementById('lp-meta');
313
+ meta.innerHTML = '';
314
+ if (L.category) {
315
+ const pill = document.createElement('span');
316
+ pill.className = 'pill';
317
+ pill.textContent = L.category;
318
+ meta.appendChild(pill);
319
+ }
320
+ if (L.curatedList && L.curatedList !== 'null' && L.curatedList) {
321
+ const pill = document.createElement('span');
322
+ pill.className = 'pill';
323
+ pill.textContent = 'List: ' + L.curatedList;
324
+ meta.appendChild(pill);
325
+ }
326
+ document.getElementById('lp-price').textContent = 'N' + L.price.toLocaleString();
327
+ document.getElementById('lp-feedback').textContent = L.priceFeedback || '';
328
+ }
329
+
330
+ // ════════════════════════════════════════════════════════════════
331
+ // MINT iNFT
332
+ // ════════════════════════════════════════════════════════════════
333
+ window.mintListing = async function () {
334
+ if (!currentListing) return;
335
+ if (!chainWallet) { alert('Chain not connected.'); return; }
336
+ const mintBtn = document.getElementById('mint-btn');
337
+ mintBtn.disabled = true;
338
+ mintBtn.textContent = 'Minting…';
339
+
340
+ const resultEl = document.getElementById('mint-result');
341
+ resultEl.innerHTML = '';
342
+
343
+ try {
344
+ // Build dataDescription (compact JSON)
345
+ const dataDescription = JSON.stringify({
346
+ title: currentListing.title,
347
+ tagline: currentListing.tagline,
348
+ description: currentListing.description,
349
+ category: currentListing.category,
350
+ curatedList: currentListing.curatedList,
351
+ creator: currentListing.creator,
352
+ price: currentListing.price,
353
+ });
354
+ // dataHash = keccak256(image bytes + listing text)
355
+ const imageHash = ethers.keccak256(ethers.toUtf8Bytes(currentListing.image.slice(0, 4000)));
356
+ const dataHash = ethers.keccak256(ethers.toUtf8Bytes(dataDescription + imageHash));
357
+
358
+ let txHash, tokenId;
359
+
360
+ if (listingContract) {
361
+ // Use deployed contract
362
+ const tx = await listingContract.mint(dataDescription, dataHash);
363
+ const receipt = await tx.wait();
364
+ txHash = receipt.hash;
365
+ // Try to parse the ListingMinted event for tokenId
366
+ const event = receipt.logs.find(l => {
367
+ try { return listingContract.interface.parseLog(l)?.name === 'ListingMinted'; } catch (_) { return false; }
368
+ });
369
+ if (event) {
370
+ tokenId = listingContract.interface.parseLog(event).args.tokenId.toString();
371
+ }
372
+ } else {
373
+ // Fallback: simple calldata transaction if contract not deployed yet
374
+ const data = ethers.AbiCoder.defaultAbiCoder().encode(
375
+ ['string', 'bytes32', 'address'],
376
+ [dataDescription, dataHash, chainWallet.address]
377
+ );
378
+ const tx = await chainWallet.sendTransaction({ to: chainWallet.address, value: 0n, data });
379
+ const receipt = await tx.wait();
380
+ txHash = receipt.hash;
381
+ tokenId = 'pending-deploy';
382
+ }
383
+
384
+ // Save to creator vault
385
+ const record = {
386
+ tokenId,
387
+ txHash,
388
+ title: currentListing.title,
389
+ price: currentListing.price,
390
+ category: currentListing.category,
391
+ mintedAt: Date.now(),
392
+ };
393
+ creatorVault[CFG.name].listings.unshift(record);
394
+ window.OG_STORAGE.save('omk-creator', creatorVault);
395
+ document.getElementById('chip-storage').classList.add('active');
396
+
397
+ // Show result
398
+ resultEl.innerHTML = '';
399
+ const success = document.createElement('div');
400
+ success.style.cssText = 'background: rgba(31,59,45,0.08); border-left: 3px solid var(--forest); padding: 12px 16px;';
401
+ const title = document.createElement('div');
402
+ title.style.cssText = 'font-family: var(--font-serif); font-style: italic; font-size: 18px; color: var(--forest);';
403
+ title.textContent = listingContract ? `Minted as iNFT #${tokenId}` : 'Logged on-chain (deploy iNFT contract for full ERC-7857)';
404
+ success.appendChild(title);
405
+ const link = document.createElement('a');
406
+ link.href = window.OG_CHAIN.explorerTx(txHash);
407
+ link.target = '_blank';
408
+ link.style.cssText = 'font-family: var(--font-mono); font-size: 11px; color: var(--fg-2); display: block; margin-top: 4px;';
409
+ link.textContent = window.shortHash(txHash) + ' β†’';
410
+ success.appendChild(link);
411
+ resultEl.appendChild(success);
412
+ renderMinted();
413
+ } catch (e) {
414
+ resultEl.innerHTML = '';
415
+ const err = document.createElement('div');
416
+ err.style.cssText = 'color: var(--terracotta); font-size: 13px;';
417
+ err.textContent = 'Mint failed: ' + e.message;
418
+ resultEl.appendChild(err);
419
+ }
420
+
421
+ mintBtn.disabled = false;
422
+ mintBtn.textContent = 'Mint another';
423
+ };
424
+
425
+ function renderMinted() {
426
+ const grid = document.getElementById('minted-grid');
427
+ const list = creatorVault[CFG.name]?.listings || [];
428
+ grid.innerHTML = '';
429
+ if (list.length === 0) {
430
+ const empty = document.createElement('div');
431
+ empty.style.cssText = 'font-size: 12px; color: var(--fg-3); padding: 12px 0;';
432
+ empty.textContent = 'None yet.';
433
+ grid.appendChild(empty);
434
+ return;
435
+ }
436
+ for (const item of list) {
437
+ const a = document.createElement('a');
438
+ a.className = 'om-minted-card';
439
+ a.href = window.OG_CHAIN.explorerTx(item.txHash);
440
+ a.target = '_blank';
441
+
442
+ const id = document.createElement('div');
443
+ id.className = 'id';
444
+ id.textContent = '#' + item.tokenId;
445
+ a.appendChild(id);
446
+
447
+ const n = document.createElement('div');
448
+ n.className = 'name';
449
+ n.textContent = item.title;
450
+ a.appendChild(n);
451
+
452
+ const p = document.createElement('div');
453
+ p.style.cssText = 'font-family: var(--font-mono); font-size: 11px; color: var(--ink);';
454
+ p.textContent = 'N' + item.price.toLocaleString();
455
+ a.appendChild(p);
456
+
457
+ const tx = document.createElement('div');
458
+ tx.className = 'tx';
459
+ tx.textContent = window.shortHash(item.txHash);
460
+ a.appendChild(tx);
461
+
462
+ grid.appendChild(a);
463
+ }
464
+ }
465
+ </script>
466
+
467
+ </body>
468
+ </html>
index.html ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Oga Market β€” Good Online on 0G</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+
11
+ <div class="om-topbar">
12
+ <div class="om-brand">
13
+ <svg width="28" height="28" viewBox="0 0 96 96" fill="none">
14
+ <rect x="14" y="30" width="68" height="56" rx="6" fill="#1F3B2D"/>
15
+ <circle cx="36" cy="58" r="6" fill="#F5EFE3"/>
16
+ <circle cx="60" cy="58" r="6" fill="#F5EFE3"/>
17
+ <line x1="34" y1="30" x2="34" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/>
18
+ <line x1="62" y1="30" x2="62" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/>
19
+ <circle cx="34" cy="12" r="3.5" fill="#C84B23"/>
20
+ <circle cx="62" cy="12" r="3.5" fill="#E8B547"/>
21
+ </svg>
22
+ <a href="/">Oga Market</a>
23
+ <span class="om-brand-tag">Good Online Β· 0G</span>
24
+ </div>
25
+ <div class="om-topbar-actions">
26
+ <a href="https://good.online" target="_blank" style="font-size: 13px; color: var(--fg-2);">good.online β†—</a>
27
+ </div>
28
+ </div>
29
+
30
+ <main class="om-landing">
31
+ <div class="om-landing-eyebrow">Commerce Γ— AI Β· 0G Onsite Lagos Β· Day 2</div>
32
+ <h1>Two sides of the same market.</h1>
33
+ <p class="sub">For shoppers who want a friendly assistant who knows every creator. For sellers who need their listings to feel as good as their craft. Both powered by 0G Compute, Storage, and Chain.</p>
34
+
35
+ <div class="om-modes">
36
+ <a href="shopper.html" class="om-mode">
37
+ <div class="om-mode-icon">
38
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
39
+ <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/>
40
+ <line x1="3" y1="6" x2="21" y2="6"/>
41
+ <path d="M16 10a4 4 0 0 1-8 0"/>
42
+ </svg>
43
+ </div>
44
+ <h2 class="om-mode-title">I'm shopping.</h2>
45
+ <p class="om-mode-desc">Ask Oga Mi what to get for your wife, your mum, yourself. Voice or text. He remembers what you like across visits and saves your wishlist on 0G Storage. Every recommendation receipted on-chain.</p>
46
+ <span class="om-mode-cta">Open shopper mode β†’</span>
47
+ </a>
48
+
49
+ <a href="creator.html" class="om-mode">
50
+ <div class="om-mode-icon">
51
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
52
+ <path d="M12 19l7-7 3 3-7 7-3-3z"/>
53
+ <path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/>
54
+ <path d="M2 2l7.586 7.586"/>
55
+ <circle cx="11" cy="11" r="2"/>
56
+ </svg>
57
+ </div>
58
+ <h2 class="om-mode-title">I'm a creator.</h2>
59
+ <p class="om-mode-desc">Upload a photo of what you made. AI writes you a listing β€” title, story, category, price feedback. Mint it as an iNFT on 0G Chain so you own the listing as an on-chain asset.</p>
60
+ <span class="om-mode-cta">Open creator studio β†’</span>
61
+ </a>
62
+ </div>
63
+
64
+ <div class="om-stack-row">
65
+ <span class="om-stack-item"><strong>0G Compute</strong> Β· GLM-5 + Whisper + Vision</span>
66
+ <span class="om-stack-item"><strong>0G Storage</strong> Β· profiles & listings</span>
67
+ <span class="om-stack-item"><strong>0G Chain</strong> Β· iNFTs (ERC-7857) & receipts</span>
68
+ </div>
69
+ </main>
70
+
71
+ </body>
72
+ </html>
shared.js ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // shared.js β€” common catalog, system prompts, and 0G helpers for Oga Market
2
+
3
+ // ════════════════════════════════════════════════════════════════
4
+ // GOOD ONLINE PRODUCT CATALOG (lifted from Oga Mi)
5
+ // ════════════════════════════════════════════════════════════════
6
+ window.GOOD_CATALOG = `
7
+ ## GOOD ONLINE PRODUCT CATALOG (good.online)
8
+ "Made by Nigerians. Curated for you." β€” A marketplace connecting you with Nigerian creators committed to quality and positive impact. Delivers within Lagos only.
9
+
10
+ ### FEATURED PRODUCTS
11
+ - Coconut Rice (430g) by Chumbys β€” N5,400 [Cook it Yourself]
12
+ - Glow Oil by Arami β€” N13,600 [Beauty]
13
+ - Whole Cashew Jar by The M & W Nuts Company β€” N11,875 [Snacks]
14
+ - Gym Socks Box by Smileys Africa β€” N17,500 [Fashion]
15
+ - Miniature Trio by Dala Craft β€” N54,000 [Home & Living]
16
+ - Sugar Wax Kit by Sugar Wax By Audrey β€” N17,000 [Beauty]
17
+ - Dudu Ball Chain by ITURA Jewelry β€” N40,000 [Fashion/Jewelry]
18
+ - Mini Scented Candle Gift Set by Jacque Lagos β€” N70,000 [Gifts/Home]
19
+ - Plantain Clusters by Good Originals β€” N5,520 [Snacks]
20
+
21
+ ### CATEGORIES
22
+ Fashion, Beauty, Home & Living, Wellness, Gifts, Leisure, Good Originals, Snacks, Drinks, Cook it Yourself, Worklife, For Moms, For Women
23
+
24
+ ### CURATED LISTS (with starting prices)
25
+ - "For Everyone Carrying This Economy on Their Back" β€” from N3,000
26
+ - "A Good Place to Start" β€” from N500
27
+ - "Something to Add to Your Skincare Routine" β€” from N4,000
28
+ - "Some Thoughtful Gifts for Everyone" β€” from N13,000
29
+ - "The Faster Way to Familiar Dishes" β€” from N2,250
30
+ - "Everyday Comforts That Make Life Feel Lighter" β€” from N7,500
31
+ - "For the Kids We're All Soft For" β€” from N5,160
32
+ - "Cyn Ugwu's Picks to Living a Good Life" β€” from N4,200
33
+ - "Travel Essentials That Actually Come in Handy" β€” from N4,500
34
+
35
+ ### TOP CREATORS (50+)
36
+ Fashion: Smileys Africa, TETE AFRICA, Garmisland, Asabithebrand, AZACH, Claic Design, Captain Atelier, Marte Egele, Sarima By Ama, TwentySix, Crivel, OhMyCowries, Floy NG, Rade Label, 1403 Luxury, Lano Lagos, THESHOEBLOCC, Outlash, Vinc & Eliz, ITURA Jewelry, KunbiWorks
37
+ Beauty: Arami, Tamed Lux, Infused Organics, ESTEVAN PERFUMERY, Sugar Wax By Audrey, Sublime Skin by Jumai, Msmetics, Viektors Bath and Body, TGIN, Onoh Naturals
38
+ Home & Living: Jacque Lagos, Wallinggs Interior, Silkluxe Hub, Oeuvre Designs, Alore Lifestyle, Ediye Home Scents, Iluna Candles & Co, Hingees, The DA Brand, Scent L'avie
39
+ Wellness: Soft Mum Era, Enjoca, Olile Enterprises, Clove and Nectar, Just Journals
40
+ Snacks & Food: Chumbys, The M & W Nuts Company, Good Originals
41
+ Gifts: The Kulture Company, Not Just Pulp, FCAL (FC Accessories), The Ajala Game Store
42
+ Leisure: Atito, The Ajala Game Store
43
+
44
+ ### STORE INFO
45
+ - Website: good.online
46
+ - Good House: 19B Sasegbon Street, Ikeja GRA, Lagos
47
+ - Good Village: Plot 33 Block 15, Admiralty Way, Lekki Phase 1, Lagos
48
+ - Contact: +2348142511154 / support@goodthingco.xyz
49
+ - Delivery: Lagos only
50
+ - Gift vouchers available at good.online
51
+ `;
52
+
53
+ // ════════════════════════════════════════════════════════════════
54
+ // SYSTEM PROMPTS
55
+ // ════════════════════════════════════════════════════════════════
56
+
57
+ window.buildShopperPrompt = function (customerName, history, wishlist) {
58
+ let prompt = `You are Oga Mi, a warm AI shopping assistant for Good Online (good.online), Nigeria's curated marketplace.
59
+
60
+ PERSONALITY:
61
+ - Warm, like a helpful Lagos market seller who genuinely cares
62
+ - Mix English with light Pidgin/Yoruba naturally ("Oga mi!", "Na good choice!", "E kaaro!")
63
+ - Enthusiastic about Nigerian creators
64
+ - Keep responses to 2-4 sentences. Recommend 1-2 products, not a long list
65
+ - Always finish your sentences
66
+ - Use Naira prices (write as N5,400)
67
+
68
+ ${window.GOOD_CATALOG}
69
+
70
+ WHEN RECOMMENDING:
71
+ - Always mention price + creator
72
+ - Budget: "A Good Place to Start" (from N500)
73
+ - Gifts: "Some Thoughtful Gifts for Everyone" (from N13,000)
74
+ - Skincare: "Something to Add to Your Skincare Routine" (from N4,000)
75
+
76
+ When you recommend a specific product, end your message with [PRODUCT:Product Name|Creator|Price] so the UI can show an add-to-wishlist button. Example: [PRODUCT:Glow Oil|Arami|13600]. Only include this tag for products from the catalog above.`;
77
+
78
+ if (customerName) {
79
+ prompt += `\n\nCUSTOMER: ${customerName}. Use their name naturally.`;
80
+ }
81
+ if (wishlist && wishlist.length > 0) {
82
+ prompt += `\n\nWISHLIST: ${wishlist.map(w => w.name).join(', ')}. Reference these naturally if relevant.`;
83
+ }
84
+ if (history && history.length > 0) {
85
+ prompt += `\n\nPREVIOUS INTERACTIONS:\n${history.map(h => `- ${h}`).join('\n')}`;
86
+ }
87
+ return prompt;
88
+ };
89
+
90
+ window.buildCreatorPrompt = function (visionDescription, productName, price, creator) {
91
+ return `You are a copywriter for Good Online (good.online), a Nigerian curated marketplace. A creator is listing a new product. Generate a beautiful, warm, Nigerian-style product listing.
92
+
93
+ PRODUCT INFO:
94
+ - Name from creator: "${productName}"
95
+ - Creator: "${creator}"
96
+ - Price: N${Number(price).toLocaleString()}
97
+ - Vision analysis of the photo: "${visionDescription}"
98
+
99
+ Reference catalog (for style + category matching):
100
+ ${window.GOOD_CATALOG}
101
+
102
+ OUTPUT REQUIREMENTS β€” respond ONLY with valid JSON, no other text:
103
+ {
104
+ "title": "A polished product name (max 6 words, sentence case)",
105
+ "tagline": "A 1-sentence editorial hook in warm, Nigerian voice (max 14 words)",
106
+ "description": "A 2-3 sentence description that tells the product's story, mentions the creator, and feels handmade/curated. Sentence case. No emoji.",
107
+ "category": "One category from this list: Fashion, Beauty, Home & Living, Wellness, Gifts, Leisure, Snacks, Drinks, Cook it Yourself, Worklife, For Moms, For Women",
108
+ "curatedList": "One of these curated lists if relevant, or null: A Good Place to Start | Some Thoughtful Gifts for Everyone | Something to Add to Your Skincare Routine | Everyday Comforts That Make Life Feel Lighter | For the Kids We're All Soft For | Travel Essentials That Actually Come in Handy",
109
+ "priceFeedback": "1 sentence: is this price aligned with similar products on Good? Be honest."
110
+ }`;
111
+ };
112
+
113
+ // ════════════════════════════════════════════════════════════════
114
+ // 0G COMPUTE HELPERS (Direct mode, browser-CORS-friendly)
115
+ // ════════════════════════════════════════════════════════════════
116
+
117
+ window.OG_ENDPOINTS = {
118
+ chat: 'https://compute-network-1.integratenetwork.work/v1/proxy/chat/completions',
119
+ whisper: 'https://compute-network-16.integratenetwork.work/v1/proxy/audio/transcriptions',
120
+ // Vision provider may live on a different compute-network β€” verify on pc.0g.ai
121
+ vision: 'https://compute-network-1.integratenetwork.work/v1/proxy/chat/completions',
122
+ };
123
+
124
+ window.askAI = async function (chatKey, model, messages, { maxTokens = 512 } = {}) {
125
+ const res = await fetch(window.OG_ENDPOINTS.chat, {
126
+ method: 'POST',
127
+ headers: { 'Authorization': `Bearer ${chatKey}`, 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
129
+ });
130
+ if (!res.ok) throw new Error(`Chat ${res.status}: ${(await res.text()).slice(0, 200)}`);
131
+ const data = await res.json();
132
+ return (data.choices?.[0]?.message?.content || '').trim();
133
+ };
134
+
135
+ window.askVision = async function (chatKey, model, imageDataUrl, instruction, { maxTokens = 256 } = {}) {
136
+ // OpenAI-compatible vision message format
137
+ const res = await fetch(window.OG_ENDPOINTS.vision, {
138
+ method: 'POST',
139
+ headers: { 'Authorization': `Bearer ${chatKey}`, 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({
141
+ model,
142
+ messages: [{
143
+ role: 'user',
144
+ content: [
145
+ { type: 'text', text: instruction },
146
+ { type: 'image_url', image_url: { url: imageDataUrl } },
147
+ ],
148
+ }],
149
+ max_tokens: maxTokens,
150
+ }),
151
+ });
152
+ if (!res.ok) throw new Error(`Vision ${res.status}: ${(await res.text()).slice(0, 200)}`);
153
+ const data = await res.json();
154
+ return (data.choices?.[0]?.message?.content || '').trim();
155
+ };
156
+
157
+ // Convert browser webm/opus audio to PCM WAV (0G Whisper rejects raw webm)
158
+ window.webmBlobToWav = async function (blob) {
159
+ const buf = await blob.arrayBuffer();
160
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
161
+ const audio = await ctx.decodeAudioData(buf);
162
+ try { ctx.close(); } catch (_) {}
163
+ const ch0 = audio.getChannelData(0);
164
+ const len = ch0.length;
165
+ const sampleRate = audio.sampleRate;
166
+ const out = new ArrayBuffer(44 + len * 2);
167
+ const v = new DataView(out);
168
+ const ws = (off, s) => { for (let i = 0; i < s.length; i++) v.setUint8(off + i, s.charCodeAt(i)); };
169
+ ws(0, 'RIFF'); v.setUint32(4, 36 + len * 2, true); ws(8, 'WAVE');
170
+ ws(12, 'fmt '); v.setUint32(16, 16, true);
171
+ v.setUint16(20, 1, true); v.setUint16(22, 1, true);
172
+ v.setUint32(24, sampleRate, true); v.setUint32(28, sampleRate * 2, true);
173
+ v.setUint16(32, 2, true); v.setUint16(34, 16, true);
174
+ ws(36, 'data'); v.setUint32(40, len * 2, true);
175
+ let off = 44;
176
+ for (let i = 0; i < len; i++) {
177
+ const s = Math.max(-1, Math.min(1, ch0[i]));
178
+ v.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
179
+ off += 2;
180
+ }
181
+ return new Blob([out], { type: 'audio/wav' });
182
+ };
183
+
184
+ window.transcribe = async function (sttKey, webmBlob) {
185
+ const wav = await window.webmBlobToWav(webmBlob);
186
+ const form = new FormData();
187
+ form.append('file', wav, 'audio.wav');
188
+ form.append('model', 'openai/whisper-large-v3');
189
+ form.append('response_format', 'json');
190
+ const res = await fetch(window.OG_ENDPOINTS.whisper, {
191
+ method: 'POST',
192
+ headers: { 'Authorization': `Bearer ${sttKey}` },
193
+ body: form,
194
+ });
195
+ if (!res.ok) throw new Error(`STT ${res.status}: ${(await res.text()).slice(0, 200)}`);
196
+ return (await res.json()).text || '';
197
+ };
198
+
199
+ // ════════════════════════════════════════════════════════════════
200
+ // 0G CHAIN HELPERS
201
+ // ════════════════════════════════════════════════════════════════
202
+ window.OG_CHAIN = {
203
+ rpc: 'https://evmrpc-testnet.0g.ai',
204
+ chainId: 16602,
205
+ explorerTx: (h) => `https://chainscan-galileo.0g.ai/tx/${h}`,
206
+ // Deployed ListingNFT contract address β€” paste after running contract/deploy.js
207
+ listingNFT: '',
208
+ };
209
+
210
+ window.LISTING_NFT_ABI = [
211
+ 'function mint(string dataDescription, bytes32 dataHash) external returns (uint256)',
212
+ 'function getData(uint256 tokenId) external view returns (string dataDescription, bytes32 dataHash, address creator, uint256 timestamp)',
213
+ 'function totalSupply() external view returns (uint256)',
214
+ 'function ownerOf(uint256 tokenId) external view returns (address)',
215
+ 'function transferFrom(address from, address to, uint256 tokenId) external',
216
+ 'event ListingMinted(uint256 indexed tokenId, address indexed creator, string dataDescription, bytes32 dataHash, uint256 timestamp)',
217
+ ];
218
+
219
+ window.shortHash = function (h) {
220
+ return h.slice(0, 10) + '…' + h.slice(-6);
221
+ };
222
+
223
+ // ════════════════════════════════════════════════════════════════
224
+ // LOCAL STORAGE HELPERS (0G Storage proxy)
225
+ // ════════════════════════════════════════════════════════════════
226
+ window.OG_STORAGE = {
227
+ load(key) {
228
+ try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch (_) { return null; }
229
+ },
230
+ save(key, value) {
231
+ localStorage.setItem(key, JSON.stringify(value));
232
+ },
233
+ };
shopper.html ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Oga Market β€” Shopper</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+
11
+ <!-- ═══ SETUP ═══ -->
12
+ <div id="setup" class="om-setup">
13
+ <div class="om-setup-card">
14
+ <h2>Welcome, oga.</h2>
15
+ <p class="sub">Tell me your name and paste your 0G API key. I'll remember what you like.</p>
16
+
17
+ <div class="om-field">
18
+ <label>Your name</label>
19
+ <input id="cfg-name" type="text" placeholder="What should I call you?" spellcheck="false" />
20
+ </div>
21
+
22
+ <div class="om-field">
23
+ <label>0G Chat API Key</label>
24
+ <input id="cfg-chat-key" type="password" placeholder="app-sk-..." spellcheck="false" />
25
+ <div class="om-field-hint"><a href="https://pc.0g.ai" target="_blank">pc.0g.ai</a> β†’ Advanced mode β†’ fund <code>zai-org/GLM-5-FP8</code> β†’ mint key</div>
26
+ </div>
27
+
28
+ <div class="om-field">
29
+ <label>Whisper API Key (optional β€” enables voice)</label>
30
+ <input id="cfg-stt-key" type="password" placeholder="app-sk-... (different key)" />
31
+ </div>
32
+
33
+ <div class="om-field">
34
+ <label>0G Chain Private Key (optional β€” on-chain receipts)</label>
35
+ <input id="cfg-chain-key" type="password" placeholder="0x..." />
36
+ <div class="om-field-hint">Free testnet tokens: <a href="https://faucet.0g.ai" target="_blank">faucet.0g.ai</a></div>
37
+ </div>
38
+
39
+ <div id="cfg-err" class="om-err"></div>
40
+ <button class="om-cta" onclick="startShopper()">Start shopping</button>
41
+ </div>
42
+ </div>
43
+
44
+ <!-- ═══ TOPBAR ═══ -->
45
+ <div class="om-topbar">
46
+ <div class="om-brand">
47
+ <svg width="24" height="24" viewBox="0 0 96 96" fill="none">
48
+ <rect x="14" y="30" width="68" height="56" rx="6" fill="#1F3B2D"/>
49
+ <circle cx="36" cy="58" r="6" fill="#F5EFE3"/>
50
+ <circle cx="60" cy="58" r="6" fill="#F5EFE3"/>
51
+ <line x1="34" y1="30" x2="34" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/>
52
+ <line x1="62" y1="30" x2="62" y2="14" stroke="#15130F" stroke-width="3" stroke-linecap="round"/>
53
+ </svg>
54
+ <a href="index.html">Oga Market</a>
55
+ <span class="om-brand-tag">Shopper</span>
56
+ </div>
57
+ <div class="om-topbar-actions">
58
+ <div class="om-services">
59
+ <span class="om-chip compute" id="chip-compute">Compute</span>
60
+ <span class="om-chip storage" id="chip-storage">Storage</span>
61
+ <span class="om-chip chain" id="chip-chain">Chain</span>
62
+ </div>
63
+ </div>
64
+ </div>
65
+
66
+ <!-- ═══ APP ═══ -->
67
+ <main class="om-app">
68
+ <div class="om-app-grid-shopper">
69
+
70
+ <div class="om-conv">
71
+ <div class="om-conv-head">
72
+ <h3>Chat with Oga Mi</h3>
73
+ <span id="customer-badge"></span>
74
+ </div>
75
+
76
+ <div id="transcript"></div>
77
+
78
+ <div class="om-input-area">
79
+ <div class="om-input-row">
80
+ <input id="chat-input" placeholder="What are you looking for today?" autocomplete="off" />
81
+ <button class="om-mic" id="mic-btn" title="Tap to record">
82
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
83
+ <rect x="9" y="2" width="6" height="11" rx="3"/>
84
+ <path d="M5 10a7 7 0 0 0 14 0"/>
85
+ <line x1="12" y1="17" x2="12" y2="21"/>
86
+ </svg>
87
+ </button>
88
+ <button class="om-send" onclick="sendMessage()">
89
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
90
+ <path d="M5 12h14m-6-6 6 6-6 6"/>
91
+ </svg>
92
+ </button>
93
+ </div>
94
+ </div>
95
+ </div>
96
+
97
+ <aside class="om-side">
98
+ <div>
99
+ <span class="om-side-eyebrow">Your wishlist Β· saved to 0G Storage</span>
100
+ <h4>What you're eyeing</h4>
101
+ </div>
102
+ <div id="wishlist" class="om-wishlist">
103
+ <div class="om-wishlist-empty">Nothing yet β€” ask Oga Mi for ideas.</div>
104
+ </div>
105
+ <div style="margin-top:auto; padding-top: 16px; border-top: 1px solid var(--border-1);">
106
+ <span class="om-side-eyebrow">Good House Β· Ikeja GRA</span>
107
+ <p style="font-size: 12px; color: var(--fg-2); margin-top: 4px; line-height: 1.5;">19B Sasegbon Street, Ikeja GRA. Stop by to meet the real Oga Mi (he's a robot!).</p>
108
+ </div>
109
+ </aside>
110
+ </div>
111
+ </main>
112
+
113
+ <!-- ethers.js for 0G Chain -->
114
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/ethers/6.13.4/ethers.umd.min.js"></script>
115
+ <script src="shared.js"></script>
116
+ <script>
117
+ // ════════════════════════════════════════════════════════════════
118
+ // STATE
119
+ // ════════════════════════════════════════════════════════════════
120
+ let CFG = {};
121
+ let conversationHistory = [];
122
+ let interactionCount = 0;
123
+ let chainProvider = null, chainWallet = null;
124
+
125
+ // Customer memory (0G Storage proxy)
126
+ let customerMemory = window.OG_STORAGE.load('omk-customers') || {};
127
+ let currentCustomer = null;
128
+ let wishlist = [];
129
+
130
+ function saveMemory() {
131
+ window.OG_STORAGE.save('omk-customers', customerMemory);
132
+ document.getElementById('chip-storage').classList.add('active');
133
+ }
134
+
135
+ // ════════════════════════════════════════════════════════════════
136
+ // RESTORE
137
+ // ════════════════════════════════════════════════════════════════
138
+ (function () {
139
+ const saved = sessionStorage.getItem('omk-shopper-cfg');
140
+ if (!saved) return;
141
+ try {
142
+ const s = JSON.parse(saved);
143
+ if (s.name) document.getElementById('cfg-name').value = s.name;
144
+ if (s.chatKey) document.getElementById('cfg-chat-key').value = s.chatKey;
145
+ if (s.sttKey) document.getElementById('cfg-stt-key').value = s.sttKey;
146
+ if (s.chainKey) document.getElementById('cfg-chain-key').value = s.chainKey;
147
+ } catch (_) {}
148
+ })();
149
+
150
+ // ════════════════════════════════════════════════════════════════
151
+ // START
152
+ // ════════════════════════════════════════════════════════════════
153
+ window.startShopper = async function () {
154
+ const chatKey = document.getElementById('cfg-chat-key').value.trim();
155
+ if (!chatKey) return showErr('Chat API key is required.');
156
+ const name = document.getElementById('cfg-name').value.trim() || 'Friend';
157
+ const sttKey = document.getElementById('cfg-stt-key').value.trim();
158
+ const chainKey = document.getElementById('cfg-chain-key').value.trim();
159
+
160
+ CFG = { chatKey, sttKey, chainKey, name, model: 'zai-org/GLM-5-FP8' };
161
+
162
+ // Verify chat key
163
+ try {
164
+ await window.askAI(chatKey, CFG.model, [{ role: 'user', content: 'hi' }], { maxTokens: 3 });
165
+ } catch (e) {
166
+ return showErr('Key verification failed: ' + e.message);
167
+ }
168
+
169
+ sessionStorage.setItem('omk-shopper-cfg', JSON.stringify(CFG));
170
+ document.getElementById('setup').classList.add('hidden');
171
+ document.getElementById('chip-compute').classList.add('active');
172
+
173
+ currentCustomer = name;
174
+ if (!customerMemory[name]) {
175
+ customerMemory[name] = { visits: 0, interactions: [], wishlist: [] };
176
+ }
177
+ customerMemory[name].visits++;
178
+ customerMemory[name].lastVisit = new Date().toISOString();
179
+ wishlist = customerMemory[name].wishlist || [];
180
+ saveMemory();
181
+ renderWishlist();
182
+
183
+ const badge = document.getElementById('customer-badge');
184
+ badge.textContent = name;
185
+ badge.style.display = '';
186
+
187
+ const history = customerMemory[name].interactions.slice(-5);
188
+ conversationHistory = [{
189
+ role: 'system',
190
+ content: window.buildShopperPrompt(name, history, wishlist),
191
+ }];
192
+
193
+ const visits = customerMemory[name].visits;
194
+ if (visits > 1) {
195
+ addSystem(`0G Storage: Returning customer detected (visit #${visits})`);
196
+ document.getElementById('chip-storage').classList.add('active');
197
+ }
198
+
199
+ if (chainKey) initChain();
200
+
201
+ // Welcome
202
+ const welcomePrompt = visits > 1
203
+ ? `${name} just came back (visit #${visits}). Welcome them warmly in 1-2 sentences and ask what they're looking for.`
204
+ : `${name} just walked in for the first time. Greet warmly in 1-2 sentences. Ask what they need.`;
205
+
206
+ try {
207
+ const reply = await window.askAI(chatKey, CFG.model, [
208
+ ...conversationHistory,
209
+ { role: 'user', content: welcomePrompt },
210
+ ], { maxTokens: 100 });
211
+ addBot(reply);
212
+ } catch (e) {
213
+ addBot(`Welcome to Good Online, ${name}! How can I help you today?`);
214
+ }
215
+ };
216
+
217
+ function showErr(msg) {
218
+ const el = document.getElementById('cfg-err');
219
+ el.textContent = msg;
220
+ el.style.display = 'block';
221
+ }
222
+
223
+ // ════════════════════════════════════════════════════════════════
224
+ // CHAIN
225
+ // ════════════════════════════════════════════════════════════════
226
+ async function initChain() {
227
+ try {
228
+ chainProvider = new ethers.JsonRpcProvider(window.OG_CHAIN.rpc);
229
+ chainWallet = new ethers.Wallet(CFG.chainKey, chainProvider);
230
+ const balance = await chainProvider.getBalance(chainWallet.address);
231
+ if (balance > 0n) {
232
+ document.getElementById('chip-chain').classList.add('active');
233
+ addSystem(`0G Chain: ${chainWallet.address.slice(0, 8)}…${chainWallet.address.slice(-4)}`);
234
+ } else {
235
+ addSystem('0G Chain: no balance β€” visit faucet.0g.ai');
236
+ }
237
+ } catch (e) {
238
+ addSystem('0G Chain: ' + e.message);
239
+ }
240
+ }
241
+
242
+ async function logShopOnChain(query, recommendation) {
243
+ if (!chainWallet) return;
244
+ try {
245
+ const data = ethers.AbiCoder.defaultAbiCoder().encode(
246
+ ['string', 'string', 'string', 'uint256'],
247
+ [CFG.name, query.slice(0, 100), recommendation.slice(0, 200), Math.floor(Date.now() / 1000)]
248
+ );
249
+ const tx = await chainWallet.sendTransaction({ to: chainWallet.address, value: 0n, data });
250
+ const receipt = await tx.wait();
251
+ addReceipt(receipt.hash);
252
+ } catch (e) {
253
+ addSystem('Chain: ' + e.message.slice(0, 80));
254
+ }
255
+ }
256
+
257
+ // ════════════════════════════════════════════════════════════════
258
+ // CHAT
259
+ // ════════════════════════════════════════════════════════════════
260
+ let sending = false;
261
+
262
+ window.sendMessage = async function () {
263
+ const input = document.getElementById('chat-input');
264
+ const text = input.value.trim();
265
+ if (!text || sending) return;
266
+ input.value = '';
267
+ sending = true;
268
+
269
+ addUser(text);
270
+ conversationHistory.push({ role: 'user', content: text });
271
+
272
+ try {
273
+ const raw = await window.askAI(CFG.chatKey, CFG.model, conversationHistory.slice(-12));
274
+ // Parse [PRODUCT:name|creator|price] tags
275
+ const productMatch = raw.match(/\[PRODUCT:([^|]+)\|([^|]+)\|(\d+)\]/i);
276
+ const clean = raw.replace(/\[PRODUCT:[^\]]+\]/gi, '').trim();
277
+ addBot(clean);
278
+ if (productMatch) {
279
+ addProductCard(productMatch[1].trim(), productMatch[2].trim(), Number(productMatch[3]));
280
+ }
281
+ conversationHistory.push({ role: 'assistant', content: raw });
282
+
283
+ // Save to memory
284
+ interactionCount++;
285
+ if (currentCustomer) {
286
+ customerMemory[currentCustomer].interactions.push(`Asked: "${text.slice(0, 60)}" β€” Said: "${clean.slice(0, 80)}"`);
287
+ if (customerMemory[currentCustomer].interactions.length > 20) {
288
+ customerMemory[currentCustomer].interactions = customerMemory[currentCustomer].interactions.slice(-20);
289
+ }
290
+ saveMemory();
291
+ }
292
+
293
+ // On-chain receipt every 2nd interaction
294
+ if (chainWallet && interactionCount % 2 === 0) {
295
+ logShopOnChain(text, clean);
296
+ }
297
+ } catch (e) {
298
+ addSystem('Error: ' + e.message);
299
+ }
300
+
301
+ sending = false;
302
+ };
303
+
304
+ document.getElementById('chat-input').addEventListener('keydown', e => {
305
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
306
+ });
307
+
308
+ // ════════════════════════════════════════════════════════════════
309
+ // VOICE
310
+ // ════════════════════════════════════════════════════════════════
311
+ let mediaRecorder = null, audioChunks = [];
312
+ const micBtn = document.getElementById('mic-btn');
313
+
314
+ micBtn.addEventListener('click', async () => {
315
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
316
+ mediaRecorder.stop();
317
+ return;
318
+ }
319
+ if (!CFG.sttKey) { addSystem('Voice needs a Whisper key β€” add one in setup.'); return; }
320
+ try {
321
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
322
+ mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
323
+ audioChunks = [];
324
+ micBtn.style.background = 'var(--terracotta)';
325
+ micBtn.style.color = 'var(--cream)';
326
+ mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
327
+ mediaRecorder.onstop = async () => {
328
+ stream.getTracks().forEach(t => t.stop());
329
+ micBtn.style.background = '';
330
+ micBtn.style.color = '';
331
+ if (audioChunks.length === 0) return;
332
+ const blob = new Blob(audioChunks, { type: 'audio/webm;codecs=opus' });
333
+ addSystem('Transcribing via 0G Whisper…');
334
+ try {
335
+ const txt = await window.transcribe(CFG.sttKey, blob);
336
+ if (txt.trim()) {
337
+ document.getElementById('chat-input').value = txt;
338
+ sendMessage();
339
+ } else { addSystem('No speech detected.'); }
340
+ } catch (e) { addSystem('STT: ' + e.message); }
341
+ };
342
+ mediaRecorder.start(250);
343
+ } catch (e) {
344
+ addSystem('Mic: ' + e.message);
345
+ }
346
+ });
347
+
348
+ // ════════════════════════════════════════════════════════════════
349
+ // WISHLIST
350
+ // ════════════════════════════════════════════════════════════════
351
+ function addToWishlist(name, creator, price) {
352
+ if (wishlist.find(w => w.name === name)) return;
353
+ wishlist.push({ name, creator, price, added: Date.now() });
354
+ if (currentCustomer) {
355
+ customerMemory[currentCustomer].wishlist = wishlist;
356
+ saveMemory();
357
+ }
358
+ renderWishlist();
359
+ }
360
+
361
+ function removeFromWishlist(name) {
362
+ wishlist = wishlist.filter(w => w.name !== name);
363
+ if (currentCustomer) {
364
+ customerMemory[currentCustomer].wishlist = wishlist;
365
+ saveMemory();
366
+ }
367
+ renderWishlist();
368
+ }
369
+
370
+ function renderWishlist() {
371
+ const el = document.getElementById('wishlist');
372
+ if (wishlist.length === 0) {
373
+ el.innerHTML = '<div class="om-wishlist-empty">Nothing yet β€” ask Oga Mi for ideas.</div>';
374
+ return;
375
+ }
376
+ el.innerHTML = wishlist.map(w => `
377
+ <div class="om-wishlist-item">
378
+ <span class="name">${escapeHtml(w.name)}<br><small style="color:var(--fg-3);font-size:10px;">${escapeHtml(w.creator)}</small></span>
379
+ <span class="price">N${w.price.toLocaleString()}</span>
380
+ <button class="remove" onclick="removeFromWishlist('${escapeHtml(w.name).replace(/'/g, "\\'")}')">Γ—</button>
381
+ </div>
382
+ `).join('');
383
+ }
384
+
385
+ window.removeFromWishlist = removeFromWishlist;
386
+
387
+ // ════════════════════════════════════════════════════════════════
388
+ // RENDER HELPERS
389
+ // ════════════════════════════════════════════════════════════════
390
+ function addUser(text) { addMsg('user', text); }
391
+ function addBot(text) { addMsg('bot', text); }
392
+ function addSystem(text) { addMsg('system', text); }
393
+
394
+ function addMsg(role, text) {
395
+ const t = document.getElementById('transcript');
396
+ const el = document.createElement('div');
397
+ el.className = `msg ${role}`;
398
+ el.textContent = text;
399
+ t.appendChild(el);
400
+ t.scrollTop = t.scrollHeight;
401
+ }
402
+
403
+ function addReceipt(txHash) {
404
+ const t = document.getElementById('transcript');
405
+ const el = document.createElement('div');
406
+ el.className = 'msg receipt';
407
+ el.innerHTML = `<a href="${window.OG_CHAIN.explorerTx(txHash)}" target="_blank" style="color:inherit;text-decoration:none;">0G Chain Β· ${window.shortHash(txHash)}</a>`;
408
+ t.appendChild(el);
409
+ t.scrollTop = t.scrollHeight;
410
+ }
411
+
412
+ function addProductCard(name, creator, price) {
413
+ const t = document.getElementById('transcript');
414
+ const el = document.createElement('div');
415
+ el.className = 'msg product-card';
416
+ el.innerHTML = `
417
+ <div class="pc-img">${escapeHtml(name.slice(0,1))}</div>
418
+ <div class="pc-body">
419
+ <div class="pc-title">${escapeHtml(name)}</div>
420
+ <div class="pc-creator">by ${escapeHtml(creator)}</div>
421
+ <div class="pc-price">N${price.toLocaleString()}</div>
422
+ <button class="pc-add">+ Add to wishlist</button>
423
+ </div>
424
+ `;
425
+ el.querySelector('.pc-add').addEventListener('click', () => {
426
+ addToWishlist(name, creator, price);
427
+ el.querySelector('.pc-add').textContent = 'βœ“ Saved';
428
+ el.querySelector('.pc-add').disabled = true;
429
+ });
430
+ t.appendChild(el);
431
+ t.scrollTop = t.scrollHeight;
432
+ }
433
+
434
+ function escapeHtml(s) {
435
+ return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
436
+ }
437
+ </script>
438
+
439
+ </body>
440
+ </html>
styles.css ADDED
@@ -0,0 +1,843 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Oga Market β€” styles using the Good Online + Oga Mi design system */
2
+
3
+ @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&display=swap');
4
+
5
+ :root {
6
+ --forest: #1F3B2D;
7
+ --forest-hover: #16291F;
8
+ --forest-soft: #2E5840;
9
+ --terracotta: #C84B23;
10
+ --terracotta-soft: #E0744F;
11
+ --saffron: #E8B547;
12
+ --saffron-soft: #F2D080;
13
+ --cream: #F5EFE3;
14
+ --cream-deep: #ECE3CF;
15
+ --paper: #FBF7EE;
16
+ --ink: #15130F;
17
+ --ink-soft: #2A2620;
18
+ --stone-1: #EAE3D2;
19
+ --stone-2: #D9D2C0;
20
+ --stone-3: #B8AD96;
21
+ --stone-4: #8A8170;
22
+ --stone-5: #6B6354;
23
+ --fg-1: var(--ink);
24
+ --fg-2: var(--stone-5);
25
+ --fg-3: var(--stone-4);
26
+ --bg-1: var(--cream);
27
+ --bg-2: var(--paper);
28
+ --bg-3: var(--cream-deep);
29
+ --border-1: var(--stone-2);
30
+ --accent: var(--forest);
31
+ --accent-hover: var(--forest-hover);
32
+ --warn: var(--terracotta);
33
+ --highlight: var(--saffron);
34
+ --font-serif: 'Instrument Serif', Georgia, serif;
35
+ --font-sans: 'Geist', system-ui, sans-serif;
36
+ --font-mono: 'Geist Mono', 'Courier New', monospace;
37
+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
38
+ --sp-6: 24px; --sp-8: 32px; --sp-12: 48px; --sp-16: 64px;
39
+ --radius-sm: 2px;
40
+ --radius-md: 4px;
41
+ --radius-pill: 999px;
42
+ --shadow-1: 0 1px 2px rgba(21,19,15,0.06);
43
+ --shadow-2: 0 8px 24px -8px rgba(21,19,15,0.12);
44
+ --shadow-3: 0 24px 64px -16px rgba(21,19,15,0.22);
45
+ --ease: cubic-bezier(.2,.7,.2,1);
46
+ --dur-quick: 120ms;
47
+ --dur-state: 200ms;
48
+ --dur-panel: 400ms;
49
+ --content-max: 1280px;
50
+ }
51
+
52
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
53
+
54
+ body {
55
+ font-family: var(--font-sans);
56
+ background: var(--cream);
57
+ color: var(--fg-1);
58
+ -webkit-font-smoothing: antialiased;
59
+ min-height: 100vh;
60
+ }
61
+
62
+ a { color: var(--forest); text-decoration: none; }
63
+ a:hover { text-decoration: underline; }
64
+
65
+ /* ═══ TOPBAR ═══ */
66
+ .om-topbar {
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: space-between;
70
+ padding: 14px var(--sp-6);
71
+ background: var(--paper);
72
+ border-bottom: 1px solid var(--border-1);
73
+ position: sticky;
74
+ top: 0;
75
+ z-index: 50;
76
+ }
77
+
78
+ .om-brand {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: var(--sp-3);
82
+ }
83
+
84
+ .om-brand a {
85
+ font-family: var(--font-serif);
86
+ font-style: italic;
87
+ font-size: 22px;
88
+ color: var(--fg-1);
89
+ text-decoration: none;
90
+ }
91
+
92
+ .om-brand-tag {
93
+ font-family: var(--font-sans);
94
+ font-size: 10px;
95
+ font-weight: 500;
96
+ letter-spacing: 0.16em;
97
+ text-transform: uppercase;
98
+ color: var(--fg-2);
99
+ padding: 3px 10px;
100
+ border: 1px solid var(--border-1);
101
+ border-radius: var(--radius-sm);
102
+ }
103
+
104
+ .om-topbar-actions {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: var(--sp-3);
108
+ }
109
+
110
+ .om-services {
111
+ display: flex;
112
+ gap: var(--sp-2);
113
+ }
114
+
115
+ .om-chip {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 4px;
119
+ font-family: var(--font-sans);
120
+ font-size: 10px;
121
+ font-weight: 500;
122
+ letter-spacing: 0.12em;
123
+ text-transform: uppercase;
124
+ padding: 3px 8px;
125
+ border-radius: var(--radius-pill);
126
+ opacity: 0.35;
127
+ transition: opacity var(--dur-state);
128
+ }
129
+
130
+ .om-chip.active { opacity: 1; }
131
+ .om-chip.compute { background: rgba(31,59,45,0.1); color: var(--forest); }
132
+ .om-chip.storage { background: rgba(232,181,71,0.12); color: #8A6A1A; }
133
+ .om-chip.chain { background: rgba(200,75,35,0.1); color: var(--terracotta); }
134
+
135
+ .om-chip::before {
136
+ content: '';
137
+ width: 5px; height: 5px;
138
+ border-radius: 50%;
139
+ background: currentColor;
140
+ }
141
+
142
+ /* ═══ LANDING (mode selector) ═══ */
143
+ .om-landing {
144
+ max-width: 1100px;
145
+ margin: 0 auto;
146
+ padding: var(--sp-16) var(--sp-6) var(--sp-8);
147
+ }
148
+
149
+ .om-landing-eyebrow {
150
+ font-family: var(--font-sans);
151
+ font-size: 12px;
152
+ font-weight: 500;
153
+ letter-spacing: 0.18em;
154
+ text-transform: uppercase;
155
+ color: var(--fg-2);
156
+ text-align: center;
157
+ }
158
+
159
+ .om-landing h1 {
160
+ font-family: var(--font-serif);
161
+ font-style: italic;
162
+ font-size: 84px;
163
+ line-height: 88px;
164
+ font-weight: 400;
165
+ letter-spacing: -0.02em;
166
+ text-align: center;
167
+ margin-top: var(--sp-4);
168
+ }
169
+
170
+ .om-landing .sub {
171
+ font-family: var(--font-sans);
172
+ font-size: 17px;
173
+ line-height: 26px;
174
+ color: var(--fg-2);
175
+ max-width: 600px;
176
+ margin: var(--sp-4) auto 0;
177
+ text-align: center;
178
+ }
179
+
180
+ .om-modes {
181
+ display: grid;
182
+ grid-template-columns: 1fr 1fr;
183
+ gap: var(--sp-6);
184
+ margin-top: var(--sp-12);
185
+ }
186
+
187
+ .om-mode {
188
+ background: var(--paper);
189
+ border: 1px solid var(--border-1);
190
+ padding: var(--sp-8);
191
+ text-decoration: none;
192
+ color: var(--fg-1);
193
+ display: flex;
194
+ flex-direction: column;
195
+ gap: var(--sp-3);
196
+ transition: all var(--dur-state) var(--ease);
197
+ }
198
+
199
+ .om-mode:hover {
200
+ background: var(--bg-3);
201
+ transform: translateY(-2px);
202
+ box-shadow: var(--shadow-2);
203
+ text-decoration: none;
204
+ }
205
+
206
+ .om-mode-icon {
207
+ width: 56px; height: 56px;
208
+ background: var(--forest);
209
+ color: var(--cream);
210
+ display: flex;
211
+ align-items: center;
212
+ justify-content: center;
213
+ margin-bottom: var(--sp-3);
214
+ }
215
+
216
+ .om-mode-icon svg { width: 28px; height: 28px; }
217
+
218
+ .om-mode-title {
219
+ font-family: var(--font-serif);
220
+ font-style: italic;
221
+ font-size: 36px;
222
+ line-height: 40px;
223
+ }
224
+
225
+ .om-mode-desc {
226
+ font-family: var(--font-sans);
227
+ font-size: 14px;
228
+ line-height: 22px;
229
+ color: var(--fg-2);
230
+ }
231
+
232
+ .om-mode-cta {
233
+ font-family: var(--font-sans);
234
+ font-size: 13px;
235
+ font-weight: 500;
236
+ color: var(--forest);
237
+ margin-top: auto;
238
+ padding-top: var(--sp-3);
239
+ display: flex;
240
+ align-items: center;
241
+ gap: 6px;
242
+ }
243
+
244
+ .om-stack-row {
245
+ display: flex;
246
+ justify-content: center;
247
+ gap: var(--sp-4);
248
+ margin-top: var(--sp-12);
249
+ padding-top: var(--sp-6);
250
+ border-top: 1px solid var(--border-1);
251
+ }
252
+
253
+ .om-stack-item {
254
+ font-family: var(--font-mono);
255
+ font-size: 11px;
256
+ color: var(--fg-3);
257
+ letter-spacing: 0.04em;
258
+ }
259
+
260
+ .om-stack-item strong {
261
+ color: var(--forest);
262
+ font-weight: 500;
263
+ }
264
+
265
+ /* ═══ APP SHELL (shopper + creator) ═══ */
266
+ .om-app {
267
+ max-width: var(--content-max);
268
+ margin: 0 auto;
269
+ padding: var(--sp-6);
270
+ }
271
+
272
+ .om-app-grid-shopper {
273
+ display: grid;
274
+ grid-template-columns: 1fr 340px;
275
+ gap: var(--sp-6);
276
+ min-height: calc(100vh - 110px);
277
+ }
278
+
279
+ .om-app-grid-creator {
280
+ display: grid;
281
+ grid-template-columns: 1fr 1fr;
282
+ gap: var(--sp-6);
283
+ }
284
+
285
+ /* ═══ SETUP CARD ═══ */
286
+ .om-setup {
287
+ position: fixed;
288
+ inset: 0;
289
+ background: rgba(21,19,15,0.5);
290
+ backdrop-filter: blur(16px);
291
+ display: flex;
292
+ align-items: center;
293
+ justify-content: center;
294
+ z-index: 1000;
295
+ }
296
+
297
+ .om-setup.hidden { display: none; }
298
+
299
+ .om-setup-card {
300
+ width: 90%;
301
+ max-width: 460px;
302
+ background: var(--paper);
303
+ border-radius: var(--radius-md);
304
+ padding: 36px;
305
+ box-shadow: var(--shadow-3);
306
+ }
307
+
308
+ .om-setup-card h2 {
309
+ font-family: var(--font-serif);
310
+ font-style: italic;
311
+ font-size: 32px;
312
+ font-weight: 400;
313
+ margin-bottom: 4px;
314
+ }
315
+
316
+ .om-setup-card .sub {
317
+ font-size: 13px;
318
+ color: var(--fg-2);
319
+ line-height: 1.5;
320
+ margin-bottom: var(--sp-6);
321
+ }
322
+
323
+ .om-field { margin-bottom: var(--sp-4); }
324
+
325
+ .om-field label {
326
+ display: block;
327
+ font-size: 11px;
328
+ font-weight: 500;
329
+ letter-spacing: 0.12em;
330
+ text-transform: uppercase;
331
+ color: var(--fg-3);
332
+ margin-bottom: 6px;
333
+ }
334
+
335
+ .om-field input, .om-field select, .om-field textarea {
336
+ width: 100%;
337
+ padding: 11px 14px;
338
+ font-family: var(--font-mono);
339
+ font-size: 13px;
340
+ color: var(--fg-1);
341
+ background: var(--bg-1);
342
+ border: 1px solid var(--border-1);
343
+ border-radius: var(--radius-sm);
344
+ outline: none;
345
+ -webkit-appearance: none;
346
+ transition: border-color var(--dur-state), box-shadow var(--dur-state);
347
+ }
348
+
349
+ .om-field input:focus, .om-field select:focus, .om-field textarea:focus {
350
+ border-color: var(--forest);
351
+ box-shadow: 0 0 0 3px rgba(31,59,45,0.08);
352
+ }
353
+
354
+ .om-field-hint {
355
+ font-size: 11px;
356
+ color: var(--fg-3);
357
+ margin-top: 4px;
358
+ }
359
+
360
+ .om-field-hint a { color: var(--forest); }
361
+
362
+ .om-cta {
363
+ width: 100%;
364
+ padding: 13px;
365
+ font-family: var(--font-sans);
366
+ font-size: 14px;
367
+ font-weight: 600;
368
+ color: var(--cream);
369
+ background: var(--forest);
370
+ border: none;
371
+ border-radius: var(--radius-sm);
372
+ cursor: pointer;
373
+ transition: background var(--dur-state);
374
+ }
375
+
376
+ .om-cta:hover { background: var(--forest-hover); }
377
+ .om-cta:disabled { opacity: 0.5; cursor: default; }
378
+
379
+ .om-cta-secondary {
380
+ background: transparent;
381
+ color: var(--ink);
382
+ border: 1px solid var(--ink);
383
+ }
384
+
385
+ .om-cta-secondary:hover { background: var(--ink); color: var(--cream); }
386
+
387
+ .om-err {
388
+ color: var(--terracotta);
389
+ font-size: 12px;
390
+ margin-top: 8px;
391
+ display: none;
392
+ }
393
+
394
+ /* ═══ CONVERSATION (shopper) ═══ */
395
+ .om-conv {
396
+ display: flex;
397
+ flex-direction: column;
398
+ background: var(--paper);
399
+ border: 1px solid var(--border-1);
400
+ min-height: 0;
401
+ }
402
+
403
+ .om-conv-head {
404
+ padding: 14px 20px;
405
+ border-bottom: 1px solid var(--border-1);
406
+ display: flex;
407
+ align-items: center;
408
+ justify-content: space-between;
409
+ }
410
+
411
+ .om-conv-head h3 {
412
+ font-family: var(--font-serif);
413
+ font-style: italic;
414
+ font-size: 18px;
415
+ font-weight: 400;
416
+ }
417
+
418
+ #customer-badge {
419
+ font-size: 10px;
420
+ font-family: var(--font-mono);
421
+ padding: 2px 8px;
422
+ border-radius: var(--radius-pill);
423
+ background: rgba(31,59,45,0.08);
424
+ color: var(--forest);
425
+ display: none;
426
+ }
427
+
428
+ #transcript {
429
+ flex: 1;
430
+ overflow-y: auto;
431
+ padding: 20px;
432
+ display: flex;
433
+ flex-direction: column;
434
+ gap: var(--sp-3);
435
+ max-height: 60vh;
436
+ }
437
+
438
+ .msg {
439
+ max-width: 88%;
440
+ padding: 10px 16px;
441
+ font-size: 14px;
442
+ line-height: 1.55;
443
+ border-radius: var(--radius-pill);
444
+ animation: msgIn 0.35s var(--ease) both;
445
+ }
446
+
447
+ @keyframes msgIn {
448
+ from { opacity: 0; transform: translateY(6px); }
449
+ to { opacity: 1; transform: translateY(0); }
450
+ }
451
+
452
+ .msg.user {
453
+ align-self: flex-end;
454
+ background: var(--forest);
455
+ color: var(--cream);
456
+ border-bottom-right-radius: var(--radius-sm);
457
+ }
458
+
459
+ .msg.bot {
460
+ align-self: flex-start;
461
+ background: var(--bg-1);
462
+ border: 1px solid var(--border-1);
463
+ border-bottom-left-radius: var(--radius-sm);
464
+ }
465
+
466
+ .msg.system {
467
+ align-self: center;
468
+ background: transparent;
469
+ color: var(--fg-3);
470
+ font-size: 12px;
471
+ }
472
+
473
+ .msg.receipt {
474
+ align-self: center;
475
+ background: var(--ink);
476
+ color: var(--cream);
477
+ font-family: var(--font-mono);
478
+ font-size: 11px;
479
+ padding: 8px 14px;
480
+ border-radius: var(--radius-sm);
481
+ }
482
+
483
+ .msg.receipt::before { content: 'β›“ '; }
484
+
485
+ .msg.product-card {
486
+ align-self: flex-start;
487
+ width: 88%;
488
+ max-width: 88%;
489
+ padding: 0;
490
+ background: var(--bg-1);
491
+ border: 1px solid var(--border-1);
492
+ border-radius: var(--radius-sm);
493
+ border-bottom-left-radius: var(--radius-sm);
494
+ display: grid;
495
+ grid-template-columns: 80px 1fr;
496
+ gap: 0;
497
+ overflow: hidden;
498
+ }
499
+
500
+ .msg.product-card .pc-img {
501
+ background: linear-gradient(135deg, var(--saffron-soft), var(--saffron));
502
+ display: flex;
503
+ align-items: center;
504
+ justify-content: center;
505
+ font-family: var(--font-serif);
506
+ font-style: italic;
507
+ font-size: 32px;
508
+ color: var(--cream);
509
+ }
510
+
511
+ .msg.product-card .pc-body {
512
+ padding: 10px 14px;
513
+ display: flex;
514
+ flex-direction: column;
515
+ gap: 4px;
516
+ }
517
+
518
+ .msg.product-card .pc-title {
519
+ font-family: var(--font-serif);
520
+ font-size: 16px;
521
+ line-height: 20px;
522
+ color: var(--fg-1);
523
+ }
524
+
525
+ .msg.product-card .pc-creator {
526
+ font-size: 11px;
527
+ color: var(--fg-2);
528
+ }
529
+
530
+ .msg.product-card .pc-price {
531
+ font-family: var(--font-mono);
532
+ font-size: 13px;
533
+ font-weight: 500;
534
+ color: var(--ink);
535
+ margin-top: 2px;
536
+ }
537
+
538
+ .msg.product-card .pc-add {
539
+ margin-top: 4px;
540
+ align-self: flex-start;
541
+ font-size: 11px;
542
+ padding: 4px 10px;
543
+ background: var(--forest);
544
+ color: var(--cream);
545
+ border: none;
546
+ border-radius: var(--radius-pill);
547
+ cursor: pointer;
548
+ font-family: var(--font-sans);
549
+ }
550
+
551
+ /* ═══ INPUT ═══ */
552
+ .om-input-area {
553
+ padding: 14px 20px;
554
+ border-top: 1px solid var(--border-1);
555
+ background: var(--bg-3);
556
+ }
557
+
558
+ .om-input-row {
559
+ display: flex;
560
+ align-items: center;
561
+ gap: 8px;
562
+ background: var(--bg-1);
563
+ border: 1px solid var(--border-1);
564
+ border-radius: var(--radius-pill);
565
+ padding: 4px 4px 4px 18px;
566
+ transition: border-color var(--dur-state);
567
+ }
568
+
569
+ .om-input-row:focus-within { border-color: var(--forest); }
570
+
571
+ #chat-input {
572
+ flex: 1;
573
+ background: transparent;
574
+ border: none;
575
+ outline: none;
576
+ font-family: var(--font-sans);
577
+ font-size: 14px;
578
+ padding: 8px 0;
579
+ min-width: 0;
580
+ }
581
+
582
+ .om-mic, .om-send {
583
+ display: flex;
584
+ align-items: center;
585
+ justify-content: center;
586
+ width: 36px;
587
+ height: 36px;
588
+ border-radius: 50%;
589
+ border: none;
590
+ cursor: pointer;
591
+ flex-shrink: 0;
592
+ transition: transform var(--dur-quick);
593
+ }
594
+
595
+ .om-mic { background: var(--bg-3); color: var(--fg-2); }
596
+ .om-mic:hover { background: var(--stone-1); color: var(--fg-1); }
597
+ .om-send { background: var(--forest); color: var(--cream); }
598
+ .om-send:hover { background: var(--forest-hover); transform: scale(1.04); }
599
+ .om-send svg { width: 16px; height: 16px; }
600
+
601
+ /* ═══ SIDEBAR (wishlist) ═══ */
602
+ .om-side {
603
+ background: var(--paper);
604
+ border: 1px solid var(--border-1);
605
+ padding: var(--sp-4);
606
+ display: flex;
607
+ flex-direction: column;
608
+ gap: var(--sp-4);
609
+ }
610
+
611
+ .om-side h4 {
612
+ font-family: var(--font-serif);
613
+ font-style: italic;
614
+ font-size: 20px;
615
+ font-weight: 400;
616
+ }
617
+
618
+ .om-side-eyebrow {
619
+ font-size: 10px;
620
+ font-weight: 500;
621
+ letter-spacing: 0.16em;
622
+ text-transform: uppercase;
623
+ color: var(--fg-3);
624
+ }
625
+
626
+ .om-wishlist {
627
+ display: flex;
628
+ flex-direction: column;
629
+ gap: 8px;
630
+ margin-top: 8px;
631
+ }
632
+
633
+ .om-wishlist-empty {
634
+ font-size: 12px;
635
+ color: var(--fg-3);
636
+ padding: 16px 0;
637
+ text-align: center;
638
+ }
639
+
640
+ .om-wishlist-item {
641
+ display: flex;
642
+ justify-content: space-between;
643
+ align-items: baseline;
644
+ padding: 8px 10px;
645
+ background: var(--bg-1);
646
+ border: 1px solid var(--border-1);
647
+ gap: 8px;
648
+ }
649
+
650
+ .om-wishlist-item .name {
651
+ font-size: 13px;
652
+ font-family: var(--font-serif);
653
+ }
654
+
655
+ .om-wishlist-item .price {
656
+ font-family: var(--font-mono);
657
+ font-size: 12px;
658
+ color: var(--forest);
659
+ white-space: nowrap;
660
+ }
661
+
662
+ .om-wishlist-item .remove {
663
+ border: none;
664
+ background: transparent;
665
+ color: var(--fg-3);
666
+ cursor: pointer;
667
+ font-size: 14px;
668
+ padding: 0 4px;
669
+ }
670
+
671
+ /* ═══ CREATOR MODE ═══ */
672
+ .om-upload {
673
+ display: flex;
674
+ flex-direction: column;
675
+ gap: var(--sp-4);
676
+ background: var(--paper);
677
+ border: 1px solid var(--border-1);
678
+ padding: var(--sp-6);
679
+ }
680
+
681
+ .om-upload h3 {
682
+ font-family: var(--font-serif);
683
+ font-style: italic;
684
+ font-size: 28px;
685
+ font-weight: 400;
686
+ }
687
+
688
+ .om-dropzone {
689
+ border: 2px dashed var(--stone-3);
690
+ padding: var(--sp-8);
691
+ text-align: center;
692
+ cursor: pointer;
693
+ background: var(--bg-1);
694
+ transition: all var(--dur-state) var(--ease);
695
+ }
696
+
697
+ .om-dropzone:hover {
698
+ border-color: var(--forest);
699
+ background: var(--bg-3);
700
+ }
701
+
702
+ .om-dropzone.has-image {
703
+ padding: 0;
704
+ border-style: solid;
705
+ border-color: var(--border-1);
706
+ }
707
+
708
+ .om-dropzone img {
709
+ display: block;
710
+ width: 100%;
711
+ max-height: 320px;
712
+ object-fit: cover;
713
+ }
714
+
715
+ .om-dropzone p {
716
+ font-size: 13px;
717
+ color: var(--fg-2);
718
+ margin-top: 6px;
719
+ }
720
+
721
+ .om-listing-preview {
722
+ background: var(--paper);
723
+ border: 1px solid var(--border-1);
724
+ padding: var(--sp-6);
725
+ }
726
+
727
+ .om-listing-preview h3 {
728
+ font-family: var(--font-serif);
729
+ font-style: italic;
730
+ font-size: 28px;
731
+ font-weight: 400;
732
+ margin-bottom: var(--sp-4);
733
+ }
734
+
735
+ .om-listing-preview-empty {
736
+ text-align: center;
737
+ color: var(--fg-3);
738
+ padding: var(--sp-12) 0;
739
+ font-size: 14px;
740
+ }
741
+
742
+ .om-listing-card {
743
+ border: 1px solid var(--border-1);
744
+ background: var(--bg-1);
745
+ padding: var(--sp-4);
746
+ display: flex;
747
+ flex-direction: column;
748
+ gap: var(--sp-3);
749
+ }
750
+
751
+ .om-listing-eyebrow {
752
+ font-size: 10px;
753
+ font-weight: 500;
754
+ letter-spacing: 0.16em;
755
+ text-transform: uppercase;
756
+ color: var(--fg-3);
757
+ }
758
+
759
+ .om-listing-title {
760
+ font-family: var(--font-serif);
761
+ font-size: 28px;
762
+ line-height: 32px;
763
+ font-weight: 400;
764
+ }
765
+
766
+ .om-listing-tagline {
767
+ font-family: var(--font-serif);
768
+ font-style: italic;
769
+ font-size: 18px;
770
+ color: var(--forest);
771
+ }
772
+
773
+ .om-listing-desc {
774
+ font-size: 14px;
775
+ line-height: 1.55;
776
+ color: var(--fg-1);
777
+ }
778
+
779
+ .om-listing-meta {
780
+ display: flex;
781
+ flex-wrap: wrap;
782
+ gap: 8px;
783
+ margin-top: 4px;
784
+ }
785
+
786
+ .om-listing-meta .pill {
787
+ font-size: 11px;
788
+ padding: 4px 10px;
789
+ border-radius: var(--radius-pill);
790
+ background: var(--bg-3);
791
+ color: var(--fg-2);
792
+ }
793
+
794
+ .om-listing-price {
795
+ font-family: var(--font-mono);
796
+ font-size: 24px;
797
+ font-weight: 500;
798
+ color: var(--ink);
799
+ }
800
+
801
+ .om-listing-feedback {
802
+ background: var(--bg-3);
803
+ padding: 10px 14px;
804
+ font-size: 12px;
805
+ color: var(--fg-2);
806
+ border-left: 3px solid var(--saffron);
807
+ }
808
+
809
+ .om-listing-actions {
810
+ display: flex;
811
+ gap: 8px;
812
+ margin-top: var(--sp-3);
813
+ }
814
+
815
+ /* ═══ MINTED LISTINGS GRID ═══ */
816
+ .om-minted-grid {
817
+ display: grid;
818
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
819
+ gap: var(--sp-3);
820
+ margin-top: var(--sp-3);
821
+ }
822
+
823
+ .om-minted-card {
824
+ background: var(--bg-1);
825
+ border: 1px solid var(--border-1);
826
+ padding: var(--sp-3);
827
+ text-decoration: none;
828
+ color: var(--fg-1);
829
+ display: flex;
830
+ flex-direction: column;
831
+ gap: 4px;
832
+ }
833
+
834
+ .om-minted-card:hover { background: var(--bg-3); text-decoration: none; }
835
+ .om-minted-card .id { font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); }
836
+ .om-minted-card .name { font-family: var(--font-serif); font-size: 15px; }
837
+ .om-minted-card .tx { font-family: var(--font-mono); font-size: 10px; color: var(--forest); }
838
+
839
+ @media (max-width: 900px) {
840
+ .om-app-grid-shopper, .om-app-grid-creator { grid-template-columns: 1fr; }
841
+ .om-modes { grid-template-columns: 1fr; }
842
+ .om-landing h1 { font-size: 56px; line-height: 60px; }
843
+ }