Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
113 changes: 103 additions & 10 deletions app/coins/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,89 @@ 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 { DataTable } from '@/components/DataTable';
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',
},
];
Comment on lines +26 to +57
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add null safety for API data access.

The coinDetails array accesses deeply nested properties and array elements from coinData without null/undefined checks. If the API response is incomplete or has a different structure, this will cause runtime errors.

🔎 Proposed fix using optional chaining and null coalescing
 const coinDetails = [
   {
     label: 'Market Cap',
-    value: formatPrice(coinData.market_data.market_cap.usd),
+    value: formatPrice(coinData.market_data?.market_cap?.usd),
   },
   {
     label: 'Market Cap Rank',
-    value: `# ${coinData.market_cap_rank}`,
+    value: coinData.market_cap_rank ? `# ${coinData.market_cap_rank}` : '-',
   },
   {
     label: 'Total Volume',
-    value: formatPrice(coinData.market_data.total_volume.usd),
+    value: formatPrice(coinData.market_data?.total_volume?.usd),
   },
   {
     label: 'Website',
     value: '-',
-    link: coinData.links.homepage[0],
+    link: coinData.links?.homepage?.[0],
     linkText: 'Website',
   },
   {
     label: 'Explorer',
     value: '-',
-    link: coinData.links.blockchain_site[0],
+    link: coinData.links?.blockchain_site?.[0],
     linkText: 'Explorer',
   },
   {
     label: 'Community Link',
     value: '-',
-    link: coinData.links.subreddit_url,
+    link: coinData.links?.subreddit_url,
     linkText: 'Community',
   },
 ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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',
},
];
const coinDetails = [
{
label: 'Market Cap',
value: formatPrice(coinData.market_data?.market_cap?.usd),
},
{
label: 'Market Cap Rank',
value: coinData.market_cap_rank ? `# ${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',
},
];
🤖 Prompt for AI Agents
In app/coins/[id]/page.tsx around lines 25 to 56, the coinDetails array reads
deeply nested properties and array items from coinData without null checks,
which can crash if the API returns missing fields; update each access to use
optional chaining and nullish coalescing (e.g., market_data?.market_cap?.usd ??
defaultValue, market_cap_rank ?? '-', market_data?.total_volume?.usd ??
defaultValue) and guard array accesses with optional chaining for indexes (e.g.,
links?.homepage?.[0] ?? undefined or '-') so values and links default safely
when absent; ensure formatPrice is only called with a defined numeric value or
wrap it so it handles undefined safely.


const exchangeColumns = [
{
header: 'Exchange',
cellClassName: 'text-green-500 font-bold',
cell: (ticker: Ticker) => (
<Link href={ticker.trade_url} target='_blank' className='exchange-link'>
{ticker.market.name}
</Link>
),
},
{
header: 'Pair',
cell: (ticker: Ticker) => (
<div className='exchange-pair'>
<p className='truncate max-w-[100px] h-full'>{ticker.base}</p>/
<p className='truncate max-w-[100px] h-full ml-2'>{ticker.target}</p>
</div>
),
},
{
header: 'Price',
cellClassName: 'font-medium',
cell: (ticker: Ticker) => formatPrice(ticker.converted_last.usd),
},
{
header: 'Last Traded',
headClassName: 'text-end',
cellClassName: 'exchange-timestamp',
cell: (ticker: Ticker) => timeAgo(ticker.timestamp),
},
];

return (
<main className='coin-details-main'>
<section className='size-full xl:col-span-2'>
Expand All @@ -27,23 +96,47 @@ 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 */}
<ExchangeListings coinData={coinData} />
<div className='w-full mt-8 space-y-4'>
<h4 className='section-title'>Exchange Listings</h4>
<div className='custom-scrollbar mt-5 exchange-container'>
<DataTable
columns={exchangeColumns}
data={coinData.tickers.slice(0, 7)}
rowKey={(_, index) => index}
/>
</div>
</div>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add safety check for tickers array.

Line 104 calls .slice() on coinData.tickers without verifying it exists and is an array. This could throw a runtime error.

🔎 Proposed fix with array safety check
           <div className='w-full mt-8 space-y-4'>
             <h4 className='section-title'>Exchange Listings</h4>
             <div className='custom-scrollbar mt-5 exchange-container'>
               <DataTable
                 columns={exchangeColumns}
-                data={coinData.tickers.slice(0, 7)}
+                data={coinData.tickers?.slice(0, 7) ?? []}
                 rowKey={(_, index) => index}
               />
             </div>
           </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className='w-full mt-8 space-y-4'>
<h4 className='section-title'>Exchange Listings</h4>
<div className='custom-scrollbar mt-5 exchange-container'>
<DataTable
columns={exchangeColumns}
data={coinData.tickers.slice(0, 7)}
rowKey={(_, index) => index}
/>
</div>
</div>
<div className='w-full mt-8 space-y-4'>
<h4 className='section-title'>Exchange Listings</h4>
<div className='custom-scrollbar mt-5 exchange-container'>
<DataTable
columns={exchangeColumns}
data={coinData.tickers?.slice(0, 7) ?? []}
rowKey={(_, index) => index}
/>
</div>
</div>
🤖 Prompt for AI Agents
In app/coins/[id]/page.tsx around lines 99 to 108, coinData.tickers is used with
.slice() without verifying it's defined or an array; change the DataTable data
prop to a safe expression such as Array.isArray(coinData.tickers) ?
coinData.tickers.slice(0, 7) : [] (or use (coinData.tickers ?? []).slice(0,7))
so you never call .slice() on undefined and pass an empty array when tickers is
missing.

</LiveDataWrapper>
</section>

<section className='size-full max-lg:mt-8 lg:col-span-1'>
{/* Converter */}
<Converter
symbol={coinData.symbol}
icon={coinData.image.small}
priceList={coinData.market_data.current_price}
/>

{/* Coin Details */}
<CoinDetailsSection coinData={coinData} />
<div className='w-full mt-8 space-y-4'>
<h4 className='section-title pb-3'>Coin Details</h4>
<div className='coin-details-grid'>
{coinDetails.map(({ label, value, link, linkText }, index) => (
<div key={index} className='detail-card'>
<p className='text-purple-100'>{label}</p>
{link ? (
<div className='detail-link'>
<Link href={link} target='_blank'>
{linkText || label}
</Link>
<ArrowUpRight size={16} />
</div>
) : (
<p className='text-base font-medium'>{value}</p>
)}
</div>
))}
</div>
</div>

{/* Top Gainers / Losers */}
<TopGainersLosers />
</section>
</main>
Expand Down
135 changes: 68 additions & 67 deletions app/coins/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
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 { 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,
Expand All @@ -32,68 +26,75 @@ 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}
<Link
href={`/coins/${coin.id}`}
className='absolute inset-0 z-10'
aria-label='View coin'
/>
</>
),
},
{
header: 'Token',
cellClassName: 'coins-token',
cell: (coin: CoinMarketData) => (
<div className='coins-token-info'>
<Image src={coin.image} alt={coin.name} width={36} height={36} />
<p className='max-w-full truncate'>
{coin.name} ({coin.symbol.toUpperCase()})
</p>
</div>
),
},
{
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 (
<span
className={cn('coins-change', {
'text-green-600': isTrendingUp,
'text-red-500': !isTrendingUp,
})}
>
{isTrendingUp && '+'}
{formatPercentage(coin.price_change_percentage_24h)}
</span>
);
},
Comment on lines 60 to 74
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add null/undefined check for price change percentage.

The comparison on Line 65 assumes coin.price_change_percentage_24h is always a valid number. If the API returns null or undefined, this could cause incorrect rendering or runtime issues.

🔎 Proposed fix with null safety
     cell: (coin: CoinMarketData) => {
-      const isTrendingUp = coin.price_change_percentage_24h > 0;
+      const change = coin.price_change_percentage_24h ?? 0;
+      const isTrendingUp = change > 0;

       return (
         <span
           className={cn('coins-change', {
             'text-green-600': isTrendingUp,
             'text-red-500': !isTrendingUp,
           })}
         >
           {isTrendingUp && '+'}
-          {formatPercentage(coin.price_change_percentage_24h)}
+          {formatPercentage(change)}
         </span>
       );
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cell: (coin: CoinMarketData) => {
const isTrendingUp = coin.price_change_percentage_24h > 0;
return (
<span
className={cn('coins-change', {
'text-green-600': isTrendingUp,
'text-red-500': !isTrendingUp,
})}
>
{isTrendingUp && '+'}
{formatPercentage(coin.price_change_percentage_24h)}
</span>
);
},
cell: (coin: CoinMarketData) => {
const change = coin.price_change_percentage_24h ?? 0;
const isTrendingUp = change > 0;
return (
<span
className={cn('coins-change', {
'text-green-600': isTrendingUp,
'text-red-500': !isTrendingUp,
})}
>
{isTrendingUp && '+'}
{formatPercentage(change)}
</span>
);
},
🤖 Prompt for AI Agents
In app/coins/page.tsx around lines 64 to 78, the code compares
coin.price_change_percentage_24h to 0 without guarding against null/undefined;
update it to first check whether price_change_percentage_24h is a finite number
(e.g. typeof value === 'number' && Number.isFinite(value)) and treat non-numeric
values as "no data" (render a placeholder like '—' or 'N/A') instead of
attempting the comparison or calling formatPercentage; if it's numeric, compute
isTrendingUp = value > 0 and call formatPercentage, otherwise render the
placeholder and apply a neutral CSS class so rendering and runtime behavior are
safe.

},
{
header: 'Market Cap',
cellClassName: 'coins-market-cap',
cell: (coin: CoinMarketData) => formatPrice(coin.market_cap),
},
];

return (
<main className='coins-main'>
<div className='flex flex-col w-full space-y-5'>
<h4 className='text-2xl'>All Coins</h4>
<div className='custom-scrollbar coins-container'>
<Table>
<TableHeader className='coins-header'>
<TableRow className='coins-header-row'>
<TableHead className='coins-header-left'>Rank</TableHead>
<TableHead className='text-purple-100'>Token</TableHead>
<TableHead className='text-purple-100'>Price</TableHead>
<TableHead className='coins-header-right'>24h Change</TableHead>
<TableHead className='coins-header-right'>Market Cap</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{coinsData.map((coin: CoinMarketData) => {
const isTrendingUp = coin.price_change_percentage_24h > 0;
return (
<ClickableTableRow
key={coin.id}
href={`/coins/${coin.id}`}
className='coins-row'
>
<TableCell className='coins-rank'>
#{coin.market_cap_rank}
</TableCell>
<TableCell className='coins-token'>
<div className='coins-token-info'>
<Image
src={coin.image}
alt={coin.name}
width={36}
height={36}
/>
<p className='max-w-[100%] truncate'>
{coin.name} ({coin.symbol.toUpperCase()})
</p>
</div>
</TableCell>
<TableCell className='coins-price'>
{formatPrice(coin.current_price)}
</TableCell>
<TableCell className='font-medium'>
<span
className={cn('coins-change', {
'text-green-600': isTrendingUp,
'text-red-500': !isTrendingUp,
})}
>
{isTrendingUp && '+'}
{formatPercentage(coin.price_change_percentage_24h)}
</span>
</TableCell>
<TableCell className='coins-market-cap'>
{formatPrice(coin.market_cap)}
</TableCell>
</ClickableTableRow>
);
})}
</TableBody>
</Table>
<DataTable
columns={columns}
data={coinsData}
rowKey={(coin) => coin.id}
headerClassName='coins-header'
/>
</div>

<CoinsPagination
Expand Down
Loading