diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 0dca4ae..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fedb6ef --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +BACKPACK_API_URL=https://api.backpack.exchange +PRICE_INDEXER_API_URL=https://price-indexer.workers.madlads.com +BACKPACK_WS_URL=wss://ws.backpack.exchange/ diff --git a/.github/workflows/Ci.yml b/.github/workflows/Ci.yml index 6ff39e2..87d84c1 100644 --- a/.github/workflows/Ci.yml +++ b/.github/workflows/Ci.yml @@ -11,16 +11,20 @@ jobs: runs-on: ubuntu-latest steps: - - name: โฌ‡๏ธ Checkout Repository + - name: Checkout Repository uses: actions/checkout@v4 - - name: โŽ” Setup Node.js - uses: actions/setup-node@v3 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20 + cache: npm - - name: ๐Ÿ“ฆ Install Dependencies + - name: Install Dependencies run: npm ci --ignore-scripts=false - - name: ๐Ÿ”จ Build Project + - name: Lint Project + run: npm run lint + + - name: Build Project run: npm run build diff --git a/.gitignore b/.gitignore index db5ddae..83c7142 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ .next -node_modules \ No newline at end of file +node_modules + +# OS files +.DS_Store +**/.DS_Store + +# env +.env +.env.local diff --git a/app/api/v1/trades/route.ts b/app/api/v1/trades/route.ts new file mode 100644 index 0000000..bd3ca75 --- /dev/null +++ b/app/api/v1/trades/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const symbol = searchParams.get("symbol"); + const limit = searchParams.get("limit") ?? "30"; + + const response = await fetch( + `https://api.backpack.exchange/api/v1/trades?symbol=${symbol}&limit=${limit}`, + { headers: { Accept: "application/json" } } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/globals.css b/app/globals.css index 85258ad..a062247 100644 --- a/app/globals.css +++ b/app/globals.css @@ -52,14 +52,126 @@ } } +/* ===== Futuristic theme tokens ===== */ +:root { + --nx-bg: #06080f; + --nx-bg-soft: #0b1020; + --nx-panel: rgba(18, 24, 42, 0.6); + --nx-border: rgba(120, 160, 255, 0.12); + --nx-cyan: #22d3ee; + --nx-violet: #8b5cf6; + --nx-blue: #3b82f6; + --nx-green: #34d399; + --nx-red: #f43f5e; +} + +html { + scroll-behavior: smooth; +} + body { - background: #222223dc; + background: + radial-gradient(1200px 600px at 80% -10%, rgba(139, 92, 246, 0.12), transparent 60%), + radial-gradient(1000px 500px at 0% 0%, rgba(34, 211, 238, 0.1), transparent 55%), + var(--nx-bg); + color: #e5e7eb; + min-height: 100vh; } @layer utilities { .text-balance { text-wrap: balance; } + + /* Gradient brand text */ + .nx-gradient-text { + background: linear-gradient(90deg, var(--nx-cyan), var(--nx-violet) 60%, var(--nx-blue)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + + /* Glassmorphism panel */ + .nx-glass { + background: var(--nx-panel); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--nx-border); + } + + /* Subtle neon glow */ + .nx-glow { + box-shadow: 0 0 0 1px rgba(120, 160, 255, 0.08), + 0 8px 40px -12px rgba(59, 130, 246, 0.45); + } + .nx-glow-hover { + transition: box-shadow 0.3s ease, transform 0.3s ease; + } + .nx-glow-hover:hover { + box-shadow: 0 0 0 1px rgba(120, 160, 255, 0.25), + 0 12px 48px -10px rgba(139, 92, 246, 0.5); + transform: translateY(-2px); + } + + /* Grid backdrop */ + .nx-grid-bg { + background-image: + linear-gradient(rgba(120, 160, 255, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(120, 160, 255, 0.05) 1px, transparent 1px); + background-size: 48px 48px; + } + + /* Hide scrollbar but keep scroll */ + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .animate-aurora { + animation: nx-aurora 12s ease-in-out infinite alternate; + } + .animate-float { + animation: nx-float 6s ease-in-out infinite; + } + .animate-shimmer { + background-size: 200% 100%; + animation: nx-shimmer 2.5s linear infinite; + } +} + +@keyframes nx-aurora { + 0% { transform: translate3d(0, 0, 0) scale(1); opacity: 0.7; } + 100% { transform: translate3d(0, -20px, 0) scale(1.1); opacity: 1; } +} +@keyframes nx-float { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} +@keyframes nx-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: linear-gradient(var(--nx-violet), var(--nx-cyan)); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; +} +::-webkit-scrollbar-thumb:hover { + background: linear-gradient(var(--nx-cyan), var(--nx-violet)); + background-clip: padding-box; } @theme inline { diff --git a/app/layout.tsx b/app/layout.tsx index de4fbe7..cd4200c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -14,8 +14,17 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Exchange", - description: "Developed by nitin walia", + title: "Nexus โ€” Real-Time Crypto Exchange", + description: + "A high-performance, real-time crypto trading platform with live order books, charts and low-latency market data.", + keywords: ["crypto", "exchange", "trading", "order book", "real-time", "DeFi"], + authors: [{ name: "Yuvraj Singh" }], + openGraph: { + title: "Nexus โ€” Real-Time Crypto Exchange", + description: + "Trade crypto with live order books, real-time charts and low-latency market data.", + type: "website", + }, }; export default function RootLayout({ @@ -24,7 +33,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/app/market/page.tsx b/app/market/page.tsx index 94bf179..cb2c5b2 100644 --- a/app/market/page.tsx +++ b/app/market/page.tsx @@ -5,11 +5,14 @@ import { ColorType, createChart, IChartApi, + LineSeries, UTCTimestamp, } from "lightweight-charts"; import { ChevronLeft, ChevronRight, + Search, + Star, TrendingDown, TrendingUp, } from "lucide-react"; @@ -77,6 +80,30 @@ export interface LineCryptoData { export default function Component() { const [data, setData] = useState(null); + const [search, setSearch] = useState(""); + const [tab, setTab] = useState<"spot" | "favorites">("spot"); + const [favorites, setFavorites] = useState(() => { + if (typeof window === "undefined") return []; + try { + return JSON.parse(localStorage.getItem("nx-favorites") || "[]"); + } catch { + return []; + } + }); + + const toggleFavorite = useCallback((symbol: string) => { + setFavorites((prev) => { + const next = prev.includes(symbol) + ? prev.filter((s) => s !== symbol) + : [...prev, symbol]; + try { + localStorage.setItem("nx-favorites", JSON.stringify(next)); + } catch { + /* ignore */ + } + return next; + }); + }, []); useEffect(() => { @@ -100,10 +127,10 @@ export default function Component() { if (!data) { return ( -
+
{/* Carousel Skeleton */} -
+
@@ -117,7 +144,7 @@ export default function Component() { {/* Three Column Grid Skeleton */}
{[1, 2, 3].map((index) => ( -
+
{[1, 2, 3, 4, 5].map((item) => ( @@ -141,7 +168,7 @@ export default function Component() {
{/* Table Skeleton */} -
+
@@ -218,15 +245,17 @@ export default function Component() { const newEntries = getNewListings(data); const number=0; return ( -
-
+
+
+
+
- +
- window.location.href = "/trade/TATA_INR"} > @@ -253,8 +282,8 @@ const number=0;
- window.location.href = "/trade/TATA_INR"} > @@ -284,54 +313,57 @@ const number=0;
-
-

New

+
+

New

-
-

Top Gainers

+
+

Top Gainers

-
-

Popular

+
+

Popular

-
-
- - Spot - - - Favorites - +
+
+
+ {(["spot", "favorites"] as const).map((t) => ( + + ))} +
+
+ + setSearch(e.target.value)} + placeholder="Search marketsโ€ฆ" + className="h-10 w-full rounded-xl border border-white/10 bg-black/30 pl-9 pr-3 text-sm text-white outline-none transition focus:border-cyan-400/60" + /> +
- +
@@ -435,56 +467,89 @@ interface CryptoTableRowProps { volume: number; change: number; klineData: LineCryptoDataPoint[] | undefined; + isFavorite: boolean; + onToggleFavorite: (symbol: string) => void; } -function CryptoTable({ data }: { data: CombinedCryptoData[] | null }) { +function CryptoTable({ + data, + search, + tab, + favorites, + onToggleFavorite, +}: { + data: CombinedCryptoData[] | null; + search: string; + tab: "spot" | "favorites"; + favorites: string[]; + onToggleFavorite: (symbol: string) => void; +}) { if (!data) return null; + + const query = search.trim().toLowerCase(); + const rows = data + .slice() + .sort((a, b) => b.market_cap - a.market_cap) + .filter((item) => !item.symbol.toLowerCase().includes("usdc")) + .filter((item) => + query + ? item.name.toLowerCase().includes(query) || + item.symbol.toLowerCase().includes(query) + : true + ) + .filter((item) => + tab === "favorites" ? favorites.includes(item.symbol.toUpperCase()) : true + ); + return ( - - - - + + + - - - - - {data - ?.sort((a, b) => b.market_cap - a.market_cap) - .slice(0, -5) - .filter((item) => !item.symbol.toLowerCase().includes("usdc")) - .map((item, index) => { - //Now we have to Take each Item and Match the data from the Table and then - // const data = dataKlines?.find((data:any) => data.symbol === item.symbol); - // if (!data) return null; - - return ( - - ); - })} + + {rows.length === 0 ? ( + + + + ) : ( + rows.map((item) => ( + + )) + )}
- Name - Price - โ†“Market Cap +
NamePrice + Market Cap + 24h Volume + 24h Change + Last 7 Days
+ {tab === "favorites" + ? "No favorites yet โ€” tap the โ˜† on any market to add one." + : "No markets match your search."} +
); @@ -499,6 +564,8 @@ function CryptoTableRow({ volume, change, klineData, + isFavorite, + onToggleFavorite, }: CryptoTableRowProps) { function formatNumber(value: number) { if (value >= 1e12) { @@ -525,11 +592,26 @@ function CryptoTableRow({ } > - { + e.stopPropagation(); + onToggleFavorite(symbol.toUpperCase()); + }} + className="mr-3 text-neutral-500 transition-colors hover:text-amber-300" + > + + + - + {symbol} - ${price} + {formatCurrency(price)} 0 ? "text-green-500" : "text-red-500" + className={`text-right py-4 tabular-nums ${ + change >= 0 ? "text-green-500" : "text-red-500" }`} > - {change} % + {change >= 0 ? "+" : ""} + {change?.toFixed(2)}% +
-
-
- ); + if (!data?.length) + return
; return (
diff --git a/app/page.tsx b/app/page.tsx index 6ce4017..bb15333 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,15 +1,15 @@ -"use client"; - - import Landing from "@/components/ui/Landing"; + export default function Home() { - return ( - <> -
- -
- - +
+ {/* Ambient aurora backdrop */} +
+
+
+
+ +
+
); } diff --git a/app/trade/[market]/page.tsx b/app/trade/[market]/page.tsx index 211f04f..7a569ad 100644 --- a/app/trade/[market]/page.tsx +++ b/app/trade/[market]/page.tsx @@ -1,52 +1,50 @@ -"use client" -import { useParams } from "next/navigation" +"use client"; +import { useParams } from "next/navigation"; import { Depth } from "@/components/ui/depth/Depth"; +import { RecentTrades } from "@/components/ui/depth/RecentTrades"; import { TradeView } from "@/components/ui/Tradeview"; import Appbar from "@/components/ui/Appbar"; -import {SwapUI} from "@/components/ui/SwapUi"; +import { SwapUI } from "@/components/ui/SwapUi"; import { MarketBar } from "@/components/ui/MarketBar"; - export default function Page() { - const { market } = useParams(); -const number =1; - return ( -
-
- {/* MarketBar at the top */} -
-
+ const { market } = useParams(); + const number = 1; + + return ( +
+
+
+ + + {/* Market summary bar */} +
-
- {/* Left half for TradeView */} -
+ {/* Main trading grid */} +
+ {/* Chart */} +
- {/*
*/} + {/* Order book */} +
+ +
- {/* Right half split between Depth and SwapUI */} -
-
- -
- {/* รท
*/} + {/* Recent trades */} +
+
-
-
- {/* */} -
-
- {/* SwapUI at the bottom on mobile, right on larger screens */} -
- + {/* Order entry */} +
+ +
+
); -} \ No newline at end of file +} diff --git a/app/utils/httpClient.tsx b/app/utils/httpClient.tsx index 15d1fdb..34cd8e5 100644 --- a/app/utils/httpClient.tsx +++ b/app/utils/httpClient.tsx @@ -1,6 +1,6 @@ import axios from "axios"; -import { KLine, Ticker, marketData } from "./types"; +import { KLine, Ticker, Trade, marketData } from "./types"; const API_PREFIX = "/api/v1"; @@ -25,6 +25,13 @@ export async function getTicker(market: string) { return tickers.find((t: Ticker) => t.symbol === market) ?? null; } +export async function getTrades(market: string, limit = 30): Promise { + const response = await api.get("/trades", { + params: { symbol: market, limit }, + }); + return Array.isArray(response.data) ? (response.data as Trade[]) : []; +} + export async function getTickers() { const response = await api.get("/tickers"); diff --git a/app/utils/types.tsx b/app/utils/types.tsx index ca95f11..6c9de47 100644 --- a/app/utils/types.tsx +++ b/app/utils/types.tsx @@ -29,9 +29,18 @@ export interface KLine { } export interface marketData{ - + lastPrice:string, symbol:string, priceChangePercent:string +} + +export interface Trade { + id: number; + price: string; + quantity: string; + quoteQuantity: string; + timestamp: number; + isBuyerMaker: boolean; } \ No newline at end of file diff --git a/components/Advertisment.tsx b/components/Advertisment.tsx index 92f746a..c8b641a 100644 --- a/components/Advertisment.tsx +++ b/components/Advertisment.tsx @@ -1,21 +1,57 @@ -import Image from "next/image" +"use client"; -export default function Advertisment(){ - return <> -
-
-
advertisement
-
+import { Repeat, ShieldCheck, Activity, Wallet } from "lucide-react"; +import Reveal from "./ui/Reveal"; -

- Turn your money
into real crypto -

-

Buy & Sell

-

Swap Token

-

Real time monetring

-
-
-
+const FEATURES = [ + { + icon: Wallet, + title: "Buy & Sell instantly", + desc: "Turn your money into real crypto with deep liquidity and tight spreads.", + }, + { + icon: Repeat, + title: "Swap any token", + desc: "Move between assets on any chain with a single, seamless transaction.", + }, + { + icon: Activity, + title: "Real-time monitoring", + desc: "Live order books and charts streamed over low-latency WebSockets.", + }, + { + icon: ShieldCheck, + title: "Secure by design", + desc: "Event-driven architecture built to keep your funds and data safe.", + }, +]; - -} \ No newline at end of file +export default function Advertisment() { + return ( +
+ +

+ Everything you need to{" "} + trade smarter +

+

+ A complete toolkit for the modern on-chain trader. +

+
+ +
+ {FEATURES.map(({ icon: Icon, title, desc }, i) => ( + +
+
+ +
+

{title}

+

{desc}

+
+
+ ))} +
+
+ ); +} diff --git a/components/HeroSection.tsx b/components/HeroSection.tsx index 3c7980e..c79d4c0 100644 --- a/components/HeroSection.tsx +++ b/components/HeroSection.tsx @@ -1,15 +1,165 @@ +"use client"; +import { useRouter } from "next/navigation"; +import { motion } from "framer-motion"; +import { ArrowRight, Zap } from "lucide-react"; -export default function Hero(){ - return ( - <> -
-

Exchange you'll love

-

- Exchange make its safe and esty for you to store,buy and sell
stake,crypto on ony blockchain -

- +const STATS = [ + { label: "24h Volume", value: "$2.4B+" }, + { label: "Markets", value: "150+" }, + { label: "Avg. Latency", value: "<5ms" }, + { label: "Active Traders", value: "1.2M" }, +]; + +const fadeUp = { + hidden: { opacity: 0, y: 24 }, + show: (i: number) => ({ + opacity: 1, + y: 0, + transition: { duration: 0.6, delay: i * 0.1, ease: [0.21, 0.47, 0.32, 0.98] as [number, number, number, number] }, + }), +}; + +export default function Hero() { + const router = useRouter(); + + return ( +
+ + + + + + Real-time order matching ยท Live market data + + + + + The exchange +
+ you'll actually love +
+ + + Store, buy, sell and stake crypto across any blockchain โ€” powered by a + low-latency matching engine and real-time WebSocket feeds. + + + + + + + + {/* Stats */} + + {STATS.map((stat) => ( +
+
+ {stat.value}
- - ); -} \ No newline at end of file +
+ {stat.label} +
+
+ ))} +
+ + {/* Product preview mock */} + +
+ + + + nexus ยท SOL / USDC +
+
+ +
+
+
+ ); +} diff --git a/components/ui/Appbar.tsx b/components/ui/Appbar.tsx index 33c3196..a293739 100644 --- a/components/ui/Appbar.tsx +++ b/components/ui/Appbar.tsx @@ -1,36 +1,60 @@ "use client"; -import { usePathname } from "next/navigation"; -import { PrimaryButton, SuccessButton } from "@/components/ui//core/button" -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import Image from "next/image"; +import { PrimaryButton, SuccessButton } from "@/components/ui/core/button"; -export default function Appbar ({TrueButton} :{TrueButton: number}) { - const route = usePathname(); - const router = useRouter() +export default function Appbar({ TrueButton }: { TrueButton: number }) { + const route = usePathname(); + const router = useRouter(); - return
-
-
-
router.push('/')}> - Exchange -
-
router.push('/market')}> - Markets -
-
router.push('/trade/SOL_USDC')}> - Explore Trade -
-
- {TrueButton==1?( + + +
+ + {TrueButton === 1 ? : null} +
+ ); } -function Button(){ - return
-
- Deposit - Withdraw -
-
-} \ No newline at end of file +function ActionButtons() { + return ( +
+ Deposit + Withdraw +
+ ); +} diff --git a/components/ui/Landing.tsx b/components/ui/Landing.tsx index 0a61c83..7eb6420 100644 --- a/components/ui/Landing.tsx +++ b/components/ui/Landing.tsx @@ -1,30 +1,141 @@ -"use client" -import Image from "next/image" -import { useRouter } from "next/navigation" +"use client"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Menu, X } from "lucide-react"; import Hero from "../HeroSection"; import Advertisment from "../Advertisment"; -export default function Landing(){ - const router = useRouter(); - return <> -
-
- logo -
-
-
    -
  • - router.push("/") - } className="hover:text-gray-300" >Explore
  • -
  • - router.push("/market") - }>Markets
  • -
  • - router.push("/trade/SOL_USDT") - } > Trades
  • -
-
+import MarketsPreview from "./MarketsPreview"; + +const NAV_LINKS = [ + { label: "Explore", path: "/" }, + { label: "Markets", path: "/market" }, + { label: "Trade", path: "/trade/SOL_USDC" }, +]; + +export default function Landing() { + const router = useRouter(); + const [open, setOpen] = useState(false); + + return ( + <> + {/* Navbar */} +
+ + + {open && ( +
+
    + {NAV_LINKS.map((link) => ( +
  • { + router.push(link.path); + setOpen(false); + }} + className="cursor-pointer rounded-lg px-3 py-2 text-neutral-300 hover:bg-white/5 hover:text-white" + > + {link.label} +
  • + ))} +
+
+ )} +
+ + + + + + {/* CTA band */} +
+
+
+
+

+ Ready to trade smarter? +

+

+ Jump into live markets with real-time charts and a low-latency + order book. No sign-up required to explore. +

+ +
- - +
+ + {/* Footer */} +
+
+
+ Nexus logo + + NEXUS + +
+
    + {NAV_LINKS.map((link) => ( +
  • router.push(link.path)} + className="cursor-pointer transition-colors hover:text-white" + > + {link.label} +
  • + ))} +
+

+ ยฉ {new Date().getFullYear()} Nexus ยท Built with Next.js +

+
+
-} \ No newline at end of file + ); +} diff --git a/components/ui/MarketBar.tsx b/components/ui/MarketBar.tsx index 4b10b05..26d0bf1 100644 --- a/components/ui/MarketBar.tsx +++ b/components/ui/MarketBar.tsx @@ -84,13 +84,13 @@ export const MarketBar = ({market}: {market: string}) => { // return
-
+
-

${ticker?.lastPrice}

-

${ticker?.lastPrice}

+

= 0 ? "text-green-500" : "text-red-500"}`}>${ticker?.lastPrice}

+

Last price

24H Change

diff --git a/components/ui/MarketsPreview.tsx b/components/ui/MarketsPreview.tsx new file mode 100644 index 0000000..22be059 --- /dev/null +++ b/components/ui/MarketsPreview.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowUpRight, TrendingDown, TrendingUp } from "lucide-react"; +import { getTickers } from "@/app/utils/httpClient"; +import type { Ticker } from "@/app/utils/types"; +import Reveal from "./Reveal"; + +const FEATURED = ["SOL_USDC", "BTC_USDC", "ETH_USDC", "JUP_USDC"]; + +function formatPrice(value: string) { + const n = Number(value); + if (!Number.isFinite(n)) return "โ€”"; + return n.toLocaleString("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: n < 1 ? 4 : 2, + }); +} + +export default function MarketsPreview() { + const router = useRouter(); + const [tickers, setTickers] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + getTickers() + .then((all) => { + if (!active) return; + const picked = FEATURED.map((s) => + all.find((t) => t.symbol === s) + ).filter(Boolean) as Ticker[]; + setTickers(picked.length ? picked : all.slice(0, 4)); + }) + .catch(() => {}) + .finally(() => active && setLoading(false)); + return () => { + active = false; + }; + }, []); + + return ( +
+ +
+

+ Live markets +

+

+ Real prices, streamed straight from the exchange. +

+
+ +
+ +
+ {(loading ? Array.from({ length: 4 }) : tickers).map((t, i) => { + const ticker = t as Ticker | undefined; + const change = Number(ticker?.priceChangePercent ?? 0) * 100; + const up = change >= 0; + return ( + + + + ); + })} +
+
+ ); +} diff --git a/components/ui/Reveal.tsx b/components/ui/Reveal.tsx new file mode 100644 index 0000000..ca06184 --- /dev/null +++ b/components/ui/Reveal.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { motion } from "framer-motion"; +import type { ReactNode } from "react"; + +export default function Reveal({ + children, + delay = 0, + y = 24, + className, +}: { + children: ReactNode; + delay?: number; + y?: number; + className?: string; +}) { + return ( + + {children} + + ); +} diff --git a/components/ui/SwapUi.tsx b/components/ui/SwapUi.tsx index 09d49ae..bfee5e4 100644 --- a/components/ui/SwapUi.tsx +++ b/components/ui/SwapUi.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState,useEffect } from "react"; -import Image from "next/image" +import { useState, useEffect } from "react"; +import Image from "next/image"; import { getAllInfo } from "@/app/utils/httpClient"; interface CoinData { @@ -8,152 +8,169 @@ interface CoinData { image: string; } -export function SwapUI({ market }: {market: string}) { - const [amount, setAmount] = useState(''); - const [activeTab, setActiveTab] = useState('buy'); - const [type, setType] = useState('limit'); - const [quantity,setquantity]=useState(''); - const[usdcImage,setimage]=useState(""); - useEffect(() => { - //fetch the Image from getAllInfo - async function fetchImage() { - const data: CoinData[] = await getAllInfo(); - const image= data.find((d: CoinData) => d.symbol.toUpperCase() === market.split('_')[0] )?.image - console.log(image) - setimage(image || "") - } - fetchImage(); - }, [market]); +export function SwapUI({ market }: { market: string }) { + const [amount, setAmount] = useState(""); + const [activeTab, setActiveTab] = useState<"buy" | "sell">("buy"); + const [type, setType] = useState<"limit" | "market">("limit"); + const [quantity, setQuantity] = useState(""); + const [tokenImage, setTokenImage] = useState(""); - return
-
-
- - -
-
-
-
- - -
-
-
-
-
-
-

Available Balance

-

36.94 USDC

-
-
-
-

- Price -

-
+ const base = market.split("_")[0]?.toUpperCase() ?? ""; + const isBuy = activeTab === "buy"; - { - setAmount(e.target.value) - }} /> + useEffect(() => { + async function fetchImage() { + try { + const data: CoinData[] = await getAllInfo(); + const image = data.find( + (d: CoinData) => d.symbol.toUpperCase() === base + )?.image; + setTokenImage(image || ""); + } catch (error) { + console.error("Failed to fetch token image:", error); + } + } + fetchImage(); + }, [base]); -
-
- Market Logo -
-
-
-
-
-
-

- Quantity -

-
- { - setquantity(e.target.value); - }} /> -
-
- / -
-
-
-
-

โ‰ˆ 0.00 USDC

-
-
-
- 25% -
-
- 50% -
-
- 75% -
-
- Max -
-
-
- -
-
-
- - -
-
- - -
-
-
-
-
-
-
-} + return ( +
+ {/* Buy / Sell tabs */} +
+ + +
-function LimitButton({ type, setType }: { type: string, setType: (type: string) => void }) { - return
setType('limit')}> -
- Limit -
-
-} + {/* Order type */} +
+ {(["limit", "market"] as const).map((t) => ( + + ))} +
-function MarketButton({ type, setType }: { type: string, setType: (type: string) => void }) { - return
setType('market')}> -
- Market -
-
-} + {/* Balance */} +
+ Available Balance + 36.94 USDC +
-function BuyButton({ activeTab, setActiveTab }: { activeTab: string, setActiveTab: (tab: string) => void }) { - return
setActiveTab('buy')}> -

- Buy -

-
-} + {/* Price */} +
+ {type === "market" ? "Price (Market)" : "Price"} +
+
+ setAmount(e.target.value)} + /> + + USDC + +
+ + {/* Quantity */} +
Quantity
+
+ setQuantity(e.target.value)} + /> + + {tokenImage ? ( + {base} + ) : ( + {base} + )} + +
+ +
โ‰ˆ 0.00 USDC
-function SellButton({ activeTab, setActiveTab }: { activeTab: string, setActiveTab: (tab: string) => void }) { - return
setActiveTab('sell')}> -

- Sell -

+ {/* Percent shortcuts */} +
+ {["25%", "50%", "75%", "Max"].map((p) => ( + + ))} +
+ + {/* Submit */} + + + {/* Options */} +
+ + +
-} \ No newline at end of file + ); +} diff --git a/components/ui/core/button.tsx b/components/ui/core/button.tsx index 020cf3b..3bfb2a9 100644 --- a/components/ui/core/button.tsx +++ b/components/ui/core/button.tsx @@ -1,16 +1,35 @@ - -export const PrimaryButton = ({ children, onClick }: { children: string, onClick?: () => void }) => { - return + ); +}; -} - -export const SuccessButton = ({ children, onClick }: { children: string, onClick?: () => void }) => { - return - -} \ No newline at end of file + ); +}; diff --git a/components/ui/depth/Depth.tsx b/components/ui/depth/Depth.tsx index 83795af..96ad81a 100644 --- a/components/ui/depth/Depth.tsx +++ b/components/ui/depth/Depth.tsx @@ -75,19 +75,24 @@ export function Depth({market }:{market:string}){ },[market]); - return
+ return
+
Order Book
{asks && } - {price &&
{price}
} + {price && ( +
+ {price} +
+ )} { bids && }
} function TableHeader(){ - return
-
Price(USD)
-
Size
-
Total
+ return
+
Price
+
Size
+
Total
} \ No newline at end of file diff --git a/components/ui/depth/RecentTrades.tsx b/components/ui/depth/RecentTrades.tsx new file mode 100644 index 0000000..b39a7f3 --- /dev/null +++ b/components/ui/depth/RecentTrades.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getTrades } from "@/app/utils/httpClient"; +import type { Trade } from "@/app/utils/types"; + +export function RecentTrades({ market }: { market: string }) { + const [trades, setTrades] = useState([]); + + useEffect(() => { + let active = true; + const load = () => + getTrades(market, 30) + .then((t) => active && setTrades(t.slice().reverse())) + .catch(() => {}); + load(); + const id = setInterval(load, 4000); + return () => { + active = false; + clearInterval(id); + }; + }, [market]); + + return ( +
+
Recent Trades
+
+ Price + Size + Time +
+
+ {trades.length === 0 ? ( +
+ Loading tradesโ€ฆ +
+ ) : ( + trades.map((t) => { + const buy = !t.isBuyerMaker; + return ( +
+ + {Number(t.price).toLocaleString("en-US", { + maximumFractionDigits: 4, + })} + + + {Number(t.quantity).toFixed(2)} + + + {new Date(t.timestamp).toLocaleTimeString("en-US", { + hour12: false, + })} + +
+ ); + }) + )} +
+
+ ); +} diff --git a/next.config.ts b/next.config.ts index d92ef3c..8078a76 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,6 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { images: { remotePatterns: [ { diff --git a/package-lock.json b/package-lock.json index 79b436c..6d15b49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "framer-motion": "^12.23.24", "lightweight-charts": "^5.0.9", "lucide-react": "^0.554.0", - "next": "^15.5.2", + "next": "15.5.9", "postcss": "^8.5.6", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -1395,9 +1395,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", - "integrity": "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1411,9 +1411,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.2.tgz", - "integrity": "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", + "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", "cpu": [ "arm64" ], @@ -1427,9 +1427,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.2.tgz", - "integrity": "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", + "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", "cpu": [ "x64" ], @@ -1443,9 +1443,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.2.tgz", - "integrity": "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", + "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", "cpu": [ "arm64" ], @@ -1459,9 +1459,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.2.tgz", - "integrity": "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", + "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", "cpu": [ "arm64" ], @@ -1475,9 +1475,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.2.tgz", - "integrity": "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", + "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", "cpu": [ "x64" ], @@ -1491,9 +1491,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.2.tgz", - "integrity": "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", + "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", "cpu": [ "x64" ], @@ -1507,9 +1507,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.2.tgz", - "integrity": "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", + "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", "cpu": [ "arm64" ], @@ -1523,9 +1523,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.2.tgz", - "integrity": "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==", + "version": "15.5.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", + "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", "cpu": [ "x64" ], @@ -5708,12 +5708,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", - "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "dependencies": { - "@next/env": "15.5.2", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -5726,14 +5726,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.2", - "@next/swc-darwin-x64": "15.5.2", - "@next/swc-linux-arm64-gnu": "15.5.2", - "@next/swc-linux-arm64-musl": "15.5.2", - "@next/swc-linux-x64-gnu": "15.5.2", - "@next/swc-linux-x64-musl": "15.5.2", - "@next/swc-win32-arm64-msvc": "15.5.2", - "@next/swc-win32-x64-msvc": "15.5.2", + "@next/swc-darwin-arm64": "15.5.7", + "@next/swc-darwin-x64": "15.5.7", + "@next/swc-linux-arm64-gnu": "15.5.7", + "@next/swc-linux-arm64-musl": "15.5.7", + "@next/swc-linux-x64-gnu": "15.5.7", + "@next/swc-linux-x64-musl": "15.5.7", + "@next/swc-win32-arm64-msvc": "15.5.7", + "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { diff --git a/package.json b/package.json index 33e35bb..f477ed9 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "framer-motion": "^12.23.24", "lightweight-charts": "^5.0.9", "lucide-react": "^0.554.0", - "next": "^15.5.2", + "next": "15.5.9", "postcss": "^8.5.6", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/readme.md b/readme.md index ddb056a..bd13335 100644 --- a/readme.md +++ b/readme.md @@ -1,95 +1,98 @@ -# โšก Exchange Platform +# โšก Nexus โ€” Real-Time Crypto Exchange -A high-performance, real-time trading system built to handle low-latency order execution at scale. The platform combines modular, event-driven architecture with efficient queuing, real-time communication, and scalable infrastructure. +**Live demo โ†’** [exchange-ruby-iota.vercel.app](https://exchange-ruby-iota.vercel.app) +A futuristic, real-time crypto trading front-end built with **Next.js 15** and **React 19**. Nexus streams live market data over WebSockets, renders interactive candlestick charts and a live order book, and ships a polished glassmorphism UI. +![Markets](https://github.com/user-attachments/assets/fad85667-a4e6-41cc-abee-69e1518931e7) +![Trade](https://github.com/user-attachments/assets/fd768972-2642-48a1-b7e0-a698539ec269) --- -## ๐Ÿš€ Tech Stack +## โœจ Features -### Frontend -- **Next.js & React** โ€“ Dynamic, server-rendered UI -- **Tailwind CSS** โ€“ Fast, responsive styling - -### Backend -- **Node.js & Express** โ€“ High-performance API services -- **PostgreSQL** โ€“ Transactional data storage -- **Time Series DB** โ€“ For high-frequency trade analytics - -### Real-Time Communication -- **WebSockets (Socket.io)** โ€“ Live trade and market updates - -### Queue & Event System -- **Redis** โ€“ Order queuing, Pub/Sub for real-time communication - -### Matching Engine -- **Custom-built in Node.js** โ€“ Fast order matching with event triggers - -### Deployment -- **Railway** โ€“ Cloud deployment +- โšก **Real-time data** โ€” live tickers and order-book depth via a singleton WebSocket manager +- ๐Ÿ“ˆ **Interactive charts** โ€” candlestick + sparkline charts powered by `lightweight-charts` +- ๐Ÿ“Š **Live order book** โ€” animated bid/ask depth bars with running totals +- ๐ŸŽจ **Futuristic UI** โ€” glassmorphism, neon glow, gradient accents and Framer Motion animations +- ๐Ÿ“ฑ **Responsive** โ€” adapts from mobile to ultra-wide trading layouts +- ๐Ÿ”Œ **API proxy layer** โ€” Next.js route handlers proxy market data to avoid CORS --- -## ๐Ÿง  Architecture & Data Flow - -### 1. Order Submission +## ๐Ÿงฑ Tech Stack -- **User Input**: Traders place buy/sell orders via the frontend. -- **API Request**: Order data is sent to `POST /api/v1/order`. -- **Redis Queue**: API validates and enqueues orders for processing. +| Layer | Technology | +| ------------ | -------------------------------------------- | +| Framework | Next.js 15 (App Router), React 19 | +| Language | TypeScript | +| Styling | Tailwind CSS v4, custom design tokens | +| Animation | Framer Motion, tw-animate-css | +| Charts | lightweight-charts | +| Data | Backpack Exchange public API + WebSockets | +| Deployment | Vercel ยท CI via GitHub Actions | -### 2. Order Matching +--- -- **Engine Polling**: A custom-built matching engine polls Redis. -- **Execution**: Orders are matched and trades executed. -- **Event Trigger**: Emits a `trade_created` event via Redis Pub/Sub. +## ๐Ÿ—๏ธ Architecture -### 3. Real-Time Distribution & Storage +```text +Browser โ”€โ”€โ–บ Next.js App Router (pages + API routes) + โ”‚ + โ”œโ”€โ”€ /api/v1/tickers โ”€โ” + โ”œโ”€โ”€ /api/v1/depth โ”œโ”€โ–บ Backpack REST API (proxied) + โ”œโ”€โ”€ /api/v1/klines โ”€โ”˜ + โ”‚ + โ””โ”€โ”€ SignalingManager โ”€โ–บ Backpack WebSocket (live ticker + depth) +``` -- **WebSockets**: Broadcast updates to connected clients instantly. -- **PostgreSQL**: Stores all trade records reliably. -- **Time Series DB**: Logs price/volume data for analytics and visualization. +- **`app/api/v1/*`** โ€” server route handlers proxy REST calls (CORS-safe). +- **`SignalingManager`** โ€” one shared `WebSocket`, message buffering, and per-stream callback registration. +- **`ChartManager` / `CombineData`** โ€” chart lifecycle and merge of price + kline data for the markets table. --- -## โœจ Key Features +## ๐Ÿš€ Getting Started -- โšก **Real-Time Execution** โ€“ Millisecond-level trade processing -- ๐Ÿ“ˆ **Live Market Updates** โ€“ Instant updates via WebSockets -- ๐Ÿ” **Scalable Design** โ€“ Horizontally scalable with decoupled services -- ๐Ÿงฎ **Efficient Storage** โ€“ Dual-database approach for speed and analytics +```bash +# 1. Clone +git clone https://github.com/yuvrajnode/exchange.git +cd exchange ---- +# 2. Install +npm install -## ๐Ÿงฉ Challenges & Solutions +# 3. Configure env (optional โ€” defaults to public Backpack endpoints) +cp .env.example .env -### Handling High Throughput -- **Problem**: Concurrent order spikes -- **Solution**: Redis queue decouples order intake from processing +# 4. Run +npm run dev # http://localhost:3000 +``` -### Ensuring Real-Time Execution -- **Problem**: Trade delay risks -- **Solution**: Optimized matching engine + Redis Pub/Sub + WebSockets +Other scripts: -### Efficient Data Persistence -- **Problem**: High-frequency data impacting performance -- **Solution**: PostgreSQL for core trades, Time Series DB for analytics +```bash +npm run build # production build +npm run start # serve production build +npm run lint # eslint (zero-warning policy) +``` --- -## ๐Ÿ“ฆ Setup Instructions +## ๐Ÿ“ Project Structure -```bash -# 1. Clone the repo -git clone https://github.com/yourusername/exchange-platform.git -cd exchange-platform +```text +app/ + api/v1/ REST proxy route handlers + market/ markets overview page + trade/[market]/ live trading view + utils/ SignalingManager, ChartManager, http client, types +components/ + ui/ Appbar, SwapUI, MarketBar, Depth, charts, core buttons +``` -# 2. Install dependencies -npm install +--- -# 3. Setup environment variables -cp .env.example .env +## ๐Ÿ“ฆ Deployment -# 4. Start the dev server -npm run dev +Deployed on **Vercel**. Every push to `main` runs the GitHub Actions CI pipeline (`lint` + `build`) and triggers a Vercel deployment.