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:
2026-03-05 08:50:47 -08:00
parent f37135d835
commit ac3daa0251
30 changed files with 1883 additions and 1109 deletions
+7 -2
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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"
},
+684 -140
View File
File diff suppressed because it is too large Load Diff
+12 -24
View File
@@ -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>
) : (
+34 -51
View File
@@ -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)} &nearr;
<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} />)
)}
+16 -32
View File
@@ -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
View File
@@ -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)' }}>&rarr;</span>
<span style={{ fontSize: 14 }}>ValidatorVault.depositRewards()</span>
<span style={{ color: 'var(--muted)' }}>&rarr;</span>
<span style={{ fontSize: 14 }}>LiquidLUX.depositValidatorRewards()</span>
<span style={{ color: 'var(--muted)' }}>&rarr;</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
View File
@@ -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
View File
@@ -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} &nearr;
{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 &nearr;
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
View File
@@ -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 &rarr;
<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 &rarr;
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
View File
@@ -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 }}>&#x1F4BC;</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
View File
@@ -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>
)
+26 -49
View File
@@ -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 }}>&rarr;</span>
<span className="text-[10px]">&rarr;</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"
>
&nearr;
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)
+15 -52
View File
@@ -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
View File
@@ -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>
)
+17 -50
View File
@@ -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
View File
@@ -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>
)
}
+12 -37
View File
@@ -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>
)
}
+18 -58
View File
@@ -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>
))}
+23 -51
View File
@@ -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>
)
})}
+61
View File
@@ -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 }
+75
View File
@@ -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 }
+121
View File
@@ -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,
}
+199
View File
@@ -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,
}
+24
View File
@@ -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 }
+91
View File
@@ -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
View File
@@ -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