Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
.next/
.env*
.DS_Store
26 changes: 10 additions & 16 deletions app/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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 (
<div className="min-h-screen bg-background/60">
Expand Down Expand Up @@ -98,9 +89,12 @@ export default function HomePage() {
<main className="container mx-auto space-y-8 px-4 py-8 sm:px-6">
<PortfolioOverview
totalValue={totalValue}
totalChange={totalChange}
totalChange={weightedChange}
connectedExchanges={connectedCount}
totalAssets={mockAssets.length}
bestPerformer={bestPerformer}
worstPerformer={worstPerformer}
allocation={allocation}
/>

<section className="space-y-4">
Expand Down
13 changes: 4 additions & 9 deletions components/AssetList.jsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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 (
<tr key={asset.symbol} className="text-sm">
<td className="px-4 py-3">
Expand All @@ -44,8 +39,8 @@ export const AssetList = ({ assets }) => {
<td className="px-4 py-3">
<div className={`flex items-center gap-2 ${isPositive ? "text-emerald-400" : "text-rose-400"}`}>
<ChangeIcon className="h-4 w-4" />
{isPositive ? "+" : ""}
{asset.change24h.toFixed(2)}%
{isPositive ? "+" : "-"}
{changeDisplay}%
</div>
</td>
<td className="px-4 py-3 font-semibold text-white">{formatCurrency(asset.value)}</td>
Expand Down
13 changes: 4 additions & 9 deletions components/ExchangeCard.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className="flex flex-col bg-background/70">
Expand Down Expand Up @@ -41,8 +36,8 @@ export const ExchangeCard = ({ exchange }) => {
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">24h Change</span>
<span className={isPositive ? "text-emerald-400" : "text-rose-400"}>
{isPositive ? "+" : ""}
{exchange.change24h.toFixed(2)}%
{isPositive ? "+" : "-"}
{changeDisplay}%
</span>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
Expand Down
33 changes: 20 additions & 13 deletions components/PortfolioOverview.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="grid grid-cols-1 gap-4 md:grid-cols-3">
Expand All @@ -37,8 +44,8 @@ export const PortfolioOverview = ({
<span className="text-muted-foreground">24h</span>
<ChangeIcon className={isPositive ? "h-4 w-4 text-emerald-400" : "h-4 w-4 text-rose-400"} />
<span className={isPositive ? "text-emerald-400" : "text-rose-400"}>
{isPositive ? "+" : ""}
{totalChange.toFixed(2)}%
{isPositive ? "+" : "-"}
{changeDisplay}%
</span>
</div>
</CardHeader>
Expand Down Expand Up @@ -80,15 +87,15 @@ export const PortfolioOverview = ({
<CardContent className="mt-auto space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-white/70">Best Performer</span>
<span className="text-sm font-semibold text-white">Solana (SOL)</span>
<span className="text-sm font-semibold text-white">{bestPerformerLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white/70">Worst Performer</span>
<span className="text-sm font-semibold text-white">Ethereum (ETH)</span>
<span className="text-sm font-semibold text-white">{worstPerformerLabel}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white/70">Allocation</span>
<span className="text-sm font-semibold text-white">45% BTC / 32% ALT / 23% DeFi</span>
<span className="text-sm font-semibold text-white">{allocationSummary}</span>
</div>
</CardContent>
</Card>
Expand Down
83 changes: 83 additions & 0 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
@@ -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:

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To improve readability and enable syntax highlighting for this code block, it's best practice to specify the language in Markdown. Adding bash is appropriate for this list of environment variables.

Suggested change
```
```bash

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.
116 changes: 116 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
Comment on lines +34 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The use of Math.round() here is unnecessary. The working array is created using Math.floor() on the percentages, which means item.percentage is always an integer. Consequently, the result of the reduce() operation is an integer, and subtracting it from 100 also yields an integer. Removing the redundant Math.round() call will make the code slightly cleaner.

  let remainder = 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
};
}
Loading