diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec79d9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.next/ +.env* +.DS_Store diff --git a/app/page.jsx b/app/page.jsx index e87415c..9efaff8 100644 --- a/app/page.jsx +++ b/app/page.jsx @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { PortfolioOverview } from "@/components/PortfolioOverview"; import { ExchangeCard } from "@/components/ExchangeCard"; import { AssetList } from "@/components/AssetList"; +import { calculatePortfolioStats } from "@/lib/utils"; const mockExchanges = [ { @@ -53,24 +54,14 @@ const mockAssets = [ ]; export default function HomePage() { - const { totalValue, totalChange } = useMemo(() => { - const totalBalance = mockAssets.reduce((acc, asset) => acc + asset.value, 0); - const change = - mockExchanges - .filter((exchange) => exchange.connected) - .reduce((acc, exchange) => acc + exchange.change24h, 0) / - Math.max(mockExchanges.filter((exchange) => exchange.connected).length, 1); + const { totalValue, weightedChange, bestPerformer, worstPerformer, allocation } = + useMemo(() => calculatePortfolioStats(mockAssets), []); - return { - totalValue: totalBalance, - totalChange: change - }; - }, []); - - const connectedCount = useMemo( - () => mockExchanges.filter((exchange) => exchange.connected).length, + const connectedExchanges = useMemo( + () => mockExchanges.filter((exchange) => exchange.connected), [] ); + const connectedCount = connectedExchanges.length; return (
@@ -98,9 +89,12 @@ export default function HomePage() {
diff --git a/components/AssetList.jsx b/components/AssetList.jsx index fae42b7..00f2381 100644 --- a/components/AssetList.jsx +++ b/components/AssetList.jsx @@ -1,12 +1,6 @@ import { TrendingDown, TrendingUp } from "lucide-react"; import { Card } from "@/components/ui/card"; - -const formatCurrency = (value) => - new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - maximumFractionDigits: 2 - }).format(value); +import { formatCurrency, formatPercentage } from "@/lib/utils"; export const AssetList = ({ assets }) => { return ( @@ -26,6 +20,7 @@ export const AssetList = ({ assets }) => { {assets.map((asset) => { const isPositive = asset.change24h >= 0; const ChangeIcon = isPositive ? TrendingUp : TrendingDown; + const changeDisplay = formatPercentage(Math.abs(asset.change24h)); return ( @@ -44,8 +39,8 @@ export const AssetList = ({ assets }) => {
- {isPositive ? "+" : ""} - {asset.change24h.toFixed(2)}% + {isPositive ? "+" : "-"} + {changeDisplay}%
{formatCurrency(asset.value)} diff --git a/components/ExchangeCard.jsx b/components/ExchangeCard.jsx index d0a1099..1944aca 100644 --- a/components/ExchangeCard.jsx +++ b/components/ExchangeCard.jsx @@ -1,16 +1,11 @@ import { Wallet, CheckCircle2, XCircle } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; - -const formatCurrency = (value) => - new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - maximumFractionDigits: 2 - }).format(value); +import { formatCurrency, formatPercentage } from "@/lib/utils"; export const ExchangeCard = ({ exchange }) => { const isPositive = exchange.change24h >= 0; + const changeDisplay = formatPercentage(Math.abs(exchange.change24h)); return ( @@ -41,8 +36,8 @@ export const ExchangeCard = ({ exchange }) => {
24h Change - {isPositive ? "+" : ""} - {exchange.change24h.toFixed(2)}% + {isPositive ? "+" : "-"} + {changeDisplay}%
diff --git a/components/PortfolioOverview.jsx b/components/PortfolioOverview.jsx index 11bf6b5..f5ed49b 100644 --- a/components/PortfolioOverview.jsx +++ b/components/PortfolioOverview.jsx @@ -1,22 +1,29 @@ import { TrendingDown, TrendingUp, Wallet } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; - -const formatCurrency = (value) => - new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - maximumFractionDigits: 2 - }).format(value); +import { formatCurrency, formatPercentage } from "@/lib/utils"; export const PortfolioOverview = ({ totalValue, totalChange, connectedExchanges, - totalAssets + totalAssets, + bestPerformer, + worstPerformer, + allocation }) => { const isPositive = totalChange >= 0; const ChangeIcon = isPositive ? TrendingUp : TrendingDown; + const changeDisplay = formatPercentage(Math.abs(totalChange)); + const bestPerformerLabel = bestPerformer + ? `${bestPerformer.name} (${bestPerformer.symbol})` + : "No assets tracked"; + const worstPerformerLabel = worstPerformer + ? `${worstPerformer.name} (${worstPerformer.symbol})` + : "No assets tracked"; + const allocationSummary = allocation?.length + ? allocation.map((item) => `${item.percentage}% ${item.symbol}`).join(" / ") + : "No tracked assets"; return (
@@ -37,8 +44,8 @@ export const PortfolioOverview = ({ 24h - {isPositive ? "+" : ""} - {totalChange.toFixed(2)}% + {isPositive ? "+" : "-"} + {changeDisplay}%
@@ -80,15 +87,15 @@ export const PortfolioOverview = ({
Best Performer - Solana (SOL) + {bestPerformerLabel}
Worst Performer - Ethereum (ETH) + {worstPerformerLabel}
Allocation - 45% BTC / 32% ALT / 23% DeFi + {allocationSummary}
diff --git a/docs/integration-guide.md b/docs/integration-guide.md new file mode 100644 index 0000000..fcc5d8c --- /dev/null +++ b/docs/integration-guide.md @@ -0,0 +1,83 @@ +# Exchange & Wallet Integration Guide + +This guide walks you through preparing API credentials or wallet connections for the data sources represented in the CryptoTracker Pro dashboard. + +> **Security first.** Never commit API keys or seed phrases to source control. Store them in `.env.local` for local development and in your deployment platform's secret manager in production. + +## 1. Binance + +1. Log into your [Binance](https://www.binance.com/) account and open **Profile → API Management**. +2. Create a new API key and label it (for example, `CryptoTrackerPro`). +3. Complete Binance's security verifications to reveal the key and secret. +4. Under **API Restrictions**: + - Enable **Read Only** access. _Do not enable withdrawal permissions._ + - If you plan to use IP restrictions, add the server's public IP and click **Confirm**. +5. Store the credentials locally in `.env.local`: + ```bash + BINANCE_API_KEY=your_key_here + BINANCE_API_SECRET=your_secret_here + ``` +6. Restart the Next.js dev server so the new environment variables are loaded. +7. In your data-fetching service (e.g., `/lib/integrations/binance.js`), use the credentials to call the [`/api/v3/account`](https://binance-docs.github.io/apidocs/spot/en/#account-information-user_data) endpoint for balances and recent 24h change stats. + +## 2. Coinbase Advanced Trade (Coinbase Pro) + +1. Visit [Coinbase Advanced Trade](https://advanced.trade.coinbase.com/) → **API** and click **Create API key**. +2. Choose the portfolio that contains the assets you want to track. +3. Give the key a descriptive name and set **Permissions → View** only. +4. Download the `API Key`, `API Secret`, and `Passphrase` that Coinbase provides. +5. Save them in `.env.local`: + ```bash + COINBASE_API_KEY=your_key_here + COINBASE_API_SECRET=your_secret_here + COINBASE_API_PASSPHRASE=your_passphrase_here + ``` +6. Use the official REST client or signed fetch calls against [`/accounts`](https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getaccounts) to retrieve balances and product tickers for pricing data. +7. When deploying, add the same values to your hosting provider's secret store. + +## 3. MetaMask Wallet + +MetaMask exposes assets directly from the browser wallet, so no API keys are needed. + +1. Install the [MetaMask extension](https://metamask.io/download/) and import or create your wallet. +2. In the CryptoTracker Pro UI, prompt the connection with `window.ethereum.request({ method: "eth_requestAccounts" })`. +3. Store the selected account address in application state (for example, in a React context) and pass it to your balances hook. +4. Use an on-chain data provider such as [Etherscan](https://docs.etherscan.io/) or [Alchemy](https://docs.alchemy.com/) to fetch token balances for the connected address. Persist the provider keys in `.env.local` (e.g., `ALCHEMY_API_KEY`). +5. Refresh holdings on an interval or with a "Refresh" button to keep the dashboard in sync. + +## 4. Kraken + +1. Log into [Kraken](https://www.kraken.com/) and go to **Security → API**. +2. Click **Add Key**, select a descriptive name, and enable the following permissions: **Query Funds**, **Query Ledgers**, and **Query Closed Orders**. Leave withdrawal permissions disabled. +3. After creating the key, copy the **Key** and **Private Key** values. +4. Add them to `.env.local`: + ```bash + KRAKEN_API_KEY=your_key_here + KRAKEN_API_SECRET=your_secret_here + ``` +5. Use Kraken's [REST API](https://docs.kraken.com/rest/) endpoints such as `Balance` and `TradeBalance` to obtain holdings and valuation. +6. If Kraken is currently disconnected in the UI, surface actionable messaging (e.g., "Reconnect" button) once credentials are added. + +## Environment Variable Checklist + +Ensure the following keys are defined locally (and in production secrets) before fetching live data: + +``` +BINANCE_API_KEY= +BINANCE_API_SECRET= +COINBASE_API_KEY= +COINBASE_API_SECRET= +COINBASE_API_PASSPHRASE= +KRAKEN_API_KEY= +KRAKEN_API_SECRET= +ALCHEMY_API_KEY= # Optional, for MetaMask balance lookups +``` + +## Next Steps + +- Build dedicated integration modules under `lib/integrations` that encapsulate request signing for each exchange. +- Create an API route (e.g., `/api/portfolio`) that aggregates balances, normalises everything to USD, and feeds the `mockAssets` shape currently used in `app/page.jsx`. +- Replace the mocked arrays with responses from the aggregation route, then persist the raw data in a lightweight database (Supabase, PlanetScale, etc.) if you need history. +- Add UX affordances such as status badges (`Connected`, `Action Required`) that react to integration health checks. + +Following these steps will let you progressively replace the mocked data with real balances from each provider while keeping secrets out of your client bundle. diff --git a/lib/utils.js b/lib/utils.js index d8b4cf5..6f8a39d 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -4,3 +4,119 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs) { return twMerge(clsx(inputs)); } + +export function formatCurrency(value, { currency = "USD", maximumFractionDigits = 2 } = {}) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits, + minimumFractionDigits: maximumFractionDigits + }).format(typeof value === "number" ? value : 0); +} + +export function formatPercentage(value, { maximumFractionDigits = 2 } = {}) { + return Number.parseFloat(value ?? 0).toLocaleString("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits + }); +} + +function normalizePercentages(items) { + if (!items.length) return []; + + const working = items.map((item, index) => ({ + symbol: item.symbol, + raw: item.percentage, + percentage: Math.floor(item.percentage), + index + })); + + let remainder = Math.round( + 100 - working.reduce((sum, item) => sum + item.percentage, 0) + ); + + if (remainder > 0) { + const prioritised = [...working].sort( + (a, b) => (b.raw - b.percentage) - (a.raw - a.percentage) + ); + let cursor = 0; + const length = prioritised.length; + while (remainder > 0 && length > 0) { + prioritised[cursor % length].percentage += 1; + remainder -= 1; + cursor += 1; + } + } + + return working + .sort((a, b) => a.index - b.index) + .map((item) => ({ symbol: item.symbol, percentage: item.percentage })); +} + +export function calculatePortfolioStats(assets = []) { + if (!Array.isArray(assets) || assets.length === 0) { + return { + totalValue: 0, + weightedChange: 0, + bestPerformer: null, + worstPerformer: null, + allocation: [] + }; + } + + const totalValue = assets.reduce((sum, asset) => sum + (asset.value ?? 0), 0); + + const weightedChange = + totalValue > 0 + ? assets.reduce( + (sum, asset) => sum + (asset.value ?? 0) * (asset.change24h ?? 0), + 0 + ) / totalValue + : 0; + + const bestPerformer = assets.reduce((best, asset) => { + if (!best) return asset; + return (asset.change24h ?? -Infinity) > (best.change24h ?? -Infinity) + ? asset + : best; + }, null); + + const worstPerformer = assets.reduce((worst, asset) => { + if (!worst) return asset; + return (asset.change24h ?? Infinity) < (worst.change24h ?? Infinity) + ? asset + : worst; + }, null); + + const sortedByValue = [...assets].sort( + (a, b) => (b.value ?? 0) - (a.value ?? 0) + ); + const primaryHoldings = sortedByValue.slice(0, 3).map((asset) => ({ + symbol: asset.symbol, + value: asset.value ?? 0 + })); + const otherValue = sortedByValue + .slice(3) + .reduce((sum, asset) => sum + (asset.value ?? 0), 0); + if (otherValue > 0) { + primaryHoldings.push({ symbol: "Other", value: otherValue }); + } + + const allocation = + totalValue > 0 + ? normalizePercentages( + primaryHoldings.map((holding) => ({ + symbol: holding.symbol, + percentage: (holding.value / totalValue) * 100 + })) + ) + : []; + + return { + totalValue, + weightedChange, + bestPerformer, + worstPerformer, + allocation + }; +} diff --git a/readme.MD b/readme.MD index f8607b4..123bd54 100644 --- a/readme.MD +++ b/readme.MD @@ -16,9 +16,14 @@ A Next.js dashboard experience for monitoring cryptocurrency holdings across exc ## Features -- Portfolio overview with total balance and 24h performance snapshot +- Portfolio overview with total balance and 24h performance snapshot derived from your holdings data +- Automatic best/worst performer highlights and allocation breakdown - Exchange connectivity cards with status indicators - Asset holdings table showing balances, prices, and change percentages - Tailwind CSS design tokens for a cohesive, glassmorphism-inspired aesthetic Mock data is provided in `app/page.jsx` to demonstrate the layout. + +## Connecting Real Data + +When you're ready to replace the mock arrays, follow the detailed steps in [`docs/integration-guide.md`](docs/integration-guide.md) to configure Binance, Coinbase Advanced Trade, MetaMask, and Kraken credentials.