From 1b94ff1cdce6c35c83b967eb976122181c994929 Mon Sep 17 00:00:00 2001 From: fretchen Date: Fri, 30 Jan 2026 17:42:27 +0100 Subject: [PATCH 1/5] Lint and implement --- shared/chain-utils/src/abi/CollectorNFTv1.ts | 28 +++ shared/chain-utils/src/abi/index.ts | 1 + website/MULTICHAIN_EXPANSION_PROPOSAL.md | 190 ++++++++++++++++++- website/components/SimpleCollectButton.tsx | 47 +++-- website/test/SimpleCollectButton.test.tsx | 37 ++-- website/utils/getChain.ts | 56 ++---- 6 files changed, 288 insertions(+), 71 deletions(-) create mode 100644 shared/chain-utils/src/abi/CollectorNFTv1.ts diff --git a/shared/chain-utils/src/abi/CollectorNFTv1.ts b/shared/chain-utils/src/abi/CollectorNFTv1.ts new file mode 100644 index 000000000..058e34ce9 --- /dev/null +++ b/shared/chain-utils/src/abi/CollectorNFTv1.ts @@ -0,0 +1,28 @@ +/** + * Minimal ABI for CollectorNFTv1 contract + * + * Only includes functions used by website components: + * - getMintStats: Get mint count and pricing info for a GenImNFT token + * - mintCollectorNFT: Mint a collector edition of a GenImNFT + */ + +export const CollectorNFTv1ABI = [ + { + name: "getMintStats", + type: "function", + stateMutability: "view", + inputs: [{ name: "genImTokenId", type: "uint256" }], + outputs: [ + { name: "mintCount", type: "uint256" }, + { name: "currentPrice", type: "uint256" }, + { name: "nextPrice", type: "uint256" }, + ], + }, + { + name: "mintCollectorNFT", + type: "function", + stateMutability: "payable", + inputs: [{ name: "genImTokenId", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; diff --git a/shared/chain-utils/src/abi/index.ts b/shared/chain-utils/src/abi/index.ts index 072d62967..f8570f5d1 100644 --- a/shared/chain-utils/src/abi/index.ts +++ b/shared/chain-utils/src/abi/index.ts @@ -8,3 +8,4 @@ export { GenImNFTv4ABI } from "./GenImNFTv4"; export { EIP3009SplitterV1ABI } from "./EIP3009SplitterV1"; export { LLMv1ABI } from "./LLMv1"; +export { CollectorNFTv1ABI } from "./CollectorNFTv1"; diff --git a/website/MULTICHAIN_EXPANSION_PROPOSAL.md b/website/MULTICHAIN_EXPANSION_PROPOSAL.md index b134158f8..bf80fcd56 100644 --- a/website/MULTICHAIN_EXPANSION_PROPOSAL.md +++ b/website/MULTICHAIN_EXPANSION_PROPOSAL.md @@ -21,8 +21,8 @@ | **1a** | `@fretchen/chain-utils` erstellen | shared/ | ✅ Fertig | | **1b** | scw_js auf chain-utils migrieren | scw_js/ | ✅ Fertig | | **1c** | x402_facilitator auf chain-utils migrieren | x402_facilitator/ | ✅ Fertig | -| **2** | GenImNFT-Komponenten migrieren | website/ | ⬜ Next | -| **3** | CollectorNFT-Komponenten migrieren | website/ | ⬜ | +| **2** | GenImNFT-Komponenten migrieren | website/ | ✅ Fertig | +| **3** | CollectorNFT-Komponenten migrieren | website/ | ⬜ Next | | **4** | GenImNFTv4 auf Base deployen | eth/, shared/ | ⬜ Später | | **5** | CollectorNFTv1 auf Base deployen | eth/, shared/ | ⬜ Später | @@ -107,6 +107,39 @@ fretchen.github.io/ --- +## Phase 2: GenImNFT Website Components Migration ✅ FERTIG + +**Status: VOLLSTÄNDIG ABGESCHLOSSEN** + +Alle GenImNFT-Komponenten wurden erfolgreich auf `@fretchen/chain-utils` migriert: + +| Datei | Status | +|-------|--------| +| `hooks/useAutoNetwork.ts` | ✅ Erstellt - zentraler Hook für Network-Detection | +| `utils/nftLoader.ts` | ✅ Nutzt chain-utils | +| `utils/nodeNftLoader.ts` | ✅ Nutzt chain-utils | +| `components/MyNFTList.tsx` | ✅ `useAutoNetwork()` + chain-utils | +| `components/NFTCard.tsx` | ✅ `useAutoNetwork()` + `getGenAiNFTAddress()` | +| `components/NFTList.tsx` | ✅ `useAutoNetwork()` + chain-utils | +| `components/PublicNFTList.tsx` | ✅ `useAutoNetwork()` + chain-utils | +| `components/EntryNftImage.tsx` | ✅ `useAutoNetwork()` + chain-utils | +| `components/NFTFloatImage.tsx` | ✅ chain-utils | +| `components/ImageGenerator.tsx` | ✅ `useAutoNetwork()` + `isTestnet()` | +| `components/AgentInfoPanel.tsx` | ✅ chain-utils Adressen | +| `hooks/useNFTListedStatus.ts` | ✅ chain-utils (korrektes `isTokenListed` ABI) | +| Tests | ✅ 303 Tests bestanden | + +**Wichtige Erkenntnisse aus Phase 2:** +- `useAutoNetwork()` gibt `{ network, switchIfNeeded }` zurück +- `switchIfNeeded()` muss vor schreibenden Operationen aufgerufen werden +- Für wagmi `readContract` muss `chainId` als `SupportedChainId` gecastet werden: + ```typescript + const chainId = fromCAIP2(network) as SupportedChainId; + ``` +- GitHub Workflows brauchen `npm run build` für chain-utils vor website-Install + +--- + ## Phase 1b: scw_js Migration **Datei:** `scw_js/getChain.js` @@ -463,12 +496,157 @@ vi.mock("../hooks/useAutoNetwork", () => ({ --- -## Phase 3: CollectorNFT-Komponenten migrieren +## Phase 3: CollectorNFT-Komponenten migrieren ⬜ NEXT + +### Komplexitätsvergleich mit Phase 2 + +| Aspekt | Phase 2 (GenImNFT) | Phase 3 (CollectorNFT) | +|--------|-------------------|------------------------| +| **Anzahl Dateien** | 12+ Komponenten + Tests | 1 Komponente + 1 Test | +| **Hook-Erstellung** | `useAutoNetwork` musste erstellt werden | Hook existiert bereits ✅ | +| **ABI in chain-utils** | GenImNFTv4ABI vorhanden | ⚠️ CollectorNFTv1ABI fehlt noch | +| **Getter in chain-utils** | `getGenAiNFTAddress()` vorhanden | `getCollectorNFTAddress()` vorhanden ✅ | +| **Netzwerk-Konstante** | `GENAI_NFT_NETWORKS` vorhanden | `COLLECTOR_NFT_NETWORKS` vorhanden ✅ | +| **Testanpassungen** | Umfangreiche Mock-Updates | Minimal | +| **Komplexität** | 🔴 Hoch | 🟢 Niedrig | +| **Geschätzter Aufwand** | 4-6 Stunden | 30-60 Minuten | + +**Fazit: Phase 3 ist ~90% einfacher als Phase 2**, da: +1. Die Infrastruktur (`useAutoNetwork`, chain-utils Dependency) bereits existiert +2. Nur 1 Komponente zu migrieren ist +3. Das Pattern aus Phase 2 einfach kopiert werden kann + +### Voraussetzung: CollectorNFTv1ABI zu chain-utils hinzufügen + +**Datei:** `shared/chain-utils/src/abi/CollectorNFTv1.ts` + +```typescript +// Minimal ABI für CollectorNFTv1 - nur benötigte Funktionen +export const CollectorNFTv1ABI = [ + { + name: "getMintStats", + type: "function", + stateMutability: "view", + inputs: [{ name: "genImTokenId", type: "uint256" }], + outputs: [ + { name: "mintCount", type: "uint256" }, + { name: "currentPrice", type: "uint256" }, + { name: "lastMinter", type: "address" }, + ], + }, + { + name: "mintCollectorNFT", + type: "function", + stateMutability: "payable", + inputs: [{ name: "genImTokenId", type: "uint256" }], + outputs: [{ name: "tokenId", type: "uint256" }], + }, +] as const; +``` + +**Datei:** `shared/chain-utils/src/abi/index.ts` - Export hinzufügen: +```typescript +export { CollectorNFTv1ABI } from "./CollectorNFTv1"; +``` + +### Implementierungsplan + +**Step 1: ABI zu chain-utils hinzufügen (5 min)** +- [ ] `shared/chain-utils/src/abi/CollectorNFTv1.ts` erstellen +- [ ] `shared/chain-utils/src/abi/index.ts` Export hinzufügen +- [ ] `npm run build` in chain-utils +- [ ] Tests hinzufügen (optional) + +**Step 2: SimpleCollectButton.tsx migrieren (15 min)** + +```tsx +// VORHER +import { collectorNFTContractConfig, getChain } from "../utils/getChain"; + +const chain = getChain(); +const isCorrectNetwork = chainId === chain.id; + +useReadContract({ + ...collectorNFTContractConfig, + functionName: "getMintStats", + args: [genImTokenId], + chainId: chain.id, +}); + +writeContract({ + ...collectorNFTContractConfig, + functionName: "mintCollectorNFT", + args: [genImTokenId], + value: currentPrice, +}); + +// NACHHER +import { useAutoNetwork } from "../hooks/useAutoNetwork"; +import { + getCollectorNFTAddress, + CollectorNFTv1ABI, + COLLECTOR_NFT_NETWORKS, + fromCAIP2 +} from "@fretchen/chain-utils"; +import type { config } from "../wagmi.config"; + +type SupportedChainId = (typeof config)["chains"][number]["id"]; + +const { network, switchIfNeeded } = useAutoNetwork(COLLECTOR_NFT_NETWORKS); +const contractAddress = getCollectorNFTAddress(network); +const networkChainId = fromCAIP2(network) as SupportedChainId; + +useReadContract({ + address: contractAddress, + abi: CollectorNFTv1ABI, + functionName: "getMintStats", + args: [genImTokenId], + chainId: networkChainId, +}); + +// Bei Schreiboperationen: erst switchIfNeeded() aufrufen +const handleCollect = async () => { + if (!isConnected) return; + + const switched = await switchIfNeeded(); + if (!switched) return; + + writeContract({ + address: contractAddress, + abi: CollectorNFTv1ABI, + functionName: "mintCollectorNFT", + args: [genImTokenId], + value: currentPrice, + }); +}; +``` + +**Step 3: Test aktualisieren (10 min)** +- [ ] `test/SimpleCollectButton.test.tsx` - Mock für `useAutoNetwork` hinzufügen +- [ ] Chain-utils Mocks analog zu anderen Tests + +**Step 4: getChain.ts aufräumen (5 min)** +- [ ] `collectorNFTContractConfig` Export entfernen +- [ ] Deprecation-Hinweis aktualisieren + +### Checkliste Phase 3 + +- [ ] `shared/chain-utils/src/abi/CollectorNFTv1.ts` - **CREATE NEW** +- [ ] `shared/chain-utils/src/abi/index.ts` - Export hinzufügen +- [ ] `shared/chain-utils` - `npm run build` +- [ ] `components/SimpleCollectButton.tsx` - Use `useAutoNetwork()` + chain-utils +- [ ] `test/SimpleCollectButton.test.tsx` - Update mocks +- [ ] `utils/getChain.ts` - Remove `collectorNFTContractConfig` export +- [ ] `npm run build` - Verifizieren +- [ ] `npm test` - Alle Tests grün -**Betroffene Dateien:** -- `SimpleCollectButton.tsx` (2 Stellen) +### Risikobewertung -Gleiches Pattern wie Phase 2. +| Risiko | Schwere | Mitigation | +|--------|---------|------------| +| **ABI-Inkompatibilität** | 🟢 Niedrig | Minimal ABI mit nur genutzten Funktionen | +| **Network-Switch UX** | 🟢 Niedrig | Pattern bereits in NFTCard getestet | +| **Breaking Change** | 🟢 Niedrig | Nur 1 Komponente betroffen | --- diff --git a/website/components/SimpleCollectButton.tsx b/website/components/SimpleCollectButton.tsx index 6c13238c1..8bb31f1e3 100644 --- a/website/components/SimpleCollectButton.tsx +++ b/website/components/SimpleCollectButton.tsx @@ -1,9 +1,14 @@ -import React, { useEffect } from "react"; -import { useWriteContract, useWaitForTransactionReceipt, useReadContract, useAccount, useChainId } from "wagmi"; +import React, { useEffect, useState } from "react"; +import { useWriteContract, useWaitForTransactionReceipt, useReadContract, useAccount } from "wagmi"; import { formatEther } from "viem"; -import { collectorNFTContractConfig, getChain } from "../utils/getChain"; +import { getCollectorNFTAddress, CollectorNFTv1ABI, COLLECTOR_NFT_NETWORKS, fromCAIP2 } from "@fretchen/chain-utils"; +import { useAutoNetwork } from "../hooks/useAutoNetwork"; +import { config } from "../utils/wagmi"; import * as styles from "../layouts/styles"; import { useLocale } from "../hooks/useLocale"; + +type SupportedChainId = (typeof config)["chains"][number]["id"]; + interface SimpleCollectButtonProps { genImTokenId: bigint; } @@ -17,13 +22,13 @@ interface SimpleCollectButtonProps { export function SimpleCollectButton({ genImTokenId }: SimpleCollectButtonProps) { // Wagmi hooks const { isConnected } = useAccount(); - const chainId = useChainId(); + const { network, switchIfNeeded } = useAutoNetwork(COLLECTOR_NFT_NETWORKS); const { writeContract, isPending, data: hash } = useWriteContract(); const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash }); + const [isLoading, setIsLoading] = useState(false); - // Chain and contract configuration - const chain = getChain(); - const isCorrectNetwork = chainId === chain.id; + // Chain ID for current network + const chainId = fromCAIP2(network) as SupportedChainId; const collectLabel = useLocale({ label: "imagegen.collect" }); @@ -34,26 +39,38 @@ export function SimpleCollectButton({ genImTokenId }: SimpleCollectButtonProps) isPending: isReadPending, refetch, } = useReadContract({ - ...collectorNFTContractConfig, + address: getCollectorNFTAddress(network), + abi: CollectorNFTv1ABI, functionName: "getMintStats", args: [genImTokenId], - chainId: chain.id, + chainId, }); // Handle collect action const handleCollect = async () => { if (!isConnected) return; - if (!isCorrectNetwork) return; if (!mintStats || !Array.isArray(mintStats)) return; + setIsLoading(true); + + // Ensure correct network before transaction + const switched = await switchIfNeeded(); + if (!switched) { + setIsLoading(false); + return; + } + const [, currentPrice] = mintStats as [bigint, bigint, bigint]; writeContract({ - ...collectorNFTContractConfig, + address: getCollectorNFTAddress(network), + abi: CollectorNFTv1ABI, functionName: "mintCollectorNFT", args: [genImTokenId], // CollectorNFTv1 doesn't need URI parameter value: currentPrice, }); + + setIsLoading(false); }; // Update state after transaction @@ -108,11 +125,15 @@ export function SimpleCollectButton({ genImTokenId }: SimpleCollectButtonProps) return ( ); } diff --git a/website/test/SimpleCollectButton.test.tsx b/website/test/SimpleCollectButton.test.tsx index c446b27ed..522f5ee59 100644 --- a/website/test/SimpleCollectButton.test.tsx +++ b/website/test/SimpleCollectButton.test.tsx @@ -7,18 +7,31 @@ import * as wagmi from "wagmi"; // Mock wagmi hooks vi.mock("wagmi", () => ({ useAccount: vi.fn(), - useChainId: vi.fn(), useWriteContract: vi.fn(), useWaitForTransactionReceipt: vi.fn(), useReadContract: vi.fn(), })); -// Mock utilities -vi.mock("../utils/getChain", () => ({ - getChain: vi.fn(() => ({ id: 10 })), - collectorNFTContractConfig: { - address: "0x123", - abi: [], +// Mock useAutoNetwork +vi.mock("../hooks/useAutoNetwork", () => ({ + useAutoNetwork: vi.fn(() => ({ + network: "eip155:10", + switchIfNeeded: vi.fn(() => Promise.resolve(true)), + })), +})); + +// Mock chain-utils +vi.mock("@fretchen/chain-utils", () => ({ + getCollectorNFTAddress: vi.fn(() => "0x123"), + CollectorNFTv1ABI: [], + COLLECTOR_NFT_NETWORKS: ["eip155:10"], + fromCAIP2: vi.fn(() => 10), +})); + +// Mock wagmi config +vi.mock("../utils/wagmi", () => ({ + config: { + chains: [{ id: 10 }, { id: 11155420 }], }, })); @@ -45,7 +58,6 @@ describe("SimpleCollectButton", () => { // Default mock implementations vi.mocked(wagmi.useAccount).mockReturnValue({ isConnected: true } as ReturnType); - vi.mocked(wagmi.useChainId).mockReturnValue(10); vi.mocked(wagmi.useWriteContract).mockReturnValue({ writeContract: vi.fn(), isPending: false, @@ -144,13 +156,4 @@ describe("SimpleCollectButton", () => { const button = screen.getByRole("button"); expect(button).toHaveProperty("disabled", true); }); - - it("should be disabled when on wrong network", () => { - vi.mocked(wagmi.useChainId).mockReturnValue(1); // Different from mocked chain.id of 10 - - render(); - - const button = screen.getByRole("button"); - expect(button).toHaveProperty("disabled", true); - }); }); diff --git a/website/utils/getChain.ts b/website/utils/getChain.ts index ab7528145..71f0a44ff 100644 --- a/website/utils/getChain.ts +++ b/website/utils/getChain.ts @@ -1,6 +1,4 @@ -import { sepolia, optimism, optimismSepolia, base, baseSepolia } from "wagmi/chains"; -import type { Chain } from "wagmi/chains"; -import CollectorNFTv1ABI from "../../eth/abi/contracts/CollectorNFTv1.json"; +import { optimism, optimismSepolia, base, baseSepolia } from "wagmi/chains"; import SupportV2ABI from "../../eth/abi/contracts/SupportV2.json"; import LLMv1ABI from "../../eth/abi/contracts/LLMv1.json"; @@ -8,6 +6,7 @@ import LLMv1ABI from "../../eth/abi/contracts/LLMv1.json"; // Chain utilities are now in @fretchen/chain-utils // Import directly where needed: // import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS } from "@fretchen/chain-utils"; +// import { getCollectorNFTAddress, CollectorNFTv1ABI, COLLECTOR_NFT_NETWORKS } from "@fretchen/chain-utils"; // ═══════════════════════════════════════════════════════════════ /** @@ -75,30 +74,19 @@ export function isSupportV2Chain(chainId: number): boolean { // ═══════════════════════════════════════════════════════════════ // Legacy Contract Configurations // -// GenAI NFT: MIGRATED to chain-utils - use: +// GenAI NFT: MIGRATED to chain-utils (Phase 2): // import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS } from "@fretchen/chain-utils"; -// const network = useAutoNetwork(GENAI_NFT_NETWORKS); +// const { network } = useAutoNetwork(GENAI_NFT_NETWORKS); // const address = getGenAiNFTAddress(network); // -// collectorNFTContractConfig and llmV1ContractConfig will be migrated in Phase 3 +// CollectorNFT: MIGRATED to chain-utils (Phase 3): +// import { getCollectorNFTAddress, CollectorNFTv1ABI, COLLECTOR_NFT_NETWORKS } from "@fretchen/chain-utils"; +// const { network, switchIfNeeded } = useAutoNetwork(COLLECTOR_NFT_NETWORKS); +// const address = getCollectorNFTAddress(network); +// +// LLMv1: Stays in legacy config (out of scope for multi-chain) // ═══════════════════════════════════════════════════════════════ -const STABLE_COLLECTOR_NFT_CONTRACT_CONFIG = (() => { - switch (CHAIN_NAME) { - case "sepolia": - // Sepolia testnet address (if deployed) - return { address: "0x0000000000000000000000000000000000000000", abi: CollectorNFTv1ABI } as const; - case "optimism": - // Production Optimism address - CollectorNFTv1 deployed on 2025-06-15 - return { address: "0x584c40d8a7cA164933b5F90a2dC11ddCB4a924ea", abi: CollectorNFTv1ABI } as const; - case "optimismSepolia": - // Optimism Sepolia testnet address (if deployed) - return { address: "0x0000000000000000000000000000000000000000", abi: CollectorNFTv1ABI } as const; - default: - return { address: "0x584c40d8a7cA164933b5F90a2dC11ddCB4a924ea", abi: CollectorNFTv1ABI } as const; - } -})(); - const STABLE_LLM_V1_CONTRACT_CONFIG = (() => { switch (CHAIN_NAME) { case "optimismSepolia": @@ -116,28 +104,26 @@ const STABLE_LLM_V1_CONTRACT_CONFIG = (() => { } })(); -// Export stable references directly - these objects never change reference -/** Phase 3: Will be migrated to chain-utils */ -export const collectorNFTContractConfig = STABLE_COLLECTOR_NFT_CONTRACT_CONFIG; /** Out of scope: LLMv1 stays in legacy config */ export const llmV1ContractConfig = STABLE_LLM_V1_CONTRACT_CONFIG; +// ═══════════════════════════════════════════════════════════════ +// Legacy getChain() for LLMv1 (Phase 4 migration candidate) +// Returns the chain object based on CHAIN_NAME environment variable +// ═══════════════════════════════════════════════════════════════ + +import type { Chain } from "wagmi/chains"; + /** - * @deprecated Use useAutoNetwork() + fromCAIP2() instead for GenImNFT components. - * This function is still used by CollectorNFT and LLMv1 components. - * - * Gibt das entsprechende Chain-Objekt basierend auf der CHAIN-Umgebungsvariable zurück - * @returns Das Chain-Objekt aus wagmi/chains + * Get chain for LLMv1 contract based on environment variable + * @returns Chain object (optimism or optimismSepolia) + * @deprecated Use chain-utils for GenAI/CollectorNFT. LLMv1 migration is Phase 4. */ export function getChain(): Chain { - // Chain-Objekt je nach Umgebungsvariable auswählen switch (CHAIN_NAME) { - case "sepolia": - return sepolia; - case "optimism": - return optimism; case "optimismSepolia": return optimismSepolia; + case "optimism": default: return optimism; } From 0e66c2968dcfa16a793de289a393c8f0bcb18e80 Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 31 Jan 2026 13:29:21 +0100 Subject: [PATCH 2/5] Clean types --- website/components/MyNFTList.tsx | 5 +---- website/components/SimpleCollectButton.tsx | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/website/components/MyNFTList.tsx b/website/components/MyNFTList.tsx index dc047c401..c29956ee5 100644 --- a/website/components/MyNFTList.tsx +++ b/website/components/MyNFTList.tsx @@ -4,10 +4,7 @@ import { readContract } from "wagmi/actions"; import { config } from "../wagmi.config"; import { useAutoNetwork } from "../hooks/useAutoNetwork"; import { getGenAiNFTAddress, GenImNFTv4ABI, GENAI_NFT_NETWORKS, fromCAIP2 } from "@fretchen/chain-utils"; -import type { config } from "../wagmi.config"; import { NFTMetadata, ModalImageData } from "../types/components"; - -type SupportedChainId = (typeof config)["chains"][number]["id"]; import * as styles from "../layouts/styles"; import { NFTCard } from "./NFTCard"; import { ImageModal } from "./ImageModal"; @@ -24,7 +21,7 @@ interface MyNFTListProps { export function MyNFTList({ newlyCreatedNFT, onNewNFTDisplayed }: MyNFTListProps) { const { address, isConnected } = useAccount(); const { network } = useAutoNetwork(GENAI_NFT_NETWORKS); - const chainId = fromCAIP2(network) as SupportedChainId; + const chainId = fromCAIP2(network); const contractAddress = getGenAiNFTAddress(network); // My NFTs state - now just store token IDs diff --git a/website/components/SimpleCollectButton.tsx b/website/components/SimpleCollectButton.tsx index 8bb31f1e3..ed7695c33 100644 --- a/website/components/SimpleCollectButton.tsx +++ b/website/components/SimpleCollectButton.tsx @@ -3,12 +3,9 @@ import { useWriteContract, useWaitForTransactionReceipt, useReadContract, useAcc import { formatEther } from "viem"; import { getCollectorNFTAddress, CollectorNFTv1ABI, COLLECTOR_NFT_NETWORKS, fromCAIP2 } from "@fretchen/chain-utils"; import { useAutoNetwork } from "../hooks/useAutoNetwork"; -import { config } from "../utils/wagmi"; import * as styles from "../layouts/styles"; import { useLocale } from "../hooks/useLocale"; -type SupportedChainId = (typeof config)["chains"][number]["id"]; - interface SimpleCollectButtonProps { genImTokenId: bigint; } @@ -28,7 +25,7 @@ export function SimpleCollectButton({ genImTokenId }: SimpleCollectButtonProps) const [isLoading, setIsLoading] = useState(false); // Chain ID for current network - const chainId = fromCAIP2(network) as SupportedChainId; + const chainId = fromCAIP2(network); const collectLabel = useLocale({ label: "imagegen.collect" }); From 38f595a3b624bae9d21a22020120d1f29612fc84 Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 31 Jan 2026 13:30:59 +0100 Subject: [PATCH 3/5] Update MULTICHAIN_EXPANSION_PROPOSAL.md --- website/MULTICHAIN_EXPANSION_PROPOSAL.md | 41 +++++++++++++++++------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/website/MULTICHAIN_EXPANSION_PROPOSAL.md b/website/MULTICHAIN_EXPANSION_PROPOSAL.md index bf80fcd56..996ed5c7c 100644 --- a/website/MULTICHAIN_EXPANSION_PROPOSAL.md +++ b/website/MULTICHAIN_EXPANSION_PROPOSAL.md @@ -8,7 +8,7 @@ |----------|:--------:|:----:|:-----------------:| | **SupportV2** | ✅ | ✅ | ✅ Ja | | **GenImNFTv4** | ✅ | ❌ | ✅ Ja (Backend ready) | -| **CollectorNFTv1** | ✅ | ❌ | ❌ Nein | +| **CollectorNFTv1** | ✅ | ❌ | ✅ Ja (Frontend ready) | | **LLMv1** | ✅ | ❌ | ❌ (out of scope) | | **EIP3009SplitterV1** | ✅ | ❌ | ✅ Ja | @@ -22,7 +22,7 @@ | **1b** | scw_js auf chain-utils migrieren | scw_js/ | ✅ Fertig | | **1c** | x402_facilitator auf chain-utils migrieren | x402_facilitator/ | ✅ Fertig | | **2** | GenImNFT-Komponenten migrieren | website/ | ✅ Fertig | -| **3** | CollectorNFT-Komponenten migrieren | website/ | ⬜ Next | +| **3** | CollectorNFT-Komponenten migrieren | website/ | ✅ Fertig | | **4** | GenImNFTv4 auf Base deployen | eth/, shared/ | ⬜ Später | | **5** | CollectorNFTv1 auf Base deployen | eth/, shared/ | ⬜ Später | @@ -496,7 +496,26 @@ vi.mock("../hooks/useAutoNetwork", () => ({ --- -## Phase 3: CollectorNFT-Komponenten migrieren ⬜ NEXT +## Phase 3: CollectorNFT-Komponenten migrieren ✅ FERTIG + +**Status: VOLLSTÄNDIG ABGESCHLOSSEN** + +Alle CollectorNFT-Komponenten wurden erfolgreich auf `@fretchen/chain-utils` migriert: + +| Datei | Status | +|-------|--------| +| `shared/chain-utils/src/abi/CollectorNFTv1.ts` | ✅ Erstellt - Minimal ABI | +| `components/SimpleCollectButton.tsx` | ✅ `useAutoNetwork()` + chain-utils | +| `test/SimpleCollectButton.test.tsx` | ✅ Mocks aktualisiert | +| `utils/getChain.ts` | ✅ `collectorNFTContractConfig` entfernt, `getChain()` für LLMv1 erhalten | +| Tests | ✅ 302 Tests bestanden | + +**Wichtige Änderungen:** +- `CollectorNFTv1ABI` hinzugefügt mit `getMintStats` und `mintCollectorNFT` +- `SimpleCollectButton` nutzt jetzt `useAutoNetwork(COLLECTOR_NFT_NETWORKS)` +- `switchIfNeeded()` wird vor `writeContract` aufgerufen +- `SupportedChainId` Type wurde entfernt (nicht notwendig) +- `getChain()` bleibt für LLMv1 (Phase 4 Migration Kandidat) ### Komplexitätsvergleich mit Phase 2 @@ -631,14 +650,14 @@ const handleCollect = async () => { ### Checkliste Phase 3 -- [ ] `shared/chain-utils/src/abi/CollectorNFTv1.ts` - **CREATE NEW** -- [ ] `shared/chain-utils/src/abi/index.ts` - Export hinzufügen -- [ ] `shared/chain-utils` - `npm run build` -- [ ] `components/SimpleCollectButton.tsx` - Use `useAutoNetwork()` + chain-utils -- [ ] `test/SimpleCollectButton.test.tsx` - Update mocks -- [ ] `utils/getChain.ts` - Remove `collectorNFTContractConfig` export -- [ ] `npm run build` - Verifizieren -- [ ] `npm test` - Alle Tests grün +- [x] `shared/chain-utils/src/abi/CollectorNFTv1.ts` - **CREATED** +- [x] `shared/chain-utils/src/abi/index.ts` - Export hinzugefügt +- [x] `shared/chain-utils` - `npm run build` +- [x] `components/SimpleCollectButton.tsx` - Use `useAutoNetwork()` + chain-utils +- [x] `test/SimpleCollectButton.test.tsx` - Update mocks +- [x] `utils/getChain.ts` - `collectorNFTContractConfig` entfernt, `getChain()` für LLMv1 erhalten +- [x] `npm run build` - Verifiziert +- [x] `npm test` - 302 Tests grün ### Risikobewertung From deb1b513c8ad73ff65a648992a877f80f16a5389 Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 31 Jan 2026 19:02:18 +0100 Subject: [PATCH 4/5] Properly localize --- website/components/SimpleCollectButton.tsx | 25 ++++++++++++++++------ website/locales/de.ts | 5 +++++ website/locales/en.ts | 5 +++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/website/components/SimpleCollectButton.tsx b/website/components/SimpleCollectButton.tsx index ed7695c33..d27065090 100644 --- a/website/components/SimpleCollectButton.tsx +++ b/website/components/SimpleCollectButton.tsx @@ -28,6 +28,10 @@ export function SimpleCollectButton({ genImTokenId }: SimpleCollectButtonProps) const chainId = fromCAIP2(network); const collectLabel = useLocale({ label: "imagegen.collect" }); + const collectingLabel = useLocale({ label: "imagegen.collecting" }); + const collectedLabel = useLocale({ label: "imagegen.collected" }); + const priceLoadingLabel = useLocale({ label: "imagegen.priceLoading" }); + const currentPriceInfoLabel = useLocale({ label: "imagegen.currentPriceInfo" }); // Read mint stats const { @@ -66,10 +70,16 @@ export function SimpleCollectButton({ genImTokenId }: SimpleCollectButtonProps) args: [genImTokenId], // CollectorNFTv1 doesn't need URI parameter value: currentPrice, }); - - setIsLoading(false); + // Don't set isLoading(false) here - let useEffect handle it when isPending becomes true }; + // Reset isLoading once wagmi takes over or transaction completes + useEffect(() => { + if (isPending || isSuccess) { + setIsLoading(false); + } + }, [isPending, isSuccess]); + // Update state after transaction useEffect(() => { if (isSuccess) { @@ -90,7 +100,7 @@ export function SimpleCollectButton({ genImTokenId }: SimpleCollectButtonProps) // Get price information for tooltip const getPriceInfo = () => { if (isReadPending || readError || !mintStats || !Array.isArray(mintStats)) { - return "Price loading..."; + return priceLoadingLabel; } const [mintCount, currentPrice] = mintStats as [bigint, bigint, bigint]; @@ -116,7 +126,10 @@ export function SimpleCollectButton({ genImTokenId }: SimpleCollectButtonProps) const nextTierPrice = baseMintPrice * BigInt(2 ** Math.floor(nextTierBoundary / 5)); const formattedNextTier = formatPrice(formatEther(nextTierPrice)); - return `Current price: ${formattedCurrent} ETH | Price after ${nextTierBoundary} mints: ${formattedNextTier} ETH`; + return currentPriceInfoLabel + .replace("{currentPrice}", formattedCurrent) + .replace("{nextTier}", nextTierBoundary.toString()) + .replace("{nextPrice}", formattedNextTier); }; return ( @@ -127,9 +140,9 @@ export function SimpleCollectButton({ genImTokenId }: SimpleCollectButtonProps) title={`Collect this NFT (${getMintCount()} collected) | ${getPriceInfo()}`} > {isPending || isLoading - ? "📦 Collecting..." + ? `📦 ${collectingLabel}` : isSuccess - ? "✅ Collected!" + ? `✅ ${collectedLabel}` : `📦 ${collectLabel} (${getMintCount()})`} ); diff --git a/website/locales/de.ts b/website/locales/de.ts index a2eb3bd43..5589cca9d 100644 --- a/website/locales/de.ts +++ b/website/locales/de.ts @@ -73,6 +73,11 @@ export default { viewContract: "Vertrag anzeigen", learnMoreOptimism: "Mehr über Optimism erfahren (öffnet in neuem Tab)", viewContractEtherscan: "Smart Contract auf Optimism Etherscan anzeigen (öffnet in neuem Tab)", + // Collector button + collecting: "Wird gesammelt...", + collected: "Gesammelt!", + priceLoading: "Preis wird geladen...", + currentPriceInfo: "Aktueller Preis: {currentPrice} ETH | Preis nach {nextTier} Mints: {nextPrice} ETH", }, assistent: { title: "Chat-Assistent", diff --git a/website/locales/en.ts b/website/locales/en.ts index 08240efe9..32cf1d34e 100644 --- a/website/locales/en.ts +++ b/website/locales/en.ts @@ -73,6 +73,11 @@ export default { viewContract: "View Contract", learnMoreOptimism: "Learn more about Optimism (opens in new tab)", viewContractEtherscan: "View smart contract on Optimism Etherscan (opens in new tab)", + // Collector button + collecting: "Collecting...", + collected: "Collected!", + priceLoading: "Price loading...", + currentPriceInfo: "Current price: {currentPrice} ETH | Price after {nextTier} mints: {nextPrice} ETH", }, assistent: { title: "Chat Assistant", From b7c82768ac71f7cbf517125c2e1fa7a153ab857d Mon Sep 17 00:00:00 2001 From: fretchen Date: Sat, 31 Jan 2026 19:16:44 +0100 Subject: [PATCH 5/5] Fix some errors --- website/test/SimpleCollectButton.test.tsx | 11 +- website/test/blogLoader.nft.test.ts | 231 +++++++++-------- website/test/blogLoader.unit.test.ts | 290 ++++++++++++++++++++++ website/utils/blogLoader.ts | 190 ++++++++------ 4 files changed, 542 insertions(+), 180 deletions(-) create mode 100644 website/test/blogLoader.unit.test.ts diff --git a/website/test/SimpleCollectButton.test.tsx b/website/test/SimpleCollectButton.test.tsx index 522f5ee59..a50a352d1 100644 --- a/website/test/SimpleCollectButton.test.tsx +++ b/website/test/SimpleCollectButton.test.tsx @@ -43,7 +43,16 @@ vi.mock("../layouts/styles", () => ({ })); vi.mock("../hooks/useLocale", () => ({ - useLocale: vi.fn(() => "Collect"), + useLocale: vi.fn(({ label }: { label: string }) => { + const labels: Record = { + "imagegen.collect": "Collect", + "imagegen.collecting": "Collecting...", + "imagegen.collected": "Collected!", + "imagegen.priceLoading": "Price loading...", + "imagegen.currentPriceInfo": "Current price: {currentPrice} ETH", + }; + return labels[label] || label; + }), })); vi.mock("viem", () => ({ diff --git a/website/test/blogLoader.nft.test.ts b/website/test/blogLoader.nft.test.ts index 09a0cdcb6..095e288ad 100644 --- a/website/test/blogLoader.nft.test.ts +++ b/website/test/blogLoader.nft.test.ts @@ -1,26 +1,54 @@ /** * Test Suite for NFT Metadata Loading in blogLoader * - * Tests the integration of NFT metadata loading with the blog loading system. - * Mocks the blockchain calls and verifies that metadata is correctly attached to blogs. - * - * Note: These tests verify the NFT loading logic but may not trigger actual NFT loading - * unless running in SSR mode (import.meta.env.SSR === true). The tests focus on verifying - * the structure and error handling of the NFT loading system. + * These tests mock GLOB_REGISTRY to avoid loading real blog files, + * making tests fast and deterministic. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import type { NFTMetadata } from "../types/BlogPost"; +import type { NFTMetadata, BlogPost } from "../types/BlogPost"; + +// Mock GLOB_REGISTRY with minimal test data +vi.mock("../utils/globRegistry", () => ({ + GLOB_REGISTRY: { + blog: { + lazy: { + "../blog/post_with_token.md": async () => ({ + frontmatter: { + title: "Post With Token", + publishing_date: "2025-01-15", + tokenID: 26, + }, + }), + "../blog/post_without_token.md": async () => ({ + frontmatter: { + title: "Post Without Token", + publishing_date: "2025-01-10", + }, + }), + "../blog/interactive.tsx": async () => ({ + meta: { + title: "Interactive Post", + publishing_date: "2025-01-20", + tokenID: 42, + }, + }), + }, + eager: {}, + }, + }, +})); -// Mock the nodeNftLoader module before importing blogLoader +// Mock nodeNftLoader vi.mock("../utils/nodeNftLoader", () => ({ loadMultipleNFTMetadataNode: vi.fn(), })); -describe("blogLoader - NFT Metadata Loading", () => { +describe("blogLoader - NFT Metadata Loading (Mocked)", () => { beforeEach(() => { - // Clear all mocks before each test vi.clearAllMocks(); + // Reset module cache to get fresh imports + vi.resetModules(); }); afterEach(() => { @@ -28,94 +56,114 @@ describe("blogLoader - NFT Metadata Loading", () => { }); it("should have NFT loader module available", async () => { - // Verify that the NFT loader module can be imported const { loadMultipleNFTMetadataNode } = await import("../utils/nodeNftLoader"); expect(loadMultipleNFTMetadataNode).toBeDefined(); expect(typeof loadMultipleNFTMetadataNode).toBe("function"); }); - it("should load blogs successfully", async () => { - // Arrange: Mock NFT metadata - const mockNFTMetadata: Record = { - 26: { - imageUrl: "https://example.com/image_26.png", - prompt: "Test prompt for token 26", - name: "Test NFT 26", - description: "Test description 26", - }, - }; - - const { loadMultipleNFTMetadataNode } = await import("../utils/nodeNftLoader"); - vi.mocked(loadMultipleNFTMetadataNode).mockResolvedValue(mockNFTMetadata); - - // Act: Load blogs + it("should load blogs from mocked registry", async () => { const { loadBlogs } = await import("../utils/blogLoader"); const blogs = await loadBlogs("blog", "publishing_date"); - // Assert: Blogs should be loaded successfully expect(blogs).toBeDefined(); expect(Array.isArray(blogs)).toBe(true); - expect(blogs.length).toBeGreaterThan(0); + expect(blogs.length).toBe(3); + + const titles = blogs.map((b) => b.title); + expect(titles).toContain("Post With Token"); + expect(titles).toContain("Post Without Token"); + expect(titles).toContain("Interactive Post"); + }); - // Check if any blog has a tokenID - const blogsWithTokenID = blogs.filter((b) => b.tokenID); - console.log(`[Test] Found ${blogsWithTokenID.length} blogs with tokenID`); + it("should sort blogs by publishing_date (oldest first)", async () => { + const { loadBlogs } = await import("../utils/blogLoader"); + const blogs = await loadBlogs("blog", "publishing_date"); - // In SSR mode, if there are blogs with tokenIDs, NFT loader should be called - if (import.meta.env.SSR && blogsWithTokenID.length > 0) { - expect(loadMultipleNFTMetadataNode).toHaveBeenCalled(); - } + expect(blogs[0].title).toBe("Post Without Token"); // 2025-01-10 + expect(blogs[1].title).toBe("Post With Token"); // 2025-01-15 + expect(blogs[2].title).toBe("Interactive Post"); // 2025-01-20 + }); + + it("should correctly identify blogs with tokenIDs", async () => { + const { loadBlogs } = await import("../utils/blogLoader"); + const blogs = await loadBlogs("blog", "publishing_date"); + + const blogsWithToken = blogs.filter((b) => b.tokenID !== undefined); + expect(blogsWithToken.length).toBe(2); + + const tokenIDs = blogsWithToken.map((b) => b.tokenID); + expect(tokenIDs).toContain(26); + expect(tokenIDs).toContain(42); }); it("should handle blogs without tokenID gracefully", async () => { - // Act: Load blogs const { loadBlogs } = await import("../utils/blogLoader"); const blogs = await loadBlogs("blog", "publishing_date"); - // Assert: Blogs without tokenID should not cause errors const blogsWithoutToken = blogs.filter((b) => !b.tokenID); + expect(blogsWithoutToken.length).toBe(1); + expect(blogsWithoutToken[0].title).toBe("Post Without Token"); + expect(blogsWithoutToken[0].nftMetadata).toBeUndefined(); + }); - blogsWithoutToken.forEach((blog) => { - // Blogs without tokenID should not have nftMetadata - expect(blog.nftMetadata).toBeUndefined(); - }); + it("should extract unique tokenIDs", async () => { + const { extractTokenIDs } = await import("../utils/blogLoader"); + + const mockBlogs: BlogPost[] = [ + { title: "A", content: "", type: "react", tokenID: 26 }, + { title: "B", content: "", type: "react", tokenID: 42 }, + { title: "C", content: "", type: "react", tokenID: 26 }, // Duplicate + { title: "D", content: "", type: "react" }, // No tokenID + ]; + + const tokenIDs = extractTokenIDs(mockBlogs); + + expect(tokenIDs.length).toBe(2); + expect(tokenIDs).toContain(26); + expect(tokenIDs).toContain(42); + }); + + it("should attach NFT metadata correctly", async () => { + const { attachNFTMetadata } = await import("../utils/blogLoader"); + + const mockBlogs: BlogPost[] = [ + { title: "A", content: "", type: "react", tokenID: 26 }, + { title: "B", content: "", type: "react" }, + ]; + + const mockNFTMetadata: Record = { + 26: { + imageUrl: "https://example.com/26.png", + prompt: "Test prompt", + name: "NFT 26", + description: "Test description", + }, + }; + + const result = attachNFTMetadata(mockBlogs, mockNFTMetadata); - // Should still load blogs successfully - expect(blogs.length).toBeGreaterThan(0); + expect(result[0].nftMetadata).toBeDefined(); + expect(result[0].nftMetadata?.name).toBe("NFT 26"); + expect(result[1].nftMetadata).toBeUndefined(); }); it("should handle NFT loading errors gracefully", async () => { - // Arrange: Mock NFT loader to throw error const { loadMultipleNFTMetadataNode } = await import("../utils/nodeNftLoader"); vi.mocked(loadMultipleNFTMetadataNode).mockRejectedValue(new Error("Network error")); - // Act: Should not throw - should log warning and continue const { loadBlogs } = await import("../utils/blogLoader"); - await expect(loadBlogs("blog", "publishing_date")).resolves.toBeDefined(); + // Should not throw const blogs = await loadBlogs("blog", "publishing_date"); - // Assert: Blogs should still be loaded - expect(blogs.length).toBeGreaterThan(0); + expect(blogs.length).toBe(3); }); - it("should correctly identify blogs with tokenIDs", async () => { - // Act: Load blogs - const { loadBlogs } = await import("../utils/blogLoader"); - const blogs = await loadBlogs("blog", "publishing_date"); + it("should validate NFT metadata structure", async () => { + const { attachNFTMetadata } = await import("../utils/blogLoader"); - // Assert: Check tokenID structure - blogs.forEach((blog) => { - if (blog.tokenID !== undefined) { - // TokenID should be a positive number - expect(typeof blog.tokenID).toBe("number"); - expect(blog.tokenID).toBeGreaterThan(0); - } - }); - }); + const mockBlogs: BlogPost[] = [{ title: "A", content: "", type: "react", tokenID: 123 }]; - it("should validate NFT metadata structure when present", async () => { - // Arrange: Mock NFT metadata with proper structure const mockNFTMetadata: Record = { 123: { imageUrl: "https://example.com/image.png", @@ -125,61 +173,34 @@ describe("blogLoader - NFT Metadata Loading", () => { }, }; - const { loadMultipleNFTMetadataNode } = await import("../utils/nodeNftLoader"); - vi.mocked(loadMultipleNFTMetadataNode).mockResolvedValue(mockNFTMetadata); - - // Act: Load blogs - const { loadBlogs } = await import("../utils/blogLoader"); - const blogs = await loadBlogs("blog", "publishing_date"); + const result = attachNFTMetadata(mockBlogs, mockNFTMetadata); - // Assert: Check NFT metadata structure if present - blogs.forEach((blog) => { - if (blog.nftMetadata) { - expect(blog.nftMetadata).toHaveProperty("imageUrl"); - expect(blog.nftMetadata).toHaveProperty("prompt"); - expect(blog.nftMetadata).toHaveProperty("name"); - expect(blog.nftMetadata).toHaveProperty("description"); - - // ImageUrl should be a valid URL string - expect(typeof blog.nftMetadata.imageUrl).toBe("string"); - expect(blog.nftMetadata.imageUrl.length).toBeGreaterThan(0); - } - }); - }); - - it("should not duplicate tokenIDs when calling NFT loader", async () => { - // This test verifies the logic that tokenIDs are unique before calling the loader - const { loadBlogs } = await import("../utils/blogLoader"); - const blogs = await loadBlogs("blog", "publishing_date"); - - // Extract tokenIDs manually - const tokenIDs = blogs.filter((blog) => blog.tokenID).map((blog) => blog.tokenID!); - - // Check for uniqueness - const uniqueTokenIDs = [...new Set(tokenIDs)]; - expect(tokenIDs.length).toBe(uniqueTokenIDs.length); - - console.log(`[Test] Found ${tokenIDs.length} unique tokenIDs: ${tokenIDs.join(", ")}`); + const nft = result[0].nftMetadata; + expect(nft).toHaveProperty("imageUrl"); + expect(nft).toHaveProperty("prompt"); + expect(nft).toHaveProperty("name"); + expect(nft).toHaveProperty("description"); + expect(typeof nft?.imageUrl).toBe("string"); + expect(nft?.imageUrl.length).toBeGreaterThan(0); }); it("should maintain blog structure with or without NFT metadata", async () => { - // Act: Load blogs const { loadBlogs } = await import("../utils/blogLoader"); const blogs = await loadBlogs("blog", "publishing_date"); - // Assert: All blogs should have required fields blogs.forEach((blog) => { expect(blog).toHaveProperty("title"); expect(blog).toHaveProperty("content"); expect(blog).toHaveProperty("type"); - expect(typeof blog.title).toBe("string"); expect(blog.title.length).toBeGreaterThan(0); - - // NFTMetadata is optional - if (blog.nftMetadata) { - expect(typeof blog.nftMetadata).toBe("object"); - } }); }); + + it("should return empty array for unsupported directory", async () => { + const { loadBlogs } = await import("../utils/blogLoader"); + const blogs = await loadBlogs("nonexistent", "publishing_date"); + + expect(blogs).toEqual([]); + }); }); diff --git a/website/test/blogLoader.unit.test.ts b/website/test/blogLoader.unit.test.ts new file mode 100644 index 000000000..df7e90821 --- /dev/null +++ b/website/test/blogLoader.unit.test.ts @@ -0,0 +1,290 @@ +/** + * Unit Tests for blogLoader Pure Functions + * + * These tests are fast and deterministic because they test pure functions + * without any I/O or module loading. + */ + +import { describe, it, expect } from "vitest"; +import { sortBlogs, extractMetadataFromModule, attachNFTMetadata, extractTokenIDs } from "../utils/blogLoader"; +import type { BlogPost, NFTMetadata } from "../types/BlogPost"; + +describe("blogLoader - Pure Functions", () => { + describe("sortBlogs", () => { + const createBlog = (overrides: Partial = {}): BlogPost => ({ + title: "Test Blog", + content: "", + type: "react", + ...overrides, + }); + + it("should sort by publishing_date (oldest first)", () => { + const blogs = [ + createBlog({ title: "New", publishing_date: "2025-03-01" }), + createBlog({ title: "Old", publishing_date: "2025-01-01" }), + createBlog({ title: "Mid", publishing_date: "2025-02-01" }), + ]; + + const sorted = sortBlogs(blogs, "publishing_date"); + + expect(sorted[0].title).toBe("Old"); + expect(sorted[1].title).toBe("Mid"); + expect(sorted[2].title).toBe("New"); + }); + + it("should sort by order field", () => { + const blogs = [ + createBlog({ title: "Third", order: 3 }), + createBlog({ title: "First", order: 1 }), + createBlog({ title: "Second", order: 2 }), + ]; + + const sorted = sortBlogs(blogs, "order"); + + expect(sorted[0].title).toBe("First"); + expect(sorted[1].title).toBe("Second"); + expect(sorted[2].title).toBe("Third"); + }); + + it("should handle blogs without sort field", () => { + const blogs = [ + createBlog({ title: "No Date 1" }), + createBlog({ title: "Has Date", publishing_date: "2025-01-01" }), + createBlog({ title: "No Date 2" }), + ]; + + const sorted = sortBlogs(blogs, "publishing_date"); + + // Blogs without dates should remain in relative order + expect(sorted.length).toBe(3); + expect(sorted.map((b) => b.title)).toContain("Has Date"); + }); + + it("should not mutate the original array", () => { + const blogs = [ + createBlog({ title: "B", publishing_date: "2025-02-01" }), + createBlog({ title: "A", publishing_date: "2025-01-01" }), + ]; + + const sorted = sortBlogs(blogs, "publishing_date"); + + expect(blogs[0].title).toBe("B"); // Original unchanged + expect(sorted[0].title).toBe("A"); // Sorted copy + }); + + it("should default to publishing_date sort", () => { + const blogs = [ + createBlog({ title: "New", publishing_date: "2025-02-01" }), + createBlog({ title: "Old", publishing_date: "2025-01-01" }), + ]; + + const sorted = sortBlogs(blogs); + + expect(sorted[0].title).toBe("Old"); + }); + }); + + describe("extractMetadataFromModule", () => { + it("should extract metadata from MDX module with frontmatter", () => { + const module = { + frontmatter: { + title: "My Blog Post", + publishing_date: "2025-01-15", + tokenID: 42, + description: "A test blog post", + }, + }; + + const result = extractMetadataFromModule(module, "../blog/test.md"); + + expect(result).not.toBeNull(); + expect(result?.title).toBe("My Blog Post"); + expect(result?.publishing_date).toBe("2025-01-15"); + expect(result?.tokenID).toBe(42); + expect(result?.description).toBe("A test blog post"); + expect(result?.type).toBe("react"); + }); + + it("should extract metadata from TSX module with meta", () => { + const module = { + meta: { + title: "Interactive Post", + publishing_date: "2025-02-01", + tokenID: 99, + }, + }; + + const result = extractMetadataFromModule(module, "../blog/interactive.tsx"); + + expect(result).not.toBeNull(); + expect(result?.title).toBe("Interactive Post"); + expect(result?.tokenID).toBe(99); + }); + + it("should generate fallback title from MDX filename", () => { + const module = { + frontmatter: { + publishing_date: "2025-01-01", + }, + }; + + const result = extractMetadataFromModule(module, "../blog/my_awesome_post.md"); + + expect(result?.title).toBe("my_awesome_post"); + }); + + it("should generate fallback title from TSX filename with formatting", () => { + const module = { + meta: {}, + }; + + const result = extractMetadataFromModule(module, "../blog/my_cool_component.tsx"); + + expect(result?.title).toBe("My Cool Component"); + }); + + it("should return null for invalid module", () => { + expect(extractMetadataFromModule(null, "test.md")).toBeNull(); + expect(extractMetadataFromModule(undefined, "test.md")).toBeNull(); + expect(extractMetadataFromModule("string", "test.md")).toBeNull(); + }); + + it("should return null for MDX without frontmatter", () => { + const module = {}; + + const result = extractMetadataFromModule(module, "../blog/test.md"); + + expect(result).toBeNull(); + }); + + it("should return null for unsupported file types", () => { + const module = { frontmatter: { title: "Test" } }; + + const result = extractMetadataFromModule(module, "../blog/test.json"); + + expect(result).toBeNull(); + }); + + it("should set componentPath correctly", () => { + const module = { + frontmatter: { title: "Test" }, + }; + + const result = extractMetadataFromModule(module, "../blog/special.mdx"); + + expect(result?.componentPath).toBe("../blog/special.mdx"); + }); + }); + + describe("attachNFTMetadata", () => { + const createBlog = (tokenID?: number): BlogPost => ({ + title: `Blog ${tokenID || "none"}`, + content: "", + type: "react", + tokenID, + }); + + const mockNFTMetadata: Record = { + 26: { + imageUrl: "https://example.com/26.png", + prompt: "Test prompt 26", + name: "NFT 26", + description: "Description 26", + }, + 42: { + imageUrl: "https://example.com/42.png", + prompt: "Test prompt 42", + name: "NFT 42", + description: "Description 42", + }, + }; + + it("should attach NFT metadata to matching blogs", () => { + const blogs = [createBlog(26), createBlog(42), createBlog(99)]; + + const result = attachNFTMetadata(blogs, mockNFTMetadata); + + expect(result[0].nftMetadata).toBeDefined(); + expect(result[0].nftMetadata?.name).toBe("NFT 26"); + expect(result[1].nftMetadata?.name).toBe("NFT 42"); + expect(result[2].nftMetadata).toBeUndefined(); + }); + + it("should not modify blogs without tokenID", () => { + const blogs = [createBlog(), createBlog()]; + + const result = attachNFTMetadata(blogs, mockNFTMetadata); + + expect(result[0].nftMetadata).toBeUndefined(); + expect(result[1].nftMetadata).toBeUndefined(); + }); + + it("should return new array (immutable)", () => { + const blogs = [createBlog(26)]; + + const result = attachNFTMetadata(blogs, mockNFTMetadata); + + expect(result).not.toBe(blogs); + expect(result[0]).not.toBe(blogs[0]); + expect(blogs[0].nftMetadata).toBeUndefined(); // Original unchanged + }); + + it("should handle empty blogs array", () => { + const result = attachNFTMetadata([], mockNFTMetadata); + + expect(result).toEqual([]); + }); + + it("should handle empty metadata map", () => { + const blogs = [createBlog(26)]; + + const result = attachNFTMetadata(blogs, {}); + + expect(result[0].nftMetadata).toBeUndefined(); + }); + }); + + describe("extractTokenIDs", () => { + const createBlog = (tokenID?: number): BlogPost => ({ + title: `Blog ${tokenID || "none"}`, + content: "", + type: "react", + tokenID, + }); + + it("should extract unique tokenIDs", () => { + const blogs = [createBlog(26), createBlog(42), createBlog(26), createBlog(99)]; + + const result = extractTokenIDs(blogs); + + expect(result).toHaveLength(3); + expect(result).toContain(26); + expect(result).toContain(42); + expect(result).toContain(99); + }); + + it("should skip blogs without tokenID", () => { + const blogs = [createBlog(26), createBlog(), createBlog(42), createBlog()]; + + const result = extractTokenIDs(blogs); + + expect(result).toHaveLength(2); + expect(result).toContain(26); + expect(result).toContain(42); + }); + + it("should return empty array for blogs without tokenIDs", () => { + const blogs = [createBlog(), createBlog(), createBlog()]; + + const result = extractTokenIDs(blogs); + + expect(result).toEqual([]); + }); + + it("should return empty array for empty input", () => { + const result = extractTokenIDs([]); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/website/utils/blogLoader.ts b/website/utils/blogLoader.ts index 499361fcd..e7e38d0ba 100644 --- a/website/utils/blogLoader.ts +++ b/website/utils/blogLoader.ts @@ -3,12 +3,114 @@ * This replaces the static JSON generation with dynamic loading */ -import { BlogPost, BlogPostMeta } from "../types/BlogPost"; +import { BlogPost, BlogPostMeta, NFTMetadata } from "../types/BlogPost"; import { GLOB_REGISTRY, type SupportedDirectory } from "./globRegistry"; // Global cache for build-time to prevent multiple loads during pre-rendering const buildTimeCache = new Map(); +/** + * Extracts metadata from a loaded module (MDX or TSX). + * Pure function for easy testing. + * + * @param module - The loaded module object + * @param path - The file path (used to determine type and generate fallback title) + * @returns Partial BlogPost with extracted metadata, or null if invalid + */ +export function extractMetadataFromModule(module: unknown, path: string): Partial | null { + if (!module || typeof module !== "object") { + return null; + } + + const cleanPath = path.replace(/\?.*$/, ""); + const isTsx = path.endsWith(".tsx"); + const isMdx = path.endsWith(".md") || path.endsWith(".mdx"); + + let title: string | undefined; + let publishingDate: string | undefined; + let order: number | undefined; + let tokenID: number | undefined; + let description: string | undefined; + let category: string | undefined; + let secondaryCategory: string | undefined; + + if (isMdx) { + const frontmatter = (module as { frontmatter?: Record }).frontmatter; + if (!frontmatter || typeof frontmatter !== "object") { + return null; + } + + title = frontmatter.title as string | undefined; + publishingDate = frontmatter.publishing_date as string | undefined; + order = frontmatter.order as number | undefined; + tokenID = frontmatter.tokenID as number | undefined; + description = frontmatter.description as string | undefined; + category = frontmatter.category as string | undefined; + secondaryCategory = frontmatter.secondaryCategory as string | undefined; + } else if (isTsx) { + const meta = (module as { meta?: BlogPostMeta })?.meta || {}; + + title = meta.title; + publishingDate = meta.publishing_date; + tokenID = meta.tokenID; + description = meta.description; + category = meta.category; + secondaryCategory = meta.secondaryCategory; + } else { + return null; + } + + // Generate fallback title from filename if needed + const fileName = cleanPath.split("/").pop() || ""; + const fallbackTitle = isTsx + ? fileName + .replace(".tsx", "") + .replace(/_/g, " ") + .replace(/\b\w/g, (l) => l.toUpperCase()) + : fileName.replace(/\.(md|mdx)$/, ""); + + return { + title: title || fallbackTitle, + content: "", + type: "react", + publishing_date: publishingDate, + order: order, + tokenID: tokenID, + description: description, + componentPath: path, + category: category, + secondaryCategory: secondaryCategory, + }; +} + +/** + * Attaches NFT metadata to blogs that have tokenIDs. + * Pure function for easy testing. + * + * @param blogs - Array of blog posts + * @param nftMetadataMap - Map of tokenID to NFTMetadata + * @returns New array with NFT metadata attached + */ +export function attachNFTMetadata(blogs: BlogPost[], nftMetadataMap: Record): BlogPost[] { + return blogs.map((blog) => { + if (blog.tokenID && nftMetadataMap[blog.tokenID]) { + return { ...blog, nftMetadata: nftMetadataMap[blog.tokenID] }; + } + return blog; + }); +} + +/** + * Extracts unique tokenIDs from an array of blogs. + * Pure function for easy testing. + * + * @param blogs - Array of blog posts + * @returns Array of unique tokenIDs + */ +export function extractTokenIDs(blogs: BlogPost[]): number[] { + return Array.from(new Set(blogs.flatMap((b) => (b.tokenID ? [b.tokenID] : [])))); +} + /** * Sorts an array of blog posts according to specified criteria. * @@ -21,7 +123,7 @@ const buildTimeCache = new Map(); * - When sorting by "publishing_date", older posts appear first to ensure URL stability * - Posts without the specified sort field remain in their original order */ -function sortBlogs(blogs: BlogPost[], sortBy: "order" | "publishing_date" = "publishing_date"): BlogPost[] { +export function sortBlogs(blogs: BlogPost[], sortBy: "order" | "publishing_date" = "publishing_date"): BlogPost[] { const sortedBlogs = [...blogs]; if (sortBy === "order") { @@ -99,78 +201,18 @@ export async function loadBlogs( for (const [path, moduleOrLoader] of Object.entries(modules)) { try { const cleanPath = path.replace(/\?.*$/, ""); - const isTsx = path.endsWith(".tsx"); - const isMdx = path.endsWith(".md") || path.endsWith(".mdx"); // Load the module (in production it's already loaded, in dev we need to await) const module = import.meta.env.PROD ? moduleOrLoader : await moduleOrLoader(); - if (!module || typeof module !== "object") { - console.error(`[BlogLoader] Invalid module structure for ${cleanPath}`); + // Use pure function for metadata extraction + const metadata = extractMetadataFromModule(module, path); + if (!metadata) { + console.warn(`[BlogLoader] No valid metadata found in ${cleanPath}, skipping`); continue; } - // Extract metadata (different sources for MDX vs TSX) - let title: string | undefined; - let publishingDate: string | undefined; - let order: number | undefined; - let tokenID: number | undefined; - let description: string | undefined; - let category: string | undefined; - let secondaryCategory: string | undefined; - - if (isMdx) { - // MDX files export frontmatter - const frontmatter = (module as { frontmatter?: Record }).frontmatter; - - if (!frontmatter || typeof frontmatter !== "object") { - console.warn(`[BlogLoader] No frontmatter found in ${cleanPath}, skipping`); - continue; - } - - title = frontmatter.title as string | undefined; - publishingDate = frontmatter.publishing_date as string | undefined; - order = frontmatter.order as number | undefined; - tokenID = frontmatter.tokenID as number | undefined; - description = frontmatter.description as string | undefined; - category = frontmatter.category as string | undefined; - secondaryCategory = frontmatter.secondaryCategory as string | undefined; - } else if (isTsx) { - // TSX files export meta object - const meta = (module as { meta?: BlogPostMeta })?.meta || {}; - - title = meta.title; - publishingDate = meta.publishing_date; - tokenID = meta.tokenID; - description = meta.description; - category = meta.category; - secondaryCategory = meta.secondaryCategory; - } - - // Generate fallback title from filename if needed - const fileName = cleanPath.split("/").pop() || ""; - const fallbackTitle = isTsx - ? fileName - .replace(".tsx", "") - .replace(/_/g, " ") - .replace(/\b\w/g, (l) => l.toUpperCase()) - : fileName.replace(/\.(md|mdx)$/, ""); - - // Create blog post object - const blog: BlogPost = { - title: title || fallbackTitle, - content: "", // Content is rendered as React component - type: "react", - publishing_date: publishingDate, - order: order, - tokenID: tokenID, - description: description, - componentPath: path, - category: category, - secondaryCategory: secondaryCategory, - }; - - blogs.push(blog); + blogs.push(metadata as BlogPost); } catch (error) { console.warn(`[BlogLoader] Failed to process ${path}:`, error); } @@ -180,7 +222,7 @@ export async function loadBlogs( // Load NFT metadata for blogs with tokenIDs (only during SSR/build) if (import.meta.env.SSR) { - const tokenIDs = Array.from(new Set(sortedBlogs.flatMap((b) => (b.tokenID ? [b.tokenID] : [])))); + const tokenIDs = extractTokenIDs(sortedBlogs); if (tokenIDs.length > 0) { try { @@ -190,14 +232,14 @@ export async function loadBlogs( const { loadMultipleNFTMetadataNode } = await import("./nodeNftLoader"); const nftMetadataMap = await loadMultipleNFTMetadataNode(tokenIDs); - // Add NFT metadata to blogs - sortedBlogs.forEach((blog) => { - if (blog.tokenID && nftMetadataMap[blog.tokenID]) { - blog.nftMetadata = nftMetadataMap[blog.tokenID]; - } - }); + // Use pure function for attaching NFT metadata + const blogsWithNFT = attachNFTMetadata(sortedBlogs, nftMetadataMap); console.log(`[BlogLoader] Successfully loaded metadata for ${Object.keys(nftMetadataMap).length} NFTs`); + + // Cache for build-time to prevent reloading + buildTimeCache.set(cacheKey, blogsWithNFT); + return blogsWithNFT; } catch (error) { console.warn("[BlogLoader] Failed to load NFT metadata:", error); }