mirror of
https://github.com/luxfi/market.git
synced 2026-09-24 00:25:57 +00:00
Rebuild market with Tailwind + shadcn/ui design system
Complete UI overhaul to match lux.exchange look and feel: - Replace all inline styles with Tailwind CSS utility classes - Add shadcn/ui components: Button, Card, Dialog, DropdownMenu, Input - Replace RainbowKit with plain Wagmi (useAccount/useConnect/useDisconnect) - Dark theme with HSL CSS variables matching exchange - Inter + JetBrains Mono fonts via next/font/google - Migrate to Tailwind v4 (@import "tailwindcss" + @theme inline) - Rebuild all 8 components and 7 pages - Add shared utils (cn, shortenAddress, formatNumber, formatCurrency)
This commit is contained in:
@@ -17,7 +17,12 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: docker/setup-buildx-action@v3
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -42,6 +47,6 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64,linux/arm64
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Packaging-only Dockerfile - copies pre-built standalone output
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY public ./public
|
||||||
|
COPY .next/standalone ./
|
||||||
|
COPY .next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
+8
-1
@@ -12,11 +12,18 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rainbow-me/rainbowkit": "2.1.2",
|
"@radix-ui/react-dialog": "^1.1.0",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@tanstack/react-query": "5.77.2",
|
"@tanstack/react-query": "5.77.2",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
"next": "15.5.0",
|
"next": "15.5.0",
|
||||||
|
"next-themes": "^0.4.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"tailwind-merge": "^2.3.0",
|
||||||
"viem": "2.43.3",
|
"viem": "2.43.3",
|
||||||
"wagmi": "2.15.5"
|
"wagmi": "2.15.5"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+684
-140
File diff suppressed because it is too large
Load Diff
+12
-24
@@ -28,38 +28,26 @@ export default function ActivityPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||||
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 8 }}>Activity</h1>
|
<h1 className="text-3xl font-bold mb-2">Activity</h1>
|
||||||
<p style={{ color: 'var(--muted)', marginBottom: 32, fontSize: 14 }}>
|
<p className="text-muted-foreground mb-8 text-sm">
|
||||||
Recent NFT transfers, mints, and sales across all collections.
|
Recent NFT transfers, mints, and sales across all collections.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Column headers */}
|
{/* Column headers */}
|
||||||
<div
|
<div className="flex items-center gap-3 py-2 border-b border-border text-[11px] text-muted-foreground uppercase tracking-widest">
|
||||||
style={{
|
<div className="w-[72px] text-center">Event</div>
|
||||||
display: 'flex',
|
<div className="flex-1">Item</div>
|
||||||
alignItems: 'center',
|
<div className="w-[200px] text-center">From / To</div>
|
||||||
gap: 12,
|
<div className="w-10 text-right">Chain</div>
|
||||||
padding: '8px 0',
|
<div className="w-20 text-right">Date</div>
|
||||||
borderBottom: '1px solid var(--border)',
|
<div className="w-5" />
|
||||||
fontSize: 11,
|
|
||||||
color: 'var(--muted)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ width: 72, textAlign: 'center' }}>Event</div>
|
|
||||||
<div style={{ flex: 1 }}>Item</div>
|
|
||||||
<div style={{ width: 200, textAlign: 'center' }}>From / To</div>
|
|
||||||
<div style={{ width: 40, textAlign: 'right' }}>Chain</div>
|
|
||||||
<div style={{ width: 80, textAlign: 'right' }}>Date</div>
|
|
||||||
<div style={{ width: 20 }} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>Loading activity...</div>
|
<div className="text-center py-12 text-muted-foreground">Loading activity...</div>
|
||||||
) : !data?.items?.length ? (
|
) : !data?.items?.length ? (
|
||||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
No NFT activity found on this chain yet.
|
No NFT activity found on this chain yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { TraitFilter } from '@/components/TraitFilter'
|
|||||||
import { ActivityRow } from '@/components/ActivityRow'
|
import { ActivityRow } from '@/components/ActivityRow'
|
||||||
import { useCollection, useTokenInstances, useCollectionTransfers } from '@/hooks/useNFTData'
|
import { useCollection, useTokenInstances, useCollectionTransfers } from '@/hooks/useNFTData'
|
||||||
import { CHAIN_INFO, EXPLORER_API } from '@/lib/chains'
|
import { CHAIN_INFO, EXPLORER_API } from '@/lib/chains'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
|
|
||||||
type Tab = 'items' | 'activity'
|
type Tab = 'items' | 'activity'
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ export default function CollectionDetailPage({
|
|||||||
const [tab, setTab] = useState<Tab>('items')
|
const [tab, setTab] = useState<Tab>('items')
|
||||||
const [selectedTraits, setSelectedTraits] = useState<Record<string, Set<string>>>({})
|
const [selectedTraits, setSelectedTraits] = useState<Record<string, Set<string>>>({})
|
||||||
|
|
||||||
const { data: collection, isLoading: loadingCollection } = useCollection(chainId, address)
|
const { data: collection } = useCollection(chainId, address)
|
||||||
const { data: instances, isLoading: loadingInstances } = useTokenInstances(chainId, address)
|
const { data: instances, isLoading: loadingInstances } = useTokenInstances(chainId, address)
|
||||||
const { data: transfers } = useCollectionTransfers(chainId, address)
|
const { data: transfers } = useCollectionTransfers(chainId, address)
|
||||||
|
|
||||||
@@ -59,82 +61,69 @@ export default function CollectionDetailPage({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
<main style={{ maxWidth: 1400, margin: '0 auto', padding: '32px 24px' }}>
|
<main className="max-w-[1400px] mx-auto px-6 py-8">
|
||||||
{/* Collection header */}
|
{/* Collection header */}
|
||||||
<div style={{ marginBottom: 32 }}>
|
<div className="mb-8">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<div
|
<div
|
||||||
style={{
|
className="w-[72px] h-[72px] rounded-[14px] shrink-0 flex items-center justify-center text-2xl font-bold text-muted-foreground/30 bg-cover bg-center"
|
||||||
width: 72,
|
style={
|
||||||
height: 72,
|
collection?.icon_url
|
||||||
borderRadius: 14,
|
? { backgroundImage: `url(${collection.icon_url})` }
|
||||||
background: collection?.icon_url
|
: { background: 'linear-gradient(135deg, hsl(var(--secondary)), hsl(var(--muted)))' }
|
||||||
? `url(${collection.icon_url}) center/cover`
|
}
|
||||||
: 'linear-gradient(135deg, #1a1a2e, #0f3460)',
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#333',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{!collection?.icon_url && (collection?.symbol?.charAt(0) ?? '?')}
|
{!collection?.icon_url && (collection?.symbol?.charAt(0) ?? '?')}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: 28, fontWeight: 700 }}>{collection?.name ?? 'Loading...'}</h1>
|
<h1 className="text-[28px] font-bold">{collection?.name ?? 'Loading...'}</h1>
|
||||||
<div style={{ display: 'flex', gap: 12, marginTop: 4, fontSize: 13, color: 'var(--muted)' }}>
|
<div className="flex gap-3 mt-1 text-[13px] text-muted-foreground">
|
||||||
{chainInfo && <span style={{ color: chainInfo.color }}>{chainInfo.name}</span>}
|
{chainInfo && <span style={{ color: chainInfo.color }}>{chainInfo.name}</span>}
|
||||||
<span>{collection?.type}</span>
|
<span>{collection?.type}</span>
|
||||||
<a
|
<a
|
||||||
href={`${explorerBase}/token/${address}`}
|
href={`${explorerBase}/token/${address}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ color: 'var(--muted)', textDecoration: 'none' }}
|
className="text-muted-foreground no-underline hover:text-foreground flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{address.slice(0, 6)}...{address.slice(-4)} ↗
|
<span className="font-mono">{address.slice(0, 6)}...{address.slice(-4)}</span>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
<div style={{ display: 'flex', gap: 24, marginBottom: 16 }}>
|
<div className="flex gap-6 mb-4">
|
||||||
{[
|
{[
|
||||||
{ label: 'Items', value: collection?.total_supply ? Number(collection.total_supply).toLocaleString() : '---' },
|
{ label: 'Items', value: collection?.total_supply ? Number(collection.total_supply).toLocaleString() : '---' },
|
||||||
{ label: 'Holders', value: collection?.holders ? Number(collection.holders).toLocaleString() : '---' },
|
{ label: 'Holders', value: collection?.holders ? Number(collection.holders).toLocaleString() : '---' },
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<div key={stat.label}>
|
<div key={stat.label}>
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>
|
<div className="text-[11px] text-muted-foreground uppercase tracking-widest">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 2 }}>{stat.value}</div>
|
<div className="text-xl font-bold mt-0.5 font-mono">{stat.value}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
|
<div className="flex gap-1 border-b border-border">
|
||||||
{(['items', 'activity'] as Tab[]).map((t) => (
|
{(['items', 'activity'] as Tab[]).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
style={{
|
className={cn(
|
||||||
padding: '10px 20px',
|
'px-5 py-2.5 bg-transparent border-none text-sm font-semibold cursor-pointer capitalize transition-colors border-b-2',
|
||||||
background: 'none',
|
tab === t
|
||||||
border: 'none',
|
? 'text-foreground border-foreground'
|
||||||
borderBottom: tab === t ? '2px solid var(--foreground)' : '2px solid transparent',
|
: 'text-muted-foreground border-transparent hover:text-foreground'
|
||||||
color: tab === t ? 'var(--foreground)' : 'var(--muted)',
|
)}
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{t}
|
{t}
|
||||||
{t === 'items' && instances?.items && (
|
{t === 'items' && instances?.items && (
|
||||||
<span style={{ marginLeft: 6, fontSize: 12, color: 'var(--muted)' }}>
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||||
({filteredInstances.length})
|
({filteredInstances.length})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +134,7 @@ export default function CollectionDetailPage({
|
|||||||
|
|
||||||
{/* Tab content */}
|
{/* Tab content */}
|
||||||
{tab === 'items' && (
|
{tab === 'items' && (
|
||||||
<div style={{ display: 'flex', gap: 24 }}>
|
<div className="flex gap-6">
|
||||||
{/* Trait sidebar */}
|
{/* Trait sidebar */}
|
||||||
<TraitFilter
|
<TraitFilter
|
||||||
instances={instances?.items ?? []}
|
instances={instances?.items ?? []}
|
||||||
@@ -155,19 +144,13 @@ export default function CollectionDetailPage({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* NFT grid */}
|
{/* NFT grid */}
|
||||||
<div style={{ flex: 1 }}>
|
<div className="flex-1">
|
||||||
{loadingInstances ? (
|
{loadingInstances ? (
|
||||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>Loading NFTs...</div>
|
<div className="text-center py-12 text-muted-foreground">Loading NFTs...</div>
|
||||||
) : filteredInstances.length === 0 ? (
|
) : filteredInstances.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>No NFTs found</div>
|
<div className="text-center py-12 text-muted-foreground">No NFTs found</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredInstances.map((inst) => (
|
{filteredInstances.map((inst) => (
|
||||||
<NFTCard
|
<NFTCard
|
||||||
key={`${inst.token.address}-${inst.id}`}
|
key={`${inst.token.address}-${inst.id}`}
|
||||||
@@ -186,7 +169,7 @@ export default function CollectionDetailPage({
|
|||||||
{tab === 'activity' && (
|
{tab === 'activity' && (
|
||||||
<div>
|
<div>
|
||||||
{!transfers?.items?.length ? (
|
{!transfers?.items?.length ? (
|
||||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>No activity yet</div>
|
<div className="text-center py-12 text-muted-foreground">No activity yet</div>
|
||||||
) : (
|
) : (
|
||||||
transfers.items.map((t, i) => <ActivityRow key={`${t.tx_hash}-${i}`} transfer={t} chainId={chainId} />)
|
transfers.items.map((t, i) => <ActivityRow key={`${t.tx_hash}-${i}`} transfer={t} chainId={chainId} />)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { CollectionCard } from '@/components/CollectionCard'
|
import { CollectionCard } from '@/components/CollectionCard'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
|
||||||
import { useChainContext } from '@/hooks/useChain'
|
import { useChainContext } from '@/hooks/useChain'
|
||||||
import { useCollections } from '@/hooks/useNFTData'
|
import { useCollections } from '@/hooks/useNFTData'
|
||||||
import { CHAIN_INFO } from '@/lib/chains'
|
import { CHAIN_INFO } from '@/lib/chains'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
export default function CollectionsPage() {
|
export default function CollectionsPage() {
|
||||||
const { chainId } = useChainContext()
|
const { chainId } = useChainContext()
|
||||||
@@ -17,57 +19,39 @@ export default function CollectionsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
<div className="flex justify-between items-center mb-2">
|
||||||
<h1 style={{ fontSize: 32, fontWeight: 700 }}>Collections</h1>
|
<h1 className="text-3xl font-bold">Collections</h1>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ color: 'var(--muted)', marginBottom: 24, fontSize: 14 }}>
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
Browse NFT collections on{' '}
|
Browse NFT collections on{' '}
|
||||||
<span style={{ color: chainInfo?.color, fontWeight: 600 }}>{chainInfo?.name ?? 'Lux'}</span>.
|
<span className="font-semibold" style={{ color: chainInfo?.color }}>{chainInfo?.name ?? 'Lux'}</span>.
|
||||||
Collections with LSSVM pools have instant buy/sell liquidity.
|
Collections with LSSVM pools have instant buy/sell liquidity.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div className="mb-6 relative max-w-[400px]">
|
||||||
<input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Search collections by name..."
|
placeholder="Search collections by name..."
|
||||||
style={{
|
className="pl-9 h-10 rounded-[10px] bg-card"
|
||||||
width: '100%',
|
|
||||||
maxWidth: 400,
|
|
||||||
padding: '10px 16px',
|
|
||||||
background: 'var(--card)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 10,
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
fontSize: 14,
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Collection list */}
|
{/* Collection list */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>Loading collections...</div>
|
<div className="text-center py-12 text-muted-foreground">Loading collections...</div>
|
||||||
) : !collections?.items?.length ? (
|
) : !collections?.items?.length ? (
|
||||||
<div
|
<Card className="text-center py-12 text-muted-foreground">
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: 48,
|
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{search
|
{search
|
||||||
? `No collections matching "${search}" on ${chainInfo?.name ?? 'this chain'}.`
|
? `No collections matching "${search}" on ${chainInfo?.name ?? 'this chain'}.`
|
||||||
: `No NFT collections found on ${chainInfo?.name ?? 'this chain'} yet.`}
|
: `No NFT collections found on ${chainInfo?.name ?? 'this chain'} yet.`}
|
||||||
</div>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div className="flex flex-col gap-2">
|
||||||
{collections.items.map((token, i) => (
|
{collections.items.map((token, i) => (
|
||||||
<CollectionCard key={token.address} token={token} chainId={chainId} rank={i + 1} />
|
<CollectionCard key={token.address} token={token} chainId={chainId} rank={i + 1} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
+31
-74
@@ -1,4 +1,8 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
const GENESIS_TIERS = [
|
const GENESIS_TIERS = [
|
||||||
{
|
{
|
||||||
@@ -8,7 +12,6 @@ const GENESIS_TIERS = [
|
|||||||
'The pinnacle of the Lux Genesis Collection. Each Genesis NFT permanently locks 1 billion LUX, with all staking rewards flowing directly to the holder.',
|
'The pinnacle of the Lux Genesis Collection. Each Genesis NFT permanently locks 1 billion LUX, with all staking rewards flowing directly to the holder.',
|
||||||
maxSupply: 10,
|
maxSupply: 10,
|
||||||
rewardMultiplier: '10x',
|
rewardMultiplier: '10x',
|
||||||
gradient: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Validator',
|
name: 'Validator',
|
||||||
@@ -17,7 +20,6 @@ const GENESIS_TIERS = [
|
|||||||
'Validator-tier Genesis NFTs lock 100M LUX permanently. Holders receive validator staking rewards through the ValidatorVault and LiquidLUX (xLUX) system.',
|
'Validator-tier Genesis NFTs lock 100M LUX permanently. Holders receive validator staking rewards through the ValidatorVault and LiquidLUX (xLUX) system.',
|
||||||
maxSupply: 100,
|
maxSupply: 100,
|
||||||
rewardMultiplier: '5x',
|
rewardMultiplier: '5x',
|
||||||
gradient: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b69 100%)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Mini',
|
name: 'Mini',
|
||||||
@@ -26,7 +28,6 @@ const GENESIS_TIERS = [
|
|||||||
'Mini Genesis NFTs lock 10M LUX permanently. A more accessible entry point to Genesis rewards with proportional staking returns.',
|
'Mini Genesis NFTs lock 10M LUX permanently. A more accessible entry point to Genesis rewards with proportional staking returns.',
|
||||||
maxSupply: 1000,
|
maxSupply: 1000,
|
||||||
rewardMultiplier: '2x',
|
rewardMultiplier: '2x',
|
||||||
gradient: 'linear-gradient(135deg, #1a0f2e 0%, #2d1654 50%, #1a1a3e 100%)',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Nano',
|
name: 'Nano',
|
||||||
@@ -35,7 +36,6 @@ const GENESIS_TIERS = [
|
|||||||
'Nano Genesis NFTs lock 1M LUX permanently. The most accessible Genesis tier, providing base-level staking rewards to holders.',
|
'Nano Genesis NFTs lock 1M LUX permanently. The most accessible Genesis tier, providing base-level staking rewards to holders.',
|
||||||
maxSupply: 10000,
|
maxSupply: 10000,
|
||||||
rewardMultiplier: '1x',
|
rewardMultiplier: '1x',
|
||||||
gradient: 'linear-gradient(135deg, #0f1a2e 0%, #1a2d4e 50%, #0f2040 100%)',
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -43,95 +43,52 @@ export default function GenesisPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||||
<section style={{ textAlign: 'center', marginBottom: 48 }}>
|
<section className="text-center mb-12">
|
||||||
<h1 style={{ fontSize: 40, fontWeight: 700, marginBottom: 12 }}>Genesis NFTs</h1>
|
<h1 className="text-4xl font-bold mb-3">Genesis NFTs</h1>
|
||||||
<p style={{ fontSize: 16, color: 'var(--muted)', maxWidth: 600, margin: '0 auto' }}>
|
<p className="text-base text-muted-foreground max-w-[600px] mx-auto">
|
||||||
Permanently locked LUX tokens with staking rewards flowing to NFT holders. Genesis NFTs control validator
|
Permanently locked LUX tokens with staking rewards flowing to NFT holders. Genesis NFTs control validator
|
||||||
staking returns through the ValidatorVault and LiquidLUX (xLUX) system.
|
staking returns through the ValidatorVault and LiquidLUX (xLUX) system.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Reward flow diagram */}
|
{/* Reward flow diagram */}
|
||||||
<section
|
<Card className="p-6 mb-12 text-center">
|
||||||
style={{
|
<h3 className="text-base font-semibold mb-4">Reward Flow</h3>
|
||||||
background: 'var(--card)',
|
<div className="flex items-center justify-center gap-4 flex-wrap text-sm">
|
||||||
borderRadius: 12,
|
<span>Validator Staking</span>
|
||||||
border: '1px solid var(--border)',
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
padding: 24,
|
<span>ValidatorVault.depositRewards()</span>
|
||||||
marginBottom: 48,
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
textAlign: 'center',
|
<span>LiquidLUX.depositValidatorRewards()</span>
|
||||||
}}
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
>
|
<span className="font-semibold">xLUX holders</span>
|
||||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>Reward Flow</h3>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{ fontSize: 14 }}>Validator Staking</span>
|
|
||||||
<span style={{ color: 'var(--muted)' }}>→</span>
|
|
||||||
<span style={{ fontSize: 14 }}>ValidatorVault.depositRewards()</span>
|
|
||||||
<span style={{ color: 'var(--muted)' }}>→</span>
|
|
||||||
<span style={{ fontSize: 14 }}>LiquidLUX.depositValidatorRewards()</span>
|
|
||||||
<span style={{ color: 'var(--muted)' }}>→</span>
|
|
||||||
<span style={{ fontSize: 14, fontWeight: 600 }}>xLUX holders</span>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</Card>
|
||||||
|
|
||||||
{/* Tier grid */}
|
{/* Tier grid */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 24 }}>
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-6">
|
||||||
{GENESIS_TIERS.map((tier) => (
|
{GENESIS_TIERS.map((tier) => (
|
||||||
<div
|
<Card key={tier.name} className="overflow-hidden">
|
||||||
key={tier.name}
|
<div className="aspect-[4/3] bg-gradient-to-br from-secondary to-muted flex flex-col items-center justify-center gap-2">
|
||||||
style={{
|
<span className="text-xs text-muted-foreground uppercase tracking-[2px]">
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
aspectRatio: '4/3',
|
|
||||||
background: tier.gradient,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'var(--muted)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Genesis
|
Genesis
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 36, fontWeight: 700 }}>{tier.name}</span>
|
<span className="text-4xl font-bold">{tier.name}</span>
|
||||||
<span style={{ fontSize: 14, color: 'var(--accent)' }}>{tier.rewardMultiplier} rewards</span>
|
<span className="text-sm text-primary">{tier.rewardMultiplier} rewards</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '20px' }}>
|
<div className="p-5">
|
||||||
<div style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 4 }}>Permanently Locked</div>
|
<div className="text-[13px] text-muted-foreground mb-1">Permanently Locked</div>
|
||||||
<div style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>{tier.lockedLux} LUX</div>
|
<div className="text-2xl font-bold mb-3 font-mono">{tier.lockedLux} LUX</div>
|
||||||
<p style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.5, marginBottom: 16 }}>
|
<p className="text-[13px] text-muted-foreground leading-relaxed mb-4">
|
||||||
{tier.description}
|
{tier.description}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div className="flex justify-between text-[13px] text-muted-foreground border-t border-border pt-3">
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
fontSize: 13,
|
|
||||||
color: 'var(--muted)',
|
|
||||||
borderTop: '1px solid var(--border)',
|
|
||||||
paddingTop: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>Max Supply: {tier.maxSupply.toLocaleString()}</span>
|
<span>Max Supply: {tier.maxSupply.toLocaleString()}</span>
|
||||||
<span>Rewards: {tier.rewardMultiplier}</span>
|
<span>Rewards: {tier.rewardMultiplier}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+156
-56
@@ -1,78 +1,178 @@
|
|||||||
@import 'tailwindcss';
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
/* Default to dark mode */
|
||||||
--background: #0a0a0a;
|
html {
|
||||||
--foreground: #ededed;
|
color-scheme: dark;
|
||||||
--accent: #e8e8e8;
|
|
||||||
--muted: #666;
|
|
||||||
--border: #1a1a1a;
|
|
||||||
--card: #0f0f0f;
|
|
||||||
--card-hover: #151515;
|
|
||||||
--success: #00cc66;
|
|
||||||
--error: #ff4444;
|
|
||||||
--warning: #ffaa00;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* CSS Variables - Light theme */
|
||||||
color: var(--foreground);
|
:root {
|
||||||
background: var(--background);
|
--radius: 0.75rem;
|
||||||
font-family:
|
--background: 0 0% 100%;
|
||||||
'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
--foreground: 240 10% 3.9%;
|
||||||
-webkit-font-smoothing: antialiased;
|
--card: 0 0% 100%;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 240 5.9% 10%;
|
||||||
|
--chart-1: 142.1 76.2% 36.3%;
|
||||||
|
--chart-2: 217.2 91.2% 59.8%;
|
||||||
|
--chart-3: 316.6 73.3% 69.4%;
|
||||||
|
--chart-4: 38.3 95.8% 56.7%;
|
||||||
|
--chart-5: 0 84.2% 60.2%;
|
||||||
|
--success: 142.1 76.2% 36.3%;
|
||||||
|
--success-foreground: 0 0% 98%;
|
||||||
|
--warning: 38.3 95.8% 56.7%;
|
||||||
|
--warning-foreground: 0 0% 0%;
|
||||||
|
--info: 217.2 91.2% 59.8%;
|
||||||
|
--info-foreground: 0 0% 98%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme - Primary for Lux apps */
|
||||||
|
.dark {
|
||||||
|
--background: 240 10% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
|
--chart-1: 142.1 70.6% 45.3%;
|
||||||
|
--chart-2: 217.2 91.2% 59.8%;
|
||||||
|
--chart-3: 316.6 73.3% 52%;
|
||||||
|
--chart-4: 38.3 95.8% 56.7%;
|
||||||
|
--chart-5: 0 84.2% 60.2%;
|
||||||
|
--success: 142.1 70.6% 45.3%;
|
||||||
|
--success-foreground: 0 0% 98%;
|
||||||
|
--warning: 38.3 95.8% 56.7%;
|
||||||
|
--warning-foreground: 0 0% 0%;
|
||||||
|
--info: 217.2 91.2% 59.8%;
|
||||||
|
--info-foreground: 0 0% 98%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--color-background: hsl(var(--background));
|
||||||
|
--color-foreground: hsl(var(--foreground));
|
||||||
|
--color-card: hsl(var(--card));
|
||||||
|
--color-card-foreground: hsl(var(--card-foreground));
|
||||||
|
--color-popover: hsl(var(--popover));
|
||||||
|
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||||
|
--color-primary: hsl(var(--primary));
|
||||||
|
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||||
|
--color-secondary: hsl(var(--secondary));
|
||||||
|
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||||
|
--color-muted: hsl(var(--muted));
|
||||||
|
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||||
|
--color-accent: hsl(var(--accent));
|
||||||
|
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||||
|
--color-destructive: hsl(var(--destructive));
|
||||||
|
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||||
|
--color-border: hsl(var(--border));
|
||||||
|
--color-input: hsl(var(--input));
|
||||||
|
--color-ring: hsl(var(--ring));
|
||||||
|
--color-success: hsl(var(--success));
|
||||||
|
--color-success-foreground: hsl(var(--success-foreground));
|
||||||
|
--color-warning: hsl(var(--warning));
|
||||||
|
--color-warning-foreground: hsl(var(--warning-foreground));
|
||||||
|
--color-info: hsl(var(--info));
|
||||||
|
--color-info-foreground: hsl(var(--info-foreground));
|
||||||
|
--color-chart-1: hsl(var(--chart-1));
|
||||||
|
--color-chart-2: hsl(var(--chart-2));
|
||||||
|
--color-chart-3: hsl(var(--chart-3));
|
||||||
|
--color-chart-4: hsl(var(--chart-4));
|
||||||
|
--color-chart-5: hsl(var(--chart-5));
|
||||||
|
|
||||||
|
--font-sans: var(--font-sans, ui-sans-serif, system-ui, sans-serif);
|
||||||
|
--font-mono: var(--font-mono, ui-monospace, monospace);
|
||||||
|
|
||||||
|
--animate-in: enter 0.2s ease-out;
|
||||||
|
--animate-out: exit 0.2s ease-in;
|
||||||
|
|
||||||
|
@keyframes enter {
|
||||||
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
@keyframes exit {
|
||||||
|
from { opacity: 1; transform: scale(1); }
|
||||||
|
to { opacity: 0; transform: scale(0.95); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
border-color: hsl(var(--border));
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
html {
|
||||||
color: inherit;
|
scroll-behavior: smooth;
|
||||||
text-decoration: none;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
body {
|
||||||
::-webkit-scrollbar {
|
background-color: hsl(var(--background));
|
||||||
width: 6px;
|
color: hsl(var(--foreground));
|
||||||
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
.glass {
|
||||||
background: transparent;
|
background-color: hsl(var(--background) / 0.8);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
.glow {
|
||||||
background: #333;
|
box-shadow: 0 0 20px rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
.no-scrollbar {
|
||||||
background: #444;
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Selection */
|
.shimmer {
|
||||||
::selection {
|
position: relative;
|
||||||
background: rgba(232, 232, 232, 0.15);
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.shimmer::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus visible */
|
@keyframes shimmer {
|
||||||
:focus-visible {
|
100% { transform: translateX(100%); }
|
||||||
outline: 1px solid var(--accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Input/select reset */
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
input, select, textarea {
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
font-family: inherit;
|
::-webkit-scrollbar-thumb { background: hsl(var(--muted)); border-radius: 9999px; }
|
||||||
}
|
::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground) / 0.5); }
|
||||||
|
|
||||||
input::-webkit-outer-spin-button,
|
|
||||||
input::-webkit-inner-spin-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type=number] {
|
|
||||||
-moz-appearance: textfield;
|
|
||||||
}
|
|
||||||
|
|||||||
+14
-4
@@ -1,14 +1,24 @@
|
|||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
|
import { Inter, JetBrains_Mono } from 'next/font/google'
|
||||||
import { ClientProviders } from './client-providers'
|
import { ClientProviders } from './client-providers'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-sans',
|
||||||
|
})
|
||||||
|
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-mono',
|
||||||
|
})
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: 'Lux Market - NFT Marketplace',
|
default: 'Lux Market - NFT Marketplace',
|
||||||
template: '%s | Lux Market',
|
template: '%s | Lux Market',
|
||||||
},
|
},
|
||||||
description:
|
description: 'Trade NFTs across all Lux chains. Seaport-powered P2P trading with LSSVM AMM liquidity pools.',
|
||||||
'Trade NFTs across all Lux chains. Seaport-powered P2P trading with LSSVM AMM liquidity pools. Genesis NFTs with permanent LUX staking rewards.',
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: 'Lux Market',
|
title: 'Lux Market',
|
||||||
description: 'The NFT marketplace for the Lux ecosystem',
|
description: 'The NFT marketplace for the Lux ecosystem',
|
||||||
@@ -32,8 +42,8 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`dark ${inter.variable} ${jetbrainsMono.variable}`}>
|
||||||
<body>
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
<ClientProviders>{children}</ClientProviders>
|
<ClientProviders>{children}</ClientProviders>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { use } from 'react'
|
import { use } from 'react'
|
||||||
import { useAccount } from 'wagmi'
|
import { useAccount } from 'wagmi'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import Link from 'next/link'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { ListingForm } from '@/components/ListingForm'
|
import { ListingForm } from '@/components/ListingForm'
|
||||||
import { OfferForm } from '@/components/OfferForm'
|
import { OfferForm } from '@/components/OfferForm'
|
||||||
@@ -10,12 +11,11 @@ import { ActivityRow } from '@/components/ActivityRow'
|
|||||||
import { useTokenInstance, useTokenInstanceTransfers } from '@/hooks/useNFTData'
|
import { useTokenInstance, useTokenInstanceTransfers } from '@/hooks/useNFTData'
|
||||||
import { getNftImageUrl, resolveMediaUrl } from '@/lib/explorer'
|
import { getNftImageUrl, resolveMediaUrl } from '@/lib/explorer'
|
||||||
import { CHAIN_INFO, EXPLORER_API } from '@/lib/chains'
|
import { CHAIN_INFO, EXPLORER_API } from '@/lib/chains'
|
||||||
|
import { shortenAddress } from '@/lib/utils'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
import type { Address } from 'viem'
|
import type { Address } from 'viem'
|
||||||
|
|
||||||
function shortenAddress(addr: string): string {
|
|
||||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function NFTDetailPage({
|
export default function NFTDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -39,24 +39,14 @@ export default function NFTDetailPage({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 64, color: 'var(--muted)' }}>Loading NFT...</div>
|
<div className="text-center py-16 text-muted-foreground">Loading NFT...</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 400px', gap: 32, alignItems: 'start' }}>
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_400px] gap-8 items-start">
|
||||||
{/* Left: Media */}
|
{/* Left: Media */}
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div className="aspect-square rounded-2xl border border-border overflow-hidden max-w-[600px] relative bg-secondary">
|
||||||
style={{
|
|
||||||
aspectRatio: '1',
|
|
||||||
borderRadius: 16,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
maxWidth: 600,
|
|
||||||
position: 'relative',
|
|
||||||
background: '#111',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{animationUrl ? (
|
{animationUrl ? (
|
||||||
<video
|
<video
|
||||||
src={animationUrl}
|
src={animationUrl}
|
||||||
@@ -64,31 +54,19 @@ export default function NFTDetailPage({
|
|||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : imageUrl ? (
|
) : imageUrl ? (
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={metadata?.name ?? `#${tokenId}`}
|
alt={metadata?.name ?? `#${tokenId}`}
|
||||||
fill
|
fill
|
||||||
style={{ objectFit: 'cover' }}
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
sizes="600px"
|
sizes="600px"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-secondary to-muted text-5xl font-bold text-muted-foreground/30">
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#333',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
#{tokenId}
|
#{tokenId}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -96,143 +74,91 @@ export default function NFTDetailPage({
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{metadata?.description && (
|
{metadata?.description && (
|
||||||
<div
|
<Card className="mt-4 p-4">
|
||||||
style={{
|
<div className="text-xs text-muted-foreground mb-2 font-semibold uppercase tracking-widest">
|
||||||
marginTop: 16,
|
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
padding: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
||||||
Description
|
Description
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, color: 'var(--foreground)', lineHeight: 1.6 }}>
|
<div className="text-sm text-foreground leading-relaxed">
|
||||||
{metadata.description}
|
{metadata.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Traits */}
|
{/* Traits */}
|
||||||
{metadata?.attributes && metadata.attributes.length > 0 && (
|
{metadata?.attributes && metadata.attributes.length > 0 && (
|
||||||
<div
|
<Card className="mt-4 p-4">
|
||||||
style={{
|
<div className="text-xs text-muted-foreground mb-3 font-semibold uppercase tracking-widest">
|
||||||
marginTop: 16,
|
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
padding: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
||||||
Traits
|
Traits
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8 }}>
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2">
|
||||||
{metadata.attributes.map((trait, i) => (
|
{metadata.attributes.map((trait, i) => (
|
||||||
<div
|
<div
|
||||||
key={`${trait.trait_type}-${i}`}
|
key={`${trait.trait_type}-${i}`}
|
||||||
style={{
|
className="bg-primary/5 rounded-lg px-3 py-2 border border-border"
|
||||||
background: 'rgba(232,232,232,0.05)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: '8px 12px',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>
|
<div className="text-[10px] text-muted-foreground uppercase tracking-widest">
|
||||||
{trait.trait_type}
|
{trait.trait_type}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2 }}>
|
<div className="text-[13px] font-semibold mt-0.5">
|
||||||
{String(trait.value)}
|
{String(trait.value)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transfer History */}
|
{/* Transfer History */}
|
||||||
{transfers?.items && transfers.items.length > 0 && (
|
{transfers?.items && transfers.items.length > 0 && (
|
||||||
<div
|
<Card className="mt-4 p-4">
|
||||||
style={{
|
<div className="text-xs text-muted-foreground mb-3 font-semibold uppercase tracking-widest">
|
||||||
marginTop: 16,
|
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
padding: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
||||||
Transfer History
|
Transfer History
|
||||||
</div>
|
</div>
|
||||||
{transfers.items.slice(0, 20).map((t, i) => (
|
{transfers.items.slice(0, 20).map((t, i) => (
|
||||||
<ActivityRow key={`${t.tx_hash}-${i}`} transfer={t} chainId={chainId} />
|
<ActivityRow key={`${t.tx_hash}-${i}`} transfer={t} chainId={chainId} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Details + Actions */}
|
{/* Right: Details + Actions */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, position: 'sticky', top: 80 }}>
|
<div className="flex flex-col gap-4 lg:sticky lg:top-20">
|
||||||
{/* Collection & Name */}
|
{/* Collection & Name */}
|
||||||
<div>
|
<div>
|
||||||
<a
|
<Link
|
||||||
href={`/collection/${chainId}/${contractAddress}`}
|
href={`/collection/${chainId}/${contractAddress}`}
|
||||||
style={{ fontSize: 13, color: 'var(--accent)', textDecoration: 'none' }}
|
className="text-[13px] text-primary no-underline hover:underline"
|
||||||
>
|
>
|
||||||
{instance?.token?.name ?? 'Collection'}
|
{instance?.token?.name ?? 'Collection'}
|
||||||
</a>
|
</Link>
|
||||||
<h1 style={{ fontSize: 28, fontWeight: 700, marginTop: 4 }}>
|
<h1 className="text-[28px] font-bold mt-1">
|
||||||
{metadata?.name ?? `#${tokenId}`}
|
{metadata?.name ?? `#${tokenId}`}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Owner */}
|
{/* Owner */}
|
||||||
<div
|
<Card className="p-3.5">
|
||||||
style={{
|
<div className="text-[11px] text-muted-foreground mb-1 uppercase tracking-widest">
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 14,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
||||||
Owner
|
Owner
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="flex items-center gap-2">
|
||||||
<span style={{ fontSize: 14, fontFamily: 'monospace' }}>
|
<span className="text-sm font-mono">
|
||||||
{ownerAddress ? shortenAddress(ownerAddress) : '---'}
|
{ownerAddress ? shortenAddress(ownerAddress) : '---'}
|
||||||
</span>
|
</span>
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<span
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-success/10 text-success font-semibold">
|
||||||
style={{
|
|
||||||
fontSize: 10,
|
|
||||||
padding: '2px 6px',
|
|
||||||
borderRadius: 4,
|
|
||||||
background: 'rgba(0,200,100,0.1)',
|
|
||||||
color: '#0c6',
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
YOU
|
YOU
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Details */}
|
{/* Details */}
|
||||||
<div
|
<Card className="p-3.5">
|
||||||
style={{
|
<div className="text-[11px] text-muted-foreground mb-2 uppercase tracking-widest">
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 14,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
||||||
Details
|
Details
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
<div className="flex flex-col gap-1.5">
|
||||||
{[
|
{[
|
||||||
{ label: 'Chain', value: chainInfo?.name ?? String(chainId), color: chainInfo?.color },
|
{ label: 'Chain', value: chainInfo?.name ?? String(chainId), color: chainInfo?.color },
|
||||||
{
|
{
|
||||||
@@ -243,26 +169,30 @@ export default function NFTDetailPage({
|
|||||||
{ label: 'Token ID', value: tokenId },
|
{ label: 'Token ID', value: tokenId },
|
||||||
{ label: 'Token Standard', value: instance?.token?.type ?? 'ERC-721' },
|
{ label: 'Token Standard', value: instance?.token?.type ?? 'ERC-721' },
|
||||||
].map((row) => (
|
].map((row) => (
|
||||||
<div key={row.label} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
<div key={row.label} className="flex justify-between text-[13px]">
|
||||||
<span style={{ color: 'var(--muted)' }}>{row.label}</span>
|
<span className="text-muted-foreground">{row.label}</span>
|
||||||
{row.href ? (
|
{row.href ? (
|
||||||
<a
|
<a
|
||||||
href={row.href}
|
href={row.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ fontFamily: 'monospace', color: 'var(--foreground)', textDecoration: 'none' }}
|
className="font-mono text-foreground no-underline flex items-center gap-1 hover:underline"
|
||||||
>
|
>
|
||||||
{row.value} ↗
|
{row.value}
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: row.color, fontFamily: row.label === 'Token ID' ? 'monospace' : undefined }}>
|
<span
|
||||||
|
className={row.label === 'Token ID' ? 'font-mono' : ''}
|
||||||
|
style={row.color ? { color: row.color } : undefined}
|
||||||
|
>
|
||||||
{row.value}
|
{row.value}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* External links */}
|
{/* External links */}
|
||||||
{metadata?.external_url && (
|
{metadata?.external_url && (
|
||||||
@@ -270,37 +200,20 @@ export default function NFTDetailPage({
|
|||||||
href={metadata.external_url}
|
href={metadata.external_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
className="block text-center py-2.5 bg-card border border-border rounded-[10px] text-primary no-underline text-[13px] hover:bg-secondary transition-colors"
|
||||||
display: 'block',
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '10px',
|
|
||||||
background: 'var(--card)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 10,
|
|
||||||
color: 'var(--accent)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
View External ↗
|
View External <ExternalLink className="inline h-3 w-3 ml-1" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Trading Actions */}
|
{/* Trading Actions */}
|
||||||
<div
|
<Card className="p-4">
|
||||||
style={{
|
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isOwner ? (
|
{isOwner ? (
|
||||||
<ListingForm contractAddress={contractAddress as Address} tokenId={tokenId} />
|
<ListingForm contractAddress={contractAddress as Address} tokenId={tokenId} />
|
||||||
) : (
|
) : (
|
||||||
<OfferForm contractAddress={contractAddress as Address} tokenId={tokenId} />
|
<OfferForm contractAddress={contractAddress as Address} tokenId={tokenId} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+38
-80
@@ -7,6 +7,8 @@ import { useChainContext } from '@/hooks/useChain'
|
|||||||
import { useCollections } from '@/hooks/useNFTData'
|
import { useCollections } from '@/hooks/useNFTData'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { CHAIN_INFO } from '@/lib/chains'
|
import { CHAIN_INFO } from '@/lib/chains'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
const GENESIS_TIERS = [
|
const GENESIS_TIERS = [
|
||||||
{ name: 'Genesis', locked: '1B LUX', tier: 'Tier 1', multiplier: '10x' },
|
{ name: 'Genesis', locked: '1B LUX', tier: 'Tier 1', multiplier: '10x' },
|
||||||
@@ -23,106 +25,70 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section style={{ textAlign: 'center', marginBottom: 48 }}>
|
<section className="text-center mb-12">
|
||||||
<h1 style={{ fontSize: 48, fontWeight: 700, marginBottom: 12, letterSpacing: '-0.03em' }}>Lux Market</h1>
|
<h1 className="text-5xl font-bold mb-3 tracking-tight">Lux Market</h1>
|
||||||
<p style={{ fontSize: 16, color: 'var(--muted)', maxWidth: 560, margin: '0 auto 24px' }}>
|
<p className="text-base text-muted-foreground max-w-[560px] mx-auto mb-6">
|
||||||
Trade NFTs across all Lux chains. Seaport-powered P2P trading with LSSVM AMM liquidity.
|
Trade NFTs across all Lux chains. Seaport-powered P2P trading with LSSVM AMM liquidity.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ maxWidth: 480, margin: '0 auto' }}>
|
<div className="max-w-[480px] mx-auto">
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Genesis Featured */}
|
{/* Genesis Featured */}
|
||||||
<section style={{ marginBottom: 48 }}>
|
<section className="mb-12">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 600 }}>Genesis Collection</h2>
|
<h2 className="text-xl font-semibold">Genesis Collection</h2>
|
||||||
<Link href="/genesis" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: 13, fontWeight: 500 }}>
|
<Link href="/genesis" className="text-primary no-underline text-[13px] font-medium flex items-center gap-1 hover:underline">
|
||||||
View All →
|
View All <ArrowRight className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
|
||||||
{GENESIS_TIERS.map((nft) => (
|
{GENESIS_TIERS.map((nft) => (
|
||||||
<Link key={nft.name} href="/genesis" style={{ textDecoration: 'none', color: 'inherit' }}>
|
<Link key={nft.name} href="/genesis" className="no-underline text-inherit group">
|
||||||
<div
|
<Card className="overflow-hidden transition-all duration-200 hover:border-muted-foreground/30 hover:-translate-y-0.5">
|
||||||
style={{
|
<div className="aspect-square bg-gradient-to-br from-secondary to-muted flex flex-col items-center justify-center gap-1.5">
|
||||||
background: 'var(--card)',
|
<span className="text-[11px] text-muted-foreground uppercase tracking-widest">
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
transition: 'all 200ms ease',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#333'
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--border)'
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
aspectRatio: '1',
|
|
||||||
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 11, color: '#555', textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
||||||
{nft.tier}
|
{nft.tier}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 24, fontWeight: 700 }}>{nft.name}</span>
|
<span className="text-2xl font-bold">{nft.name}</span>
|
||||||
<span style={{ fontSize: 12, color: 'var(--accent)' }}>{nft.multiplier} rewards</span>
|
<span className="text-xs text-primary">{nft.multiplier} rewards</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '10px 12px' }}>
|
<div className="px-3 py-2.5">
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Permanently Locked</div>
|
<div className="text-[11px] text-muted-foreground">Permanently Locked</div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, marginTop: 2 }}>{nft.locked}</div>
|
<div className="text-sm font-semibold mt-0.5">{nft.locked}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Collections on selected chain */}
|
{/* Collections on selected chain */}
|
||||||
<section style={{ marginBottom: 48 }}>
|
<section className="mb-12">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 600 }}>
|
<h2 className="text-xl font-semibold">
|
||||||
Collections on{' '}
|
Collections on{' '}
|
||||||
<span style={{ color: chainInfo?.color }}>{chainInfo?.name ?? 'Lux'}</span>
|
<span style={{ color: chainInfo?.color }}>{chainInfo?.name ?? 'Lux'}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<Link
|
<Link
|
||||||
href="/collections"
|
href="/collections"
|
||||||
style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: 13, fontWeight: 500 }}
|
className="text-primary no-underline text-[13px] font-medium flex items-center gap-1 hover:underline"
|
||||||
>
|
>
|
||||||
View All →
|
View All <ArrowRight className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>Loading collections...</div>
|
<div className="text-center py-8 text-muted-foreground">Loading collections...</div>
|
||||||
) : !collections?.items?.length ? (
|
) : !collections?.items?.length ? (
|
||||||
<div
|
<Card className="text-center py-8 text-muted-foreground">
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: 32,
|
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
color: 'var(--muted)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No NFT collections found on {chainInfo?.name ?? 'this chain'} yet. Deploy an ERC-721 or ERC-1155 to get started.
|
No NFT collections found on {chainInfo?.name ?? 'this chain'} yet. Deploy an ERC-721 or ERC-1155 to get started.
|
||||||
</div>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div className="flex flex-col gap-2">
|
||||||
{collections.items.slice(0, 10).map((token, i) => (
|
{collections.items.slice(0, 10).map((token, i) => (
|
||||||
<CollectionCard key={token.address} token={token} chainId={chainId} rank={i + 1} />
|
<CollectionCard key={token.address} token={token} chainId={chainId} rank={i + 1} />
|
||||||
))}
|
))}
|
||||||
@@ -131,28 +97,20 @@ export default function Home() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<section style={{ marginBottom: 48 }}>
|
<section className="mb-12">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: 'Supported Chains', value: '5' },
|
{ label: 'Supported Chains', value: '5' },
|
||||||
{ label: 'Collections', value: collections?.items?.length?.toString() ?? '---' },
|
{ label: 'Collections', value: collections?.items?.length?.toString() ?? '---' },
|
||||||
{ label: 'Trading Protocol', value: 'Seaport' },
|
{ label: 'Trading Protocol', value: 'Seaport' },
|
||||||
{ label: 'AMM Protocol', value: 'LSSVM' },
|
{ label: 'AMM Protocol', value: 'LSSVM' },
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<div
|
<Card key={stat.label} className="px-5 py-4">
|
||||||
key={stat.label}
|
<div className="text-[11px] text-muted-foreground mb-1 uppercase tracking-widest">
|
||||||
style={{
|
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: '16px 20px',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: 1 }}>
|
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700 }}>{stat.value}</div>
|
<div className="text-xl font-bold">{stat.value}</div>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+29
-42
@@ -6,6 +6,11 @@ import { NFTCard } from '@/components/NFTCard'
|
|||||||
import { useChainContext } from '@/hooks/useChain'
|
import { useChainContext } from '@/hooks/useChain'
|
||||||
import { usePortfolioNFTs } from '@/hooks/useNFTData'
|
import { usePortfolioNFTs } from '@/hooks/useNFTData'
|
||||||
import { CHAIN_INFO } from '@/lib/chains'
|
import { CHAIN_INFO } from '@/lib/chains'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Wallet } from 'lucide-react'
|
||||||
|
import { useConnect } from 'wagmi'
|
||||||
|
import { injected } from 'wagmi/connectors'
|
||||||
|
|
||||||
const ALL_CHAIN_IDS = [96369, 200200, 36963, 36911, 494949]
|
const ALL_CHAIN_IDS = [96369, 200200, 36963, 36911, 494949]
|
||||||
|
|
||||||
@@ -13,21 +18,15 @@ function ChainPortfolio({ chainId, walletAddress }: { chainId: number; walletAdd
|
|||||||
const { data, isLoading } = usePortfolioNFTs(chainId, walletAddress)
|
const { data, isLoading } = usePortfolioNFTs(chainId, walletAddress)
|
||||||
const chainInfo = CHAIN_INFO[chainId]
|
const chainInfo = CHAIN_INFO[chainId]
|
||||||
|
|
||||||
if (isLoading) return <div style={{ color: 'var(--muted)', padding: '12px 0' }}>Loading {chainInfo?.name}...</div>
|
if (isLoading) return <div className="text-muted-foreground py-3">Loading {chainInfo?.name}...</div>
|
||||||
if (!data?.items?.length) return null
|
if (!data?.items?.length) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ marginBottom: 32 }}>
|
<section className="mb-8">
|
||||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: chainInfo?.color }}>
|
<h3 className="text-base font-semibold mb-3" style={{ color: chainInfo?.color }}>
|
||||||
{chainInfo?.name} ({data.items.length})
|
{chainInfo?.name} ({data.items.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{data.items.map((item) => (
|
{data.items.map((item) => (
|
||||||
<NFTCard
|
<NFTCard
|
||||||
key={`${item.token.address}-${item.token_id}`}
|
key={`${item.token.address}-${item.token_id}`}
|
||||||
@@ -45,48 +44,36 @@ function ChainPortfolio({ chainId, walletAddress }: { chainId: number; walletAdd
|
|||||||
|
|
||||||
export default function PortfolioPage() {
|
export default function PortfolioPage() {
|
||||||
const { address, isConnected } = useAccount()
|
const { address, isConnected } = useAccount()
|
||||||
const { chainId: selectedChain } = useChainContext()
|
const { connect } = useConnect()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Header />
|
<Header />
|
||||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||||
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 24 }}>My NFTs</h1>
|
<h1 className="text-3xl font-bold mb-6">My NFTs</h1>
|
||||||
|
|
||||||
{!isConnected ? (
|
{!isConnected ? (
|
||||||
<div
|
<Card className="text-center py-16 px-6">
|
||||||
style={{
|
<Wallet className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
textAlign: 'center',
|
<h2 className="text-xl font-semibold mb-2">Connect your wallet</h2>
|
||||||
padding: '64px 24px',
|
<p className="text-muted-foreground text-sm mb-6">
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 16,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontSize: 48, marginBottom: 16 }}>💼</div>
|
|
||||||
<h2 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Connect your wallet</h2>
|
|
||||||
<p style={{ color: 'var(--muted)', fontSize: 14 }}>
|
|
||||||
Connect a wallet to view your NFTs across all Lux chains.
|
Connect a wallet to view your NFTs across all Lux chains.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<Button
|
||||||
|
variant="connect"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => connect({ connector: injected() })}
|
||||||
|
>
|
||||||
|
<Wallet className="h-4 w-4" />
|
||||||
|
Connect Wallet
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<Card className="flex items-center gap-2 mb-6 px-4 py-3 text-[13px]">
|
||||||
style={{
|
<span className="text-muted-foreground">Wallet:</span>
|
||||||
display: 'flex',
|
<span className="font-mono">{address}</span>
|
||||||
alignItems: 'center',
|
</Card>
|
||||||
gap: 8,
|
|
||||||
marginBottom: 24,
|
|
||||||
padding: '12px 16px',
|
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 10,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: 'var(--muted)' }}>Wallet:</span>
|
|
||||||
<span style={{ fontFamily: 'monospace' }}>{address}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* NFTs grouped by chain */}
|
{/* NFTs grouped by chain */}
|
||||||
{ALL_CHAIN_IDS.map((cId) => (
|
{ALL_CHAIN_IDS.map((cId) => (
|
||||||
|
|||||||
+19
-24
@@ -1,39 +1,34 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit'
|
|
||||||
import '@rainbow-me/rainbowkit/styles.css'
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { useState, type ReactNode } from 'react'
|
import { useState, type ReactNode } from 'react'
|
||||||
import { WagmiProvider } from 'wagmi'
|
import { WagmiProvider, createConfig, http } from 'wagmi'
|
||||||
import { config } from '@/lib/wagmi'
|
import { injected } from 'wagmi/connectors'
|
||||||
import { ChainContext } from '@/hooks/useChain'
|
import { ChainContext } from '@/hooks/useChain'
|
||||||
|
import { supportedChains } from '@/lib/chains'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
function createWagmiConfig() {
|
||||||
defaultOptions: {
|
return createConfig({
|
||||||
queries: {
|
chains: supportedChains,
|
||||||
refetchOnWindowFocus: false,
|
connectors: [injected()],
|
||||||
retry: 2,
|
transports: Object.fromEntries(supportedChains.map((c) => [c.id, http()])) as any,
|
||||||
},
|
ssr: true,
|
||||||
},
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
const [chainId, setChainId] = useState(96369) // Default to Lux mainnet
|
const [chainId, setChainId] = useState(96369)
|
||||||
|
const [config] = useState(() => createWagmiConfig())
|
||||||
|
const [queryClient] = useState(() => new QueryClient({
|
||||||
|
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 2, staleTime: 60_000 } },
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WagmiProvider config={config}>
|
<WagmiProvider config={config}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RainbowKitProvider
|
<ChainContext.Provider value={{ chainId, setChainId }}>
|
||||||
theme={darkTheme({
|
{children}
|
||||||
accentColor: '#E8E8E8',
|
</ChainContext.Provider>
|
||||||
accentColorForeground: '#000',
|
|
||||||
borderRadius: 'medium',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ChainContext.Provider value={{ chainId, setChainId }}>
|
|
||||||
{children}
|
|
||||||
</ChainContext.Provider>
|
|
||||||
</RainbowKitProvider>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</WagmiProvider>
|
</WagmiProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,17 +2,15 @@
|
|||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { CHAIN_INFO, EXPLORER_API } from '@/lib/chains'
|
import { CHAIN_INFO, EXPLORER_API } from '@/lib/chains'
|
||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
import type { ExplorerTransfer } from '@/lib/explorer'
|
import type { ExplorerTransfer } from '@/lib/explorer'
|
||||||
|
import { shortenAddress } from '@/lib/utils'
|
||||||
|
|
||||||
function shortenAddress(addr: string): string {
|
function getEventType(method: string): { label: string } {
|
||||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventType(method: string): { label: string; color: string } {
|
|
||||||
const m = method.toLowerCase()
|
const m = method.toLowerCase()
|
||||||
if (m.includes('mint') || m === 'transfer' && true) return { label: 'Transfer', color: '#888' }
|
if (m.includes('mint') || m === 'transfer') return { label: 'Transfer' }
|
||||||
if (m.includes('safe')) return { label: 'Transfer', color: '#888' }
|
if (m.includes('safe')) return { label: 'Transfer' }
|
||||||
return { label: method || 'Transfer', color: '#888' }
|
return { label: method || 'Transfer' }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityRowProps {
|
interface ActivityRowProps {
|
||||||
@@ -29,63 +27,44 @@ export function ActivityRow({ transfer, chainId }: ActivityRowProps) {
|
|||||||
const tokenId = transfer.total?.token_id ?? '?'
|
const tokenId = transfer.total?.token_id ?? '?'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center gap-3 py-3 border-b border-border text-[13px]">
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
padding: '12px 0',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Event type */}
|
{/* Event type */}
|
||||||
<div
|
<div
|
||||||
style={{
|
className={`px-2.5 py-1 rounded-md text-xs font-semibold w-[72px] text-center shrink-0 ${
|
||||||
padding: '4px 10px',
|
isMint
|
||||||
borderRadius: 6,
|
? 'bg-success/10 text-success'
|
||||||
background: isMint ? 'rgba(0,200,100,0.1)' : 'rgba(136,136,136,0.1)',
|
: 'bg-muted-foreground/10 text-muted-foreground'
|
||||||
color: isMint ? '#0c6' : event.color,
|
}`}
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
width: 72,
|
|
||||||
textAlign: 'center',
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isMint ? 'Mint' : event.label}
|
{isMint ? 'Mint' : event.label}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Item */}
|
{/* Item */}
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div className="flex-1 min-w-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/nft/${chainId}/${transfer.token.address}/${tokenId}`}
|
href={`/nft/${chainId}/${transfer.token.address}/${tokenId}`}
|
||||||
style={{
|
className="text-foreground no-underline font-medium hover:underline"
|
||||||
color: 'var(--foreground)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{transfer.token.name} #{tokenId}
|
{transfer.token.name} #{tokenId}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* From → To */}
|
{/* From -> To */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--muted)', flexShrink: 0 }}>
|
<div className="flex items-center gap-1.5 text-muted-foreground shrink-0">
|
||||||
<a
|
<a
|
||||||
href={`${explorerBase}/address/${transfer.from.hash}`}
|
href={`${explorerBase}/address/${transfer.from.hash}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ color: 'var(--muted)', textDecoration: 'none', fontFamily: 'monospace', fontSize: 12 }}
|
className="text-muted-foreground no-underline font-mono text-xs hover:text-foreground"
|
||||||
>
|
>
|
||||||
{isMint ? 'NullAddress' : shortenAddress(transfer.from.hash)}
|
{isMint ? 'NullAddress' : shortenAddress(transfer.from.hash)}
|
||||||
</a>
|
</a>
|
||||||
<span style={{ fontSize: 10 }}>→</span>
|
<span className="text-[10px]">→</span>
|
||||||
<a
|
<a
|
||||||
href={`${explorerBase}/address/${transfer.to.hash}`}
|
href={`${explorerBase}/address/${transfer.to.hash}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ color: 'var(--muted)', textDecoration: 'none', fontFamily: 'monospace', fontSize: 12 }}
|
className="text-muted-foreground no-underline font-mono text-xs hover:text-foreground"
|
||||||
>
|
>
|
||||||
{shortenAddress(transfer.to.hash)}
|
{shortenAddress(transfer.to.hash)}
|
||||||
</a>
|
</a>
|
||||||
@@ -93,13 +72,16 @@ export function ActivityRow({ transfer, chainId }: ActivityRowProps) {
|
|||||||
|
|
||||||
{/* Chain */}
|
{/* Chain */}
|
||||||
{chainInfo && (
|
{chainInfo && (
|
||||||
<div style={{ fontSize: 11, color: chainInfo.color, fontWeight: 600, width: 40, textAlign: 'right', flexShrink: 0 }}>
|
<div
|
||||||
|
className="text-[11px] font-semibold w-10 text-right shrink-0"
|
||||||
|
style={{ color: chainInfo.color }}
|
||||||
|
>
|
||||||
{chainInfo.name}
|
{chainInfo.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Time */}
|
{/* Time */}
|
||||||
<div style={{ color: 'var(--muted)', fontSize: 12, width: 80, textAlign: 'right', flexShrink: 0 }}>
|
<div className="text-muted-foreground text-xs w-20 text-right shrink-0">
|
||||||
{transfer.timestamp ? new Date(transfer.timestamp).toLocaleDateString() : '---'}
|
{transfer.timestamp ? new Date(transfer.timestamp).toLocaleDateString() : '---'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,15 +90,10 @@ export function ActivityRow({ transfer, chainId }: ActivityRowProps) {
|
|||||||
href={`${explorerBase}/tx/${transfer.tx_hash}`}
|
href={`${explorerBase}/tx/${transfer.tx_hash}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
className="text-muted-foreground shrink-0 hover:text-foreground transition-colors"
|
||||||
color: 'var(--muted)',
|
|
||||||
textDecoration: 'none',
|
|
||||||
fontSize: 11,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
title="View on explorer"
|
title="View on explorer"
|
||||||
>
|
>
|
||||||
↗
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,78 +16,41 @@ export function CollectionCard({ token, chainId, rank }: CollectionCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/collection/${chainId}/${token.address}`}
|
href={`/collection/${chainId}/${token.address}`}
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
className="no-underline text-inherit"
|
||||||
>
|
>
|
||||||
<div
|
<div className="bg-card rounded-xl p-4 border border-border flex items-center gap-4 transition-colors cursor-pointer hover:border-muted-foreground/30 hover:bg-secondary/50">
|
||||||
style={{
|
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 16,
|
|
||||||
transition: 'all 150ms ease',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#333'
|
|
||||||
e.currentTarget.style.background = 'var(--card-hover)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--border)'
|
|
||||||
e.currentTarget.style.background = 'var(--card)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rank !== undefined && (
|
{rank !== undefined && (
|
||||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--muted)', width: 24, textAlign: 'center' }}>
|
<div className="text-sm font-bold text-muted-foreground w-6 text-center font-mono">
|
||||||
{rank}
|
{rank}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Collection icon */}
|
{/* Collection icon */}
|
||||||
<div
|
<div
|
||||||
style={{
|
className="w-14 h-14 rounded-[10px] shrink-0 flex items-center justify-center text-xl font-bold text-muted-foreground/30 bg-cover bg-center"
|
||||||
width: 56,
|
style={
|
||||||
height: 56,
|
token.icon_url
|
||||||
borderRadius: 10,
|
? { backgroundImage: `url(${token.icon_url})` }
|
||||||
background: token.icon_url
|
: { background: 'linear-gradient(135deg, hsl(var(--secondary)), hsl(var(--muted)))' }
|
||||||
? `url(${token.icon_url}) center/cover`
|
}
|
||||||
: 'linear-gradient(135deg, #1a1a2e, #0f3460)',
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#333',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{!token.icon_url && (token.symbol?.charAt(0) ?? '?')}
|
{!token.icon_url && (token.symbol?.charAt(0) ?? '?')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div className="flex-1 min-w-0">
|
||||||
<div
|
<div className="text-[15px] font-semibold truncate">
|
||||||
style={{
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: 600,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{token.name}
|
{token.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 16, marginTop: 4, fontSize: 12, color: 'var(--muted)' }}>
|
<div className="flex gap-4 mt-1 text-xs text-muted-foreground">
|
||||||
<span>{token.type}</span>
|
<span>{token.type}</span>
|
||||||
{chainInfo && (
|
{chainInfo && (
|
||||||
<span style={{ color: chainInfo.color }}>{chainInfo.name}</span>
|
<span style={{ color: chainInfo.color }}>{chainInfo.name}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
<div className="text-right shrink-0">
|
||||||
<div style={{ fontSize: 13 }}>
|
<div className="text-[13px]">
|
||||||
{token.holders ? Number(token.holders).toLocaleString() : '---'} holders
|
{token.holders ? Number(token.holders).toLocaleString() : '---'} holders
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
|
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||||
{token.total_supply ? Number(token.total_supply).toLocaleString() : '---'} items
|
{token.total_supply ? Number(token.total_supply).toLocaleString() : '---'} items
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+53
-55
@@ -1,10 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { ConnectButton } from '@rainbow-me/rainbowkit'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
|
import { useAccount, useConnect, useDisconnect } from 'wagmi'
|
||||||
|
import { injected } from 'wagmi/connectors'
|
||||||
import { useChainContext } from '@/hooks/useChain'
|
import { useChainContext } from '@/hooks/useChain'
|
||||||
import { CHAIN_INFO } from '@/lib/chains'
|
import { CHAIN_INFO } from '@/lib/chains'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Wallet, LogOut } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { shortenAddress } from '@/lib/utils'
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ href: '/', label: 'Explore' },
|
{ href: '/', label: 'Explore' },
|
||||||
@@ -19,51 +24,32 @@ const CHAIN_IDS = [96369, 200200, 36963, 36911, 494949]
|
|||||||
export function Header() {
|
export function Header() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { chainId, setChainId } = useChainContext()
|
const { chainId, setChainId } = useChainContext()
|
||||||
|
const { address, isConnected } = useAccount()
|
||||||
|
const { connect } = useConnect()
|
||||||
|
const { disconnect } = useDisconnect()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header className="sticky top-0 z-50 flex items-center justify-between px-6 py-3 border-b border-border bg-background/80 backdrop-blur-xl">
|
||||||
style={{
|
<div className="flex items-center gap-8">
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '12px 24px',
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
position: 'sticky',
|
|
||||||
top: 0,
|
|
||||||
background: 'rgba(10, 10, 10, 0.85)',
|
|
||||||
backdropFilter: 'blur(12px)',
|
|
||||||
zIndex: 100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 32 }}>
|
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
style={{
|
className="text-lg font-bold tracking-tight text-foreground no-underline"
|
||||||
textDecoration: 'none',
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '-0.02em',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Lux Market
|
Lux Market
|
||||||
</Link>
|
</Link>
|
||||||
<nav style={{ display: 'flex', gap: 4 }}>
|
<nav className="flex gap-1">
|
||||||
{NAV_LINKS.map((link) => {
|
{NAV_LINKS.map((link) => {
|
||||||
const isActive = link.href === '/' ? pathname === '/' : pathname.startsWith(link.href)
|
const isActive = link.href === '/' ? pathname === '/' : pathname.startsWith(link.href)
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
style={{
|
className={cn(
|
||||||
textDecoration: 'none',
|
'px-3 py-1.5 rounded-lg text-sm no-underline transition-colors',
|
||||||
color: isActive ? 'var(--foreground)' : 'var(--muted)',
|
isActive
|
||||||
fontSize: 14,
|
? 'text-foreground bg-card'
|
||||||
padding: '6px 12px',
|
: 'text-muted-foreground hover:text-foreground'
|
||||||
borderRadius: 8,
|
)}
|
||||||
background: isActive ? 'var(--card)' : 'transparent',
|
|
||||||
transition: 'all 150ms ease',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -72,9 +58,9 @@ export function Header() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div className="flex items-center gap-3">
|
||||||
{/* Chain selector */}
|
{/* Chain selector */}
|
||||||
<div style={{ display: 'flex', gap: 2, background: 'var(--card)', borderRadius: 8, padding: 2 }}>
|
<div className="flex gap-0.5 bg-card rounded-lg p-0.5">
|
||||||
{CHAIN_IDS.map((id) => {
|
{CHAIN_IDS.map((id) => {
|
||||||
const info = CHAIN_INFO[id]
|
const info = CHAIN_INFO[id]
|
||||||
const isActive = chainId === id
|
const isActive = chainId === id
|
||||||
@@ -83,17 +69,13 @@ export function Header() {
|
|||||||
key={id}
|
key={id}
|
||||||
onClick={() => setChainId(id)}
|
onClick={() => setChainId(id)}
|
||||||
title={info.name}
|
title={info.name}
|
||||||
style={{
|
className={cn(
|
||||||
padding: '6px 10px',
|
'px-2.5 py-1.5 text-xs font-semibold rounded-md cursor-pointer transition-colors border-none',
|
||||||
fontSize: 12,
|
isActive
|
||||||
fontWeight: 600,
|
? 'text-black'
|
||||||
border: 'none',
|
: 'text-muted-foreground bg-transparent hover:text-foreground'
|
||||||
borderRadius: 6,
|
)}
|
||||||
cursor: 'pointer',
|
style={isActive ? { background: info.color } : undefined}
|
||||||
background: isActive ? info.color : 'transparent',
|
|
||||||
color: isActive ? '#000' : 'var(--muted)',
|
|
||||||
transition: 'all 150ms ease',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{info.name}
|
{info.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -105,19 +87,35 @@ export function Header() {
|
|||||||
href="https://exchange.lux.network"
|
href="https://exchange.lux.network"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
className="px-3 py-1.5 text-[13px] text-muted-foreground no-underline border border-border rounded-lg hover:text-foreground transition-colors"
|
||||||
textDecoration: 'none',
|
|
||||||
color: 'var(--muted)',
|
|
||||||
fontSize: 13,
|
|
||||||
padding: '6px 12px',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Exchange
|
Exchange
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ConnectButton showBalance={false} chainStatus="icon" accountStatus="avatar" />
|
{isConnected ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-mono text-muted-foreground">
|
||||||
|
{address ? shortenAddress(address) : ''}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => disconnect()}
|
||||||
|
title="Disconnect"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="connect"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => connect({ connector: injected() })}
|
||||||
|
>
|
||||||
|
<Wallet className="h-4 w-4" />
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState } from 'react'
|
|||||||
import { parseEther, type Address } from 'viem'
|
import { parseEther, type Address } from 'viem'
|
||||||
import { useWriteContract, useAccount, useChainId } from 'wagmi'
|
import { useWriteContract, useAccount, useChainId } from 'wagmi'
|
||||||
import { CONTRACTS, MARKET_ABI, ERC721_ABI } from '@/lib/contracts'
|
import { CONTRACTS, MARKET_ABI, ERC721_ABI } from '@/lib/contracts'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
interface ListingFormProps {
|
interface ListingFormProps {
|
||||||
contractAddress: Address
|
contractAddress: Address
|
||||||
@@ -51,43 +53,26 @@ export function ListingForm({ contractAddress, tokenId }: ListingFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div className="flex flex-col gap-3">
|
||||||
<h3 style={{ fontSize: 16, fontWeight: 600 }}>List for Sale</h3>
|
<h3 className="text-base font-semibold">List for Sale</h3>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 4 }}>Price (LUX)</label>
|
<label className="text-xs text-muted-foreground block mb-1">Price (LUX)</label>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={price}
|
value={price}
|
||||||
onChange={(e) => setPrice(e.target.value)}
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
placeholder="0.0"
|
placeholder="0.0"
|
||||||
style={{
|
className="h-10 text-base bg-card"
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
background: 'var(--card)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
fontSize: 16,
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 4 }}>
|
<label className="text-xs text-muted-foreground block mb-1">
|
||||||
Duration (days)
|
Duration (days)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={duration}
|
value={duration}
|
||||||
onChange={(e) => setDuration(e.target.value)}
|
onChange={(e) => setDuration(e.target.value)}
|
||||||
style={{
|
className="w-full h-10 px-3 bg-card border border-input rounded-md text-foreground text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
background: 'var(--card)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="1">1 day</option>
|
<option value="1">1 day</option>
|
||||||
<option value="3">3 days</option>
|
<option value="3">3 days</option>
|
||||||
@@ -96,40 +81,22 @@ export function ListingForm({ contractAddress, tokenId }: ListingFormProps) {
|
|||||||
<option value="30">30 days</option>
|
<option value="30">30 days</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div className="flex gap-2">
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
onClick={handleApproveAndList}
|
onClick={handleApproveAndList}
|
||||||
disabled={isPending || !price}
|
disabled={isPending || !price}
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '12px',
|
|
||||||
background: 'transparent',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
cursor: isPending ? 'wait' : 'pointer',
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
className="flex-1"
|
||||||
onClick={handleList}
|
onClick={handleList}
|
||||||
disabled={isPending || !price}
|
disabled={isPending || !price}
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '12px',
|
|
||||||
background: 'var(--accent)',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: '#000',
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: isPending ? 'wait' : 'pointer',
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isPending ? 'Listing...' : 'List NFT'}
|
{isPending ? 'Listing...' : 'List NFT'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-72
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||||||
import { formatEther } from 'viem'
|
import { formatEther } from 'viem'
|
||||||
import { getNftImageUrl, type ExplorerTokenInstance } from '@/lib/explorer'
|
import { getNftImageUrl, type ExplorerTokenInstance } from '@/lib/explorer'
|
||||||
import { CHAIN_INFO } from '@/lib/chains'
|
import { CHAIN_INFO } from '@/lib/chains'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
|
||||||
export interface NFTCardProps {
|
export interface NFTCardProps {
|
||||||
contractAddress: string
|
contractAddress: string
|
||||||
@@ -26,110 +27,50 @@ export function NFTCard({ contractAddress, tokenId, name, imageUrl, collectionNa
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/nft/${chainId}/${contractAddress}/${tokenId}`}
|
href={`/nft/${chainId}/${contractAddress}/${tokenId}`}
|
||||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
className="no-underline text-inherit group"
|
||||||
>
|
>
|
||||||
<div
|
<Card className="overflow-hidden transition-all duration-200 hover:border-muted-foreground/30 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/30">
|
||||||
style={{
|
<div className="aspect-square relative bg-secondary">
|
||||||
background: 'var(--card)',
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
transition: 'all 200ms ease',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = '#333'
|
|
||||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
|
||||||
e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.3)'
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = 'var(--border)'
|
|
||||||
e.currentTarget.style.transform = 'translateY(0)'
|
|
||||||
e.currentTarget.style.boxShadow = 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ aspectRatio: '1', position: 'relative', background: '#111' }}>
|
|
||||||
{resolvedImage ? (
|
{resolvedImage ? (
|
||||||
<Image
|
<Image
|
||||||
src={resolvedImage}
|
src={resolvedImage}
|
||||||
alt={resolvedName ?? `#${tokenId}`}
|
alt={resolvedName ?? `#${tokenId}`}
|
||||||
fill
|
fill
|
||||||
style={{ objectFit: 'cover' }}
|
className="object-cover"
|
||||||
unoptimized
|
unoptimized
|
||||||
sizes="(max-width: 768px) 50vw, 25vw"
|
sizes="(max-width: 768px) 50vw, 25vw"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-secondary to-muted text-4xl font-bold text-muted-foreground/30">
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#333',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
#{tokenId}
|
#{tokenId}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Chain badge */}
|
{/* Chain badge */}
|
||||||
{chainInfo && (
|
{chainInfo && (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="absolute top-2 right-2 px-2 py-0.5 rounded-md bg-black/60 backdrop-blur-sm text-[10px] font-semibold"
|
||||||
position: 'absolute',
|
style={{ color: chainInfo.color }}
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: 6,
|
|
||||||
background: 'rgba(0,0,0,0.6)',
|
|
||||||
backdropFilter: 'blur(4px)',
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: chainInfo.color,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{chainInfo.name}
|
{chainInfo.name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '10px 12px' }}>
|
<div className="px-3 py-2.5">
|
||||||
{resolvedCollection && (
|
{resolvedCollection && (
|
||||||
<div
|
<div className="text-[11px] text-muted-foreground uppercase tracking-wider truncate">
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'var(--muted)',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.05em',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{resolvedCollection}
|
{resolvedCollection}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="text-sm font-semibold mt-0.5 truncate">
|
||||||
style={{
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
marginTop: 2,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{resolvedName ?? `#${tokenId}`}
|
{resolvedName ?? `#${tokenId}`}
|
||||||
</div>
|
</div>
|
||||||
{price !== undefined && price > 0n && (
|
{price !== undefined && price > 0n && (
|
||||||
<div style={{ fontSize: 13, color: 'var(--accent)', marginTop: 6, fontWeight: 500 }}>
|
<div className="text-[13px] text-primary mt-1.5 font-medium font-mono">
|
||||||
{formatEther(price)} {chainInfo?.symbol ?? 'LUX'}
|
{formatEther(price)} {chainInfo?.symbol ?? 'LUX'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState } from 'react'
|
|||||||
import { parseEther, type Address } from 'viem'
|
import { parseEther, type Address } from 'viem'
|
||||||
import { useWriteContract, useChainId } from 'wagmi'
|
import { useWriteContract, useChainId } from 'wagmi'
|
||||||
import { CONTRACTS, MARKET_ABI } from '@/lib/contracts'
|
import { CONTRACTS, MARKET_ABI } from '@/lib/contracts'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
interface OfferFormProps {
|
interface OfferFormProps {
|
||||||
contractAddress: Address
|
contractAddress: Address
|
||||||
@@ -39,43 +41,26 @@ export function OfferForm({ contractAddress, tokenId }: OfferFormProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div className="flex flex-col gap-3">
|
||||||
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Make an Offer</h3>
|
<h3 className="text-base font-semibold">Make an Offer</h3>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 4 }}>
|
<label className="text-xs text-muted-foreground block mb-1">
|
||||||
Offer Amount (LUX)
|
Offer Amount (LUX)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={amount}
|
value={amount}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
placeholder="0.0"
|
placeholder="0.0"
|
||||||
style={{
|
className="h-10 text-base bg-card"
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
background: 'var(--card)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
fontSize: 16,
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 4 }}>Expires in</label>
|
<label className="text-xs text-muted-foreground block mb-1">Expires in</label>
|
||||||
<select
|
<select
|
||||||
value={duration}
|
value={duration}
|
||||||
onChange={(e) => setDuration(e.target.value)}
|
onChange={(e) => setDuration(e.target.value)}
|
||||||
style={{
|
className="w-full h-10 px-3 bg-card border border-input rounded-md text-foreground text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
width: '100%',
|
|
||||||
padding: '10px 12px',
|
|
||||||
background: 'var(--card)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<option value="1">1 day</option>
|
<option value="1">1 day</option>
|
||||||
<option value="3">3 days</option>
|
<option value="3">3 days</option>
|
||||||
@@ -83,23 +68,13 @@ export function OfferForm({ contractAddress, tokenId }: OfferFormProps) {
|
|||||||
<option value="14">14 days</option>
|
<option value="14">14 days</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
|
className="w-full"
|
||||||
onClick={handleOffer}
|
onClick={handleOffer}
|
||||||
disabled={isPending || !amount}
|
disabled={isPending || !amount}
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '12px',
|
|
||||||
background: 'var(--accent)',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: 8,
|
|
||||||
color: '#000',
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: isPending ? 'wait' : 'pointer',
|
|
||||||
fontSize: 14,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isPending ? 'Submitting...' : 'Make Offer'}
|
{isPending ? 'Submitting...' : 'Make Offer'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState, useRef, useEffect } from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchCollections } from '@/hooks/useNFTData'
|
import { useSearchCollections } from '@/hooks/useNFTData'
|
||||||
import { useChainContext } from '@/hooks/useChain'
|
import { useChainContext } from '@/hooks/useChain'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar() {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
@@ -27,45 +29,23 @@ export function SearchBar() {
|
|||||||
) ?? []
|
) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative', width: '100%', maxWidth: 480 }}>
|
<div ref={ref} className="relative w-full max-w-[480px]">
|
||||||
<input
|
<div className="relative">
|
||||||
type="text"
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
value={query}
|
<Input
|
||||||
onChange={(e) => {
|
type="text"
|
||||||
setQuery(e.target.value)
|
value={query}
|
||||||
setIsOpen(true)
|
onChange={(e) => {
|
||||||
}}
|
setQuery(e.target.value)
|
||||||
onFocus={() => setIsOpen(true)}
|
setIsOpen(true)
|
||||||
placeholder="Search collections, NFTs..."
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '10px 16px',
|
|
||||||
background: 'var(--card)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 10,
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
fontSize: 14,
|
|
||||||
outline: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{isOpen && query.length >= 2 && nftResults.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '100%',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
marginTop: 4,
|
|
||||||
background: 'var(--card)',
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 8,
|
|
||||||
maxHeight: 320,
|
|
||||||
overflowY: 'auto',
|
|
||||||
zIndex: 200,
|
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
|
|
||||||
}}
|
}}
|
||||||
>
|
onFocus={() => setIsOpen(true)}
|
||||||
|
placeholder="Search collections, NFTs..."
|
||||||
|
className="pl-9 h-10 rounded-[10px] bg-card"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isOpen && query.length >= 2 && nftResults.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border rounded-xl p-2 max-h-80 overflow-y-auto z-[200] shadow-xl shadow-black/40">
|
||||||
{nftResults.map((item: { address: string; name: string; symbol: string; type: string; icon_url: string | null }) => (
|
{nftResults.map((item: { address: string; name: string; symbol: string; type: string; icon_url: string | null }) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.address}
|
key={item.address}
|
||||||
@@ -74,41 +54,21 @@ export function SearchBar() {
|
|||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
}}
|
}}
|
||||||
style={{
|
className="flex items-center gap-3 px-3 py-2 rounded-lg no-underline text-foreground transition-colors hover:bg-secondary"
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 12,
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: 8,
|
|
||||||
textDecoration: 'none',
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
transition: 'background 100ms ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--card-hover)')}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
className="w-9 h-9 rounded-lg shrink-0 flex items-center justify-center text-sm font-bold text-muted-foreground/50 bg-cover bg-center"
|
||||||
width: 36,
|
style={
|
||||||
height: 36,
|
item.icon_url
|
||||||
borderRadius: 8,
|
? { backgroundImage: `url(${item.icon_url})` }
|
||||||
background: item.icon_url
|
: { background: 'linear-gradient(135deg, hsl(var(--secondary)), hsl(var(--muted)))' }
|
||||||
? `url(${item.icon_url}) center/cover`
|
}
|
||||||
: 'linear-gradient(135deg, #1a1a2e, #0f3460)',
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: '#444',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{!item.icon_url && (item.symbol?.charAt(0) ?? '?')}
|
{!item.icon_url && (item.symbol?.charAt(0) ?? '?')}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{item.name}</div>
|
<div className="text-sm font-medium">{item.name}</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{item.type}</div>
|
<div className="text-[11px] text-muted-foreground">{item.type}</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import type { NFTTrait } from '@/lib/explorer'
|
import type { NFTTrait } from '@/lib/explorer'
|
||||||
|
|
||||||
interface TraitGroup {
|
interface TraitGroup {
|
||||||
@@ -50,28 +52,13 @@ export function TraitFilter({ instances, selectedTraits, onTraitToggle, onClear
|
|||||||
if (traitGroups.length === 0) return null
|
if (traitGroups.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="bg-card rounded-xl border border-border p-4 w-60 shrink-0">
|
||||||
style={{
|
<div className="flex justify-between items-center mb-3">
|
||||||
background: 'var(--card)',
|
<div className="text-sm font-semibold">Traits</div>
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
padding: 16,
|
|
||||||
width: 240,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 600 }}>Traits</div>
|
|
||||||
{hasSelected && (
|
{hasSelected && (
|
||||||
<button
|
<button
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
style={{
|
className="bg-transparent border-none text-primary text-xs cursor-pointer hover:underline"
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'var(--accent)',
|
|
||||||
fontSize: 12,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Clear all
|
Clear all
|
||||||
</button>
|
</button>
|
||||||
@@ -84,7 +71,7 @@ export function TraitFilter({ instances, selectedTraits, onTraitToggle, onClear
|
|||||||
const activeCount = selected?.size ?? 0
|
const activeCount = selected?.size ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.trait_type} style={{ marginBottom: 4 }}>
|
<div key={group.trait_type} className="mb-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = new Set(expandedGroups)
|
const next = new Set(expandedGroups)
|
||||||
@@ -92,54 +79,39 @@ export function TraitFilter({ instances, selectedTraits, onTraitToggle, onClear
|
|||||||
else next.add(group.trait_type)
|
else next.add(group.trait_type)
|
||||||
setExpandedGroups(next)
|
setExpandedGroups(next)
|
||||||
}}
|
}}
|
||||||
style={{
|
className="w-full flex justify-between items-center py-2 bg-transparent border-none text-foreground cursor-pointer text-[13px] font-medium"
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: '8px 0',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: 'var(--foreground)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{group.trait_type}
|
{group.trait_type}
|
||||||
{activeCount > 0 && (
|
{activeCount > 0 && (
|
||||||
<span style={{ marginLeft: 6, color: 'var(--accent)', fontSize: 11 }}>({activeCount})</span>
|
<span className="ml-1.5 text-primary text-[11px]">({activeCount})</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{isExpanded ? '\u25B2' : '\u25BC'}</span>
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div style={{ paddingLeft: 4, paddingBottom: 8, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<div className="pl-1 pb-2 flex flex-col gap-0.5">
|
||||||
{group.values.slice(0, 20).map((v) => {
|
{group.values.slice(0, 20).map((v) => {
|
||||||
const isActive = selected?.has(v.value) ?? false
|
const isActive = selected?.has(v.value) ?? false
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={v.value}
|
key={v.value}
|
||||||
onClick={() => onTraitToggle(group.trait_type, v.value)}
|
onClick={() => onTraitToggle(group.trait_type, v.value)}
|
||||||
style={{
|
className={cn(
|
||||||
display: 'flex',
|
'flex justify-between items-center px-2 py-1 rounded-md cursor-pointer text-xs text-left border transition-colors',
|
||||||
justifyContent: 'space-between',
|
isActive
|
||||||
alignItems: 'center',
|
? 'bg-primary/10 border-primary/50 text-foreground'
|
||||||
padding: '4px 8px',
|
: 'bg-transparent border-transparent text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||||
borderRadius: 6,
|
)}
|
||||||
background: isActive ? 'rgba(232,232,232,0.1)' : 'transparent',
|
|
||||||
border: isActive ? '1px solid var(--accent)' : '1px solid transparent',
|
|
||||||
color: isActive ? 'var(--foreground)' : 'var(--muted)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: 12,
|
|
||||||
textAlign: 'left',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
{v.value}
|
{v.value}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, flexShrink: 0, marginLeft: 8 }}>{v.count}</span>
|
<span className="text-[11px] shrink-0 ml-2 font-mono">{v.count}</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
// DEX-specific variants
|
||||||
|
swap: "bg-gradient-to-r from-primary to-accent text-primary-foreground shadow-lg hover:opacity-90",
|
||||||
|
connect:
|
||||||
|
"bg-accent text-accent-foreground shadow-sm hover:bg-accent/80 border border-border",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
xl: "h-12 rounded-xl px-8 text-base",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge Tailwind CSS classes with clsx
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date to a readable string
|
||||||
|
*/
|
||||||
|
export function formatDate(input: string | number | Date): string {
|
||||||
|
const date = new Date(input)
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number with commas
|
||||||
|
*/
|
||||||
|
export function formatNumber(num: number, decimals = 2): string {
|
||||||
|
return num.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorten an Ethereum address
|
||||||
|
*/
|
||||||
|
export function shortenAddress(address: string, chars = 4): string {
|
||||||
|
if (!address) return ""
|
||||||
|
return `${address.slice(0, chars + 2)}...${address.slice(-chars)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a currency amount
|
||||||
|
*/
|
||||||
|
export function formatCurrency(
|
||||||
|
amount: number,
|
||||||
|
currency = "USD",
|
||||||
|
decimals = 2
|
||||||
|
): string {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a percentage
|
||||||
|
*/
|
||||||
|
export function formatPercent(value: number, decimals = 2): string {
|
||||||
|
return `${value >= 0 ? "+" : ""}${value.toFixed(decimals)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a value is a valid Ethereum address
|
||||||
|
*/
|
||||||
|
export function isAddress(value: string): boolean {
|
||||||
|
return /^0x[a-fA-F0-9]{40}$/.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce a function
|
||||||
|
*/
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
timeoutId = setTimeout(() => fn(...args), delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the absolute URL for a path
|
||||||
|
*/
|
||||||
|
export function absoluteUrl(path: string): string {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://lux.market"
|
||||||
|
return `${baseUrl}${path}`
|
||||||
|
}
|
||||||
+7
-6
@@ -1,9 +1,10 @@
|
|||||||
import { getDefaultConfig } from '@rainbow-me/rainbowkit'
|
import { createConfig, http } from 'wagmi'
|
||||||
import { luxMainnet, zooMainnet, hanzoMainnet, spcMainnet, parsMainnet } from './chains'
|
import { injected } from 'wagmi/connectors'
|
||||||
|
import { supportedChains } from './chains'
|
||||||
|
|
||||||
export const config = getDefaultConfig({
|
export const config = createConfig({
|
||||||
appName: 'Lux Market',
|
chains: supportedChains,
|
||||||
projectId: 'a739fdd0c00cbb93bc06df1e27a83d63',
|
connectors: [injected()],
|
||||||
chains: [luxMainnet, zooMainnet, hanzoMainnet, spcMainnet, parsMainnet],
|
transports: Object.fromEntries(supportedChains.map((c) => [c.id, http()])) as any,
|
||||||
ssr: true,
|
ssr: true,
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user