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
|
||||
steps:
|
||||
- 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
|
||||
uses: docker/login-action@v3
|
||||
@@ -42,6 +47,6 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
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"
|
||||
},
|
||||
"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",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"next": "15.5.0",
|
||||
"next-themes": "^0.4.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"viem": "2.43.3",
|
||||
"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 (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 8 }}>Activity</h1>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: 32, fontSize: 14 }}>
|
||||
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Activity</h1>
|
||||
<p className="text-muted-foreground mb-8 text-sm">
|
||||
Recent NFT transfers, mints, and sales across all collections.
|
||||
</p>
|
||||
|
||||
{/* Column headers */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
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 className="flex items-center gap-3 py-2 border-b border-border text-[11px] text-muted-foreground uppercase tracking-widest">
|
||||
<div className="w-[72px] text-center">Event</div>
|
||||
<div className="flex-1">Item</div>
|
||||
<div className="w-[200px] text-center">From / To</div>
|
||||
<div className="w-10 text-right">Chain</div>
|
||||
<div className="w-20 text-right">Date</div>
|
||||
<div className="w-5" />
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
<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.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -7,6 +7,8 @@ import { TraitFilter } from '@/components/TraitFilter'
|
||||
import { ActivityRow } from '@/components/ActivityRow'
|
||||
import { useCollection, useTokenInstances, useCollectionTransfers } from '@/hooks/useNFTData'
|
||||
import { CHAIN_INFO, EXPLORER_API } from '@/lib/chains'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
|
||||
type Tab = 'items' | 'activity'
|
||||
|
||||
@@ -23,7 +25,7 @@ export default function CollectionDetailPage({
|
||||
const [tab, setTab] = useState<Tab>('items')
|
||||
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: transfers } = useCollectionTransfers(chainId, address)
|
||||
|
||||
@@ -59,82 +61,69 @@ export default function CollectionDetailPage({
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1400, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<main className="max-w-[1400px] mx-auto px-6 py-8">
|
||||
{/* Collection header */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 14,
|
||||
background: collection?.icon_url
|
||||
? `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',
|
||||
}}
|
||||
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"
|
||||
style={
|
||||
collection?.icon_url
|
||||
? { backgroundImage: `url(${collection.icon_url})` }
|
||||
: { background: 'linear-gradient(135deg, hsl(var(--secondary)), hsl(var(--muted)))' }
|
||||
}
|
||||
>
|
||||
{!collection?.icon_url && (collection?.symbol?.charAt(0) ?? '?')}
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700 }}>{collection?.name ?? 'Loading...'}</h1>
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 4, fontSize: 13, color: 'var(--muted)' }}>
|
||||
<h1 className="text-[28px] font-bold">{collection?.name ?? 'Loading...'}</h1>
|
||||
<div className="flex gap-3 mt-1 text-[13px] text-muted-foreground">
|
||||
{chainInfo && <span style={{ color: chainInfo.color }}>{chainInfo.name}</span>}
|
||||
<span>{collection?.type}</span>
|
||||
<a
|
||||
href={`${explorerBase}/token/${address}`}
|
||||
target="_blank"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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: 'Holders', value: collection?.holders ? Number(collection.holders).toLocaleString() : '---' },
|
||||
].map((stat) => (
|
||||
<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}
|
||||
</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>
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: tab === t ? '2px solid var(--foreground)' : '2px solid transparent',
|
||||
color: tab === t ? 'var(--foreground)' : 'var(--muted)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
className={cn(
|
||||
'px-5 py-2.5 bg-transparent border-none text-sm font-semibold cursor-pointer capitalize transition-colors border-b-2',
|
||||
tab === t
|
||||
? 'text-foreground border-foreground'
|
||||
: 'text-muted-foreground border-transparent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t}
|
||||
{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})
|
||||
</span>
|
||||
)}
|
||||
@@ -145,7 +134,7 @@ export default function CollectionDetailPage({
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === 'items' && (
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<div className="flex gap-6">
|
||||
{/* Trait sidebar */}
|
||||
<TraitFilter
|
||||
instances={instances?.items ?? []}
|
||||
@@ -155,19 +144,13 @@ export default function CollectionDetailPage({
|
||||
/>
|
||||
|
||||
{/* NFT grid */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="flex-1">
|
||||
{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 ? (
|
||||
<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
|
||||
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">
|
||||
{filteredInstances.map((inst) => (
|
||||
<NFTCard
|
||||
key={`${inst.token.address}-${inst.id}`}
|
||||
@@ -186,7 +169,7 @@ export default function CollectionDetailPage({
|
||||
{tab === 'activity' && (
|
||||
<div>
|
||||
{!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} />)
|
||||
)}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import { useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { CollectionCard } from '@/components/CollectionCard'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { useChainContext } from '@/hooks/useChain'
|
||||
import { useCollections } from '@/hooks/useNFTData'
|
||||
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() {
|
||||
const { chainId } = useChainContext()
|
||||
@@ -17,57 +19,39 @@ export default function CollectionsPage() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700 }}>Collections</h1>
|
||||
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h1 className="text-3xl font-bold">Collections</h1>
|
||||
</div>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: 24, fontSize: 14 }}>
|
||||
<p className="text-muted-foreground mb-6 text-sm">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<input
|
||||
<div className="mb-6 relative max-w-[400px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search collections by name..."
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
padding: '10px 16px',
|
||||
background: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
}}
|
||||
className="pl-9 h-10 rounded-[10px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collection list */}
|
||||
{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 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 48,
|
||||
background: 'var(--card)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<Card className="text-center py-12 text-muted-foreground">
|
||||
{search
|
||||
? `No collections matching "${search}" on ${chainInfo?.name ?? 'this chain'}.`
|
||||
: `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) => (
|
||||
<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 { Card } from '@/components/ui/card'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
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.',
|
||||
maxSupply: 10,
|
||||
rewardMultiplier: '10x',
|
||||
gradient: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
maxSupply: 100,
|
||||
rewardMultiplier: '5x',
|
||||
gradient: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b69 100%)',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
maxSupply: 1000,
|
||||
rewardMultiplier: '2x',
|
||||
gradient: 'linear-gradient(135deg, #1a0f2e 0%, #2d1654 50%, #1a1a3e 100%)',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
maxSupply: 10000,
|
||||
rewardMultiplier: '1x',
|
||||
gradient: 'linear-gradient(135deg, #0f1a2e 0%, #1a2d4e 50%, #0f2040 100%)',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -43,95 +43,52 @@ export default function GenesisPage() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<section style={{ textAlign: 'center', marginBottom: 48 }}>
|
||||
<h1 style={{ fontSize: 40, fontWeight: 700, marginBottom: 12 }}>Genesis NFTs</h1>
|
||||
<p style={{ fontSize: 16, color: 'var(--muted)', maxWidth: 600, margin: '0 auto' }}>
|
||||
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||
<section className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold mb-3">Genesis NFTs</h1>
|
||||
<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
|
||||
staking returns through the ValidatorVault and LiquidLUX (xLUX) system.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Reward flow diagram */}
|
||||
<section
|
||||
style={{
|
||||
background: 'var(--card)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
padding: 24,
|
||||
marginBottom: 48,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Card className="p-6 mb-12 text-center">
|
||||
<h3 className="text-base font-semibold mb-4">Reward Flow</h3>
|
||||
<div className="flex items-center justify-center gap-4 flex-wrap text-sm">
|
||||
<span>Validator Staking</span>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<span>ValidatorVault.depositRewards()</span>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<span>LiquidLUX.depositValidatorRewards()</span>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-semibold">xLUX holders</span>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
|
||||
{/* 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) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
style={{
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Card key={tier.name} className="overflow-hidden">
|
||||
<div className="aspect-[4/3] bg-gradient-to-br from-secondary to-muted flex flex-col items-center justify-center gap-2">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-[2px]">
|
||||
Genesis
|
||||
</span>
|
||||
<span style={{ fontSize: 36, fontWeight: 700 }}>{tier.name}</span>
|
||||
<span style={{ fontSize: 14, color: 'var(--accent)' }}>{tier.rewardMultiplier} rewards</span>
|
||||
<span className="text-4xl font-bold">{tier.name}</span>
|
||||
<span className="text-sm text-primary">{tier.rewardMultiplier} rewards</span>
|
||||
</div>
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--muted)', marginBottom: 4 }}>Permanently Locked</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700, marginBottom: 12 }}>{tier.lockedLux} LUX</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.5, marginBottom: 16 }}>
|
||||
<div className="p-5">
|
||||
<div className="text-[13px] text-muted-foreground mb-1">Permanently Locked</div>
|
||||
<div className="text-2xl font-bold mb-3 font-mono">{tier.lockedLux} LUX</div>
|
||||
<p className="text-[13px] text-muted-foreground leading-relaxed mb-4">
|
||||
{tier.description}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 13,
|
||||
color: 'var(--muted)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between text-[13px] text-muted-foreground border-t border-border pt-3">
|
||||
<span>Max Supply: {tier.maxSupply.toLocaleString()}</span>
|
||||
<span>Rewards: {tier.rewardMultiplier}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+156
-56
@@ -1,78 +1,178 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--accent: #e8e8e8;
|
||||
--muted: #666;
|
||||
--border: #1a1a1a;
|
||||
--card: #0f0f0f;
|
||||
--card-hover: #151515;
|
||||
--success: #00cc66;
|
||||
--error: #ff4444;
|
||||
--warning: #ffaa00;
|
||||
/* Default to dark mode */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family:
|
||||
'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/* CSS Variables - Light theme */
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--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;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.glass {
|
||||
background-color: hsl(var(--background) / 0.8);
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
.glow {
|
||||
box-shadow: 0 0 20px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: rgba(232, 232, 232, 0.15);
|
||||
.shimmer {
|
||||
position: relative;
|
||||
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 */
|
||||
:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
@keyframes shimmer {
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* Input/select reset */
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: hsl(var(--muted)); border-radius: 9999px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: hsl(var(--muted-foreground) / 0.5); }
|
||||
|
||||
+14
-4
@@ -1,14 +1,24 @@
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { Inter, JetBrains_Mono } from 'next/font/google'
|
||||
import { ClientProviders } from './client-providers'
|
||||
import './globals.css'
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
})
|
||||
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Lux Market - NFT Marketplace',
|
||||
template: '%s | Lux Market',
|
||||
},
|
||||
description:
|
||||
'Trade NFTs across all Lux chains. Seaport-powered P2P trading with LSSVM AMM liquidity pools. Genesis NFTs with permanent LUX staking rewards.',
|
||||
description: 'Trade NFTs across all Lux chains. Seaport-powered P2P trading with LSSVM AMM liquidity pools.',
|
||||
openGraph: {
|
||||
title: 'Lux Market',
|
||||
description: 'The NFT marketplace for the Lux ecosystem',
|
||||
@@ -32,8 +42,8 @@ export const viewport: Viewport = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<html lang="en" className={`dark ${inter.variable} ${jetbrainsMono.variable}`}>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { use } from 'react'
|
||||
import { useAccount } from 'wagmi'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Header } from '@/components/Header'
|
||||
import { ListingForm } from '@/components/ListingForm'
|
||||
import { OfferForm } from '@/components/OfferForm'
|
||||
@@ -10,12 +11,11 @@ import { ActivityRow } from '@/components/ActivityRow'
|
||||
import { useTokenInstance, useTokenInstanceTransfers } from '@/hooks/useNFTData'
|
||||
import { getNftImageUrl, resolveMediaUrl } from '@/lib/explorer'
|
||||
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'
|
||||
|
||||
function shortenAddress(addr: string): string {
|
||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
||||
}
|
||||
|
||||
export default function NFTDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -39,24 +39,14 @@ export default function NFTDetailPage({
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||
{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 */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
borderRadius: 16,
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
maxWidth: 600,
|
||||
position: 'relative',
|
||||
background: '#111',
|
||||
}}
|
||||
>
|
||||
<div className="aspect-square rounded-2xl border border-border overflow-hidden max-w-[600px] relative bg-secondary">
|
||||
{animationUrl ? (
|
||||
<video
|
||||
src={animationUrl}
|
||||
@@ -64,31 +54,19 @@ export default function NFTDetailPage({
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={metadata?.name ?? `#${tokenId}`}
|
||||
fill
|
||||
style={{ objectFit: 'cover' }}
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
sizes="600px"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
#{tokenId}
|
||||
</div>
|
||||
)}
|
||||
@@ -96,143 +74,91 @@ export default function NFTDetailPage({
|
||||
|
||||
{/* Description */}
|
||||
{metadata?.description && (
|
||||
<div
|
||||
style={{
|
||||
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 }}>
|
||||
<Card className="mt-4 p-4">
|
||||
<div className="text-xs text-muted-foreground mb-2 font-semibold uppercase tracking-widest">
|
||||
Description
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--foreground)', lineHeight: 1.6 }}>
|
||||
<div className="text-sm text-foreground leading-relaxed">
|
||||
{metadata.description}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Traits */}
|
||||
{metadata?.attributes && metadata.attributes.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
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 }}>
|
||||
<Card className="mt-4 p-4">
|
||||
<div className="text-xs text-muted-foreground mb-3 font-semibold uppercase tracking-widest">
|
||||
Traits
|
||||
</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) => (
|
||||
<div
|
||||
key={`${trait.trait_type}-${i}`}
|
||||
style={{
|
||||
background: 'rgba(232,232,232,0.05)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
className="bg-primary/5 rounded-lg px-3 py-2 border border-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}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2 }}>
|
||||
<div className="text-[13px] font-semibold mt-0.5">
|
||||
{String(trait.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transfer History */}
|
||||
{transfers?.items && transfers.items.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
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 }}>
|
||||
<Card className="mt-4 p-4">
|
||||
<div className="text-xs text-muted-foreground mb-3 font-semibold uppercase tracking-widest">
|
||||
Transfer History
|
||||
</div>
|
||||
{transfers.items.slice(0, 20).map((t, i) => (
|
||||
<ActivityRow key={`${t.tx_hash}-${i}`} transfer={t} chainId={chainId} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div>
|
||||
<a
|
||||
<Link
|
||||
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'}
|
||||
</a>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, marginTop: 4 }}>
|
||||
</Link>
|
||||
<h1 className="text-[28px] font-bold mt-1">
|
||||
{metadata?.name ?? `#${tokenId}`}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Owner */}
|
||||
<div
|
||||
style={{
|
||||
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 }}>
|
||||
<Card className="p-3.5">
|
||||
<div className="text-[11px] text-muted-foreground mb-1 uppercase tracking-widest">
|
||||
Owner
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 14, fontFamily: 'monospace' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono">
|
||||
{ownerAddress ? shortenAddress(ownerAddress) : '---'}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
background: 'rgba(0,200,100,0.1)',
|
||||
color: '#0c6',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-success/10 text-success font-semibold">
|
||||
YOU
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Details */}
|
||||
<div
|
||||
style={{
|
||||
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 }}>
|
||||
<Card className="p-3.5">
|
||||
<div className="text-[11px] text-muted-foreground mb-2 uppercase tracking-widest">
|
||||
Details
|
||||
</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 },
|
||||
{
|
||||
@@ -243,26 +169,30 @@ export default function NFTDetailPage({
|
||||
{ label: 'Token ID', value: tokenId },
|
||||
{ label: 'Token Standard', value: instance?.token?.type ?? 'ERC-721' },
|
||||
].map((row) => (
|
||||
<div key={row.label} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>{row.label}</span>
|
||||
<div key={row.label} className="flex justify-between text-[13px]">
|
||||
<span className="text-muted-foreground">{row.label}</span>
|
||||
{row.href ? (
|
||||
<a
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
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>
|
||||
) : (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* External links */}
|
||||
{metadata?.external_url && (
|
||||
@@ -270,37 +200,20 @@ export default function NFTDetailPage({
|
||||
href={metadata.external_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'block',
|
||||
textAlign: 'center',
|
||||
padding: '10px',
|
||||
background: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
color: 'var(--accent)',
|
||||
textDecoration: 'none',
|
||||
fontSize: 13,
|
||||
}}
|
||||
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"
|
||||
>
|
||||
View External ↗
|
||||
View External <ExternalLink className="inline h-3 w-3 ml-1" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Trading Actions */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--card)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<Card className="p-4">
|
||||
{isOwner ? (
|
||||
<ListingForm contractAddress={contractAddress as Address} tokenId={tokenId} />
|
||||
) : (
|
||||
<OfferForm contractAddress={contractAddress as Address} tokenId={tokenId} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+38
-80
@@ -7,6 +7,8 @@ import { useChainContext } from '@/hooks/useChain'
|
||||
import { useCollections } from '@/hooks/useNFTData'
|
||||
import Link from 'next/link'
|
||||
import { CHAIN_INFO } from '@/lib/chains'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
const GENESIS_TIERS = [
|
||||
{ name: 'Genesis', locked: '1B LUX', tier: 'Tier 1', multiplier: '10x' },
|
||||
@@ -23,106 +25,70 @@ export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||
{/* Hero */}
|
||||
<section style={{ textAlign: 'center', marginBottom: 48 }}>
|
||||
<h1 style={{ fontSize: 48, fontWeight: 700, marginBottom: 12, letterSpacing: '-0.03em' }}>Lux Market</h1>
|
||||
<p style={{ fontSize: 16, color: 'var(--muted)', maxWidth: 560, margin: '0 auto 24px' }}>
|
||||
<section className="text-center mb-12">
|
||||
<h1 className="text-5xl font-bold mb-3 tracking-tight">Lux Market</h1>
|
||||
<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.
|
||||
</p>
|
||||
<div style={{ maxWidth: 480, margin: '0 auto' }}>
|
||||
<div className="max-w-[480px] mx-auto">
|
||||
<SearchBar />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Genesis Featured */}
|
||||
<section style={{ marginBottom: 48 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600 }}>Genesis Collection</h2>
|
||||
<Link href="/genesis" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: 13, fontWeight: 500 }}>
|
||||
View All →
|
||||
<section className="mb-12">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">Genesis Collection</h2>
|
||||
<Link href="/genesis" className="text-primary no-underline text-[13px] font-medium flex items-center gap-1 hover:underline">
|
||||
View All <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</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) => (
|
||||
<Link key={nft.name} href="/genesis" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<div
|
||||
style={{
|
||||
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)'
|
||||
}}
|
||||
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 }}>
|
||||
<Link key={nft.name} href="/genesis" className="no-underline text-inherit group">
|
||||
<Card className="overflow-hidden transition-all duration-200 hover:border-muted-foreground/30 hover:-translate-y-0.5">
|
||||
<div className="aspect-square bg-gradient-to-br from-secondary to-muted flex flex-col items-center justify-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground uppercase tracking-widest">
|
||||
{nft.tier}
|
||||
</span>
|
||||
<span style={{ fontSize: 24, fontWeight: 700 }}>{nft.name}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--accent)' }}>{nft.multiplier} rewards</span>
|
||||
</div>
|
||||
<div style={{ padding: '10px 12px' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>Permanently Locked</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, marginTop: 2 }}>{nft.locked}</div>
|
||||
<span className="text-2xl font-bold">{nft.name}</span>
|
||||
<span className="text-xs text-primary">{nft.multiplier} rewards</span>
|
||||
</div>
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="text-[11px] text-muted-foreground">Permanently Locked</div>
|
||||
<div className="text-sm font-semibold mt-0.5">{nft.locked}</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Collections on selected chain */}
|
||||
<section style={{ marginBottom: 48 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600 }}>
|
||||
<section className="mb-12">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Collections on{' '}
|
||||
<span style={{ color: chainInfo?.color }}>{chainInfo?.name ?? 'Lux'}</span>
|
||||
</h2>
|
||||
<Link
|
||||
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>
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: 32,
|
||||
background: 'var(--card)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
color: 'var(--muted)',
|
||||
}}
|
||||
>
|
||||
<Card className="text-center py-8 text-muted-foreground">
|
||||
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) => (
|
||||
<CollectionCard key={token.address} token={token} chainId={chainId} rank={i + 1} />
|
||||
))}
|
||||
@@ -131,28 +97,20 @@ export default function Home() {
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section style={{ marginBottom: 48 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<section className="mb-12">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
|
||||
{[
|
||||
{ label: 'Supported Chains', value: '5' },
|
||||
{ label: 'Collections', value: collections?.items?.length?.toString() ?? '---' },
|
||||
{ label: 'Trading Protocol', value: 'Seaport' },
|
||||
{ label: 'AMM Protocol', value: 'LSSVM' },
|
||||
].map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
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 }}>
|
||||
<Card key={stat.label} className="px-5 py-4">
|
||||
<div className="text-[11px] text-muted-foreground mb-1 uppercase tracking-widest">
|
||||
{stat.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700 }}>{stat.value}</div>
|
||||
</div>
|
||||
<div className="text-xl font-bold">{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+29
-42
@@ -6,6 +6,11 @@ import { NFTCard } from '@/components/NFTCard'
|
||||
import { useChainContext } from '@/hooks/useChain'
|
||||
import { usePortfolioNFTs } from '@/hooks/useNFTData'
|
||||
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]
|
||||
|
||||
@@ -13,21 +18,15 @@ function ChainPortfolio({ chainId, walletAddress }: { chainId: number; walletAdd
|
||||
const { data, isLoading } = usePortfolioNFTs(chainId, walletAddress)
|
||||
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
|
||||
|
||||
return (
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: chainInfo?.color }}>
|
||||
<section className="mb-8">
|
||||
<h3 className="text-base font-semibold mb-3" style={{ color: chainInfo?.color }}>
|
||||
{chainInfo?.name} ({data.items.length})
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
|
||||
{data.items.map((item) => (
|
||||
<NFTCard
|
||||
key={`${item.token.address}-${item.token_id}`}
|
||||
@@ -45,48 +44,36 @@ function ChainPortfolio({ chainId, walletAddress }: { chainId: number; walletAdd
|
||||
|
||||
export default function PortfolioPage() {
|
||||
const { address, isConnected } = useAccount()
|
||||
const { chainId: selectedChain } = useChainContext()
|
||||
const { connect } = useConnect()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 24 }}>My NFTs</h1>
|
||||
<main className="max-w-[1200px] mx-auto px-6 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">My NFTs</h1>
|
||||
|
||||
{!isConnected ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '64px 24px',
|
||||
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 }}>
|
||||
<Card className="text-center py-16 px-6">
|
||||
<Wallet className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<h2 className="text-xl font-semibold mb-2">Connect your wallet</h2>
|
||||
<p className="text-muted-foreground text-sm mb-6">
|
||||
Connect a wallet to view your NFTs across all Lux chains.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="connect"
|
||||
size="lg"
|
||||
onClick={() => connect({ connector: injected() })}
|
||||
>
|
||||
<Wallet className="h-4 w-4" />
|
||||
Connect Wallet
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
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>
|
||||
<Card className="flex items-center gap-2 mb-6 px-4 py-3 text-[13px]">
|
||||
<span className="text-muted-foreground">Wallet:</span>
|
||||
<span className="font-mono">{address}</span>
|
||||
</Card>
|
||||
|
||||
{/* NFTs grouped by chain */}
|
||||
{ALL_CHAIN_IDS.map((cId) => (
|
||||
|
||||
+15
-20
@@ -1,39 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { RainbowKitProvider, darkTheme } from '@rainbow-me/rainbowkit'
|
||||
import '@rainbow-me/rainbowkit/styles.css'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { WagmiProvider } from 'wagmi'
|
||||
import { config } from '@/lib/wagmi'
|
||||
import { WagmiProvider, createConfig, http } from 'wagmi'
|
||||
import { injected } from 'wagmi/connectors'
|
||||
import { ChainContext } from '@/hooks/useChain'
|
||||
import { supportedChains } from '@/lib/chains'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
function createWagmiConfig() {
|
||||
return createConfig({
|
||||
chains: supportedChains,
|
||||
connectors: [injected()],
|
||||
transports: Object.fromEntries(supportedChains.map((c) => [c.id, http()])) as any,
|
||||
ssr: true,
|
||||
})
|
||||
}
|
||||
|
||||
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 (
|
||||
<WagmiProvider config={config}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RainbowKitProvider
|
||||
theme={darkTheme({
|
||||
accentColor: '#E8E8E8',
|
||||
accentColorForeground: '#000',
|
||||
borderRadius: 'medium',
|
||||
})}
|
||||
>
|
||||
<ChainContext.Provider value={{ chainId, setChainId }}>
|
||||
{children}
|
||||
</ChainContext.Provider>
|
||||
</RainbowKitProvider>
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
)
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import { CHAIN_INFO, EXPLORER_API } from '@/lib/chains'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { ExplorerTransfer } from '@/lib/explorer'
|
||||
import { shortenAddress } from '@/lib/utils'
|
||||
|
||||
function shortenAddress(addr: string): string {
|
||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
||||
}
|
||||
|
||||
function getEventType(method: string): { label: string; color: string } {
|
||||
function getEventType(method: string): { label: string } {
|
||||
const m = method.toLowerCase()
|
||||
if (m.includes('mint') || m === 'transfer' && true) return { label: 'Transfer', color: '#888' }
|
||||
if (m.includes('safe')) return { label: 'Transfer', color: '#888' }
|
||||
return { label: method || 'Transfer', color: '#888' }
|
||||
if (m.includes('mint') || m === 'transfer') return { label: 'Transfer' }
|
||||
if (m.includes('safe')) return { label: 'Transfer' }
|
||||
return { label: method || 'Transfer' }
|
||||
}
|
||||
|
||||
interface ActivityRowProps {
|
||||
@@ -29,63 +27,44 @@ export function ActivityRow({ transfer, chainId }: ActivityRowProps) {
|
||||
const tokenId = transfer.total?.token_id ?? '?'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '12px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 py-3 border-b border-border text-[13px]">
|
||||
{/* Event type */}
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: 6,
|
||||
background: isMint ? 'rgba(0,200,100,0.1)' : 'rgba(136,136,136,0.1)',
|
||||
color: isMint ? '#0c6' : event.color,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
width: 72,
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-md text-xs font-semibold w-[72px] text-center shrink-0 ${
|
||||
isMint
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-muted-foreground/10 text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isMint ? 'Mint' : event.label}
|
||||
</div>
|
||||
|
||||
{/* Item */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`/nft/${chainId}/${transfer.token.address}/${tokenId}`}
|
||||
style={{
|
||||
color: 'var(--foreground)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
className="text-foreground no-underline font-medium hover:underline"
|
||||
>
|
||||
{transfer.token.name} #{tokenId}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* From → To */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--muted)', flexShrink: 0 }}>
|
||||
{/* From -> To */}
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground shrink-0">
|
||||
<a
|
||||
href={`${explorerBase}/address/${transfer.from.hash}`}
|
||||
target="_blank"
|
||||
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)}
|
||||
</a>
|
||||
<span style={{ fontSize: 10 }}>→</span>
|
||||
<span className="text-[10px]">→</span>
|
||||
<a
|
||||
href={`${explorerBase}/address/${transfer.to.hash}`}
|
||||
target="_blank"
|
||||
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)}
|
||||
</a>
|
||||
@@ -93,13 +72,16 @@ export function ActivityRow({ transfer, chainId }: ActivityRowProps) {
|
||||
|
||||
{/* Chain */}
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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() : '---'}
|
||||
</div>
|
||||
|
||||
@@ -108,15 +90,10 @@ export function ActivityRow({ transfer, chainId }: ActivityRowProps) {
|
||||
href={`${explorerBase}/tx/${transfer.tx_hash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--muted)',
|
||||
textDecoration: 'none',
|
||||
fontSize: 11,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
className="text-muted-foreground shrink-0 hover:text-foreground transition-colors"
|
||||
title="View on explorer"
|
||||
>
|
||||
↗
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,78 +16,41 @@ export function CollectionCard({ token, chainId, rank }: CollectionCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={`/collection/${chainId}/${token.address}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<div
|
||||
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)'
|
||||
}}
|
||||
className="no-underline text-inherit"
|
||||
>
|
||||
<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">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
{/* Collection icon */}
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 10,
|
||||
background: token.icon_url
|
||||
? `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',
|
||||
}}
|
||||
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"
|
||||
style={
|
||||
token.icon_url
|
||||
? { backgroundImage: `url(${token.icon_url})` }
|
||||
: { background: 'linear-gradient(135deg, hsl(var(--secondary)), hsl(var(--muted)))' }
|
||||
}
|
||||
>
|
||||
{!token.icon_url && (token.symbol?.charAt(0) ?? '?')}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[15px] font-semibold truncate">
|
||||
{token.name}
|
||||
</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>
|
||||
{chainInfo && (
|
||||
<span style={{ color: chainInfo.color }}>{chainInfo.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-[13px]">
|
||||
{token.holders ? Number(token.holders).toLocaleString() : '---'} holders
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+53
-55
@@ -1,10 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { ConnectButton } from '@rainbow-me/rainbowkit'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useAccount, useConnect, useDisconnect } from 'wagmi'
|
||||
import { injected } from 'wagmi/connectors'
|
||||
import { useChainContext } from '@/hooks/useChain'
|
||||
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 = [
|
||||
{ href: '/', label: 'Explore' },
|
||||
@@ -19,51 +24,32 @@ const CHAIN_IDS = [96369, 200200, 36963, 36911, 494949]
|
||||
export function Header() {
|
||||
const pathname = usePathname()
|
||||
const { chainId, setChainId } = useChainContext()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connect } = useConnect()
|
||||
const { disconnect } = useDisconnect()
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
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 }}>
|
||||
<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">
|
||||
<div className="flex items-center gap-8">
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
className="text-lg font-bold tracking-tight text-foreground no-underline"
|
||||
>
|
||||
Lux Market
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', gap: 4 }}>
|
||||
<nav className="flex gap-1">
|
||||
{NAV_LINKS.map((link) => {
|
||||
const isActive = link.href === '/' ? pathname === '/' : pathname.startsWith(link.href)
|
||||
return (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: isActive ? 'var(--foreground)' : 'var(--muted)',
|
||||
fontSize: 14,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 8,
|
||||
background: isActive ? 'var(--card)' : 'transparent',
|
||||
transition: 'all 150ms ease',
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-sm no-underline transition-colors',
|
||||
isActive
|
||||
? 'text-foreground bg-card'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
@@ -72,9 +58,9 @@ export function Header() {
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 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) => {
|
||||
const info = CHAIN_INFO[id]
|
||||
const isActive = chainId === id
|
||||
@@ -83,17 +69,13 @@ export function Header() {
|
||||
key={id}
|
||||
onClick={() => setChainId(id)}
|
||||
title={info.name}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
background: isActive ? info.color : 'transparent',
|
||||
color: isActive ? '#000' : 'var(--muted)',
|
||||
transition: 'all 150ms ease',
|
||||
}}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 text-xs font-semibold rounded-md cursor-pointer transition-colors border-none',
|
||||
isActive
|
||||
? 'text-black'
|
||||
: 'text-muted-foreground bg-transparent hover:text-foreground'
|
||||
)}
|
||||
style={isActive ? { background: info.color } : undefined}
|
||||
>
|
||||
{info.name}
|
||||
</button>
|
||||
@@ -105,19 +87,35 @@ export function Header() {
|
||||
href="https://exchange.lux.network"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'var(--muted)',
|
||||
fontSize: 13,
|
||||
padding: '6px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
className="px-3 py-1.5 text-[13px] text-muted-foreground no-underline border border-border rounded-lg hover:text-foreground transition-colors"
|
||||
>
|
||||
Exchange
|
||||
</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>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from 'react'
|
||||
import { parseEther, type Address } from 'viem'
|
||||
import { useWriteContract, useAccount, useChainId } from 'wagmi'
|
||||
import { CONTRACTS, MARKET_ABI, ERC721_ABI } from '@/lib/contracts'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ListingFormProps {
|
||||
contractAddress: Address
|
||||
@@ -51,43 +53,26 @@ export function ListingForm({ contractAddress, tokenId }: ListingFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600 }}>List for Sale</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-base font-semibold">List for Sale</h3>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 4 }}>Price (LUX)</label>
|
||||
<input
|
||||
<label className="text-xs text-muted-foreground block mb-1">Price (LUX)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="0.0"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 16,
|
||||
outline: 'none',
|
||||
}}
|
||||
className="h-10 text-base bg-card"
|
||||
/>
|
||||
</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)
|
||||
</label>
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<option value="1">1 day</option>
|
||||
<option value="3">3 days</option>
|
||||
@@ -96,40 +81,22 @@ export function ListingForm({ contractAddress, tokenId }: ListingFormProps) {
|
||||
<option value="30">30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={handleApproveAndList}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleList}
|
||||
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'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+13
-72
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
||||
import { formatEther } from 'viem'
|
||||
import { getNftImageUrl, type ExplorerTokenInstance } from '@/lib/explorer'
|
||||
import { CHAIN_INFO } from '@/lib/chains'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
export interface NFTCardProps {
|
||||
contractAddress: string
|
||||
@@ -26,110 +27,50 @@ export function NFTCard({ contractAddress, tokenId, name, imageUrl, collectionNa
|
||||
return (
|
||||
<Link
|
||||
href={`/nft/${chainId}/${contractAddress}/${tokenId}`}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
className="no-underline text-inherit group"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
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' }}>
|
||||
<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">
|
||||
<div className="aspect-square relative bg-secondary">
|
||||
{resolvedImage ? (
|
||||
<Image
|
||||
src={resolvedImage}
|
||||
alt={resolvedName ?? `#${tokenId}`}
|
||||
fill
|
||||
style={{ objectFit: 'cover' }}
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
sizes="(max-width: 768px) 50vw, 25vw"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
#{tokenId}
|
||||
</div>
|
||||
)}
|
||||
{/* Chain badge */}
|
||||
{chainInfo && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
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,
|
||||
}}
|
||||
className="absolute top-2 right-2 px-2 py-0.5 rounded-md bg-black/60 backdrop-blur-sm text-[10px] font-semibold"
|
||||
style={{ color: chainInfo.color }}
|
||||
>
|
||||
{chainInfo.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '10px 12px' }}>
|
||||
<div className="px-3 py-2.5">
|
||||
{resolvedCollection && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<div className="text-[11px] text-muted-foreground uppercase tracking-wider truncate">
|
||||
{resolvedCollection}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
marginTop: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<div className="text-sm font-semibold mt-0.5 truncate">
|
||||
{resolvedName ?? `#${tokenId}`}
|
||||
</div>
|
||||
{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'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState } from 'react'
|
||||
import { parseEther, type Address } from 'viem'
|
||||
import { useWriteContract, useChainId } from 'wagmi'
|
||||
import { CONTRACTS, MARKET_ABI } from '@/lib/contracts'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface OfferFormProps {
|
||||
contractAddress: Address
|
||||
@@ -39,43 +41,26 @@ export function OfferForm({ contractAddress, tokenId }: OfferFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Make an Offer</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<h3 className="text-base font-semibold">Make an Offer</h3>
|
||||
<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)
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.0"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 16,
|
||||
outline: 'none',
|
||||
}}
|
||||
className="h-10 text-base bg-card"
|
||||
/>
|
||||
</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
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
background: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<option value="1">1 day</option>
|
||||
<option value="3">3 days</option>
|
||||
@@ -83,23 +68,13 @@ export function OfferForm({ contractAddress, tokenId }: OfferFormProps) {
|
||||
<option value="14">14 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleOffer}
|
||||
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'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useRef, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchCollections } from '@/hooks/useNFTData'
|
||||
import { useChainContext } from '@/hooks/useChain'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
export function SearchBar() {
|
||||
const [query, setQuery] = useState('')
|
||||
@@ -27,8 +29,10 @@ export function SearchBar() {
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', width: '100%', maxWidth: 480 }}>
|
||||
<input
|
||||
<div ref={ref} className="relative w-full max-w-[480px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
@@ -37,35 +41,11 @@ export function SearchBar() {
|
||||
}}
|
||||
onFocus={() => 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',
|
||||
}}
|
||||
className="pl-9 h-10 rounded-[10px] bg-card"
|
||||
/>
|
||||
</div>
|
||||
{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)',
|
||||
}}
|
||||
>
|
||||
<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 }) => (
|
||||
<Link
|
||||
key={item.address}
|
||||
@@ -74,41 +54,21 @@ export function SearchBar() {
|
||||
setIsOpen(false)
|
||||
setQuery('')
|
||||
}}
|
||||
style={{
|
||||
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')}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg no-underline text-foreground transition-colors hover:bg-secondary"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
background: item.icon_url
|
||||
? `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',
|
||||
}}
|
||||
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"
|
||||
style={
|
||||
item.icon_url
|
||||
? { backgroundImage: `url(${item.icon_url})` }
|
||||
: { background: 'linear-gradient(135deg, hsl(var(--secondary)), hsl(var(--muted)))' }
|
||||
}
|
||||
>
|
||||
{!item.icon_url && (item.symbol?.charAt(0) ?? '?')}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{item.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{item.type}</div>
|
||||
<div className="text-sm font-medium">{item.name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{item.type}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { NFTTrait } from '@/lib/explorer'
|
||||
|
||||
interface TraitGroup {
|
||||
@@ -50,28 +52,13 @@ export function TraitFilter({ instances, selectedTraits, onTraitToggle, onClear
|
||||
if (traitGroups.length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--card)',
|
||||
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>
|
||||
<div className="bg-card rounded-xl border border-border p-4 w-60 shrink-0">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<div className="text-sm font-semibold">Traits</div>
|
||||
{hasSelected && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--accent)',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
className="bg-transparent border-none text-primary text-xs cursor-pointer hover:underline"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
@@ -84,7 +71,7 @@ export function TraitFilter({ instances, selectedTraits, onTraitToggle, onClear
|
||||
const activeCount = selected?.size ?? 0
|
||||
|
||||
return (
|
||||
<div key={group.trait_type} style={{ marginBottom: 4 }}>
|
||||
<div key={group.trait_type} className="mb-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = new Set(expandedGroups)
|
||||
@@ -92,54 +79,39 @@ export function TraitFilter({ instances, selectedTraits, onTraitToggle, onClear
|
||||
else next.add(group.trait_type)
|
||||
setExpandedGroups(next)
|
||||
}}
|
||||
style={{
|
||||
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,
|
||||
}}
|
||||
className="w-full flex justify-between items-center py-2 bg-transparent border-none text-foreground cursor-pointer text-[13px] font-medium"
|
||||
>
|
||||
<span>
|
||||
{group.trait_type}
|
||||
{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 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>
|
||||
{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) => {
|
||||
const isActive = selected?.has(v.value) ?? false
|
||||
return (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => onTraitToggle(group.trait_type, v.value)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px 8px',
|
||||
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',
|
||||
}}
|
||||
className={cn(
|
||||
'flex justify-between items-center px-2 py-1 rounded-md cursor-pointer text-xs text-left border transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 border-primary/50 text-foreground'
|
||||
: 'bg-transparent border-transparent text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
)}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{v.value}
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 { luxMainnet, zooMainnet, hanzoMainnet, spcMainnet, parsMainnet } from './chains'
|
||||
import { createConfig, http } from 'wagmi'
|
||||
import { injected } from 'wagmi/connectors'
|
||||
import { supportedChains } from './chains'
|
||||
|
||||
export const config = getDefaultConfig({
|
||||
appName: 'Lux Market',
|
||||
projectId: 'a739fdd0c00cbb93bc06df1e27a83d63',
|
||||
chains: [luxMainnet, zooMainnet, hanzoMainnet, spcMainnet, parsMainnet],
|
||||
export const config = createConfig({
|
||||
chains: supportedChains,
|
||||
connectors: [injected()],
|
||||
transports: Object.fromEntries(supportedChains.map((c) => [c.id, http()])) as any,
|
||||
ssr: true,
|
||||
})
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user