diff --git a/app/api/my-assets/route.ts b/app/api/my-assets/route.ts
new file mode 100644
index 0000000..c7f2fb8
--- /dev/null
+++ b/app/api/my-assets/route.ts
@@ -0,0 +1,22 @@
+import { NextResponse } from 'next/server';
+import db from '@/lib/database';
+
+export async function GET(req: Request) {
+ const { searchParams } = new URL(req.url);
+ const owner = searchParams.get('owner');
+
+ if (!owner) {
+ return NextResponse.json({ error: 'Missing owner query parameter' }, { status: 400 });
+ }
+
+ try {
+ const assets = await db.localIPAsset.findMany({
+ where: { ownerAddress: owner.toLowerCase() },
+ orderBy: { createdAt: 'desc' },
+ });
+ return NextResponse.json({ assets });
+ } catch (error: any) {
+ console.error('Failed to fetch local assets:', error);
+ return NextResponse.json({ error: error.message }, { status: 500 });
+ }
+}
diff --git a/app/api/register-ip/route.ts b/app/api/register-ip/route.ts
index ebd1c85..9db3958 100644
--- a/app/api/register-ip/route.ts
+++ b/app/api/register-ip/route.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server';
+import db from '@/lib/database';
export async function POST(req: Request) {
try {
@@ -119,6 +120,28 @@ export async function POST(req: Request) {
}
const data = await res.json();
+
+ // Persist to local DB for immediate visibility before subgraph indexes
+ try {
+ await db.localIPAsset.create({
+ data: {
+ ownerAddress: creatorAddress.toLowerCase(),
+ title,
+ description,
+ ipType,
+ mediaUrl: mediaUrl,
+ imageUrl: finalImageUrl,
+ metadataUri: '',
+ licenses,
+ royaltyRate: parseInt(royalty, 10) || 10,
+ crossmintId: data.id || data.actionId || null,
+ },
+ });
+ } catch (dbErr) {
+ // Log but don't fail the registration — Crossmint succeeded
+ console.error('Failed to persist IP asset locally:', dbErr);
+ }
+
return NextResponse.json(data);
} catch (error: any) {
console.error("Register IP Route Error:", error);
diff --git a/components/ip-card.tsx b/components/ip-card.tsx
index 9a25767..fa93ed7 100644
--- a/components/ip-card.tsx
+++ b/components/ip-card.tsx
@@ -2,7 +2,7 @@
import Link from 'next/link'
import Image from 'next/image'
-import { Music, BookOpen, ImageIcon, ShoppingCart, ExternalLink, Video } from 'lucide-react'
+import { Music, BookOpen, ImageIcon, ShoppingCart, ExternalLink, Video, AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { type IPAsset } from '@/lib/data'
import { Button } from './ui/button'
@@ -75,6 +75,13 @@ export function IPCard({ asset, className }: IPCardProps) {
{asset.type}
+ {/* Metadata status warning */}
+ {(asset as any).metadataStatus === 'failed' && (
+
+ )}
+
{/* Story Protocol ID */}
{asset.storyProtocolId}
diff --git a/components/launchpad.tsx b/components/launchpad.tsx
index 7c13008..fea0117 100644
--- a/components/launchpad.tsx
+++ b/components/launchpad.tsx
@@ -18,7 +18,7 @@ import {
Loader2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
-import { graphQLClient, GET_USER_IP_ASSETS, mapSubgraphAssetToIPAsset } from '@/lib/graphql'
+import { graphQLClient, GET_USER_IP_ASSETS, mapSubgraphAssetToIPAsset, MetadataStatus } from '@/lib/graphql'
const RECENT_ACTIVITY: { action: string, asset: string, by: string, time: string, amount: string | null }[] = [] // Empty activity stream for production MVP
@@ -37,18 +37,61 @@ export function Launchpad() {
}
setLoading(true)
try {
- const data: any = await graphQLClient.request(GET_USER_IP_ASSETS, {
- first: 10,
- skip: 0
- })
- if (data.ipregistereds) {
- const assets = await Promise.all(
- data.ipregistereds.map((item: any) => mapSubgraphAssetToIPAsset(item))
- )
- setMyPortfolio(assets)
+ const ownerAddress = wallet.address.toLowerCase()
+
+ // Fetch from both subgraph and local DB in parallel
+ const [subgraphResult, localResult] = await Promise.allSettled([
+ graphQLClient.request(GET_USER_IP_ASSETS, {
+ first: 50,
+ skip: 0,
+ owner: ownerAddress,
+ }),
+ fetch(`/api/my-assets?owner=${encodeURIComponent(ownerAddress)}`).then(r => r.json()),
+ ])
+
+ // Map subgraph assets
+ let subgraphAssets: (IPAsset & { metadataStatus?: MetadataStatus })[] = []
+ if (subgraphResult.status === 'fulfilled') {
+ const data = subgraphResult.value as any
+ if (data.ipregistereds) {
+ subgraphAssets = await Promise.all(
+ data.ipregistereds.map((item: any) => mapSubgraphAssetToIPAsset(item))
+ )
+ }
+ } else {
+ console.error('Failed to fetch from Goldsky:', subgraphResult.reason)
}
+
+ // Map local DB assets to IPAsset shape
+ let localAssets: (IPAsset & { metadataStatus?: MetadataStatus })[] = []
+ if (localResult.status === 'fulfilled' && localResult.value.assets) {
+ localAssets = localResult.value.assets.map((a: any) => ({
+ id: a.id,
+ storyProtocolId: a.storyProtocolId || '',
+ title: a.title,
+ creator: 'You',
+ creatorHandle: `${ownerAddress.substring(0, 6)}...${ownerAddress.substring(ownerAddress.length - 4)}`,
+ type: a.ipType || 'music',
+ coverImage: a.imageUrl || '/images/art-1.jpg',
+ description: a.description || '',
+ licenses: a.licenses ? a.licenses.split(',').map((s: string) => s.trim()) : ['Commercial'],
+ price: 0,
+ currency: 'USDC' as const,
+ royaltyRate: a.royaltyRate || 10,
+ registered: new Date(a.createdAt).toISOString().split('T')[0],
+ tags: ['On-Chain', 'Story Protocol'],
+ stats: { views: 0, licenses: 0, revenue: 0 },
+ metadataStatus: 'loaded' as MetadataStatus,
+ metadataURI: a.metadataUri || '',
+ }))
+ }
+
+ // Deduplicate: subgraph wins when storyProtocolId matches
+ const seenIds = new Set(subgraphAssets.map(a => a.storyProtocolId).filter(Boolean))
+ const uniqueLocal = localAssets.filter(a => !a.storyProtocolId || !seenIds.has(a.storyProtocolId))
+ setMyPortfolio([...subgraphAssets, ...uniqueLocal])
} catch (err) {
- console.error('Failed to fetch from Goldsky:', err)
+ console.error('Failed to fetch assets:', err)
} finally {
setLoading(false)
}
diff --git a/lib/graphql.ts b/lib/graphql.ts
index 9662033..474d648 100644
--- a/lib/graphql.ts
+++ b/lib/graphql.ts
@@ -28,8 +28,8 @@ export const GET_RECENT_IP_ASSETS = gql`
`
export const GET_USER_IP_ASSETS = gql`
- query GetUserIPAssets($first: Int!, $skip: Int!) {
- ipregistereds(first: $first, skip: $skip, orderBy: timestamp_, orderDirection: desc) {
+ query GetUserIPAssets($first: Int!, $skip: Int!, $owner: String!) {
+ ipregistereds(first: $first, skip: $skip, orderBy: timestamp_, orderDirection: desc, where: { caller: $owner }) {
id
ipId
chainId
@@ -38,6 +38,7 @@ export const GET_USER_IP_ASSETS = gql`
uri
timestamp_
transactionHash_
+ caller
}
}
`
@@ -57,22 +58,44 @@ export const GET_IP_ASSET_BY_ID = gql`
}
`
+export type MetadataStatus = 'loading' | 'loaded' | 'failed'
+
+async function fetchWithRetry(url: string, retries = 3, baseDelayMs = 500): Promise {
+ for (let attempt = 0; attempt < retries; attempt++) {
+ const res = await fetch(url)
+ if (res.ok) return res
+ if (attempt < retries - 1) {
+ await new Promise(r => setTimeout(r, baseDelayMs * Math.pow(2, attempt)))
+ }
+ }
+ // Final attempt — let caller handle the error
+ return fetch(url)
+}
+
// Helper to map the raw Subgraph data to our application's IPAsset interface
-export async function mapSubgraphAssetToIPAsset(subgraphData: any): Promise {
+export async function mapSubgraphAssetToIPAsset(subgraphData: any): Promise {
// Extract block timestamp safely (converting seconds to ms if needed)
const dateObj = new Date(Number(subgraphData.timestamp_) * 1000)
let metadata: any = null
+ let metadataStatus: MetadataStatus = 'loading'
if (subgraphData.uri) {
try {
const url = resolveGroveURI(subgraphData.uri)
if (url) {
- const res = await fetch(url)
+ const res = await fetchWithRetry(url)
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
metadata = await res.json()
+ metadataStatus = 'loaded'
+ } else {
+ metadataStatus = 'failed'
}
} catch (e) {
console.warn('Failed to fetch metadata from Grove:', e)
+ metadataStatus = 'failed'
}
+ } else {
+ metadataStatus = 'failed'
}
const getAttr = (key: string) => metadata?.attributes?.find((a: any) => a.key === key)?.value
@@ -94,6 +117,7 @@ export async function mapSubgraphAssetToIPAsset(subgraphData: any): Promise