From eefebc5d315fbbc11fb76b1039b120dc9e6e2748 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Sat, 20 Dec 2025 16:09:42 +0530 Subject: [PATCH 01/23] refactor: pages and components --- .vscode/settings.json | 18 +++ app/coins/[id]/page.tsx | 131 +++++++++++++++++- app/coins/page.tsx | 21 +-- app/page.tsx | 127 ++--------------- components/ClickableTableRow.tsx | 18 --- components/CoinDetailCard.tsx | 32 ----- components/CoinDetailsSection.tsx | 58 -------- components/CoinsPagination.tsx | 4 +- components/ExchangeListings.tsx | 73 ---------- components/{ => coin-details}/Converter.tsx | 2 +- .../{ => coin-details}/TopGainersLosers.tsx | 6 +- .../Categories.tsx} | 51 ++++++- .../CoinOverview.tsx} | 23 ++- .../TrendingCoins.tsx} | 40 +++++- types.d.ts | 6 - 15 files changed, 282 insertions(+), 328 deletions(-) create mode 100644 .vscode/settings.json delete mode 100644 components/ClickableTableRow.tsx delete mode 100644 components/CoinDetailCard.tsx delete mode 100644 components/CoinDetailsSection.tsx delete mode 100644 components/ExchangeListings.tsx rename components/{ => coin-details}/Converter.tsx (98%) rename components/{ => coin-details}/TopGainersLosers.tsx (85%) rename components/{CategoriesSection.tsx => home/Categories.tsx} (61%) rename components/{CoinOverviewSection.tsx => home/CoinOverview.tsx} (59%) rename components/{TrendingCoinsSection.tsx => home/TrendingCoins.tsx} (66%) diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..581af7c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.addMissingImports": "explicit" + }, + "prettier.tabWidth": 2, + "prettier.useTabs": false, + "prettier.semi": true, + "prettier.singleQuote": true, + "prettier.jsxSingleQuote": true, + "prettier.trailingComma": "es5", + "prettier.arrowParens": "always", + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/app/coins/[id]/page.tsx b/app/coins/[id]/page.tsx index 1954775..2307851 100644 --- a/app/coins/[id]/page.tsx +++ b/app/coins/[id]/page.tsx @@ -4,20 +4,64 @@ import { fetchPools, fetchTopPool, } from '@/lib/coingecko.actions'; -import { Converter } from '@/components/Converter'; +import { Converter } from '@/components/coin-details/Converter'; import LiveDataWrapper from '@/components/LiveDataWrapper'; -import { ExchangeListings } from '@/components/ExchangeListings'; -import { CoinDetailsSection } from '@/components/CoinDetailsSection'; -import { TopGainersLosers } from '@/components/TopGainersLosers'; +import { TopGainersLosers } from '@/components/coin-details/TopGainersLosers'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { formatPrice, timeAgo } from '@/lib/utils'; +import Link from 'next/link'; +import { ArrowUpRight } from 'lucide-react'; const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => { const { id } = await params; const coinData = await getCoinDetails(id); + const pool = coinData.asset_platform_id ? await fetchTopPool(coinData.asset_platform_id, coinData.contract_address) : await fetchPools(id); + const coinOHLCData = await getCoinOHLC(id, 1, 'usd', 'hourly', 'full'); + const coinDetails = [ + { + label: 'Market Cap', + value: formatPrice(coinData.market_data.market_cap.usd), + }, + { + label: 'Market Cap Rank', + value: `# ${coinData.market_cap_rank}`, + }, + { + label: 'Total Volume', + value: formatPrice(coinData.market_data.total_volume.usd), + }, + { + label: 'Website', + value: '-', + link: coinData.links.homepage[0], + linkText: 'Website', + }, + { + label: 'Explorer', + value: '-', + link: coinData.links.blockchain_site[0], + linkText: 'Explorer', + }, + { + label: 'Community Link', + value: '-', + link: coinData.links.subreddit_url, + linkText: 'Community', + }, + ]; + return (
@@ -27,8 +71,62 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => { coin={coinData} coinOHLCData={coinOHLCData} > - {/* Exchange Listings - pass it as a child of a client component so it will be render server side */} - +
+

Exchange Listings

+
+ + + + + Exchange + + Pair + Price + + Last Traded + + + + + {coinData.tickers + .slice(0, 7) + .map((ticker: Ticker, index: number) => ( + + + + {ticker.market.name} + + + +
+

+ {ticker.base} +

+ / +

+ {ticker.target} +

+
+
+ + {formatPrice(ticker.converted_last.usd)} + + + {timeAgo(ticker.timestamp)} + +
+ ))} +
+
+
+
@@ -41,7 +139,26 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => { /> {/* Coin Details */} - +
+

Coin Details

+
+ {coinDetails.map(({ label, value, link, linkText }, index) => ( +
+

{label}

+ {link ? ( +
+ + {linkText || label} + + +
+ ) : ( +

{value}

+ )} +
+ ))} +
+
{/* Top Gainers / Losers */} diff --git a/app/coins/page.tsx b/app/coins/page.tsx index 6bb1f9f..b2d7d89 100644 --- a/app/coins/page.tsx +++ b/app/coins/page.tsx @@ -8,9 +8,10 @@ import { TableRow, } from '@/components/ui/table'; import Image from 'next/image'; -import { cn, formatPercentage, formatPrice } from '@/lib/utils'; +import Link from 'next/link'; + import CoinsPagination from '@/components/CoinsPagination'; -import { ClickableTableRow } from '@/components/ClickableTableRow'; +import { cn, formatPercentage, formatPrice } from '@/lib/utils'; const Coins = async ({ searchParams, @@ -50,14 +51,16 @@ const Coins = async ({ {coinsData.map((coin: CoinMarketData) => { const isTrendingUp = coin.price_change_percentage_24h > 0; + return ( - + #{coin.market_cap_rank} +
@@ -67,7 +70,7 @@ const Coins = async ({ width={36} height={36} /> -

+

{coin.name} ({coin.symbol.toUpperCase()})

@@ -89,7 +92,7 @@ const Coins = async ({ {formatPrice(coin.market_cap)} -
+ ); })}
diff --git a/app/page.tsx b/app/page.tsx index 879f31b..87b4a14 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,35 +1,34 @@ import { Suspense } from 'react'; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; - -import { Skeleton } from '@/components/ui/skeleton'; -import { CoinOverviewSection } from '@/components/CoinOverviewSection'; -import { CategoriesSection } from '@/components/CategoriesSection'; -import { TrendingCoinsSection } from '@/components/TrendingCoinsSection'; + Categories, + CategoriesFallback, +} from '@/components/home/Categories'; +import { + CoinOverview, + CoinOverviewFallback, +} from '@/components/home/CoinOverview'; +import { + TrendingCoins, + TrendingCoinsFallback, +} from '@/components/home/TrendingCoins'; const Home = () => { return (
}> - + }> - +
}> - +
@@ -37,101 +36,3 @@ const Home = () => { }; export default Home; - -const CoinOverviewFallback = () => ( -
-
-
- -
- - -
-
- -
-
-); - -const TrendingCoinsFallback = () => ( -
-

Trending Coins

-
- - - - Name - - 24h Change - - Price - - - - {Array.from({ length: 6 }).map((_, index) => ( - - -
- - -
-
- - - - - - -
- ))} -
-
-
-
-); - -const CategoriesFallback = () => ( -
-

Top Categories

- - - - Category - Top Gainers - 24h Change - Market Cap - 24h Volume - - - - {Array.from({ length: 6 }).map((_, index) => ( - - - - - - {Array.from({ length: 3 }).map((__, coinIndex) => ( - - ))} - - - - - - - - - - - - ))} - -
-
-); diff --git a/components/ClickableTableRow.tsx b/components/ClickableTableRow.tsx deleted file mode 100644 index ad872c9..0000000 --- a/components/ClickableTableRow.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import { useRouter } from 'next/navigation'; -import { TableRow } from '@/components/ui/table'; - -export function ClickableTableRow({ - href, - children, - className, -}: ClickableTableRowProps) { - const router = useRouter(); - - return ( - router.push(href)}> - {children} - - ); -} diff --git a/components/CoinDetailCard.tsx b/components/CoinDetailCard.tsx deleted file mode 100644 index 9c16771..0000000 --- a/components/CoinDetailCard.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ArrowUpRight } from 'lucide-react'; -import Link from 'next/link'; - -interface CoinDetailCardProps { - label: string; - value: string | number; - link?: string; - linkText?: string; -} - -export default function CoinDetailCard({ - label, - value, - link, - linkText, -}: CoinDetailCardProps) { - return ( -
-

{label}

- {link ? ( -
- - {linkText || label} - - -
- ) : ( -

{value}

- )} -
- ); -} diff --git a/components/CoinDetailsSection.tsx b/components/CoinDetailsSection.tsx deleted file mode 100644 index dbd221e..0000000 --- a/components/CoinDetailsSection.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import CoinDetailCard from './CoinDetailCard'; -import { formatPrice } from '@/lib/utils'; - -export const CoinDetailsSection = ({ - coinData, -}: { - coinData: CoinDetailsData; -}) => { - const coinDetails = [ - { - label: 'Market Cap', - value: formatPrice(coinData.market_data.market_cap.usd), - }, - { - label: 'Market Cap Rank', - value: `# ${coinData.market_cap_rank}`, - }, - { - label: 'Total Volume', - value: formatPrice(coinData.market_data.total_volume.usd), - }, - { - label: 'Website', - value: '-', - link: coinData.links.homepage[0], - linkText: 'Website', - }, - { - label: 'Explorer', - value: '-', - link: coinData.links.blockchain_site[0], - linkText: 'Explorer', - }, - { - label: 'Community Link', - value: '-', - link: coinData.links.subreddit_url, - linkText: 'Community', - }, - ]; - - return ( -
-

Coin Details

-
- {coinDetails.map((detail, index) => ( - - ))} -
-
- ); -}; diff --git a/components/CoinsPagination.tsx b/components/CoinsPagination.tsx index f23847b..621c79b 100644 --- a/components/CoinsPagination.tsx +++ b/components/CoinsPagination.tsx @@ -90,9 +90,9 @@ export default function CoinsPagination({ handlePageChange(page as number)} className={cn( - `hover:!bg-dark-400 rounded-sm text-base cursor-pointer`, + `hover:bg-dark-400! rounded-sm text-base cursor-pointer`, { - '!bg-green-500 text-dark-900 font-semibold': + 'bg-green-500! text-dark-900 font-semibold': currentPage === page, } )} diff --git a/components/ExchangeListings.tsx b/components/ExchangeListings.tsx deleted file mode 100644 index c16818b..0000000 --- a/components/ExchangeListings.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { formatPrice, timeAgo } from '@/lib/utils'; -import Link from 'next/link'; - -export const ExchangeListings = ({ - coinData, -}: { - coinData: CoinDetailsData; -}) => { - return ( -
-

Exchange Listings

-
- - - - Exchange - Pair - Price - - Last Traded - - - - - {coinData.tickers - .slice(0, 7) - .map((ticker: Ticker, index: number) => ( - - - - {ticker.market.name} - - - -
-

- {ticker.base} -

- / -

- {ticker.target} -

-
-
- - {formatPrice(ticker.converted_last.usd)} - - - {timeAgo(ticker.timestamp)} - -
- ))} -
-
-
-
- ); -}; diff --git a/components/Converter.tsx b/components/coin-details/Converter.tsx similarity index 98% rename from components/Converter.tsx rename to components/coin-details/Converter.tsx index 6ee310e..ebf3b47 100644 --- a/components/Converter.tsx +++ b/components/coin-details/Converter.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/select'; import Image from 'next/image'; import { useState } from 'react'; -import { Input } from './ui/input'; +import { Input } from '../ui/input'; import { formatPrice } from '@/lib/utils'; export const Converter = ({ symbol, icon, priceList }: ConverterProps) => { diff --git a/components/TopGainersLosers.tsx b/components/coin-details/TopGainersLosers.tsx similarity index 85% rename from components/TopGainersLosers.tsx rename to components/coin-details/TopGainersLosers.tsx index 3bff957..d5f67fe 100644 --- a/components/TopGainersLosers.tsx +++ b/components/coin-details/TopGainersLosers.tsx @@ -1,5 +1,5 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import CoinCard from './CoinCard'; +import CoinCard from '../CoinCard'; import { getTopGainersLosers } from '@/lib/coingecko.actions'; export const TopGainersLosers = async () => { @@ -10,13 +10,13 @@ export const TopGainersLosers = async () => { Top Gainers Top Losers diff --git a/components/CategoriesSection.tsx b/components/home/Categories.tsx similarity index 61% rename from components/CategoriesSection.tsx rename to components/home/Categories.tsx index 81857d7..0dbb544 100644 --- a/components/CategoriesSection.tsx +++ b/components/home/Categories.tsx @@ -6,12 +6,13 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; import { getCategories } from '@/lib/coingecko.actions'; import { cn, formatPercentage, formatPrice } from '@/lib/utils'; import { TrendingDown, TrendingUp } from 'lucide-react'; import Image from 'next/image'; -export const CategoriesSection = async () => { +export const Categories = async () => { const categories = (await getCategories()) as Category[]; return ( @@ -33,7 +34,7 @@ export const CategoriesSection = async () => { return ( {category.name} @@ -82,3 +83,49 @@ export const CategoriesSection = async () => { ); }; + +export const CategoriesFallback = () => ( +
+

Top Categories

+ + + + Category + Top Gainers + 24h Change + Market Cap + 24h Volume + + + + {Array.from({ length: 6 }).map((_, index) => ( + + + + + + {Array.from({ length: 3 }).map((__, coinIndex) => ( + + ))} + + + + + + + + + + + + ))} + +
+
+); diff --git a/components/CoinOverviewSection.tsx b/components/home/CoinOverview.tsx similarity index 59% rename from components/CoinOverviewSection.tsx rename to components/home/CoinOverview.tsx index 8411f88..e12e7f1 100644 --- a/components/CoinOverviewSection.tsx +++ b/components/home/CoinOverview.tsx @@ -1,9 +1,11 @@ -import { getCoinDetails, getCoinOHLC } from '@/lib/coingecko.actions'; -import CandlestickChart from './CandlestickChart'; import Image from 'next/image'; + +import CandlestickChart from '../CandlestickChart'; +import { Skeleton } from '../ui/skeleton'; +import { getCoinDetails, getCoinOHLC } from '@/lib/coingecko.actions'; import { formatPrice } from '@/lib/utils'; -export const CoinOverviewSection = async () => { +export const CoinOverview = async () => { const [coinData, coinOHLCData] = await Promise.all([ getCoinDetails('bitcoin'), getCoinOHLC('bitcoin', 1, 'usd', 'hourly', 'full'), @@ -33,3 +35,18 @@ export const CoinOverviewSection = async () => { ); }; + +export const CoinOverviewFallback = () => ( +
+
+
+ +
+ + +
+
+ +
+
+); diff --git a/components/TrendingCoinsSection.tsx b/components/home/TrendingCoins.tsx similarity index 66% rename from components/TrendingCoinsSection.tsx rename to components/home/TrendingCoins.tsx index 21b9d25..dc51a81 100644 --- a/components/TrendingCoinsSection.tsx +++ b/components/home/TrendingCoins.tsx @@ -6,13 +6,14 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; import { getTrendingCoins } from '@/lib/coingecko.actions'; import { cn, formatPercentage, formatPrice } from '@/lib/utils'; import { TrendingDown, TrendingUp } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; -export const TrendingCoinsSection = async () => { +export const TrendingCoins = async () => { const trendingCoins = (await getTrendingCoins()) as TrendingCoin[]; return ( @@ -82,3 +83,40 @@ export const TrendingCoinsSection = async () => { ); }; + +export const TrendingCoinsFallback = () => ( +
+

Trending Coins

+
+ + + + Name + + 24h Change + + Price + + + + {Array.from({ length: 6 }).map((_, index) => ( + + +
+ + +
+
+ + + + + + +
+ ))} +
+
+
+
+); diff --git a/types.d.ts b/types.d.ts index 9b3e78a..49e20d6 100644 --- a/types.d.ts +++ b/types.d.ts @@ -252,9 +252,3 @@ interface UseCoinGeckoWebSocketReturn { ohlcv: OHLCData | null; isConnected: boolean; } - -interface ClickableTableRowProps { - href: string; - children: React.ReactNode; - className?: string; -} From 6c0893a47ea992b0785e362ca01948740e007b2b Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Sat, 20 Dec 2025 22:52:42 +0530 Subject: [PATCH 02/23] refactor: data table --- app/coins/[id]/page.tsx | 106 +++++++++++------------ app/coins/page.tsx | 134 +++++++++++++++--------------- components/DataTable.tsx | 58 +++++++++++++ components/LiveDataWrapper.tsx | 92 ++++++++++---------- components/home/Categories.tsx | 129 ++++++++++++++-------------- components/home/TrendingCoins.tsx | 123 ++++++++++++++------------- hooks/useCoinGeckoWebSocket.ts | 11 ++- types.d.ts | 22 ++++- 8 files changed, 372 insertions(+), 303 deletions(-) create mode 100644 components/DataTable.tsx diff --git a/app/coins/[id]/page.tsx b/app/coins/[id]/page.tsx index 2307851..c394b22 100644 --- a/app/coins/[id]/page.tsx +++ b/app/coins/[id]/page.tsx @@ -7,14 +7,7 @@ import { import { Converter } from '@/components/coin-details/Converter'; import LiveDataWrapper from '@/components/LiveDataWrapper'; import { TopGainersLosers } from '@/components/coin-details/TopGainersLosers'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { DataTable } from '@/components/DataTable'; import { formatPrice, timeAgo } from '@/lib/utils'; import Link from 'next/link'; import { ArrowUpRight } from 'lucide-react'; @@ -62,6 +55,44 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => { }, ]; + const exchangeColumns = [ + { + header: 'Exchange', + cellClassName: ' text-green-500 font-bold', + cell: (ticker: Ticker) => ( + + {ticker.market.name} + + ), + }, + { + header: 'Pair', + cell: (ticker: Ticker) => ( +
+

{ticker.base}

+ / +

+ {ticker.target} +

+
+ ), + }, + { + header: 'Price', + cellClassName: 'font-medium', + cell: (ticker: Ticker) => formatPrice(ticker.converted_last.usd), + }, + { + header: 'Last Traded', + cellClassName: 'exchange-timestamp', + cell: (ticker: Ticker) => timeAgo(ticker.timestamp), + }, + ]; + return (
@@ -74,57 +105,14 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => {

Exchange Listings

- - - - - Exchange - - Pair - Price - - Last Traded - - - - - {coinData.tickers - .slice(0, 7) - .map((ticker: Ticker, index: number) => ( - - - - {ticker.market.name} - - - -
-

- {ticker.base} -

- / -

- {ticker.target} -

-
-
- - {formatPrice(ticker.converted_last.usd)} - - - {timeAgo(ticker.timestamp)} - -
- ))} -
-
+ index} + headerClassName='text-purple-100' + headerRowClassName='hover:bg-transparent' + bodyRowClassName='overflow-hidden rounded-lg hover:bg-dark-400/30!' + />
diff --git a/app/coins/page.tsx b/app/coins/page.tsx index b2d7d89..066f3b9 100644 --- a/app/coins/page.tsx +++ b/app/coins/page.tsx @@ -1,12 +1,5 @@ import { getCoinList } from '@/lib/coingecko.actions'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { DataTable } from '@/components/DataTable'; import Image from 'next/image'; import Link from 'next/link'; @@ -33,70 +26,77 @@ const Coins = async ({ const estimatedTotalPages = currentPage >= 100 ? Math.ceil(currentPage / 100) * 100 + 100 : 100; + const columns = [ + { + header: 'Rank', + cellClassName: 'coins-rank', + cell: (coin: CoinMarketData) => ( + <> + #{coin.market_cap_rank} + + + ), + }, + { + header: 'Token', + cellClassName: 'coins-token', + cell: (coin: CoinMarketData) => ( +
+ {coin.name} +

+ {coin.name} ({coin.symbol.toUpperCase()}) +

+
+ ), + }, + { + header: 'Price', + cellClassName: 'coins-price', + cell: (coin: CoinMarketData) => formatPrice(coin.current_price), + }, + { + header: '24h Change', + cellClassName: 'font-medium', + cell: (coin: CoinMarketData) => { + const isTrendingUp = coin.price_change_percentage_24h > 0; + + return ( + + {isTrendingUp && '+'} + {formatPercentage(coin.price_change_percentage_24h)} + + ); + }, + }, + { + header: 'Market Cap', + cellClassName: 'coins-market-cap', + cell: (coin: CoinMarketData) => formatPrice(coin.market_cap), + }, + ]; + return (

All Coins

- - - - Rank - Token - Price - 24h Change - Market Cap - - - - {coinsData.map((coin: CoinMarketData) => { - const isTrendingUp = coin.price_change_percentage_24h > 0; - - return ( - - - #{coin.market_cap_rank} - - - -
- -

- {coin.name} ({coin.symbol.toUpperCase()}) -

-
-
- - {formatPrice(coin.current_price)} - - - - {isTrendingUp && '+'} - {formatPercentage(coin.price_change_percentage_24h)} - - - - {formatPrice(coin.market_cap)} - -
- ); - })} -
-
+ coin.id} + headerClassName='coins-header' + headerRowClassName='coins-header-row' + bodyRowClassName='coins-row relative' + />
({ + columns, + data, + rowKey, + tableClassName, + headerClassName, + headerRowClassName, + headerCellClassName = 'text-purple-100', + bodyRowClassName, +}: DataTableProps) => { + return ( + + + + {columns.map((column, columnIndex) => ( + + {column.header} + + ))} + + + + {data.map((row, rowIndex) => ( + + {columns.map((column, columnIndex) => ( + + {column.cell(row, rowIndex)} + + ))} + + ))} + +
+ ); +}; diff --git a/components/LiveDataWrapper.tsx b/components/LiveDataWrapper.tsx index c03757a..d6f57b7 100644 --- a/components/LiveDataWrapper.tsx +++ b/components/LiveDataWrapper.tsx @@ -1,13 +1,6 @@ 'use client'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; +import { DataTable } from '@/components/DataTable'; import CoinHeader from './CoinHeader'; import { Separator } from './ui/separator'; import CandlestickChart from './CandlestickChart'; @@ -27,6 +20,40 @@ export default function LiveDataWrapper({ }); console.log('=========poolId', poolId); + const tradeColumns = [ + { + header: 'Price', + cellClassName: 'pl-5 py-5 font-medium', + cell: (trade: Trade) => (trade.price ? formatPrice(trade.price) : '-'), + }, + { + header: 'Amount', + cellClassName: 'py-4 font-medium', + cell: (trade: Trade) => trade.amount?.toFixed(4) ?? '-', + }, + { + header: 'Value', + cellClassName: 'font-medium', + cell: (trade: Trade) => (trade.value ? formatPrice(trade.value) : '-'), + }, + { + header: 'Buy/Sell', + cellClassName: 'font-medium', + cell: (trade: Trade) => ( + + {trade.type === 'b' ? 'Buy' : 'Sell'} + + ), + }, + { + header: 'Time', + cellClassName: 'pr-5', + cell: (trade: Trade) => + trade.timestamp ? timeAgo(trade.timestamp) : '-', + }, + ]; return (
@@ -66,46 +93,15 @@ export default function LiveDataWrapper({

Recent Trades

{trades.length > 0 ? ( - - - - Price - Amount - Value - - Buy/Sell - - Time - - - - {trades?.map((trade, index) => ( - - - {trade.price ? formatPrice(trade.price) : '-'} - - - {trade.amount?.toFixed(4) ?? '-'} - - - {trade.value ? formatPrice(trade.value) : '-'} - - - - {trade.type === 'b' ? 'Buy' : 'Sell'} - - - - {trade.timestamp ? timeAgo(trade.timestamp) : '-'} - - - ))} - -
+ index} + tableClassName='bg-dark-500' + headerClassName='text-purple-100' + headerRowClassName='hover:bg-transparent text-sm' + bodyRowClassName='hover:bg-transparent' + /> ) : (
No recent trades diff --git a/components/home/Categories.tsx b/components/home/Categories.tsx index 0dbb544..ec2cfd9 100644 --- a/components/home/Categories.tsx +++ b/components/home/Categories.tsx @@ -1,3 +1,4 @@ +import { DataTable } from '@/components/DataTable'; import { Table, TableBody, @@ -14,72 +15,76 @@ import Image from 'next/image'; export const Categories = async () => { const categories = (await getCategories()) as Category[]; + const columns = [ + { + header: 'Category', + cellClassName: 'pl-5 font-bold', + cell: (category: Category) => category.name, + }, + { + header: 'Top Gainers', + cellClassName: 'flex gap-1 mr-5', + cell: (category: Category) => + category.top_3_coins.map((coin: string) => ( + Coin image + )), + }, + { + header: '24h Change', + cellClassName: 'font-medium', + cell: (category: Category) => { + const isTrendingUp = category.market_cap_change_24h > 0; + + return ( +
0, + 'text-red-500': category.market_cap_change_24h < 0, + } + )} + > +

{formatPercentage(category.market_cap_change_24h)}

+ {isTrendingUp ? ( + + ) : ( + + )} +
+ ); + }, + }, + { + header: 'Market Cap', + cellClassName: 'font-medium', + cell: (category: Category) => formatPrice(category.market_cap), + }, + { + header: '24h Volume', + cellClassName: 'font-medium', + cell: (category: Category) => formatPrice(category.volume_24h), + }, + ]; return (

Top Categories

- - - - Category - Top Gainers - 24h Change - Market Cap - 24h Volume - - - - {categories.map((category: Category, index: number) => { - const isTrendingUp = category.market_cap_change_24h > 0; - return ( - - - {category.name} - - - {category.top_3_coins.map((coin: string) => ( - - ))} - - -
0, - 'text-red-500': category.market_cap_change_24h < 0, - } - )} - > -

{formatPercentage(category.market_cap_change_24h)}

- {isTrendingUp ? ( - - ) : ( - - )} -
-
- - {formatPrice(category.market_cap)} - - - {formatPrice(category.volume_24h)} - -
- ); - })} -
-
+ index} + headerClassName='text-purple-100' + headerRowClassName='hover:bg-transparent' + bodyRowClassName='md:text-base rounded-lg hover:bg-dark-400/30!' + />
); }; diff --git a/components/home/TrendingCoins.tsx b/components/home/TrendingCoins.tsx index dc51a81..e2e1154 100644 --- a/components/home/TrendingCoins.tsx +++ b/components/home/TrendingCoins.tsx @@ -1,3 +1,4 @@ +import { DataTable } from '@/components/DataTable'; import { Table, TableBody, @@ -15,70 +16,74 @@ import Link from 'next/link'; export const TrendingCoins = async () => { const trendingCoins = (await getTrendingCoins()) as TrendingCoin[]; + const columns = [ + { + header: 'Name', + cellClassName: 'font-bold', + cell: (coin: TrendingCoin) => { + const item = coin.item; + + return ( + + {item.name} +
+

{item.name}

+
+ + ); + }, + }, + { + header: '24h Change', + cellClassName: 'table-cell-change', + cell: (coin: TrendingCoin) => { + const item = coin.item; + const isTrendingUp = item.data.price_change_percentage_24h.usd > 0; + + return ( +
+

{formatPercentage(item.data.price_change_percentage_24h.usd)}

+ {isTrendingUp ? ( + + ) : ( + + )} +
+ ); + }, + }, + { + header: 'Price', + cellClassName: 'table-cell-price', + cell: (coin: TrendingCoin) => { + return formatPrice(coin.item.data.price); + }, + }, + ]; return (

Trending Coins

- - - - Name - - 24h Change - - Price - - - - {trendingCoins.slice(0, 6).map((coin: TrendingCoin, index) => { - const item = coin.item; - const isTrendingUp = - item.data.price_change_percentage_24h.usd > 0; - - return ( - - - - -
-

{item.name}

-
- -
- -
-

- {formatPercentage( - item.data.price_change_percentage_24h.usd - )} -

- {isTrendingUp ? ( - - ) : ( - - )} -
-
- - {formatPrice(item.data.price)} - -
- ); - })} -
-
+ index} + headerClassName='table-header-cell' + headerRowClassName='hover:bg-transparent' + bodyRowClassName='table-row-hover' + />
); diff --git a/hooks/useCoinGeckoWebSocket.ts b/hooks/useCoinGeckoWebSocket.ts index 1f52774..124a4be 100644 --- a/hooks/useCoinGeckoWebSocket.ts +++ b/hooks/useCoinGeckoWebSocket.ts @@ -11,7 +11,7 @@ export function useCoinGeckoWebSocket({ const subscribed = useRef>(new Set()); const [price, setPrice] = useState(null); - const [trades, setTrades] = useState([]); + const [trades, setTrades] = useState([]); const [ohlcv, setOhlcv] = useState(null); const lastOhlcvTimestamp = useRef(0); @@ -46,7 +46,7 @@ export function useCoinGeckoWebSocket({ // G2: Trade updates if (msg.c === 'G2') { - const newTrade: TradeData = { + const newTrade: Trade = { price: msg.pu, value: msg.vo, timestamp: msg.t ?? 0, @@ -56,7 +56,7 @@ export function useCoinGeckoWebSocket({ setTrades((prev) => [newTrade, ...prev].slice(0, 7)); } - // G3: OHLCV updates + // G3: OHLCV updates if (msg.ch === 'G3') { const timestamp = msg.t || 0; // already in seconds const newCandle: OHLCData = [ @@ -66,7 +66,7 @@ export function useCoinGeckoWebSocket({ Number(msg.l ?? 0), Number(msg.c ?? 0), ]; - + // Always update with the latest candle - chart will handle deduplication setOhlcv(newCandle); lastOhlcvTimestamp.current = timestamp; @@ -145,8 +145,7 @@ export function useCoinGeckoWebSocket({ subscribe('CGSimplePrice', { coin_id: [coinId], action: 'set_tokens' }); const wsPools = [poolId.replace('_', ':')]; - - + if (wsPools.length) { subscribe('OnchainTrade', { 'network_id:pool_addresses': wsPools, diff --git a/types.d.ts b/types.d.ts index 49e20d6..c062c43 100644 --- a/types.d.ts +++ b/types.d.ts @@ -136,7 +136,7 @@ interface PriceData { usd: number; } -interface TradeData { +interface Trade { price?: number; timestamp?: number; type?: string; @@ -248,7 +248,25 @@ interface UseCoinGeckoWebSocketProps { interface UseCoinGeckoWebSocketReturn { price: ExtendedPriceData | null; - trades: TradeData[]; + trades: Trade[]; ohlcv: OHLCData | null; isConnected: boolean; } + +interface DataTableColumn { + header: ReactNode; + cell: (row: T, index: number) => ReactNode; + headClassName?: string; + cellClassName?: string; +} + +interface DataTableProps { + columns: DataTableColumn[]; + data: T[]; + rowKey: (row: T, index: number) => Key; + tableClassName?: string; + headerClassName?: string; + headerRowClassName?: string; + headerCellClassName?: string; + bodyRowClassName?: string; +} From 58ac76f9f0a5657dc31d182f4c816a278d05bab6 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Sat, 20 Dec 2025 23:21:36 +0530 Subject: [PATCH 03/23] refactor: types and other --- app/coins/[id]/page.tsx | 22 ++---- app/coins/page.tsx | 2 - app/page.tsx | 12 ++-- components/CoinsPagination.tsx | 6 +- components/DataTable.tsx | 17 +++-- components/Header.tsx | 6 +- components/LiveDataWrapper.tsx | 21 ++---- components/home/Categories.tsx | 58 ---------------- components/home/CoinOverview.tsx | 16 ----- components/home/Fallback.tsx | 107 ++++++++++++++++++++++++++++++ components/home/TrendingCoins.tsx | 46 ------------- components/ui/pagination.tsx | 7 +- types.d.ts | 23 ++++++- 13 files changed, 156 insertions(+), 187 deletions(-) create mode 100644 components/home/Fallback.tsx diff --git a/app/coins/[id]/page.tsx b/app/coins/[id]/page.tsx index c394b22..3626397 100644 --- a/app/coins/[id]/page.tsx +++ b/app/coins/[id]/page.tsx @@ -58,13 +58,9 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => { const exchangeColumns = [ { header: 'Exchange', - cellClassName: ' text-green-500 font-bold', + cellClassName: 'text-green-500 font-bold', cell: (ticker: Ticker) => ( - + {ticker.market.name} ), @@ -73,11 +69,8 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => { header: 'Pair', cell: (ticker: Ticker) => (
-

{ticker.base}

- / -

- {ticker.target} -

+

{ticker.base}

/ +

{ticker.target}

), }, @@ -88,6 +81,7 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => { }, { header: 'Last Traded', + headClassName: 'text-end', cellClassName: 'exchange-timestamp', cell: (ticker: Ticker) => timeAgo(ticker.timestamp), }, @@ -109,9 +103,6 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => { columns={exchangeColumns} data={coinData.tickers.slice(0, 7)} rowKey={(_, index) => index} - headerClassName='text-purple-100' - headerRowClassName='hover:bg-transparent' - bodyRowClassName='overflow-hidden rounded-lg hover:bg-dark-400/30!' />
@@ -119,14 +110,12 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => {
- {/* Converter */} - {/* Coin Details */}

Coin Details

@@ -148,7 +137,6 @@ const CoinDetails = async ({ params }: { params: Promise<{ id: string }> }) => {
- {/* Top Gainers / Losers */}
diff --git a/app/coins/page.tsx b/app/coins/page.tsx index 066f3b9..22c5abd 100644 --- a/app/coins/page.tsx +++ b/app/coins/page.tsx @@ -94,8 +94,6 @@ const Coins = async ({ data={coinsData} rowKey={(coin) => coin.id} headerClassName='coins-header' - headerRowClassName='coins-header-row' - bodyRowClassName='coins-row relative' /> diff --git a/app/page.tsx b/app/page.tsx index 87b4a14..5ef2ad1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,17 +1,13 @@ import { Suspense } from 'react'; +import { Categories } from '@/components/home/Categories'; +import { CoinOverview } from '@/components/home/CoinOverview'; +import { TrendingCoins } from '@/components/home/TrendingCoins'; import { - Categories, CategoriesFallback, -} from '@/components/home/Categories'; -import { - CoinOverview, CoinOverviewFallback, -} from '@/components/home/CoinOverview'; -import { - TrendingCoins, TrendingCoinsFallback, -} from '@/components/home/TrendingCoins'; +} from '@/components/home/Fallback'; const Home = () => { return ( diff --git a/components/CoinsPagination.tsx b/components/CoinsPagination.tsx index 621c79b..3c1786f 100644 --- a/components/CoinsPagination.tsx +++ b/components/CoinsPagination.tsx @@ -15,11 +15,7 @@ export default function CoinsPagination({ currentPage, totalPages, hasMorePages, -}: { - currentPage: number; - totalPages: number; - hasMorePages: boolean; -}) { +}: Pagination) { const router = useRouter(); const handlePageChange = (page: number) => { diff --git a/components/DataTable.tsx b/components/DataTable.tsx index 22dd2ca..18865db 100644 --- a/components/DataTable.tsx +++ b/components/DataTable.tsx @@ -21,14 +21,15 @@ export const DataTable = ({ return ( - + {columns.map((column, columnIndex) => ( {column.header} @@ -38,13 +39,19 @@ export const DataTable = ({ {data.map((row, rowIndex) => ( - + {columns.map((column, columnIndex) => ( {column.cell(row, rowIndex)} diff --git a/components/Header.tsx b/components/Header.tsx index 18ab0ff..b905bd1 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -7,11 +7,7 @@ import { cn } from '@/lib/utils'; import { navItems } from '@/lib/constants'; import { SearchModal } from './SearchModal'; -export const Header = ({ - trendingCoins = [], -}: { - trendingCoins: TrendingCoin[]; -}) => { +export const Header = ({ trendingCoins = [] }: HeaderProps) => { const pathname = usePathname(); return ( diff --git a/components/LiveDataWrapper.tsx b/components/LiveDataWrapper.tsx index d6f57b7..88e6492 100644 --- a/components/LiveDataWrapper.tsx +++ b/components/LiveDataWrapper.tsx @@ -19,7 +19,6 @@ export default function LiveDataWrapper({ poolId, }); - console.log('=========poolId', poolId); const tradeColumns = [ { header: 'Price', @@ -92,21 +91,11 @@ export default function LiveDataWrapper({

Recent Trades

- {trades.length > 0 ? ( - index} - tableClassName='bg-dark-500' - headerClassName='text-purple-100' - headerRowClassName='hover:bg-transparent text-sm' - bodyRowClassName='hover:bg-transparent' - /> - ) : ( -
- No recent trades -
- )} + index} + />
diff --git a/components/home/Categories.tsx b/components/home/Categories.tsx index ec2cfd9..f1a4c7c 100644 --- a/components/home/Categories.tsx +++ b/components/home/Categories.tsx @@ -1,13 +1,4 @@ import { DataTable } from '@/components/DataTable'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Skeleton } from '@/components/ui/skeleton'; import { getCategories } from '@/lib/coingecko.actions'; import { cn, formatPercentage, formatPrice } from '@/lib/utils'; import { TrendingDown, TrendingUp } from 'lucide-react'; @@ -81,56 +72,7 @@ export const Categories = async () => { columns={columns} data={categories} rowKey={(_, index) => index} - headerClassName='text-purple-100' - headerRowClassName='hover:bg-transparent' - bodyRowClassName='md:text-base rounded-lg hover:bg-dark-400/30!' /> ); }; - -export const CategoriesFallback = () => ( -
-

Top Categories

-
- - - Category - Top Gainers - 24h Change - Market Cap - 24h Volume - - - - {Array.from({ length: 6 }).map((_, index) => ( - - - - - - {Array.from({ length: 3 }).map((__, coinIndex) => ( - - ))} - - - - - - - - - - - - ))} - -
- -); diff --git a/components/home/CoinOverview.tsx b/components/home/CoinOverview.tsx index e12e7f1..80e225c 100644 --- a/components/home/CoinOverview.tsx +++ b/components/home/CoinOverview.tsx @@ -1,7 +1,6 @@ import Image from 'next/image'; import CandlestickChart from '../CandlestickChart'; -import { Skeleton } from '../ui/skeleton'; import { getCoinDetails, getCoinOHLC } from '@/lib/coingecko.actions'; import { formatPrice } from '@/lib/utils'; @@ -35,18 +34,3 @@ export const CoinOverview = async () => { ); }; - -export const CoinOverviewFallback = () => ( -
-
-
- -
- - -
-
- -
-
-); diff --git a/components/home/Fallback.tsx b/components/home/Fallback.tsx new file mode 100644 index 0000000..970d016 --- /dev/null +++ b/components/home/Fallback.tsx @@ -0,0 +1,107 @@ +import { Skeleton } from '../ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '../ui/table'; + +export const CategoriesFallback = () => ( +
+

Top Categories

+ + + + Category + Top Gainers + 24h Change + Market Cap + 24h Volume + + + + {Array.from({ length: 6 }).map((_, index) => ( + + + + + + {Array.from({ length: 3 }).map((__, coinIndex) => ( + + ))} + + + + + + + + + + + + ))} + +
+
+); + +export const CoinOverviewFallback = () => ( +
+
+
+ +
+ + +
+
+ +
+
+); + +export const TrendingCoinsFallback = () => ( +
+

Trending Coins

+
+ + + + Name + + 24h Change + + Price + + + + {Array.from({ length: 6 }).map((_, index) => ( + + +
+ + +
+
+ + + + + + +
+ ))} +
+
+
+
+); diff --git a/components/home/TrendingCoins.tsx b/components/home/TrendingCoins.tsx index e2e1154..32534e5 100644 --- a/components/home/TrendingCoins.tsx +++ b/components/home/TrendingCoins.tsx @@ -1,13 +1,4 @@ import { DataTable } from '@/components/DataTable'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Skeleton } from '@/components/ui/skeleton'; import { getTrendingCoins } from '@/lib/coingecko.actions'; import { cn, formatPercentage, formatPrice } from '@/lib/utils'; import { TrendingDown, TrendingUp } from 'lucide-react'; @@ -88,40 +79,3 @@ export const TrendingCoins = async () => { ); }; - -export const TrendingCoinsFallback = () => ( -
-

Trending Coins

-
- - - - Name - - 24h Change - - Price - - - - {Array.from({ length: 6 }).map((_, index) => ( - - -
- - -
-
- - - - - - -
- ))} -
-
-
-
-); diff --git a/components/ui/pagination.tsx b/components/ui/pagination.tsx index 1dcfb0c..586028f 100644 --- a/components/ui/pagination.tsx +++ b/components/ui/pagination.tsx @@ -6,7 +6,7 @@ import { } from "lucide-react" import { cn } from "@/lib/utils" -import { buttonVariants, type Button } from "@/components/ui/button" +import { buttonVariants } from "@/components/ui/button" function Pagination({ className, ...props }: React.ComponentProps<"nav">) { return ( @@ -37,11 +37,6 @@ function PaginationItem({ ...props }: React.ComponentProps<"li">) { return
  • } -type PaginationLinkProps = { - isActive?: boolean -} & Pick, "size"> & - React.ComponentProps<"a"> - function PaginationLink({ className, isActive, diff --git a/types.d.ts b/types.d.ts index c062c43..34a6562 100644 --- a/types.d.ts +++ b/types.d.ts @@ -254,8 +254,8 @@ interface UseCoinGeckoWebSocketReturn { } interface DataTableColumn { - header: ReactNode; - cell: (row: T, index: number) => ReactNode; + header: React.ReactNode; + cell: (row: T, index: number) => React.ReactNode; headClassName?: string; cellClassName?: string; } @@ -263,10 +263,27 @@ interface DataTableColumn { interface DataTableProps { columns: DataTableColumn[]; data: T[]; - rowKey: (row: T, index: number) => Key; + rowKey: (row: T, index: number) => React.Key; tableClassName?: string; headerClassName?: string; headerRowClassName?: string; headerCellClassName?: string; bodyRowClassName?: string; } + +type ButtonSize = 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg'; + +type PaginationLinkProps = { + isActive?: boolean; + size?: ButtonSize; +} & React.ComponentProps<'a'>; + +interface Pagination { + currentPage: number; + totalPages: number; + hasMorePages: boolean; +} + +interface HeaderProps { + trendingCoins: TrendingCoin[]; +} From f25651d23c65e04fd8af18d7e4b83e02fa12958b Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Sat, 20 Dec 2025 23:46:45 +0530 Subject: [PATCH 04/23] refactor: pagination and socket hook --- components/CoinsPagination.tsx | 48 +------ hooks/useCoinGeckoWebSocket.ts | 221 +++++++++++++++------------------ lib/utils.ts | 52 ++++++-- 3 files changed, 147 insertions(+), 174 deletions(-) diff --git a/components/CoinsPagination.tsx b/components/CoinsPagination.tsx index 3c1786f..b9fa025 100644 --- a/components/CoinsPagination.tsx +++ b/components/CoinsPagination.tsx @@ -9,7 +9,7 @@ import { PaginationNext, PaginationPrevious, } from '@/components/ui/pagination'; -import { cn } from '@/lib/utils'; +import { buildPageNumbers, cn, ELLIPSIS } from '@/lib/utils'; export default function CoinsPagination({ currentPage, @@ -22,45 +22,7 @@ export default function CoinsPagination({ router.push(`/coins?page=${page}`); }; - // Generate page numbers to display - const getPageNumbers = () => { - const pages: (number | string)[] = []; - const showPages = 5; // Number of page buttons to show - - if (totalPages <= showPages) { - // Show all pages if total is less than or equal to showPages - for (let i = 1; i <= totalPages; i++) { - pages.push(i); - } - } else { - pages.push(1); - - // Calculate start and end of middle pages - const start = Math.max(2, currentPage - 1); - const end = Math.min(totalPages - 1, currentPage + 1); - - // Add ellipsis after first page if needed - if (start > 2) { - pages.push('...'); - } - - // Add middle pages - for (let i = start; i <= end; i++) { - pages.push(i); - } - - // Add ellipsis before last page if needed - if (end < totalPages - 1) { - pages.push('...'); - } - - pages.push(totalPages); - } - - return pages; - }; - - const pageNumbers = getPageNumbers(); + const pageNumbers = buildPageNumbers(currentPage, totalPages); const isLastPage = !hasMorePages || currentPage === totalPages; return ( @@ -80,13 +42,13 @@ export default function CoinsPagination({
    {pageNumbers.map((page, index) => ( - {page === '...' ? ( + {page === ELLIPSIS ? ( ... ) : ( handlePageChange(page as number)} + onClick={() => handlePageChange(page)} className={cn( - `hover:bg-dark-400! rounded-sm text-base cursor-pointer`, + 'hover:bg-dark-400! rounded-sm text-base cursor-pointer', { 'bg-green-500! text-dark-900 font-semibold': currentPage === page, diff --git a/hooks/useCoinGeckoWebSocket.ts b/hooks/useCoinGeckoWebSocket.ts index 124a4be..a57711f 100644 --- a/hooks/useCoinGeckoWebSocket.ts +++ b/hooks/useCoinGeckoWebSocket.ts @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState } from 'react'; const WS_BASE = `${process.env.NEXT_PUBLIC_COINGECKO_WEBSOCKET_URL}?x_cg_pro_api_key=${process.env.NEXT_PUBLIC_COINGECKO_API_KEY}`; @@ -13,157 +12,131 @@ export function useCoinGeckoWebSocket({ const [price, setPrice] = useState(null); const [trades, setTrades] = useState([]); const [ohlcv, setOhlcv] = useState(null); - const lastOhlcvTimestamp = useRef(0); const [isWsReady, setIsWsReady] = useState(false); - const handleMessage = useCallback((event: MessageEvent) => { - const ws = wsRef.current; - const msg: WebSocketMessage = JSON.parse(event.data); - - // Ping/Pong to keep connection alive - if (msg.type === 'ping') return ws?.send(JSON.stringify({ type: 'pong' })); - - // Confirm subscription - if (msg.type === 'confirm_subscription') { - const { channel } = JSON.parse(msg?.identifier ?? ''); - subscribed.current.add(channel); - return; - } - - // C1: Price updates - if (msg.c === 'C1') { - setPrice({ - usd: msg.p ?? 0, - coin: msg.i, - price: msg.p, - change24h: msg.pp, - marketCap: msg.m, - volume24h: msg.v, - timestamp: msg.t, - }); - } - - // G2: Trade updates - if (msg.c === 'G2') { - const newTrade: Trade = { - price: msg.pu, - value: msg.vo, - timestamp: msg.t ?? 0, - type: msg.ty, - amount: msg.to, - }; - - setTrades((prev) => [newTrade, ...prev].slice(0, 7)); - } - // G3: OHLCV updates - if (msg.ch === 'G3') { - const timestamp = msg.t || 0; // already in seconds - const newCandle: OHLCData = [ - timestamp, - Number(msg.o ?? 0), - Number(msg.h ?? 0), - Number(msg.l ?? 0), - Number(msg.c ?? 0), - ]; - - // Always update with the latest candle - chart will handle deduplication - setOhlcv(newCandle); - lastOhlcvTimestamp.current = timestamp; - } - }, []); - // WebSocket connection useEffect(() => { const ws = new WebSocket(WS_BASE); wsRef.current = ws; + const send = (payload: Record) => + ws.send(JSON.stringify(payload)); - ws.onopen = () => setIsWsReady(true); - ws.onmessage = handleMessage; - ws.onclose = () => setIsWsReady(false); + const handleMessage = (event: MessageEvent) => { + const msg: WebSocketMessage = JSON.parse(event.data); - return () => ws.close(); - }, [handleMessage]); + if (msg.type === 'ping') { + send({ type: 'pong' }); + return; + } - // Subscribe helper - const subscribe = useCallback( - (channel: string, data?: Record) => { - const ws = wsRef.current; - if (!ws || !isWsReady || subscribed.current.has(channel)) return; + if (msg.type === 'confirm_subscription') { + const { channel } = JSON.parse(msg?.identifier ?? ''); + subscribed.current.add(channel); + return; + } - ws.send( - JSON.stringify({ - command: 'subscribe', - identifier: JSON.stringify({ channel }), - }) - ); + if (msg.c === 'C1') { + setPrice({ + usd: msg.p ?? 0, + coin: msg.i, + price: msg.p, + change24h: msg.pp, + marketCap: msg.m, + volume24h: msg.v, + timestamp: msg.t, + }); + } - if (data) { - ws.send( - JSON.stringify({ - command: 'message', - identifier: JSON.stringify({ channel }), - data: JSON.stringify(data), - }) - ); + if (msg.c === 'G2') { + const newTrade: Trade = { + price: msg.pu, + value: msg.vo, + timestamp: msg.t ?? 0, + type: msg.ty, + amount: msg.to, + }; + + setTrades((prev) => [newTrade, ...prev].slice(0, 7)); } - }, - [isWsReady] - ); - const unsubscribeAll = useCallback(() => { - const ws = wsRef.current; - subscribed.current.forEach((channel) => { - ws?.send( - JSON.stringify({ - command: 'unsubscribe', - identifier: JSON.stringify({ channel }), - }) - ); - }); - subscribed.current.clear(); + if (msg.ch === 'G3') { + const timestamp = msg.t || 0; + const newCandle: OHLCData = [ + timestamp, + Number(msg.o ?? 0), + Number(msg.h ?? 0), + Number(msg.l ?? 0), + Number(msg.c ?? 0), + ]; + + setOhlcv(newCandle); + } + }; + + ws.onopen = () => setIsWsReady(true); + ws.onmessage = handleMessage; + ws.onclose = () => setIsWsReady(false); + + return () => ws.close(); }, []); // Subscribe on connection ready useEffect(() => { if (!isWsReady) return; + const ws = wsRef.current; + if (!ws) return; + const send = (payload: Record) => + ws.send(JSON.stringify(payload)); - let active = true; + const unsubscribeAll = () => { + subscribed.current.forEach((channel) => { + send({ + command: 'unsubscribe', + identifier: JSON.stringify({ channel }), + }); + }); + subscribed.current.clear(); + }; - (async () => { - if (!active) return; + const subscribe = (channel: string, data?: Record) => { + if (subscribed.current.has(channel)) return; - // Reset state + send({ command: 'subscribe', identifier: JSON.stringify({ channel }) }); + + if (data) { + send({ + command: 'message', + identifier: JSON.stringify({ channel }), + data: JSON.stringify(data), + }); + } + }; + + queueMicrotask(() => { setPrice(null); setTrades([]); setOhlcv(null); - lastOhlcvTimestamp.current = 0; - - unsubscribeAll(); - - // Subscribe channels - subscribe('CGSimplePrice', { coin_id: [coinId], action: 'set_tokens' }); + }); - const wsPools = [poolId.replace('_', ':')]; + unsubscribeAll(); - if (wsPools.length) { - subscribe('OnchainTrade', { - 'network_id:pool_addresses': wsPools, - action: 'set_pools', - }); + subscribe('CGSimplePrice', { coin_id: [coinId], action: 'set_tokens' }); - subscribe('OnchainOHLCV', { - 'network_id:pool_addresses': wsPools, - interval: '1s', - action: 'set_pools', - }); - } - })(); + const poolAddress = poolId.replace('_', ':'); + if (poolAddress) { + subscribe('OnchainTrade', { + 'network_id:pool_addresses': [poolAddress], + action: 'set_pools', + }); - return () => { - active = false; - }; - }, [coinId, poolId, isWsReady, subscribe, unsubscribeAll]); + subscribe('OnchainOHLCV', { + 'network_id:pool_addresses': [poolAddress], + interval: '1s', + action: 'set_pools', + }); + } + }, [coinId, poolId, isWsReady]); return { price, diff --git a/lib/utils.ts b/lib/utils.ts index 4b3ac9a..f8b025c 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -10,7 +10,7 @@ export function formatPrice( value: number | null | undefined, digits?: number, currency?: string, - showSymbol?: boolean, + showSymbol?: boolean ) { if (value === null || value === undefined || isNaN(value)) { return showSymbol !== false ? '$0.00' : '0.00'; @@ -24,10 +24,10 @@ export function formatPrice( maximumFractionDigits: digits ?? 2, }); } - return value.toLocaleString(undefined, { - minimumFractionDigits: digits ?? 2, - maximumFractionDigits: digits ?? 2, - }); + return value.toLocaleString(undefined, { + minimumFractionDigits: digits ?? 2, + maximumFractionDigits: digits ?? 2, + }); } export function formatPercentage(change: number | null | undefined): string { @@ -78,7 +78,45 @@ export function convertOHLCData(data: OHLCData[]) { low: d[3], close: d[4], })) - .filter((item, index, arr) => - index === 0 || item.time !== arr[index - 1].time + .filter( + (item, index, arr) => index === 0 || item.time !== arr[index - 1].time ); } + +export const ELLIPSIS = 'ellipsis' as const; +export const buildPageNumbers = ( + currentPage: number, + totalPages: number +): (number | typeof ELLIPSIS)[] => { + const MAX_VISIBLE_PAGES = 5; + + const pages: (number | typeof ELLIPSIS)[] = []; + + if (totalPages <= MAX_VISIBLE_PAGES) { + for (let i = 1; i <= totalPages; i += 1) { + pages.push(i); + } + return pages; + } + + pages.push(1); + + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + if (start > 2) { + pages.push(ELLIPSIS); + } + + for (let i = start; i <= end; i += 1) { + pages.push(i); + } + + if (end < totalPages - 1) { + pages.push(ELLIPSIS); + } + + pages.push(totalPages); + + return pages; +}; From cbe15e5b889870b3e3521f4f8384e1d48ec6fecc Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Sat, 20 Dec 2025 23:54:26 +0530 Subject: [PATCH 05/23] refactor: top gainers losers --- components/coin-details/TopGainersLosers.tsx | 77 +++++++++----------- 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/components/coin-details/TopGainersLosers.tsx b/components/coin-details/TopGainersLosers.tsx index d5f67fe..6da3baf 100644 --- a/components/coin-details/TopGainersLosers.tsx +++ b/components/coin-details/TopGainersLosers.tsx @@ -2,55 +2,46 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import CoinCard from '../CoinCard'; import { getTopGainersLosers } from '@/lib/coingecko.actions'; +const TAB_CONFIG = [ + { value: 'top-gainers', label: 'Top Gainers', key: 'top_gainers' }, + { value: 'top-losers', label: 'Top Losers', key: 'top_losers' }, +] as const; + +const renderCoinCard = (coin: TopGainersLosersResponse) => ( + +); + export const TopGainersLosers = async () => { const topGainersLosers = await getTopGainersLosers(); return ( - + - - Top Gainers - - - Top Losers - - - - {topGainersLosers.top_gainers.map((coin: TopGainersLosersResponse) => ( - + {TAB_CONFIG.map((tab) => ( + + {tab.label} + ))} - - - {topGainersLosers.top_losers.map((coin: TopGainersLosersResponse) => ( - - ))} - + + {TAB_CONFIG.map((tab) => ( + + {topGainersLosers[tab.key].map(renderCoinCard)} + + ))} ); }; From 37ca69ce95f2a3a27232c2914abd933ba74d94d6 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Sun, 21 Dec 2025 00:08:34 +0530 Subject: [PATCH 06/23] refactor: coin header --- components/CoinHeader.tsx | 135 +++++++++++++++++++------------------- 1 file changed, 69 insertions(+), 66 deletions(-) diff --git a/components/CoinHeader.tsx b/components/CoinHeader.tsx index 783e3ab..1241c38 100644 --- a/components/CoinHeader.tsx +++ b/components/CoinHeader.tsx @@ -14,82 +14,85 @@ export default function CoinHeader({ priceChange24h, }: LiveCoinHeaderProps) { const isTrendingUp = livePriceChangePercentage24h > 0; + const isThirtyDayUp = priceChangePercentage30d > 0; + const isPriceChangeUp = priceChange24h > 0; + + const stats = [ + { + label: 'Today', + value: livePriceChangePercentage24h, + isUp: isTrendingUp, + formatter: formatPercentage, + valueClassName: 'coin-header-stat-value', + showIcon: true, + }, + { + label: '30 Days', + value: priceChangePercentage30d, + isUp: isThirtyDayUp, + formatter: formatPercentage, + valueClassName: 'coin-header-stat-value-30d', + showIcon: true, + }, + { + label: 'Price Change (24h)', + value: priceChange24h, + isUp: isPriceChangeUp, + formatter: formatPrice, + valueClassName: 'coin-header-stat-price', + showIcon: false, + }, + ]; return (
    -
    -

    {name}

    -
    - {name} -
    -

    {formatPrice(livePrice)}

    - - {formatPercentage(livePriceChangePercentage24h)} - {isTrendingUp ? : } - (24h) - -
    +

    {name}

    + +
    + {name} +
    +

    {formatPrice(livePrice)}

    + + {formatPercentage(livePriceChangePercentage24h)} + {isTrendingUp ? : } + (24h) +
    -
    -
    -

    Today

    -
    0, - 'text-red-500': livePriceChangePercentage24h < 0, - })} - > -

    {formatPercentage(livePriceChangePercentage24h)}

    - {isTrendingUp ? ( - - ) : ( - - )} -
    -
    +
    -
    -

    30 Days

    +
    + {stats.map((stat) => ( +
    +

    {stat.label}

    0, - 'text-red-500': priceChangePercentage30d < 0, + className={cn(stat.valueClassName, { + 'text-green-500': stat.isUp, + 'text-red-500': !stat.isUp, })} > -

    {formatPercentage(priceChangePercentage30d)}

    - {isTrendingUp ? ( - - ) : ( - - )} +

    {stat.formatter(stat.value)}

    + {stat.showIcon && + (stat.isUp ? ( + + ) : ( + + ))}
    - -
    -

    Price Change (24h)

    -

    0, - 'text-red-500': priceChange24h < 0, - })} - > - {formatPrice(priceChange24h)} -

    -
    -
    + ))}
    ); From 0fbe8a4aacb01ad5663b7da25a017e42b35abc39 Mon Sep 17 00:00:00 2001 From: sujatagunale Date: Sun, 21 Dec 2025 10:57:08 +0530 Subject: [PATCH 07/23] refactor: converter styles --- app/globals.css | 92 +++++++++++++++------------ components/coin-details/Converter.tsx | 60 ++++++++--------- 2 files changed, 77 insertions(+), 75 deletions(-) diff --git a/app/globals.css b/app/globals.css index 7752477..27a0ce3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -286,10 +286,6 @@ @apply pr-5 text-end; } - .converter-title { - @apply text-2xl font-semibold; - } - .coin-details-grid { @apply rounded-lg grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-2 gap-3 sm:gap-5; } @@ -413,56 +409,68 @@ } /* Converter Component Utilities */ - .converter-container { - @apply space-y-2 bg-dark-500 px-5 py-7 rounded-lg; - } + #converter { + @apply w-full space-y-5; - .converter-input-wrapper { - @apply bg-dark-400 h-12 w-full rounded-md flex items-center justify-between py-4 pr-4; - } + h4 { + @apply text-2xl font-semibold; + } - .converter-input { - @apply flex-1 !text-lg border-none font-medium !bg-dark-400 focus-visible:ring-0 shadow-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none; - } + .panel { + @apply space-y-2 bg-dark-500 px-5 py-7 rounded-lg; + } - .converter-coin-info { - @apply flex items-center gap-1; - } + .input-wrapper { + @apply bg-dark-400 h-12 w-full rounded-md flex items-center justify-between py-4 pr-4; - .converter-coin-symbol { - @apply font-semibold text-base text-purple-100; - } + .input { + @apply flex-1 text-lg! border-none font-medium bg-dark-400! focus-visible:ring-0 shadow-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none; + } - .converter-divider-wrapper { - @apply relative flex justify-center items-center my-4; - } + .coin-info { + @apply flex items-center gap-1; - .converter-divider-line { - @apply h-[1px] z-10 w-full bg-dark-400 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2; - } + p { + @apply font-semibold text-base text-purple-100; + } + } + } - .converter-icon { - @apply size-8 z-20 bg-dark-400 rounded-full p-2 text-green-500; - } + .divider { + @apply relative flex justify-center items-center my-4; - .converter-output-wrapper { - @apply bg-dark-400 h-12 w-full rounded-md flex items-center justify-between py-4 pl-4; - } + .line { + @apply h-px z-10 w-full bg-dark-400 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2; + } - .converter-select-trigger { - @apply w-fit border-none cursor-pointer !h-12 !bg-dark-400 hover:!bg-dark-400 focus-visible:!ring-0; - } + .icon { + @apply size-8 z-20 bg-dark-400 rounded-full p-2 text-green-500; + } + } - .converter-select-content { - @apply bg-dark-400 max-h-[500px]; - } + .output-wrapper { + @apply bg-dark-400 h-12 w-full rounded-md flex items-center justify-between py-4 pl-4; - .converter-select-item { - @apply cursor-pointer hover:!bg-dark-500; - } + p { + @apply text-base font-medium; + } + + .select-trigger { + @apply w-fit border-none cursor-pointer h-12! bg-dark-400! hover:bg-dark-400! focus-visible:ring-0!; + + .select-value { + @apply font-semibold text-xs text-purple-100; + } + } + + .select-content[data-converter] { + @apply bg-dark-400 max-h-[500px]; - .converter-currency { - @apply font-semibold text-sm text-purple-100; + .select-item { + @apply cursor-pointer hover:bg-dark-500!; + } + } + } } /* CoinHeader Component Utilities */ diff --git a/components/coin-details/Converter.tsx b/components/coin-details/Converter.tsx index ebf3b47..eb306ee 100644 --- a/components/coin-details/Converter.tsx +++ b/components/coin-details/Converter.tsx @@ -16,61 +16,55 @@ export const Converter = ({ symbol, icon, priceList }: ConverterProps) => { const [currency, setCurrency] = useState('usd'); const [amount, setAmount] = useState('10'); - // Calculate converted price const convertedPrice = (parseFloat(amount) || 0) * (priceList[currency] || 0); return ( -
    -

    {symbol.toUpperCase()} Converter

    -
    -
    +
    +

    {symbol.toUpperCase()} Converter

    + +
    +
    setAmount(e.target.value)} - className='converter-input' + className='input' /> -
    +
    {symbol} -

    {symbol.toUpperCase()}

    +

    {symbol.toUpperCase()}

    -
    -
    -
    - converter -
    +
    +
    + + converter
    -
    -

    - {formatPrice(convertedPrice, 2, currency, false)} -

    +
    +

    {formatPrice(convertedPrice, 2, currency, false)}

    +