feat: replace old docs marketplace with full NFT marketplace
- Explorer API client for on-chain NFT data across all 5 Lux chains - Seaport (Market.sol) P2P trading: list, buy, make/cancel offers - LSSVM AMM liquidity pools for NFT trading curves - Collection browsing, search, activity feed, portfolio - Genesis NFT featured section - Multi-chain support: Lux, Zoo, Hanzo, SPC, Pars - RainbowKit wallet connection with wagmi/viem - Docker standalone build for K8s deployment
@@ -1,42 +1,47 @@
|
||||
name: Deploy to GitHub Pages
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: luxfi/market
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
- uses: actions/configure-pages@v5
|
||||
- run: pnpm install --no-frozen-lockfile
|
||||
- run: pnpm build
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./out
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/deploy-pages@v4
|
||||
id: deployment
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -1,56 +1,31 @@
|
||||
# Multi-stage Dockerfile for Marketplace Next.js app
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Lux Market - Next.js 15 Standalone Dockerfile
|
||||
FROM node:22-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
RUN npm install -g pnpm
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
COPY yarn.lock* ./
|
||||
COPY pnpm-lock.yaml* ./
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile
|
||||
|
||||
# Install dependencies
|
||||
RUN \
|
||||
if [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN pnpm exec next build
|
||||
|
||||
# Build the application
|
||||
RUN \
|
||||
if [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f yarn.lock ]; then yarn build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then pnpm build; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
# Runner
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
ENV NODE_ENV production
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
ENV PORT 3002
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2025 Lux Industries Inc
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,42 +0,0 @@
|
||||
# AI Assistant Knowledge Base
|
||||
|
||||
**Last Updated**: $(date +%Y-%m-%d)
|
||||
**Project**: $(basename "$REPO_PATH")
|
||||
**Organization**: $(basename "$(dirname "$REPO_PATH")")
|
||||
|
||||
## Project Overview
|
||||
|
||||
This repository is part of the $(basename "$(dirname "$REPO_PATH")") organization.
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Add common commands here
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
## Key Technologies
|
||||
|
||||
## Development Workflow
|
||||
|
||||
## Context for All AI Assistants
|
||||
|
||||
This file (`LLM.md`) is symlinked as:
|
||||
- `.AGENTS.md`
|
||||
- `CLAUDE.md`
|
||||
- `QWEN.md`
|
||||
- `GEMINI.md`
|
||||
|
||||
All files reference the same knowledge base. Updates here propagate to all AI systems.
|
||||
|
||||
## Rules for AI Assistants
|
||||
|
||||
1. **ALWAYS** update LLM.md with significant discoveries
|
||||
2. **NEVER** commit symlinked files (.AGENTS.md, CLAUDE.md, etc.) - they're in .gitignore
|
||||
3. **NEVER** create random summary files - update THIS file
|
||||
|
||||
---
|
||||
|
||||
**Note**: This file serves as the single source of truth for all AI assistants working on this project.
|
||||
@@ -1,38 +0,0 @@
|
||||
<h3 align="center">Lux Finance [BETA]</h3>
|
||||
<p align="center">
|
||||
|
||||
At [Lux](https://lux.partners), we're building the future of real world assets.
|
||||
|
||||
Lux Finance is an open source protocol built on the best in DeFi.
|
||||
|
||||
## Development
|
||||
|
||||
### Run the App
|
||||
|
||||
Once you have your setup ready, run:
|
||||
|
||||
With yarn:
|
||||
|
||||
$ yarn dev
|
||||
|
||||
With npm:
|
||||
|
||||
$ npm run dev
|
||||
|
||||
### Deploy with Vercel
|
||||
|
||||
This is a Next.js app that can be easily deployed using [Vercel](https://vercel.com/). For more information on how to deploy your Github repository with Vercel visit their [docs](https://vercel.com/docs/concepts/projects/overview).
|
||||
|
||||
<p align="right">(<a href="#top">back to top</a>)</p>
|
||||
|
||||
<!-- CONTACT -->
|
||||
|
||||
## Contact
|
||||
|
||||
Twitter: [@luxdefi](https://twitter.com/luxdefi)
|
||||
Project Link: [Reservoir](https://lux.finance/)
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This project is MIT licensed.
|
||||
@@ -1,29 +0,0 @@
|
||||
// https://react-svgr.com/docs/next
|
||||
|
||||
module.exports = (config) => {
|
||||
// Grab the existing rule that handles SVG imports
|
||||
const fileLoaderRule = config.module.rules.find((rule) =>
|
||||
rule.test?.test?.('.svg'),
|
||||
)
|
||||
|
||||
config.module.rules.push(
|
||||
// Reapply the existing rule, but only for svg imports ending in ?url
|
||||
{
|
||||
...fileLoaderRule,
|
||||
test: /\.svg$/i,
|
||||
resourceQuery: /url/, // *.svg?url
|
||||
},
|
||||
// Convert all other *.svg imports to React components
|
||||
{
|
||||
test: /\.svg$/i,
|
||||
issuer: fileLoaderRule.issuer,
|
||||
resourceQuery: { not: [...fileLoaderRule.resourceQuery.not, /url/] }, // exclude if *.svg?url
|
||||
use: ['@svgr/webpack'],
|
||||
},
|
||||
)
|
||||
|
||||
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||
fileLoaderRule.exclude = /\.svg$/i
|
||||
|
||||
return config
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
const watchExternalFilesPlugin = require('webpack-watch-files-plugin').default
|
||||
|
||||
const disable = false
|
||||
|
||||
const localModules = [
|
||||
//'../../../../hanzoai/react-sdk/packages/ui',
|
||||
// '../../../../hanzoai/vaul-fork',
|
||||
//'../../../../hanzoai/react-sdk/packages/commerce'
|
||||
]
|
||||
|
||||
const suffixes = [
|
||||
'/**/*.ts',
|
||||
'/**/*.tsx',
|
||||
]
|
||||
|
||||
const flatArray = localModules.map((p) => {
|
||||
return suffixes.map((s) => (p + s))
|
||||
}).flat()
|
||||
|
||||
|
||||
module.exports = ( config ) => {
|
||||
|
||||
if (disable) {
|
||||
return config
|
||||
}
|
||||
|
||||
config.watchOptions = {
|
||||
poll: 1000,
|
||||
aggregateTimeout: 300,
|
||||
}
|
||||
config.plugins.push(
|
||||
new watchExternalFilesPlugin({
|
||||
files: flatArray,
|
||||
})
|
||||
)
|
||||
return config
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
output: 'export',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
transpilePackages: [
|
||||
'@hanzo/ui',
|
||||
'@hanzo/auth',
|
||||
'@hanzo/commerce',
|
||||
'@luxfi/ui',
|
||||
'@luxfi/data',
|
||||
'@luxfi/menu-icons'
|
||||
],
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -0,0 +1,24 @@
|
||||
import path from 'path'
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
outputFileTracingRoot: path.resolve(import.meta.dirname ?? __dirname),
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: 'https', hostname: '*.lux.network' },
|
||||
{ protocol: 'https', hostname: 'ipfs.io' },
|
||||
{ protocol: 'https', hostname: '*.ipfs.io' },
|
||||
{ protocol: 'https', hostname: 'cloudflare-ipfs.com' },
|
||||
{ protocol: 'https', hostname: 'arweave.net' },
|
||||
{ protocol: 'https', hostname: 'gateway.pinata.cloud' },
|
||||
{ protocol: 'https', hostname: 'dweb.link' },
|
||||
{ protocol: 'https', hostname: 'w3s.link' },
|
||||
{ protocol: 'https', hostname: 'nft-cdn.alchemy.com' },
|
||||
{ protocol: 'https', hostname: '*.nftstorage.link' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
@@ -1,75 +1,31 @@
|
||||
{
|
||||
"name": "@luxfi/market",
|
||||
"version": "1.0.0",
|
||||
"description": "Lux NFT Marketplace - Genesis NFTs, trading, and NFT AMM",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"dev": "yarn prebuild && next",
|
||||
"build": "yarn prebuild && next build",
|
||||
"prebuild": "node ./scripts/prebuild.js",
|
||||
"start": "yarn prebuild && next start",
|
||||
"tsc": "tsc"
|
||||
"dev": "next dev --port 3100",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3100",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@datadog/browser-rum": "^4.30.1",
|
||||
"@davatar/react": "^1.11.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.2.1",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@radix-ui/colors": "^2.0.0",
|
||||
"@radix-ui/popper": "^0.1.0",
|
||||
"@radix-ui/react-avatar": "^1.0.1",
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
"@radix-ui/react-collapsible": "^1.0.1",
|
||||
"@radix-ui/react-dialog": "1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "2.0.5",
|
||||
"@radix-ui/react-hover-card": "^1.0.6",
|
||||
"@radix-ui/react-popover": "1.0.6",
|
||||
"@radix-ui/react-switch": "^1.0.1",
|
||||
"@radix-ui/react-tabs": "^1.0.1",
|
||||
"@radix-ui/react-toast": "1.1.4",
|
||||
"@radix-ui/react-toggle-group": "^1.0.1",
|
||||
"@radix-ui/react-tooltip": "1.0.6",
|
||||
"@rainbow-me/rainbowkit": "2.1.2",
|
||||
"@reservoir0x/reservoir-kit-ui": "^2.8.5",
|
||||
"@sentry/nextjs": "^7.85.0",
|
||||
"@tanstack/react-query": "5.20.2",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@vercel/og": "^0.5.20",
|
||||
"dayjs": "^1.11.6",
|
||||
"eslint-config-next": "^14.1.0",
|
||||
"framer-motion": "^6.3.6",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "^14.1.0",
|
||||
"openapi-typescript-fetch": "^1.1.3",
|
||||
"posthog-js": "^1.93.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hotkeys-hook": "^4.0.4",
|
||||
"react-jazzicon": "^1.0.4",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-responsive": "^9.0.0",
|
||||
"react-use-websocket": "^4.3.1",
|
||||
"react-window": "^1.8.9",
|
||||
"swr": "2.0.1",
|
||||
"upgrade": "^1.1.0",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"uuid": "^9.0.0",
|
||||
"viem": "~2.9.31",
|
||||
"wagmi": "~2.10.3",
|
||||
"yarn": "^1.22.19"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
"@tanstack/react-query": "5.77.2",
|
||||
"next": "15.5.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"viem": "2.43.3",
|
||||
"wagmi": "2.15.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stitches/react": "1.3.1-1",
|
||||
"@types/lodash": "^4.14.189",
|
||||
"@types/node": "^12.12.21",
|
||||
"@types/react": "^18.0",
|
||||
"@types/react-dom": "^18.0",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"next-themes": "^0.2.0",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"name": "@luxdefi/app"
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/node": "22.13.1",
|
||||
"@types/react": "19.1.5",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// https://tailwindcss.com/docs/using-with-preprocessors#nesting
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'tailwindcss/nesting': {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 426 B |
|
Before Width: | Height: | Size: 831 B |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,12 +0,0 @@
|
||||
import type { ImageDef } from '@hanzo/ui/types'
|
||||
|
||||
const ASSETS_PATH = '/assets/img/products/'
|
||||
|
||||
export default {
|
||||
AU_B: { src: ASSETS_PATH + 'gold/gold-bar-pt-800x800.png', dim: {w: 800, h:800 } } satisfies ImageDef,
|
||||
AG_B: { src: ASSETS_PATH + 'silver/silver-bar-pt-800x800.png', dim: {w: 800, h:800 } } satisfies ImageDef,
|
||||
AU_C: { src: ASSETS_PATH + 'gold/gold-coin-700x700.png', dim: {w: 700, h:700 } } satisfies ImageDef,
|
||||
AG_C: { src: ASSETS_PATH + 'silver/silver-coin-700x700.png', dim: {w: 700, h:700 } } satisfies ImageDef,
|
||||
AU_CB: { src: ASSETS_PATH + 'gold/gold-bar-700x700.png', dim: {w: 700, h:700 } } satisfies ImageDef,
|
||||
AG_CB: { src: ASSETS_PATH + 'silver/silver-bar-700x700.png', dim: {w: 700, h:700 } } satisfies ImageDef,
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# Bullion Data and Data generation
|
||||
|
||||
#### The Lux Bullion products are specified [here](https://docs.google.com/document/d/1d-aWwd2ijz8o6UT2MxtFY_RRfR18CjbSgdIKhN0qdV8)
|
||||
|
||||
This document species the way SKUs correspond to those products so that
|
||||
the commerce ui can set up searches by categories.
|
||||
|
||||
The Hanzo SKUs are correspond as follows. This allows for
|
||||
easy implementation of search categories.
|
||||
|
||||
### SKU Tokens for Bullion
|
||||
|
||||
Category: 'All'
|
||||
```
|
||||
First token:
|
||||
LXB 'Lux Bullion'
|
||||
```
|
||||
|
||||
Category: 'Type'
|
||||
```
|
||||
Second token:
|
||||
AU 'Lux Gold Bullion'
|
||||
AG 'Lux Silver Bullion'
|
||||
PD 'Lux Palladium Bullion'
|
||||
PT 'Lux Platinum Bullion'
|
||||
```
|
||||
|
||||
Category: 'Form'
|
||||
```
|
||||
third token:
|
||||
B 'Minted Bar'
|
||||
C 'Coin'
|
||||
CB 'Cast bar'
|
||||
```
|
||||
|
||||
Category: 'Size'
|
||||
```
|
||||
forth / last token:
|
||||
<integer | decimal>_<unit>
|
||||
eg: 100_OZ '100oz'
|
||||
eg: 2.5_G = '2.5g'
|
||||
```
|
||||
|
||||
Full examples:
|
||||
```
|
||||
LXB-AU-B-50_G 'Lux Gold Bullion, 50g Minted Bar'
|
||||
LXB-AU-B-2.5_G 'Lux Gold Bullion, 2.5g Minted Bar'
|
||||
LXB-AU-B-10_OZ 'Lux Gold Bullion, 10oz Minted Bar'
|
||||
LXB-AG-CB-500_G 'Lux Silver Bullion, 500g Cast Bar'
|
||||
LXB-PT-C-1_OZ 'Lux Platinum Bullion, 1oz Minted Coin'
|
||||
```
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export { default as ROOT } from './to-import/root.ts'
|
||||
|
||||
// must have last '/'
|
||||
// For testing: export const OUT_DIR = './'
|
||||
export const OUT_DIR = '../../packages/core/commerce-util/'
|
||||
export const OUT_FN = 'AUTO-GEN-bullion-by-family.json'
|
||||
|
||||
export const SEP = {
|
||||
tok: '-',
|
||||
subTok: '_',
|
||||
decimal: '.'
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import fs from 'fs-extra'
|
||||
|
||||
import { type Product, type Family } from '@hanzo/commerce/types'
|
||||
|
||||
import { type LeafImportData, type NodeImportData } from './types.ts'
|
||||
|
||||
import { OUT_DIR, OUT_FN, ROOT, SEP } from './config.ts'
|
||||
import IMG from './IMG.ts'
|
||||
|
||||
const families: Family[] = []
|
||||
|
||||
const amountStrFromItemToken = (t: string): string => {
|
||||
const tokens = t.split(SEP.subTok)
|
||||
// Must lower-case it because multibar tokens have an 'X' in them
|
||||
const amount = (tokens[0].includes(SEP.decimal) ? tokens[0].split(SEP.decimal).join('.') : tokens[0]).toLowerCase()
|
||||
const unit = tokens[1].toLowerCase() // OZ -> oz
|
||||
return amount + unit
|
||||
}
|
||||
|
||||
const visitNode = (
|
||||
levelData: NodeImportData,
|
||||
titleTokens: string[] = [],
|
||||
skuTokens: string[] = [],
|
||||
): void => {
|
||||
|
||||
const levelTitle = levelData.titleToken ?? levelData.label
|
||||
const parentTitle = titleTokens.length > 0 ? titleTokens[titleTokens.length - 1] : undefined
|
||||
titleTokens.push(levelTitle)
|
||||
|
||||
skuTokens.push(levelData.tok)
|
||||
|
||||
if (levelData.ch.length > 0 && 'price' in levelData.ch[0]) {
|
||||
|
||||
const familyId = skuTokens.join(SEP.tok)
|
||||
|
||||
// Since we are at the leaf level,
|
||||
// these are valid for the entire array.
|
||||
const bullionForm = titleTokens.pop()
|
||||
const previousTitle = titleTokens.filter((el) => (el.length > 0)).join(', ')
|
||||
|
||||
const getSku = (prodTok: string) => (
|
||||
[...skuTokens, prodTok].join(SEP.tok)
|
||||
)
|
||||
|
||||
// from LeafImportData to hanzo/cart Product
|
||||
const hanzoProducts = (levelData.ch as LeafImportData[]).map(
|
||||
(prod) => ({
|
||||
id: getSku(prod.tok),
|
||||
sku: getSku(prod.tok),
|
||||
|
||||
// Desired result: "Lux Bullion, Gold, 1oz Minted Bar", ie,
|
||||
// `<previous title tokens joined>, <amount> <form>`
|
||||
fullTitle: `${previousTitle}, ${amountStrFromItemToken(prod.tok)} ${bullionForm!}`,
|
||||
optionLabel: amountStrFromItemToken(prod.tok),
|
||||
familyTitle: bullionForm!,
|
||||
familyId: familyId,
|
||||
desc: prod.desc ? prod.desc : levelData.desc,
|
||||
price: 0,
|
||||
// @ts-ignore
|
||||
img: IMG[prod.imgCode ?? levelData.imgCode]
|
||||
} satisfies Product)
|
||||
)
|
||||
|
||||
// from NodeImportData to hanzo Family
|
||||
families.push({
|
||||
id: skuTokens.join(SEP.tok),
|
||||
title: levelTitle,
|
||||
parentTitle,
|
||||
desc: levelData.desc,
|
||||
// @ts-ignore
|
||||
img: IMG[levelData.imgCode],
|
||||
products: hanzoProducts
|
||||
} satisfies Family)
|
||||
|
||||
}
|
||||
else if (levelData.ch.length > 0 && 'ch' in levelData.ch[0]) {
|
||||
|
||||
const {imgCode: parentImg, desc: parentDesc} = levelData
|
||||
const subLevels = levelData.ch as NodeImportData[]
|
||||
|
||||
subLevels.forEach(({tok, label, imgCode, desc, ch}) => {
|
||||
visitNode({
|
||||
tok,
|
||||
label,
|
||||
imgCode: imgCode ?? parentImg,
|
||||
desc: desc ?? parentDesc,
|
||||
ch,
|
||||
} satisfies NodeImportData,
|
||||
// Each branch (ch levelData) must have it's own fresh copies to reduce
|
||||
[...titleTokens],
|
||||
[...skuTokens]
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
visitNode(ROOT as unknown as NodeImportData)
|
||||
|
||||
console.log(`Writing Family with their Products to ${OUT_DIR + OUT_FN}...`)
|
||||
fs.writeJsonSync(
|
||||
OUT_DIR + OUT_FN,
|
||||
families,
|
||||
{ spaces: 2 }
|
||||
)
|
||||
console.log('done')
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"name": "market-data-generator",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "main.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"COMMENT": "ts-node sucks! Just say NO!",
|
||||
"tc": "tsc --noEmit",
|
||||
"go": "vite-node main.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@hanzo/commerce": "catalog:",
|
||||
"@hanzo/ui": "catalog:",
|
||||
"fs-extra": "^11.2.0",
|
||||
"typescript": "catalog:",
|
||||
"vite-node": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"tailwindcss" : "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
tok: '1_OZ',
|
||||
price: 0
|
||||
},
|
||||
/*
|
||||
{
|
||||
tok: '1_G',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '10_G',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '100_G',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '1_KG',
|
||||
price: 0
|
||||
},
|
||||
*/
|
||||
|
||||
]
|
||||
@@ -1,14 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
tok: '500_G',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '1_KG',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '1000_OZ',
|
||||
price: 0
|
||||
},
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
tok: '1_OZ',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '1_G',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '10_G',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '100_G',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '400_OZ',
|
||||
price: 0
|
||||
}
|
||||
]
|
||||
@@ -1,14 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
tok: '500_G',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '1_KG',
|
||||
price: 0
|
||||
},
|
||||
{
|
||||
tok: '400_OZ',
|
||||
price: 0
|
||||
},
|
||||
]
|
||||
@@ -1,76 +0,0 @@
|
||||
import AU_B from './AU-B.ts'
|
||||
import AG_B from './AG-B.ts'
|
||||
import AU_CB from './AU-CB.ts'
|
||||
import AG_CB from './AG-CB.ts'
|
||||
|
||||
export default {
|
||||
tok: 'LXM',
|
||||
label: 'Root',
|
||||
titleToken: '',
|
||||
ch: [
|
||||
/*
|
||||
{
|
||||
tok: 'AU',
|
||||
label: 'Lux Gold',
|
||||
desc: "Forge your connection to the real world with Lux Gold, crafted with excellence in Dubai.",
|
||||
ch: [
|
||||
{
|
||||
tok: 'B',
|
||||
label: 'Minted Bar',
|
||||
imgCode: 'AU_B',
|
||||
ch: AU_B
|
||||
},
|
||||
{
|
||||
tok: 'C',
|
||||
label: 'Minted Coin',
|
||||
imgCode: 'AU_C',
|
||||
ch: [
|
||||
{
|
||||
tok: '1_OZ',
|
||||
price: 0,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
tok: 'CB',
|
||||
label: 'Cast Bar',
|
||||
imgCode: 'AU_CB',
|
||||
ch: AU_CB
|
||||
}
|
||||
]
|
||||
},
|
||||
*/
|
||||
{
|
||||
tok: 'AG',
|
||||
desc: 'Get unprecedented access to silver with 1:1 asset-backed Lux Silver NFTs, sovereign ownership of physical silver without management fees, and mine-direct discount pricing.',
|
||||
label: 'Lux Silver',
|
||||
ch: [
|
||||
{
|
||||
tok: 'B',
|
||||
label: 'Minted Bar',
|
||||
imgCode: 'AG_B',
|
||||
ch: AG_B
|
||||
},
|
||||
/*
|
||||
{
|
||||
tok: 'C',
|
||||
label: 'Minted Coin',
|
||||
imgCode: 'AG_C',
|
||||
ch: [
|
||||
{
|
||||
tok: '1_OZ',
|
||||
price: 0,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
tok: 'CB',
|
||||
label: 'Cast Bar',
|
||||
imgCode: 'AG_CB',
|
||||
ch: AG_CB
|
||||
}
|
||||
*/
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.apps.base.json",
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions":true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
interface LeafImportData {
|
||||
tok: string
|
||||
imgCode?: string
|
||||
desc?: string
|
||||
}
|
||||
|
||||
interface NodeImportData {
|
||||
tok: string
|
||||
titleToken?: string
|
||||
label: string
|
||||
imgCode?: string
|
||||
desc?: string
|
||||
ch: NodeImportData[] | LeafImportData[]
|
||||
}
|
||||
|
||||
export {
|
||||
type LeafImportData,
|
||||
type NodeImportData
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { Header } from '@/components/Header'
|
||||
import { ActivityRow } from '@/components/ActivityRow'
|
||||
import { useChainContext } from '@/hooks/useChain'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { EXPLORER_API } from '@/lib/chains'
|
||||
|
||||
/** Fetch recent NFT transfers across all NFT contracts on the selected chain */
|
||||
async function getRecentNftTransfers(chainId: number) {
|
||||
const base = EXPLORER_API[chainId] ?? EXPLORER_API[96369]
|
||||
const res = await fetch(`${base}/token-transfers?type=ERC-721,ERC-1155`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
next: { revalidate: 15 },
|
||||
})
|
||||
if (!res.ok) return { items: [] }
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const { chainId } = useChainContext()
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['nft', 'activity', chainId],
|
||||
queryFn: () => getRecentNftTransfers(chainId),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
|
||||
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 }}>
|
||||
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>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>Loading activity...</div>
|
||||
) : !data?.items?.length ? (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>
|
||||
No NFT activity found on this chain yet.
|
||||
</div>
|
||||
) : (
|
||||
data.items.map((transfer: any, i: number) => (
|
||||
<ActivityRow key={`${transfer.tx_hash}-${i}`} transfer={transfer} chainId={chainId} />
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const Providers = dynamic(() => import('./providers').then((m) => m.Providers), { ssr: false })
|
||||
|
||||
export function ClientProviders({ children }: { children: ReactNode }) {
|
||||
return <Providers>{children}</Providers>
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useMemo } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { NFTCard } from '@/components/NFTCard'
|
||||
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'
|
||||
|
||||
type Tab = 'items' | 'activity'
|
||||
|
||||
export default function CollectionDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chainId: string; address: string }>
|
||||
}) {
|
||||
const { chainId: chainIdStr, address } = use(params)
|
||||
const chainId = Number(chainIdStr)
|
||||
const chainInfo = CHAIN_INFO[chainId]
|
||||
const explorerBase = EXPLORER_API[chainId]?.replace('/api/v2', '') ?? 'https://explore.lux.network'
|
||||
|
||||
const [tab, setTab] = useState<Tab>('items')
|
||||
const [selectedTraits, setSelectedTraits] = useState<Record<string, Set<string>>>({})
|
||||
|
||||
const { data: collection, isLoading: loadingCollection } = useCollection(chainId, address)
|
||||
const { data: instances, isLoading: loadingInstances } = useTokenInstances(chainId, address)
|
||||
const { data: transfers } = useCollectionTransfers(chainId, address)
|
||||
|
||||
const handleTraitToggle = (traitType: string, value: string) => {
|
||||
setSelectedTraits((prev) => {
|
||||
const next = { ...prev }
|
||||
const set = new Set(next[traitType] ?? [])
|
||||
if (set.has(value)) set.delete(value)
|
||||
else set.add(value)
|
||||
next[traitType] = set
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Filter instances by selected traits
|
||||
const filteredInstances = useMemo(() => {
|
||||
const items = instances?.items ?? []
|
||||
const hasFilters = Object.values(selectedTraits).some((s) => s.size > 0)
|
||||
if (!hasFilters) return items
|
||||
|
||||
return items.filter((inst) => {
|
||||
const attrs = inst.metadata?.attributes
|
||||
if (!attrs) return false
|
||||
for (const [traitType, values] of Object.entries(selectedTraits)) {
|
||||
if (values.size === 0) continue
|
||||
const match = attrs.some((a) => a.trait_type === traitType && values.has(String(a.value)))
|
||||
if (!match) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [instances?.items, selectedTraits])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1400, margin: '0 auto', padding: '32px 24px' }}>
|
||||
{/* Collection header */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{!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)' }}>
|
||||
{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' }}
|
||||
>
|
||||
{address.slice(0, 6)}...{address.slice(-4)} ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div style={{ display: 'flex', gap: 24, marginBottom: 16 }}>
|
||||
{[
|
||||
{ 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 }}>
|
||||
{stat.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, marginTop: 2 }}>{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
|
||||
{(['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',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
{t === 'items' && instances?.items && (
|
||||
<span style={{ marginLeft: 6, fontSize: 12, color: 'var(--muted)' }}>
|
||||
({filteredInstances.length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === 'items' && (
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
{/* Trait sidebar */}
|
||||
<TraitFilter
|
||||
instances={instances?.items ?? []}
|
||||
selectedTraits={selectedTraits}
|
||||
onTraitToggle={handleTraitToggle}
|
||||
onClear={() => setSelectedTraits({})}
|
||||
/>
|
||||
|
||||
{/* NFT grid */}
|
||||
<div style={{ flex: 1 }}>
|
||||
{loadingInstances ? (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>Loading NFTs...</div>
|
||||
) : filteredInstances.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>No NFTs found</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{filteredInstances.map((inst) => (
|
||||
<NFTCard
|
||||
key={`${inst.token.address}-${inst.id}`}
|
||||
contractAddress={inst.token.address}
|
||||
tokenId={inst.id}
|
||||
chainId={chainId}
|
||||
instance={inst}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'activity' && (
|
||||
<div>
|
||||
{!transfers?.items?.length ? (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>No activity yet</div>
|
||||
) : (
|
||||
transfers.items.map((t, i) => <ActivityRow key={`${t.tx_hash}-${i}`} transfer={t} chainId={chainId} />)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const { chainId } = useChainContext()
|
||||
const [search, setSearch] = useState('')
|
||||
const { data: collections, isLoading } = useCollections(chainId, search || undefined)
|
||||
const chainInfo = CHAIN_INFO[chainId]
|
||||
|
||||
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>
|
||||
</div>
|
||||
<p style={{ color: 'var(--muted)', marginBottom: 24, fontSize: 14 }}>
|
||||
Browse NFT collections on{' '}
|
||||
<span style={{ color: chainInfo?.color, fontWeight: 600 }}>{chainInfo?.name ?? 'Lux'}</span>.
|
||||
Collections with LSSVM pools have instant buy/sell liquidity.
|
||||
</p>
|
||||
|
||||
{/* Search */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Collection list */}
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48, color: 'var(--muted)' }}>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)',
|
||||
}}
|
||||
>
|
||||
{search
|
||||
? `No collections matching "${search}" on ${chainInfo?.name ?? 'this chain'}.`
|
||||
: `No NFT collections found on ${chainInfo?.name ?? 'this chain'} yet.`}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{collections.items.map((token, i) => (
|
||||
<CollectionCard key={token.address} token={token} chainId={chainId} rank={i + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Header } from '@/components/Header'
|
||||
|
||||
const GENESIS_TIERS = [
|
||||
{
|
||||
name: 'Genesis',
|
||||
lockedLux: '1,000,000,000',
|
||||
description:
|
||||
'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',
|
||||
lockedLux: '100,000,000',
|
||||
description:
|
||||
'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',
|
||||
lockedLux: '10,000,000',
|
||||
description:
|
||||
'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',
|
||||
lockedLux: '1,000,000',
|
||||
description:
|
||||
'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%)',
|
||||
},
|
||||
]
|
||||
|
||||
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' }}>
|
||||
Permanently locked LUX tokens with staking rewards flowing to NFT holders. Genesis NFTs control validator
|
||||
staking returns through the ValidatorVault and LiquidLUX (xLUX) system.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Reward flow diagram */}
|
||||
<section
|
||||
style={{
|
||||
background: 'var(--card)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
padding: 24,
|
||||
marginBottom: 48,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>Reward Flow</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 14 }}>Validator Staking</span>
|
||||
<span style={{ color: 'var(--muted)' }}>→</span>
|
||||
<span style={{ fontSize: 14 }}>ValidatorVault.depositRewards()</span>
|
||||
<span style={{ color: 'var(--muted)' }}>→</span>
|
||||
<span style={{ fontSize: 14 }}>LiquidLUX.depositValidatorRewards()</span>
|
||||
<span style={{ color: 'var(--muted)' }}>→</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>xLUX holders</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tier grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 24 }}>
|
||||
{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,
|
||||
}}
|
||||
>
|
||||
Genesis
|
||||
</span>
|
||||
<span style={{ fontSize: 36, fontWeight: 700 }}>{tier.name}</span>
|
||||
<span style={{ fontSize: 14, color: 'var(--accent)' }}>{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 }}>
|
||||
{tier.description}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: 13,
|
||||
color: 'var(--muted)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<span>Max Supply: {tier.maxSupply.toLocaleString()}</span>
|
||||
<span>Rewards: {tier.rewardMultiplier}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--accent: #e8e8e8;
|
||||
--muted: #666;
|
||||
--border: #1a1a1a;
|
||||
--card: #0f0f0f;
|
||||
--card-hover: #151515;
|
||||
--success: #00cc66;
|
||||
--error: #ff4444;
|
||||
--warning: #ffaa00;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: rgba(232, 232, 232, 0.15);
|
||||
}
|
||||
|
||||
/* Focus visible */
|
||||
:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,22 +1,40 @@
|
||||
import React, { type PropsWithChildren } from 'react'
|
||||
import type { Viewport, Metadata } from 'next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import { ClientProviders } from './client-providers'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'market - LUX',
|
||||
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.',
|
||||
openGraph: {
|
||||
title: 'Lux Market',
|
||||
description: 'The NFT marketplace for the Lux ecosystem',
|
||||
url: 'https://lux.market',
|
||||
siteName: 'Lux Market',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Lux Market',
|
||||
description: 'Trade NFTs across all Lux chains',
|
||||
},
|
||||
robots: { index: true, follow: true },
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: 'white' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: 'black' },
|
||||
],
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
themeColor: '#0a0a0a',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: PropsWithChildren) {
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang='en' className='dark'>
|
||||
<body className='bg-black text-white flex flex-col min-h-full'>
|
||||
{children}
|
||||
<html lang="en">
|
||||
<body>
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
import { useAccount } from 'wagmi'
|
||||
import Image from 'next/image'
|
||||
import { Header } from '@/components/Header'
|
||||
import { ListingForm } from '@/components/ListingForm'
|
||||
import { OfferForm } from '@/components/OfferForm'
|
||||
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 type { Address } from 'viem'
|
||||
|
||||
function shortenAddress(addr: string): string {
|
||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
||||
}
|
||||
|
||||
export default function NFTDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chainId: string; address: string; tokenId: string }>
|
||||
}) {
|
||||
const { chainId: chainIdStr, address: contractAddress, tokenId } = use(params)
|
||||
const chainId = Number(chainIdStr)
|
||||
const { address: userAddress } = useAccount()
|
||||
const chainInfo = CHAIN_INFO[chainId]
|
||||
const explorerBase = EXPLORER_API[chainId]?.replace('/api/v2', '') ?? 'https://explore.lux.network'
|
||||
|
||||
const { data: instance, isLoading } = useTokenInstance(chainId, contractAddress, tokenId)
|
||||
const { data: transfers } = useTokenInstanceTransfers(chainId, contractAddress, tokenId)
|
||||
|
||||
const imageUrl = instance ? getNftImageUrl(instance) : null
|
||||
const animationUrl = instance ? resolveMediaUrl(instance.animation_url ?? instance.metadata?.animation_url) : null
|
||||
const ownerAddress = instance?.owner?.hash
|
||||
const isOwner = userAddress && ownerAddress && userAddress.toLowerCase() === ownerAddress.toLowerCase()
|
||||
const metadata = instance?.metadata
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 64, color: 'var(--muted)' }}>Loading NFT...</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 400px', gap: 32, alignItems: 'start' }}>
|
||||
{/* Left: Media */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: '1',
|
||||
borderRadius: 16,
|
||||
border: '1px solid var(--border)',
|
||||
overflow: 'hidden',
|
||||
maxWidth: 600,
|
||||
position: 'relative',
|
||||
background: '#111',
|
||||
}}
|
||||
>
|
||||
{animationUrl ? (
|
||||
<video
|
||||
src={animationUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={metadata?.name ?? `#${tokenId}`}
|
||||
fill
|
||||
style={{ objectFit: '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',
|
||||
}}
|
||||
>
|
||||
#{tokenId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 }}>
|
||||
Description
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--foreground)', lineHeight: 1.6 }}>
|
||||
{metadata.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 }}>
|
||||
Traits
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8 }}>
|
||||
{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)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
{trait.trait_type}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginTop: 2 }}>
|
||||
{String(trait.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 }}>
|
||||
Transfer History
|
||||
</div>
|
||||
{transfers.items.slice(0, 20).map((t, i) => (
|
||||
<ActivityRow key={`${t.tx_hash}-${i}`} transfer={t} chainId={chainId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Details + Actions */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, position: 'sticky', top: 80 }}>
|
||||
{/* Collection & Name */}
|
||||
<div>
|
||||
<a
|
||||
href={`/collection/${chainId}/${contractAddress}`}
|
||||
style={{ fontSize: 13, color: 'var(--accent)', textDecoration: 'none' }}
|
||||
>
|
||||
{instance?.token?.name ?? 'Collection'}
|
||||
</a>
|
||||
<h1 style={{ fontSize: 28, fontWeight: 700, marginTop: 4 }}>
|
||||
{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 }}>
|
||||
Owner
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 14, fontFamily: 'monospace' }}>
|
||||
{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,
|
||||
}}
|
||||
>
|
||||
YOU
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 }}>
|
||||
Details
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{[
|
||||
{ label: 'Chain', value: chainInfo?.name ?? String(chainId), color: chainInfo?.color },
|
||||
{
|
||||
label: 'Contract',
|
||||
value: shortenAddress(contractAddress),
|
||||
href: `${explorerBase}/token/${contractAddress}`,
|
||||
},
|
||||
{ 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>
|
||||
{row.href ? (
|
||||
<a
|
||||
href={row.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontFamily: 'monospace', color: 'var(--foreground)', textDecoration: 'none' }}
|
||||
>
|
||||
{row.value} ↗
|
||||
</a>
|
||||
) : (
|
||||
<span style={{ color: row.color, fontFamily: row.label === 'Token ID' ? 'monospace' : undefined }}>
|
||||
{row.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* External links */}
|
||||
{metadata?.external_url && (
|
||||
<a
|
||||
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,
|
||||
}}
|
||||
>
|
||||
View External ↗
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Trading Actions */}
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--card)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{isOwner ? (
|
||||
<ListingForm contractAddress={contractAddress as Address} tokenId={tokenId} />
|
||||
) : (
|
||||
<OfferForm contractAddress={contractAddress as Address} tokenId={tokenId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,162 @@
|
||||
import React from 'react'
|
||||
'use client'
|
||||
|
||||
import { Header } from '@/components/Header'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { CollectionCard } from '@/components/CollectionCard'
|
||||
import { useChainContext } from '@/hooks/useChain'
|
||||
import { useCollections } from '@/hooks/useNFTData'
|
||||
import Link from 'next/link'
|
||||
import { CHAIN_INFO } from '@/lib/chains'
|
||||
|
||||
const GENESIS_TIERS = [
|
||||
{ name: 'Genesis', locked: '1B LUX', tier: 'Tier 1', multiplier: '10x' },
|
||||
{ name: 'Validator', locked: '100M LUX', tier: 'Tier 2', multiplier: '5x' },
|
||||
{ name: 'Mini', locked: '10M LUX', tier: 'Tier 3', multiplier: '2x' },
|
||||
{ name: 'Nano', locked: '1M LUX', tier: 'Tier 4', multiplier: '1x' },
|
||||
]
|
||||
|
||||
export default function Home() {
|
||||
const { chainId } = useChainContext()
|
||||
const { data: collections, isLoading } = useCollections(chainId)
|
||||
const chainInfo = CHAIN_INFO[chainId]
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8">
|
||||
<h1 className="text-4xl font-bold mb-4">LUX market</h1>
|
||||
<p className="text-lg text-gray-400 mb-8">Coming soon</p>
|
||||
<a
|
||||
href="https://lux.network"
|
||||
className="px-6 py-3 bg-white text-black rounded-lg hover:bg-gray-200 transition"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
||||
{/* 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' }}>
|
||||
Trade NFTs across all Lux chains. Seaport-powered P2P trading with LSSVM AMM liquidity.
|
||||
</p>
|
||||
<div style={{ maxWidth: 480, margin: '0 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 →
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
|
||||
{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 }}>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
</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 }}>
|
||||
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 }}
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>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)',
|
||||
}}
|
||||
>
|
||||
No NFT collections found on {chainInfo?.name ?? 'this chain'} yet. Deploy an ERC-721 or ERC-1155 to get started.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{collections.items.slice(0, 10).map((token, i) => (
|
||||
<CollectionCard key={token.address} token={token} chainId={chainId} rank={i + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section style={{ marginBottom: 48 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
{[
|
||||
{ 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 }}>
|
||||
{stat.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700 }}>{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useAccount } from 'wagmi'
|
||||
import { Header } from '@/components/Header'
|
||||
import { NFTCard } from '@/components/NFTCard'
|
||||
import { useChainContext } from '@/hooks/useChain'
|
||||
import { usePortfolioNFTs } from '@/hooks/useNFTData'
|
||||
import { CHAIN_INFO } from '@/lib/chains'
|
||||
|
||||
const ALL_CHAIN_IDS = [96369, 200200, 36963, 36911, 494949]
|
||||
|
||||
function ChainPortfolio({ chainId, walletAddress }: { chainId: number; walletAddress: string }) {
|
||||
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 (!data?.items?.length) return null
|
||||
|
||||
return (
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12, color: chainInfo?.color }}>
|
||||
{chainInfo?.name} ({data.items.length})
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{data.items.map((item) => (
|
||||
<NFTCard
|
||||
key={`${item.token.address}-${item.token_id}`}
|
||||
contractAddress={item.token.address}
|
||||
tokenId={item.token_id ?? '0'}
|
||||
collectionName={item.token.name}
|
||||
chainId={chainId}
|
||||
instance={item.token_instance ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PortfolioPage() {
|
||||
const { address, isConnected } = useAccount()
|
||||
const { chainId: selectedChain } = useChainContext()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
<main style={{ maxWidth: 1200, margin: '0 auto', padding: '32px 24px' }}>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 24 }}>My NFTs</h1>
|
||||
|
||||
{!isConnected ? (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '64px 24px',
|
||||
background: 'var(--card)',
|
||||
borderRadius: 16,
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>💼</div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 600, marginBottom: 8 }}>Connect your wallet</h2>
|
||||
<p style={{ color: 'var(--muted)', fontSize: 14 }}>
|
||||
Connect a wallet to view your NFTs across all Lux chains.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* NFTs grouped by chain */}
|
||||
{ALL_CHAIN_IDS.map((cId) => (
|
||||
<ChainPortfolio key={cId} chainId={cId} walletAddress={address!} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'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 { ChainContext } from '@/hooks/useChain'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [chainId, setChainId] = useState(96369) // Default to Lux mainnet
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
'use client' // Put spline back into a client component, make this ssr comp
|
||||
import React from 'react'
|
||||
|
||||
import type { TShirtSize } from '@hanzo/ui/types'
|
||||
import { cn } from '@hanzo/ui/util'
|
||||
import { LinkElement, buttonVariants } from '@hanzo/ui/primitives'
|
||||
import { VideoBlockComponent, type BlockComponentProps } from '@hanzo/ui/blocks'
|
||||
|
||||
import { BuyButton } from '@luxfi/ui'
|
||||
|
||||
import Spline from '@splinetool/react-spline'
|
||||
import type BannerBlock from '@/blocks/def/banner-block'
|
||||
|
||||
type BannerGrouping = 'separate' | 'titleAndMedia-cta'
|
||||
|
||||
interface GroupClx {
|
||||
title?: string // for media and title if grouping === 'titleAndMedia-cta'
|
||||
media?: string
|
||||
cta?: string
|
||||
}
|
||||
|
||||
const BannerBlockComponent: React.FC<BlockComponentProps & {
|
||||
videoSize?: TShirtSize
|
||||
videoConstraint?: {w: number, h: number}
|
||||
grouping?: BannerGrouping
|
||||
groupClx?: GroupClx
|
||||
}> = ({
|
||||
block,
|
||||
agent,
|
||||
grouping = 'titleAndMedia-cta',
|
||||
groupClx={},
|
||||
videoSize='md',
|
||||
videoConstraint
|
||||
}) => {
|
||||
|
||||
if (block.blockType !== 'banner') {
|
||||
return <>banner block required</>
|
||||
}
|
||||
const banner = block as BannerBlock
|
||||
const specifies = (s: string) => (banner.specifiers?.includes(s))
|
||||
|
||||
const CTAs: React.FC<{className?: string}> = ({
|
||||
className=''
|
||||
}) => ((banner.skuPath || banner.learnLink) ? (
|
||||
<div className={cn(
|
||||
(banner.skuPath && banner.learnLink) ? 'grid grid-cols-2 gap-2' : 'flex flex-row',
|
||||
'sm:flex sm:flex-row justify-center items-center sm:gap-4 lg:gap-6',
|
||||
className
|
||||
)}>
|
||||
{banner.learnLink && (
|
||||
<LinkElement
|
||||
def={{
|
||||
href: banner.learnLink.href,
|
||||
title: banner.learnLink.title ? banner.learnLink.title : 'Learn More',
|
||||
newTab: banner.learnLink.newTab
|
||||
}}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: banner.skuPath ? 'outline' : 'primary',
|
||||
size: 'default',
|
||||
rounded: 'md'
|
||||
}),
|
||||
'lg:min-w-[220px] sm:min-w-[220px]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{banner.skuPath && (
|
||||
<BuyButton
|
||||
skuPath={banner.skuPath}
|
||||
size='default'
|
||||
className={'lg:min-w-[220px] sm:min-w-[220px] ' + (banner.learnLink ? '' : '!min-w-[320px]')}
|
||||
>
|
||||
Buy
|
||||
</BuyButton>
|
||||
)}
|
||||
</div>
|
||||
) : null)
|
||||
|
||||
return (grouping === 'separate') ?
|
||||
(<>
|
||||
<div className={'text-center ' + groupClx.title ?? ''}>
|
||||
{banner.bylineBefore && (<h5 className='text-center'>{banner.bylineBefore}</h5>)}
|
||||
<h1>{banner.title}</h1>
|
||||
{banner.byline && (<h5 className='text-center'>{banner.byline}</h5>)}
|
||||
</div>
|
||||
<div className={'self-center w-full max-w-[40rem] flex flex-col justify-center items-center text-center ' + groupClx.media ?? ''}>
|
||||
{ banner.video ? (
|
||||
<VideoBlockComponent
|
||||
className='self-center not-typography'
|
||||
block={{blockType: 'video', ...banner.video}}
|
||||
agent={agent}
|
||||
size={videoSize}
|
||||
constrainTo={videoConstraint}
|
||||
/>
|
||||
) :
|
||||
banner.animation ? (
|
||||
<Spline scene={banner.animation} className='!w-full !h-auto pointer-events-none !aspect-[12/10]'/>
|
||||
) : null}
|
||||
</div>
|
||||
<CTAs className={groupClx.cta ?? ''}/>
|
||||
</>) : /* grouping === 'titleAndMedia-cta' */ (<>
|
||||
<div className={'self-center flex flex-col justify-start items-center text-center ' + groupClx.title ?? ''} >
|
||||
{banner.bylineBefore && (<h5 className='text-center'>{banner.bylineBefore}</h5>)}
|
||||
<h1>{banner.title}</h1>
|
||||
{banner.byline && (<h5 className='text-center'>{banner.byline}</h5>)}
|
||||
{banner.video ? (
|
||||
<VideoBlockComponent
|
||||
className='self-center not-typography'
|
||||
block={{blockType: 'video', ...banner.video}}
|
||||
agent={agent}
|
||||
size={videoSize}
|
||||
constrainTo={videoConstraint}
|
||||
/>
|
||||
) :
|
||||
banner.animation ? (
|
||||
<Spline scene={banner.animation} className='!w-full !h-auto pointer-events-none !aspect-[12/10]'/>
|
||||
) : null}
|
||||
</div>
|
||||
<CTAs className={groupClx.cta ?? ''}/>
|
||||
</>)
|
||||
}
|
||||
|
||||
export {
|
||||
BannerBlockComponent as default,
|
||||
type BannerGrouping as AssetBannderGroupingType
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
'use client' // Put spline back into a client component, make this ssr comp
|
||||
|
||||
import React from 'react'
|
||||
|
||||
import { ApplyTypography } from '@hanzo/ui/primitives'
|
||||
import { cn } from '@hanzo/ui/util'
|
||||
|
||||
import {
|
||||
type BlockComponentProps,
|
||||
ContentComponent,
|
||||
SpaceBlockComponent,
|
||||
CardBlockComponent as CardComponent,
|
||||
AccordianBlockComponent,
|
||||
VideoBlockComponent
|
||||
} from '@hanzo/ui/blocks'
|
||||
|
||||
import type ProductDetailBlock from '@/blocks/def/product-detail-block'
|
||||
|
||||
import Spline from '@splinetool/react-spline'
|
||||
|
||||
const Spacer: React.FC = () => (
|
||||
<SpaceBlockComponent block={{blockType: 'space'}} />
|
||||
)
|
||||
|
||||
const ProductDetailBlockComponent: React.FC<BlockComponentProps> = ({
|
||||
agent,
|
||||
block,
|
||||
}) => {
|
||||
|
||||
if (block.blockType !== 'product-detail') {
|
||||
return <>product detail block required</>
|
||||
}
|
||||
const p = block as ProductDetailBlock
|
||||
|
||||
const videoSize = (agent === 'phone') ? 'md' : 'lg'
|
||||
|
||||
const TitleArea: React.FC<{className?: string}> = ({
|
||||
className=''
|
||||
}) => (
|
||||
<ApplyTypography className={cn(
|
||||
'typography-headings:text-left typography-h2:md:text-3xl',
|
||||
'typography-h2:lg:text-4xl',
|
||||
className
|
||||
)}>
|
||||
<h2 className='text-left'>{p.title}</h2>
|
||||
{p.desc && (typeof p.desc === 'string') ? (
|
||||
<h6>{p.desc}</h6>
|
||||
) : (
|
||||
p.desc
|
||||
)
|
||||
}
|
||||
</ApplyTypography>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 w-full'>
|
||||
<div className='mb-6 md:mb-12 w-full'>
|
||||
{p.video ? (
|
||||
<VideoBlockComponent block={p.video} agent={agent} size={videoSize} className='md:sticky md:top-[80px] md:mt-0 mt-[16px] mx-auto'/>
|
||||
) :
|
||||
p.animation ? (
|
||||
<Spline scene={p.animation} className='!w-full !h-auto pointer-events-none !aspect-square'/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='md:bg-scroll w-full'>
|
||||
<div className='md:max-w-[555px] flex flex-col items-start gap-4' >
|
||||
<TitleArea className='flex flex-col justify-start items-start ' />
|
||||
{p.price && (<>
|
||||
{p.price.heading && (
|
||||
<ApplyTypography >
|
||||
<h3>{p.price.heading}</h3>
|
||||
</ApplyTypography>
|
||||
)}
|
||||
<div className='flex flex-col justify-start items-stretch self-stretch w-full sm:self-center sm:grid sm:grid-cols-2 gap-4 '>
|
||||
<CardComponent block={p.price.priceCard} agent={agent} />
|
||||
<CardComponent block={p.price.msCard} agent={agent} />
|
||||
</div>
|
||||
</>)}
|
||||
<AccordianBlockComponent block={p.accordian} agent={agent} className='mt-5'/>
|
||||
<Spacer />
|
||||
<ContentComponent blocks={p.blocks} agent={agent}/>
|
||||
<Spacer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductDetailBlockComponent
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { Block } from '@hanzo/ui/blocks'
|
||||
import type { LinkDef, MediaStackDef } from '@hanzo/ui/types'
|
||||
|
||||
interface BannerBlock extends Block, MediaStackDef {
|
||||
blockType: 'banner'
|
||||
specifiers?: string
|
||||
bylineBefore?: React.ReactNode
|
||||
title: string
|
||||
byline?: React.ReactNode
|
||||
learnLink?: LinkDef
|
||||
skuPath?: string
|
||||
}
|
||||
|
||||
export {
|
||||
type BannerBlock as default
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type {
|
||||
Block,
|
||||
VideoBlock,
|
||||
AccordianBlock,
|
||||
CardBlock
|
||||
} from '@hanzo/ui/blocks'
|
||||
|
||||
interface ProductDetailBlock extends Block {
|
||||
blockType: 'product-detail'
|
||||
title: string
|
||||
desc?: ReactNode
|
||||
video?: VideoBlock
|
||||
animation?: string
|
||||
accordian: AccordianBlock
|
||||
price?: {
|
||||
heading?: string
|
||||
priceCard: CardBlock
|
||||
msCard: CardBlock
|
||||
},
|
||||
blocks?: Block[]
|
||||
}
|
||||
|
||||
export {
|
||||
type ProductDetailBlock as default
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { CHAIN_INFO, EXPLORER_API } from '@/lib/chains'
|
||||
import type { ExplorerTransfer } from '@/lib/explorer'
|
||||
|
||||
function shortenAddress(addr: string): string {
|
||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`
|
||||
}
|
||||
|
||||
function getEventType(method: string): { label: string; color: 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' }
|
||||
}
|
||||
|
||||
interface ActivityRowProps {
|
||||
transfer: ExplorerTransfer
|
||||
chainId: number
|
||||
}
|
||||
|
||||
export function ActivityRow({ transfer, chainId }: ActivityRowProps) {
|
||||
const chainInfo = CHAIN_INFO[chainId]
|
||||
const apiBase = EXPLORER_API[chainId]
|
||||
const explorerBase = apiBase?.replace('/api/v2', '') ?? 'https://explore.lux.network'
|
||||
const event = getEventType(transfer.method)
|
||||
const isMint = transfer.from.hash === '0x0000000000000000000000000000000000000000'
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{/* 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,
|
||||
}}
|
||||
>
|
||||
{isMint ? 'Mint' : event.label}
|
||||
</div>
|
||||
|
||||
{/* Item */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Link
|
||||
href={`/nft/${chainId}/${transfer.token.address}/${tokenId}`}
|
||||
style={{
|
||||
color: 'var(--foreground)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{transfer.token.name} #{tokenId}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* From → To */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--muted)', flexShrink: 0 }}>
|
||||
<a
|
||||
href={`${explorerBase}/address/${transfer.from.hash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--muted)', textDecoration: 'none', fontFamily: 'monospace', fontSize: 12 }}
|
||||
>
|
||||
{isMint ? 'NullAddress' : shortenAddress(transfer.from.hash)}
|
||||
</a>
|
||||
<span style={{ fontSize: 10 }}>→</span>
|
||||
<a
|
||||
href={`${explorerBase}/address/${transfer.to.hash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--muted)', textDecoration: 'none', fontFamily: 'monospace', fontSize: 12 }}
|
||||
>
|
||||
{shortenAddress(transfer.to.hash)}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Chain */}
|
||||
{chainInfo && (
|
||||
<div style={{ fontSize: 11, color: chainInfo.color, fontWeight: 600, width: 40, textAlign: 'right', flexShrink: 0 }}>
|
||||
{chainInfo.name}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time */}
|
||||
<div style={{ color: 'var(--muted)', fontSize: 12, width: 80, textAlign: 'right', flexShrink: 0 }}>
|
||||
{transfer.timestamp ? new Date(transfer.timestamp).toLocaleDateString() : '---'}
|
||||
</div>
|
||||
|
||||
{/* Tx link */}
|
||||
<a
|
||||
href={`${explorerBase}/tx/${transfer.tx_hash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: 'var(--muted)',
|
||||
textDecoration: 'none',
|
||||
fontSize: 11,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="View on explorer"
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { CHAIN_INFO } from '@/lib/chains'
|
||||
import type { ExplorerToken } from '@/lib/explorer'
|
||||
|
||||
interface CollectionCardProps {
|
||||
token: ExplorerToken
|
||||
chainId: number
|
||||
rank?: number
|
||||
}
|
||||
|
||||
export function CollectionCard({ token, chainId, rank }: CollectionCardProps) {
|
||||
const chainInfo = CHAIN_INFO[chainId]
|
||||
|
||||
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)'
|
||||
}}
|
||||
>
|
||||
{rank !== undefined && (
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--muted)', width: 24, textAlign: 'center' }}>
|
||||
{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',
|
||||
}}
|
||||
>
|
||||
{!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',
|
||||
}}
|
||||
>
|
||||
{token.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 4, fontSize: 12, color: 'var(--muted)' }}>
|
||||
<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 }}>
|
||||
{token.holders ? Number(token.holders).toLocaleString() : '---'} holders
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{token.total_supply ? Number(token.total_supply).toLocaleString() : '---'} items
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { ConnectButton } from '@rainbow-me/rainbowkit'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useChainContext } from '@/hooks/useChain'
|
||||
import { CHAIN_INFO } from '@/lib/chains'
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ href: '/', label: 'Explore' },
|
||||
{ href: '/collections', label: 'Collections' },
|
||||
{ href: '/activity', label: 'Activity' },
|
||||
{ href: '/genesis', label: 'Genesis' },
|
||||
{ href: '/portfolio', label: 'Portfolio' },
|
||||
]
|
||||
|
||||
const CHAIN_IDS = [96369, 200200, 36963, 36911, 494949]
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname()
|
||||
const { chainId, setChainId } = useChainContext()
|
||||
|
||||
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 }}>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
Lux Market
|
||||
</Link>
|
||||
<nav style={{ display: 'flex', gap: 4 }}>
|
||||
{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',
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{/* Chain selector */}
|
||||
<div style={{ display: 'flex', gap: 2, background: 'var(--card)', borderRadius: 8, padding: 2 }}>
|
||||
{CHAIN_IDS.map((id) => {
|
||||
const info = CHAIN_INFO[id]
|
||||
const isActive = chainId === id
|
||||
return (
|
||||
<button
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{info.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<a
|
||||
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,
|
||||
}}
|
||||
>
|
||||
Exchange
|
||||
</a>
|
||||
|
||||
<ConnectButton showBalance={false} chainStatus="icon" accountStatus="avatar" />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
interface ListingFormProps {
|
||||
contractAddress: Address
|
||||
tokenId: string
|
||||
}
|
||||
|
||||
export function ListingForm({ contractAddress, tokenId }: ListingFormProps) {
|
||||
const [price, setPrice] = useState('')
|
||||
const [duration, setDuration] = useState('7') // days
|
||||
const chainId = useChainId()
|
||||
const { address } = useAccount()
|
||||
const { writeContract, isPending } = useWriteContract()
|
||||
|
||||
const contracts = CONTRACTS[chainId]
|
||||
if (!contracts) return null
|
||||
|
||||
const handleApproveAndList = async () => {
|
||||
if (!address || !price) return
|
||||
|
||||
// First approve the Market contract
|
||||
writeContract({
|
||||
address: contractAddress,
|
||||
abi: ERC721_ABI,
|
||||
functionName: 'approve',
|
||||
args: [contracts.markets, BigInt(tokenId)],
|
||||
})
|
||||
}
|
||||
|
||||
const handleList = () => {
|
||||
if (!price) return
|
||||
const durationSeconds = BigInt(Number(duration) * 86400)
|
||||
|
||||
writeContract({
|
||||
address: contracts.markets,
|
||||
abi: MARKET_ABI,
|
||||
functionName: 'list',
|
||||
args: [
|
||||
contractAddress,
|
||||
BigInt(tokenId),
|
||||
'0x0000000000000000000000000000000000000000' as Address, // native LUX
|
||||
parseEther(price),
|
||||
durationSeconds,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600 }}>List for Sale</h3>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 4 }}>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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 4 }}>
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<option value="1">1 day</option>
|
||||
<option value="3">3 days</option>
|
||||
<option value="7">7 days</option>
|
||||
<option value="14">14 days</option>
|
||||
<option value="30">30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
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
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { formatEther } from 'viem'
|
||||
import { getNftImageUrl, type ExplorerTokenInstance } from '@/lib/explorer'
|
||||
import { CHAIN_INFO } from '@/lib/chains'
|
||||
|
||||
export interface NFTCardProps {
|
||||
contractAddress: string
|
||||
tokenId: string
|
||||
name?: string
|
||||
imageUrl?: string
|
||||
collectionName?: string
|
||||
price?: bigint
|
||||
chainId: number
|
||||
instance?: ExplorerTokenInstance
|
||||
}
|
||||
|
||||
export function NFTCard({ contractAddress, tokenId, name, imageUrl, collectionName, price, chainId, instance }: NFTCardProps) {
|
||||
const resolvedImage = instance ? getNftImageUrl(instance) : imageUrl
|
||||
const resolvedName = instance?.metadata?.name ?? name
|
||||
const resolvedCollection = instance?.token?.name ?? collectionName
|
||||
const chainInfo = CHAIN_INFO[chainId]
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/nft/${chainId}/${contractAddress}/${tokenId}`}
|
||||
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)'
|
||||
e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border)'
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ aspectRatio: '1', position: 'relative', background: '#111' }}>
|
||||
{resolvedImage ? (
|
||||
<Image
|
||||
src={resolvedImage}
|
||||
alt={resolvedName ?? `#${tokenId}`}
|
||||
fill
|
||||
style={{ objectFit: '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',
|
||||
}}
|
||||
>
|
||||
#{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,
|
||||
}}
|
||||
>
|
||||
{chainInfo.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ padding: '10px 12px' }}>
|
||||
{resolvedCollection && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{resolvedCollection}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
marginTop: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{resolvedName ?? `#${tokenId}`}
|
||||
</div>
|
||||
{price !== undefined && price > 0n && (
|
||||
<div style={{ fontSize: 13, color: 'var(--accent)', marginTop: 6, fontWeight: 500 }}>
|
||||
{formatEther(price)} {chainInfo?.symbol ?? 'LUX'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { parseEther, type Address } from 'viem'
|
||||
import { useWriteContract, useChainId } from 'wagmi'
|
||||
import { CONTRACTS, MARKET_ABI } from '@/lib/contracts'
|
||||
|
||||
interface OfferFormProps {
|
||||
contractAddress: Address
|
||||
tokenId: string
|
||||
}
|
||||
|
||||
export function OfferForm({ contractAddress, tokenId }: OfferFormProps) {
|
||||
const [amount, setAmount] = useState('')
|
||||
const [duration, setDuration] = useState('3')
|
||||
const chainId = useChainId()
|
||||
const { writeContract, isPending } = useWriteContract()
|
||||
|
||||
const contracts = CONTRACTS[chainId]
|
||||
if (!contracts) return null
|
||||
|
||||
const handleOffer = () => {
|
||||
if (!amount) return
|
||||
const durationSeconds = BigInt(Number(duration) * 86400)
|
||||
|
||||
writeContract({
|
||||
address: contracts.markets,
|
||||
abi: MARKET_ABI,
|
||||
functionName: 'makeOffer',
|
||||
args: [
|
||||
contractAddress,
|
||||
BigInt(tokenId),
|
||||
'0x0000000000000000000000000000000000000000' as Address,
|
||||
parseEther(amount),
|
||||
durationSeconds,
|
||||
],
|
||||
value: parseEther(amount),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600 }}>Make an Offer</h3>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 4 }}>
|
||||
Offer Amount (LUX)
|
||||
</label>
|
||||
<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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 4 }}>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,
|
||||
}}
|
||||
>
|
||||
<option value="1">1 day</option>
|
||||
<option value="3">3 days</option>
|
||||
<option value="7">7 days</option>
|
||||
<option value="14">14 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchCollections } from '@/hooks/useNFTData'
|
||||
import { useChainContext } from '@/hooks/useChain'
|
||||
|
||||
export function SearchBar() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { chainId } = useChainContext()
|
||||
const { data: results } = useSearchCollections(chainId, query)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
const nftResults = results?.items?.filter(
|
||||
(r: { type: string }) => r.type === 'ERC-721' || r.type === 'ERC-1155'
|
||||
) ?? []
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', width: '100%', maxWidth: 480 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setIsOpen(true)
|
||||
}}
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
{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)',
|
||||
}}
|
||||
>
|
||||
{nftResults.map((item: { address: string; name: string; symbol: string; type: string; icon_url: string | null }) => (
|
||||
<Link
|
||||
key={item.address}
|
||||
href={`/collection/${chainId}/${item.address}`}
|
||||
onClick={() => {
|
||||
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')}
|
||||
>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{!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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { NFTTrait } from '@/lib/explorer'
|
||||
|
||||
interface TraitGroup {
|
||||
trait_type: string
|
||||
values: { value: string; count: number }[]
|
||||
}
|
||||
|
||||
interface TraitFilterProps {
|
||||
instances: Array<{ metadata?: { attributes?: NFTTrait[] } | null }>
|
||||
selectedTraits: Record<string, Set<string>>
|
||||
onTraitToggle: (traitType: string, value: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function TraitFilter({ instances, selectedTraits, onTraitToggle, onClear }: TraitFilterProps) {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
// Aggregate trait groups
|
||||
const traitGroups: TraitGroup[] = []
|
||||
const groupMap = new Map<string, Map<string, number>>()
|
||||
|
||||
for (const inst of instances) {
|
||||
const attrs = inst.metadata?.attributes
|
||||
if (!attrs) continue
|
||||
for (const attr of attrs) {
|
||||
if (!attr.trait_type) continue
|
||||
let valueMap = groupMap.get(attr.trait_type)
|
||||
if (!valueMap) {
|
||||
valueMap = new Map()
|
||||
groupMap.set(attr.trait_type, valueMap)
|
||||
}
|
||||
const val = String(attr.value)
|
||||
valueMap.set(val, (valueMap.get(val) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [trait_type, valueMap] of groupMap) {
|
||||
const values = Array.from(valueMap.entries())
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
traitGroups.push({ trait_type, values })
|
||||
}
|
||||
traitGroups.sort((a, b) => a.trait_type.localeCompare(b.trait_type))
|
||||
|
||||
const hasSelected = Object.values(selectedTraits).some((s) => s.size > 0)
|
||||
|
||||
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>
|
||||
{hasSelected && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--accent)',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{traitGroups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.trait_type)
|
||||
const selected = selectedTraits[group.trait_type]
|
||||
const activeCount = selected?.size ?? 0
|
||||
|
||||
return (
|
||||
<div key={group.trait_type} style={{ marginBottom: 4 }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = new Set(expandedGroups)
|
||||
if (isExpanded) next.delete(group.trait_type)
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{group.trait_type}
|
||||
{activeCount > 0 && (
|
||||
<span style={{ marginLeft: 6, color: 'var(--accent)', fontSize: 11 }}>({activeCount})</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{isExpanded ? '\u25B2' : '\u25BC'}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div style={{ paddingLeft: 4, paddingBottom: 8, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{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',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{v.value}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, flexShrink: 0, marginLeft: 8 }}>{v.count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { Button } from '@hanzo/ui/primitives'
|
||||
import { cn } from '@hanzo/ui/util'
|
||||
|
||||
import { BuyButton } from '@luxfi/ui'
|
||||
|
||||
|
||||
const ProductDetailCTA: React.FC<{
|
||||
readMoreUrl: string,
|
||||
skuPath: string,
|
||||
className?: string
|
||||
}> = ({
|
||||
readMoreUrl,
|
||||
skuPath,
|
||||
className
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className={cn('w-full flex justify-center items-center gap-4', className)}>
|
||||
<Button onClick={() => router.push(readMoreUrl)} variant='outline' className='w-40'>Read more</Button>
|
||||
<BuyButton skuPath={skuPath} className='w-40'>Buy</BuyButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProductDetailCTA
|
||||
@@ -1,182 +0,0 @@
|
||||
|
||||
import { type CTABlock} from '@hanzo/ui/blocks'
|
||||
|
||||
import { Icons } from '@luxfi/ui'
|
||||
|
||||
const SOC_ICON_SIZE = 18
|
||||
|
||||
export default [
|
||||
{
|
||||
blockType: 'cta',
|
||||
specifiers: 'nav',
|
||||
elements: [
|
||||
{
|
||||
title: 'Market',
|
||||
href: "https://app.lux.market/",
|
||||
external: true,
|
||||
newTab: false,
|
||||
variant: 'linkFG'
|
||||
},
|
||||
{
|
||||
title: 'Lux Silver',
|
||||
href: '/silver',
|
||||
},
|
||||
{
|
||||
title: 'Lux Gold',
|
||||
href: '/gold',
|
||||
},
|
||||
{
|
||||
title: 'Lux Coin',
|
||||
href: '/coin',
|
||||
},
|
||||
/*{
|
||||
title: 'Lux Uranium',
|
||||
href: '/uranium',
|
||||
}*/,
|
||||
{
|
||||
title: 'Lux Credit',
|
||||
href: '/credit',
|
||||
},
|
||||
{
|
||||
title: 'Lux Validator',
|
||||
href: '/validator',
|
||||
},
|
||||
{
|
||||
title: 'Lux Key',
|
||||
href: '/key',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
blockType: 'cta',
|
||||
elements: [
|
||||
{
|
||||
title: 'Network',
|
||||
href: "https://lux.network/",
|
||||
variant: 'linkFG'
|
||||
},
|
||||
{
|
||||
title: 'Apply for Beta',
|
||||
href: 'https://apply.lux.network/',
|
||||
external: true,
|
||||
newTab: false
|
||||
},
|
||||
{
|
||||
title: 'Whitepapers',
|
||||
href: 'https://docs.lux.network/intro/about',
|
||||
},
|
||||
{
|
||||
title: 'Github',
|
||||
href: 'https://github.com/luxfi',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
blockType: 'cta',
|
||||
elements: [
|
||||
{
|
||||
title: 'Partners',
|
||||
href: "https://lux.partners/",
|
||||
variant: 'linkFG',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
title: 'About',
|
||||
href: 'https://lux.partners',
|
||||
external: true,
|
||||
newTab: false,
|
||||
},
|
||||
{
|
||||
title: 'Partner with Lux',
|
||||
href: 'https://apply.lux.partners/',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
title: 'Lux Fund',
|
||||
href: 'https://lux.fund',
|
||||
newTab: false,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
title: 'Lux Support',
|
||||
href: 'mailto:[email protected]?subject=%E2%96%BC%20Lux%20Support',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
title: 'Careers',
|
||||
href: 'https://docs.lux.network/about/v/lux-job-listings/',
|
||||
newTab: false,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
title: 'Press Kit',
|
||||
href: 'https://drive.google.com/drive/folders/14OJtKLVakGY6883XO9yGbiHtlFxQUUm5?usp=share_link',
|
||||
external: true
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
blockType: 'cta',
|
||||
elements: [
|
||||
{
|
||||
title: 'Community',
|
||||
variant: 'linkFG',
|
||||
},
|
||||
{
|
||||
title: 'Discord',
|
||||
href: 'https://discord.gg/luxdefi',
|
||||
external: true,
|
||||
icon: <Icons.SocialIcon network='discord' size={SOC_ICON_SIZE} />
|
||||
},
|
||||
{
|
||||
title: 'Telegram',
|
||||
href: 'https://t.me/luxdefi',
|
||||
external: true,
|
||||
icon: <Icons.SocialIcon network='telegram' size={SOC_ICON_SIZE} />
|
||||
},
|
||||
{
|
||||
title: 'X (Twitter)',
|
||||
href: 'https://twitter.com/luxdefi',
|
||||
external: true,
|
||||
icon: <Icons.SocialIcon network='x' size={SOC_ICON_SIZE} />
|
||||
},
|
||||
{
|
||||
title: 'Facebook',
|
||||
href: 'https://facebook.com/luxdefi',
|
||||
external: true,
|
||||
icon: <Icons.SocialIcon network='facebook' size={SOC_ICON_SIZE + 2} />
|
||||
},
|
||||
{
|
||||
title: 'LinkedIn',
|
||||
href: 'https://linkedin.com/company/luxdefi',
|
||||
external: true,
|
||||
icon: <Icons.SocialIcon network='linkedin' size={SOC_ICON_SIZE + 2} />
|
||||
},
|
||||
{
|
||||
title: 'Instagram',
|
||||
href: 'https://instagram.com/luxdefi',
|
||||
external: true,
|
||||
icon: <Icons.SocialIcon network='instagram' size={SOC_ICON_SIZE + 2} />
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
blockType: 'cta',
|
||||
elements: [
|
||||
{
|
||||
title: 'Legal',
|
||||
variant: 'linkFG',
|
||||
},
|
||||
{
|
||||
title: 'Terms and Conditions',
|
||||
href: '/assets/pdf/LUX-NFT-Terms-and-Conditions.pdf',
|
||||
newTab: true,
|
||||
},
|
||||
{
|
||||
title: 'Privacy Policy',
|
||||
href: '/assets/pdf/LUX-Privacy-Policy.pdf',
|
||||
newTab: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
] as CTABlock[]
|
||||
@@ -1,25 +0,0 @@
|
||||
import landing from './landing'
|
||||
import footer from './footer'
|
||||
import silver from './products/silver'
|
||||
import gold from './products/gold'
|
||||
import coin from './products/coin'
|
||||
import credit from './products/credit'
|
||||
import validator from './products/validator'
|
||||
import key from './products/key'
|
||||
import uranium from './products/uranium'
|
||||
|
||||
const products = {
|
||||
silver,
|
||||
gold,
|
||||
coin,
|
||||
credit,
|
||||
validator,
|
||||
key,
|
||||
uranium,
|
||||
}
|
||||
|
||||
export {
|
||||
landing,
|
||||
footer,
|
||||
products
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import type BannerBlock from '@/blocks/def/banner-block'
|
||||
|
||||
import silver from '../products/silver/banner'
|
||||
// import gold from '../products/gold/banner'
|
||||
import coin from '../products/coin/banner'
|
||||
import validator from '../products/validator/banner'
|
||||
import credit from '../products/credit/banner'
|
||||
import key from '../products/key/banner'
|
||||
// import uranium from '../products/uranium/banner'
|
||||
|
||||
import last from './last'
|
||||
|
||||
|
||||
export default {
|
||||
|
||||
banners: [
|
||||
silver,
|
||||
//gold,
|
||||
credit,
|
||||
validator,
|
||||
coin,
|
||||
// uranium,
|
||||
key,
|
||||
last
|
||||
] satisfies BannerBlock[],
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { ElementBlock, Block } from '@hanzo/ui/blocks'
|
||||
|
||||
import { BuyButton } from '@luxfi/ui'
|
||||
|
||||
import type BannerBlock from '@/blocks/def/banner-block'
|
||||
|
||||
import silver from '../products/silver/banner'
|
||||
import gold from '../products/gold/banner'
|
||||
import coin from '../products/coin/banner'
|
||||
import validator from '../products/validator/banner'
|
||||
import credit from '../products/credit/banner'
|
||||
import key from '../products/key/banner'
|
||||
// import uranium from '../products/uranium/banner'
|
||||
|
||||
import last from './last'
|
||||
|
||||
//import BottomMDX from './landing-bottom.mdx'
|
||||
|
||||
|
||||
export default {
|
||||
|
||||
banners: [
|
||||
silver,
|
||||
//gold,
|
||||
credit,
|
||||
validator,
|
||||
coin,
|
||||
// uranium,
|
||||
key,
|
||||
last
|
||||
] satisfies BannerBlock[],
|
||||
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
#### Join the global rush to own silver
|
||||
## THE SILVER RUSH IS HERE
|
||||
@@ -1,8 +0,0 @@
|
||||
import type BannerBlock from '@/blocks/def/banner-block'
|
||||
|
||||
export default {
|
||||
blockType: 'banner',
|
||||
title: 'THE SILVER RUSH IS HERE',
|
||||
bylineBefore: 'Join the global rush to own silver!',
|
||||
skuPath: 'LXM-AG'
|
||||
} satisfies BannerBlock
|
||||
@@ -1,23 +0,0 @@
|
||||
import LuxCoinTokenomicsMDX from './lux-coin-tokenomics.mdx'
|
||||
|
||||
export default {
|
||||
blockType: 'accordian',
|
||||
items: [
|
||||
{
|
||||
trigger: 'Get LUX pre-launch',
|
||||
content: 'Buy the currency that powers all network transactions as a genesis wallet, pre-loaded with a set amount of LUX Coin.'
|
||||
},
|
||||
{
|
||||
trigger: 'Stake and earn',
|
||||
content: 'Stake LUX Coin in your Lux Wallet to earn an industry leading APY for lending and network liquidity.'
|
||||
},
|
||||
{
|
||||
trigger: 'Automatic DAO membership',
|
||||
content: 'Purchase a genesis LUX Coin wallet and become a member of the Lux Network decentralized governance organization.'
|
||||
},
|
||||
{
|
||||
trigger: 'LUX Coin tokenomics',
|
||||
content: <LuxCoinTokenomicsMDX />
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
* **Deflationary**: LUX Coin is deflationary by burning or deleting 50% of all transaction fees to ensure enduring currency value.
|
||||
* **Supply-locked**: LUX Coin is scarce whereby no more LUX can ever be minted or created beyond the 200 billion genesis coins already minted.
|
||||
* **LUX Coin distribution**:<br/>Total Supply: 200 billion<br/>DAO: 100 billion<br/>Public Sale: 80 billion<br/>Lux Team: 10 billion<br/>Lux Treasury: 10 billion
|
||||
@@ -1,11 +0,0 @@
|
||||
import type BannerBlock from '@/blocks/def/banner-block'
|
||||
import video from './video'
|
||||
|
||||
export default {
|
||||
blockType: 'banner',
|
||||
title: 'LUX COIN',
|
||||
byline: <>Genesis Collection now available <br className='sm:hidden' /> on Open Sea.</>,
|
||||
video: {...video, sizing: { vh: 50}},
|
||||
learnLink: { href: "/coin" },
|
||||
skuPath: 'LXM-CN'
|
||||
} satisfies BannerBlock
|
||||
@@ -1,19 +0,0 @@
|
||||
import type * as C from '@hanzo/ui/blocks'
|
||||
import type ProductDetailBlock from '@/blocks/def/product-detail-block'
|
||||
|
||||
import video from './video'
|
||||
import accordian from './accordian'
|
||||
import Link from 'next/link'
|
||||
|
||||
import ProductDetailCTA from '@/components/product-detail-cta'
|
||||
|
||||
export default {
|
||||
blockType: 'product-detail',
|
||||
title: 'LUX COIN',
|
||||
desc: <p>One-time opportunity to get LUX Coin, the deflationary and supply-locked <Link href="http://lux.network">Lux Network</Link> currency at pre-launch prices.</p>,
|
||||
video: {...video, sizing: { vh: 70}},
|
||||
accordian,
|
||||
blocks: [{blockType: 'element',
|
||||
element: <ProductDetailCTA readMoreUrl='https://docs.lux.network' skuPath='LXM-CN' />
|
||||
} satisfies C.ElementBlock as C.Block]
|
||||
} as ProductDetailBlock
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { VideoBlock } from '@hanzo/ui/blocks'
|
||||
import { DEF_VIDEO_PROPS } from '@luxfi/data'
|
||||
|
||||
export default {
|
||||
blockType: 'video',
|
||||
videoProps: DEF_VIDEO_PROPS,
|
||||
poster: 'https://cdn.lux.network/commerce/cn/product/LUX-COIN-poster.jpg',
|
||||
sources: [
|
||||
'https://cdn.lux.network/commerce/cn/product/LUX-COIN-transcode.mp4',
|
||||
'https://cdn.lux.network/commerce/cn/product/LUX-COIN-transcode.webm'
|
||||
],
|
||||
// Determin aspect ration from dims manually...
|
||||
// https://stackoverflow.com/questions/684015/how-can-i-get-the-resolution-width-and-height-for-a-video-file-from-a-linux-co
|
||||
dim: {
|
||||
// dims are 546x540, let's cut in half
|
||||
// ratio: 1.01
|
||||
|
||||
md: {
|
||||
w: 273,
|
||||
h: 270
|
||||
},
|
||||
|
||||
lg: {
|
||||
w: 415,
|
||||
h: 410
|
||||
}
|
||||
}
|
||||
} as VideoBlock
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ButtonModalDef } from '@hanzo/ui/types'
|
||||
import { ContactDialog } from '@luxfi/ui'
|
||||
const saveToWaitlist = async () => ({ success: true })
|
||||
|
||||
export default {
|
||||
Comp: ContactDialog,
|
||||
title: 'Join the Waitlist',
|
||||
byline: 'Be the first to own LUX Coin',
|
||||
action: saveToWaitlist,
|
||||
actionEnclosure: {
|
||||
dbId: 'lux-waitlist',
|
||||
listId: 'coin',
|
||||
reply: "You're on the LUX Coin Waitlist."
|
||||
}
|
||||
} satisfies ButtonModalDef
|
||||
@@ -1,25 +0,0 @@
|
||||
export default {
|
||||
blockType: 'accordian',
|
||||
items: [
|
||||
{
|
||||
trigger: 'Direct Ownership of Digital Assets',
|
||||
content: 'Maintain sovereign ownership of your digital assets with the security and transparency of blockchain technology.',
|
||||
},
|
||||
{
|
||||
trigger: 'Easy Asset Management',
|
||||
content: 'Manage, trade, and convert your digital assets effortlessly with the intuitive Lux Card interface.',
|
||||
},
|
||||
{
|
||||
trigger: 'Lux Standard Guaranteed Backing',
|
||||
content: 'Each transaction and asset held on the Lux Card is backed by the Lux Standard, ensuring a trustworthy and reliable experience.',
|
||||
},
|
||||
{
|
||||
trigger: 'Enhanced Security',
|
||||
content: 'Experience peace of mind with advanced security features ensuring the safety of your assets.',
|
||||
},
|
||||
{
|
||||
trigger: 'DAO membership included',
|
||||
content: 'Automatically become a member of the Lux Network decentralized governance organization.'
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import type BannerBlock from '@/blocks/def/banner-block'
|
||||
import type { LinkDef } from '@hanzo/ui/types'
|
||||
import video from './video'
|
||||
|
||||
export default {
|
||||
blockType: 'banner',
|
||||
title: 'LUX CREDIT',
|
||||
byline: 'Never sell your crypto again.',
|
||||
video: {...video, sizing: {vh:50}},
|
||||
learnLink: { href: "https://lux.credit", newTab: false } satisfies LinkDef,
|
||||
skuPath: 'LXM-CR'
|
||||
} satisfies BannerBlock
|
||||
@@ -1,35 +0,0 @@
|
||||
import type * as C from '@hanzo/ui/blocks'
|
||||
import type ProductDetailBlock from '@/blocks/def/product-detail-block'
|
||||
|
||||
//import video from './video'
|
||||
import accordian from './accordian'
|
||||
//import modal from './waitlist-modal'
|
||||
|
||||
export default {
|
||||
blockType: 'product-detail',
|
||||
title: 'LUX CREDIT',
|
||||
desc: 'Unlock a world of DeFi with the Lux Credit Card, and spend while saving in the Lux ecosystem.',
|
||||
//video: {...video, sizing: { vh: 60 }},
|
||||
accordian,
|
||||
blocks: [
|
||||
{
|
||||
blockType: 'cta',
|
||||
elements: [
|
||||
{
|
||||
href: 'https://lux.credit',
|
||||
title: 'Read More',
|
||||
external: true,
|
||||
newTab: false,
|
||||
variant: 'outline'
|
||||
},
|
||||
{
|
||||
title: 'Buy Now',
|
||||
href: "https://lux.credit/cards/black?sku=LXM-CR-B-ABT",
|
||||
external: true,
|
||||
newTab: false,
|
||||
variant: 'primary'
|
||||
},
|
||||
]
|
||||
} as C.CTABlock
|
||||
] as C.Block[]
|
||||
} as ProductDetailBlock
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { VideoBlock } from '@hanzo/ui/blocks'
|
||||
import { DEF_VIDEO_PROPS } from '@luxfi/data'
|
||||
|
||||
export default {
|
||||
blockType: 'video',
|
||||
videoProps: DEF_VIDEO_PROPS,
|
||||
poster: 'https://cdn.lux.network/commerce/cr/product/black-f-700x441.jpg',
|
||||
sources: ['https://cdn.lux.network/commerce/cr/product/gunmetal-card.mp4'],
|
||||
// From manually looking at aspect ratio
|
||||
// https://stackoverflow.com/questions/684015/how-can-i-get-the-resolution-width-and-height-for-a-video-file-from-a-linux-co
|
||||
dim: {
|
||||
// 1920x1080
|
||||
// .67 ratio
|
||||
sm: {
|
||||
// 1/4 * md
|
||||
w: 145,
|
||||
h: 82
|
||||
},
|
||||
|
||||
md:{
|
||||
w: 581,
|
||||
h: 327
|
||||
},
|
||||
|
||||
lg:{
|
||||
w: 728,
|
||||
h: 410
|
||||
}
|
||||
}
|
||||
} as VideoBlock
|
||||
@@ -1,21 +0,0 @@
|
||||
export default {
|
||||
blockType: 'accordian',
|
||||
items: [
|
||||
{
|
||||
trigger: 'Direct Ownership of Gold',
|
||||
content: 'Acquire sovereign ownership of physical gold through Lux Gold NFTs, each representing a specific amount of gold. Your ownership is recorded on the blockchain, ensuring transparency and security.'
|
||||
},
|
||||
{
|
||||
trigger: 'Lux Standard Guaranteed Backing',
|
||||
content: 'Every Lux Gold NFT is backed 1:1 by physical gold, adhering to the Lux Standard of guaranteed backing. Experience the confidence of true asset-backed ownership.'
|
||||
},
|
||||
{
|
||||
trigger: 'Hold securely and borrow',
|
||||
content: 'Retain your Lux Gold securely within our digital vault. Borrow against your holdings with favorable terms through our platform, providing financial flexibility.'
|
||||
},
|
||||
{
|
||||
trigger: 'Sell, stake, or automatically convert',
|
||||
content: 'Trade your Lux Gold NFTs within our ecosystem, stake them to earn rewards, or set preferences to automatically convert based on market conditions.'
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type BannerBlock from '@/blocks/def/banner-block'
|
||||
import video from './video'
|
||||
|
||||
export default {
|
||||
blockType: 'banner',
|
||||
title: 'LUX GOLD',
|
||||
byline: <>Responsibly manufactured, <br className='sm:hidden'/>ethically sourced.</>,
|
||||
video: {...video, sizing: { vh: 50}},
|
||||
learnLink: { href: "/gold" },
|
||||
} satisfies BannerBlock
|
||||
@@ -1,86 +0,0 @@
|
||||
import type * as C from '@hanzo/ui/blocks'
|
||||
import { formatCurrencyValue } from '@hanzo/commerce'
|
||||
|
||||
import { MiniChart } from '@luxfi/ui'
|
||||
import { bullionPrice1oz } from '@luxfi/data/commerce'
|
||||
|
||||
import type ProductDetailBlock from '@/blocks/def/product-detail-block'
|
||||
import video from './video'
|
||||
import accordian from './accordian'
|
||||
import modal from './waitlist-modal'
|
||||
|
||||
export default {
|
||||
blockType: 'product-detail',
|
||||
title: 'LUX GOLD',
|
||||
desc: "Forge your connection to the real world with Lux Gold, crafted with excellence in Dubai. This extraordinary digital collectable is more than a token, —it's the future of hyper-liquid real-world assets. Lux Gold is a embodiment of timeless wealth, prestige, and the synthesis of tradition and innovation. Fully redeemable for physical gold, Lux Gold goes beyond traditional ownership to where gold's timelessness meets the dynamic opportunities of the digital era. It's a seamless gateway where digital ownership evolves into physical reality, enabling any cryptocurrency to fluently be transmuted to gold, and even earn additional yield by lending and providing liquidity in DeFi.",
|
||||
video: {...video, sizing: { vh: 80 }},
|
||||
accordian,
|
||||
price: {
|
||||
priceCard: {
|
||||
blockType: 'card',
|
||||
title: 'Lux Gold Price / Oz',
|
||||
content: <h4 className='font-heading text-xl sm:text-2xl md:text-lg lg:text-2xl'>{formatCurrencyValue(bullionPrice1oz('au'))}</h4>,
|
||||
},
|
||||
msCard: {blockType: 'card',
|
||||
specifiers: 'full-width',
|
||||
title: 'Market Spot Price / Oz',
|
||||
content: <MiniChart symbol='GOLD' />
|
||||
} satisfies C.CardBlock,
|
||||
},
|
||||
|
||||
blocks: [
|
||||
{
|
||||
blockType: 'cta',
|
||||
elements: [
|
||||
{
|
||||
text: 'Waitlist',
|
||||
props: {
|
||||
variant: 'primary',
|
||||
size: 'lg',
|
||||
},
|
||||
action: {
|
||||
type: 'modal',
|
||||
def: modal
|
||||
}
|
||||
},
|
||||
]
|
||||
} as C.CTABlock,
|
||||
/*
|
||||
{
|
||||
blockType: 'heading',
|
||||
heading: 'Editions'
|
||||
} as C.HeadingBlock,
|
||||
{
|
||||
blockType: 'card',
|
||||
specifiers: 'media-left content-left heading-style-title',
|
||||
title: 'NEXT MINT',
|
||||
content: markdown(`
|
||||
Available: 1,000,100 oz<br/>
|
||||
Price: $2000 / oz<br/>
|
||||
Spot price:: ~$2050 / oz
|
||||
`),
|
||||
video,
|
||||
} as C.CardBlock,
|
||||
{
|
||||
blockType: 'cta',
|
||||
elements: [
|
||||
{
|
||||
title: 'Buy Now',
|
||||
href: "https://mint.lux.network/gold",
|
||||
external: true,
|
||||
newTab: false,
|
||||
},
|
||||
{
|
||||
href: 'https://docs.lux.network',
|
||||
title: 'Read More',
|
||||
external: true,
|
||||
newTab: false,
|
||||
variant: 'outline'
|
||||
}
|
||||
]
|
||||
} as C.CTABlock,
|
||||
*/
|
||||
] as C.Block[]
|
||||
} as ProductDetailBlock
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { VideoBlock } from '@hanzo/ui/blocks'
|
||||
import { DEF_VIDEO_PROPS } from '@luxfi/data'
|
||||
|
||||
export default {
|
||||
blockType: 'video',
|
||||
videoProps: DEF_VIDEO_PROPS,
|
||||
poster: 'https://cdn.lux.network/commerce/gold/product/luxgold-poster.jpg',
|
||||
sources: ['https://cdn.lux.network/commerce/gold/product/luxgold.mp4'],
|
||||
// From manually looking at aspect ratio
|
||||
// https://stackoverflow.com/questions/684015/how-can-i-get-the-resolution-width-and-height-for-a-video-file-from-a-linux-co
|
||||
dim: {
|
||||
// 1282x1920
|
||||
// .67 ratio
|
||||
sm: {
|
||||
// 1/4 * md
|
||||
w: 55,
|
||||
h: 82
|
||||
},
|
||||
|
||||
md:{
|
||||
w: 219,
|
||||
h: 327
|
||||
},
|
||||
|
||||
lg:{
|
||||
w: 275,
|
||||
h: 410
|
||||
}
|
||||
}
|
||||
} as VideoBlock
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ButtonModalDef } from '@hanzo/ui/types'
|
||||
import { ContactDialog } from '@luxfi/ui'
|
||||
const saveToWaitlist = async () => ({ success: true })
|
||||
|
||||
export default {
|
||||
Comp: ContactDialog,
|
||||
title: 'Join the Waitlist',
|
||||
byline: 'Be the first to get access to Lux Gold.',
|
||||
action: saveToWaitlist,
|
||||
actionEnclosure: {
|
||||
dbId: 'lux-waitlist',
|
||||
listId: 'gold',
|
||||
reply: "You're on the LUX Gold Waitlist."
|
||||
}
|
||||
} satisfies ButtonModalDef
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { AccordianBlock } from '@hanzo/ui/blocks'
|
||||
|
||||
export default {
|
||||
blockType: 'accordian',
|
||||
items: [
|
||||
{
|
||||
trigger: 'Get priority access',
|
||||
content: 'Be the first to get access to Lux product launches and special events.'
|
||||
},
|
||||
{
|
||||
trigger: 'LUX Coin as a gift',
|
||||
content: 'Get 1,000,000 LUX Coin, the deflationary and supply-locked Lux Network currency.'
|
||||
},
|
||||
{
|
||||
trigger: 'Apple Wallet Supported',
|
||||
content: 'Lux Key is the first NFT that can be added to your Apple Wallet as a reward card with NFC support at events.'
|
||||
},
|
||||
{
|
||||
trigger: 'Automatic DAO membership',
|
||||
content: 'Lux Key includes a membership to the Lux Network decentralized governance organization.'
|
||||
},
|
||||
]
|
||||
} satisfies AccordianBlock
|
||||
@@ -1,11 +0,0 @@
|
||||
import type BannerBlock from '@/blocks/def/banner-block'
|
||||
import video from './video'
|
||||
|
||||
export default {
|
||||
blockType: 'banner',
|
||||
title: 'LUX KEY',
|
||||
byline: `Unlock access to the world of Lux and join the infinite game.`,
|
||||
video: {...video, sizing: {vh:50}},
|
||||
learnLink: { href: "/key" },
|
||||
skuPath: 'LXM-PS'
|
||||
} satisfies BannerBlock
|
||||