diff --git a/shared/chain-utils/src/abi/CollectorNFTv1.ts b/shared/chain-utils/src/abi/CollectorNFTv1.ts
new file mode 100644
index 00000000..058e34ce
--- /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 072d6296..f8570f5d 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 b134158f..996ed5c7 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 |
@@ -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/ | ✅ Fertig |
| **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,176 @@ vi.mock("../hooks/useAutoNetwork", () => ({
---
-## Phase 3: CollectorNFT-Komponenten migrieren
+## 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
+
+| 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
+
+- [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
-**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/MyNFTList.tsx b/website/components/MyNFTList.tsx
index dc047c40..c29956ee 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 6c13238c..d2706509 100644
--- a/website/components/SimpleCollectButton.tsx
+++ b/website/components/SimpleCollectButton.tsx
@@ -1,9 +1,11 @@
-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 * as styles from "../layouts/styles";
import { useLocale } from "../hooks/useLocale";
+
interface SimpleCollectButtonProps {
genImTokenId: bigint;
}
@@ -17,15 +19,19 @@ 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);
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 {
@@ -34,28 +40,46 @@ 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,
});
+ // 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) {
@@ -76,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];
@@ -102,17 +126,24 @@ 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 (
);
}
diff --git a/website/locales/de.ts b/website/locales/de.ts
index a2eb3bd4..5589cca9 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 08240efe..32cf1d34 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",
diff --git a/website/test/SimpleCollectButton.test.tsx b/website/test/SimpleCollectButton.test.tsx
index c446b27e..a50a352d 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 }],
},
}));
@@ -30,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", () => ({
@@ -45,7 +67,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 +165,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/test/blogLoader.nft.test.ts b/website/test/blogLoader.nft.test.ts
index 09a0cdcb..095e288a 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 00000000..df7e9082
--- /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 499361fc..e7e38d0b 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);
}
diff --git a/website/utils/getChain.ts b/website/utils/getChain.ts
index ab752814..71f0a44f 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;
}