init commit

This commit is contained in:
lwb 2026-01-20 11:20:04 +01:00
commit 730590bdc9
55 changed files with 19432 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
.DS_*
*.log
logs
**/*.backup.*
**/*.back.*
__*
.env
bower_components
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

17898
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "react-crypto",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "react-scripts build",
"eject": "react-scripts eject",
"start": "react-scripts start",
"test": "react-scripts test"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.13.0",
"chart.js": "^4.5.0",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"react": "^19.2.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-router": "^7.7.0",
"react-scripts": "5.0.1",
"react-spinners": "^0.17.0",
"sass": "^1.97.0",
"web-vitals": "^2.1.4",
"zustand": "^5.0.0"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

43
public/index.html Normal file
View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

27
src/App.js Normal file
View File

@ -0,0 +1,27 @@
import { Routes, Route } from "react-router";
import Header from "./components/header";
import Home from "./pages/home";
import About from "./pages/about";
import CoinDetail from "./pages/coin-detail";
import NotFound from "./pages/not-found";
import Footer from "./components/footer";
import "./styles/main.scss";
const App = () => {
return (
<>
<Header />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/coin/:id" element={<CoinDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>
<Footer />
</>
);
};
export default App;

8
src/App.test.js Normal file
View File

@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

BIN
src/assets/bitcoin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,34 @@
import { Link } from "react-router";
import { nameShorten } from "../utils/string-helper";
const CoinCard = ({ coin }) => {
return (
<Link to={`/coin/${coin.id}`} >
<div className="coin-card">
<div className="coin-header">
<img src={coin.image} alt={coin.name} className="coin-image" />
<h2>{nameShorten(coin.name)}</h2>
<p className="symbol">{coin.symbol.toUpperCase()}</p>
</div>
<p>
Price: <span>$ {coin.current_price.toLocaleString()}</span>
</p>
<p className={
coin?.price_change_percentage_24h
? coin.price_change_percentage_24h >= 0 ? 'positive' : 'negative'
: ''
}>
{/* coin.price_change_percentage_24h.toFixed(2) */}
{parseFloat(coin?.price_change_percentage_24h || 0).toFixed(2)}
</p>
<p>
Market Cap: <span>{parseInt(coin?.market_cap || 0).toLocaleString()}</span>
</p>
</div>
</Link>
);
}
export default CoinCard;

View File

@ -0,0 +1,108 @@
import { useState, useEffect } from "react";
import { Line } from "react-chartjs-2";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
TimeScale,
} from "chart.js";
import "chartjs-adapter-date-fns";
import { useCoinPricesStore, fetchCoinPrices } from "../store/coins-prices";
import Spinner from "./spinner";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
TimeScale
);
const CoinChart = ({ coinId }) => {
const [chartData, setChartData] = useState(null);
const error = useCoinPricesStore(state => state.error);
const coinPrices = useCoinPricesStore(state => state.coinPrices);
const loading = useCoinPricesStore(state => state.loading);
useEffect(() => {
fetchCoinPrices(coinId);
}, [coinId])
useEffect(() => {
if (coinPrices) {
const quotes = coinPrices.prices.map(price => {
return {
x: price[0],
y: price[1],
}
});
setChartData({
datasets: [
{
label: "Price (USD)",
data: quotes,
fill: true,
borderColor: "#007bff",
backgroundColor: "rgba(0, 123, 255, 0.1)",
pointRadius: 0,
tension: 0.3,
}
]
});
}
}, [coinPrices])
if (!chartData) return <p>Loading</p>;
return (
<>
{loading && <Spinner color="#3b88c3" />}
{!loading && error && <div>Loading Price Data Error</div>}
{!loading && !error && (
<div style={{ marginTop: "30px" }}>
<Line
data={chartData}
options={{
responsive: true,
plugins: {
legend: { display: false },
tooltip: { mode: "index", intersect: false },
},
scales: {
x: {
type: "time",
time: {
unit: "day",
},
ticks: {
autoSkip: true,
maxTicksLimit: 7,
},
},
y: {
ticks: {
callback: (val) => `$${val.toLocaleString()}`,
},
},
},
}}
/>
</div>
)}
</>
);
}
export default CoinChart;

View File

@ -0,0 +1,84 @@
const CoinInfo = ({ coin }) => {
return (
<>
<h1 className="coin-details-title">
{coin ? `${coin?.name} ${coin?.symbol.toUpperCase()}` : "Coin Details"}
</h1>
{coin && (
<>
<img src={coin?.image?.large} alt={coin?.name} className="coin-details-image" />
<p className="coin-details-description">
{coin?.description?.en.split(". ")[0] + "."}
</p>
<div className="coin-details-info">
<h3>
Rank: <span># {coin.market_cap_rank}</span>
</h3>
<h3>
Current Price: {" "}
<span>
${coin.market_data.current_price.usd.toLocaleString()}
</span>
</h3>
<h4>
Market Cap:{" "}
<span>${coin.market_data.market_cap.usd.toLocaleString()}</span>
</h4>
<h4>
24h High:{" "}
<span>${coin.market_data.high_24h.usd.toLocaleString()}</span>
</h4>
<h4>
24h Low:{" "}
<span>${coin.market_data.low_24h.usd.toLocaleString()}</span>
</h4>
<h4>
24h Price Change:{" "}
<span>
${coin.market_data.price_change_24h.toFixed(2)} (
{coin.market_data.price_change_percentage_24h.toFixed(2)}%)
</span>
</h4>
<h4>
Circulating Supply:{" "}
<span>
{coin.market_data.circulating_supply.toLocaleString()}
</span>
</h4>
<h4>
Total Supply:{" "}
<span>
{coin.market_data.total_supply?.toLocaleString() || "N/A"}
</span>
</h4>
<h4>
All-Time High:{" "}
<span>
${coin.market_data.ath.usd.toLocaleString()} on{" "}
{new Date(coin.market_data.ath_date.usd).toLocaleDateString()}
</span>
</h4>
<h4>
All-Time Low:{" "}
<span>
${coin.market_data.atl.usd.toLocaleString()} on{" "}
{new Date(coin.market_data.atl_date.usd).toLocaleDateString()}
</span>
</h4>
<h4>
Last Updated:{" "}
<span>{new Date(coin.last_updated).toLocaleDateString()}</span>
</h4>
</div>
</>
)}
</>
);
}
export default CoinInfo;

View File

@ -0,0 +1,19 @@
import { messages } from "../utils/constants";
import RefreshButton from "./refresh-button";
const ErrorCard = ({ message }) => {
return (
<>
<div className="error">
<p> {messages.serverOverload}</p>
<p>Try again in few seconds</p>
<RefreshButton />
</div>
</>
);
}
export default ErrorCard;

View File

@ -0,0 +1,18 @@
import { useFilterStore } from "../store/controls";
import { messages } from "../utils/constants";
const FilterInput = () => {
const toggleFilter = useFilterStore(state => state.toggleFilter);
const onFilterChange = (e) => {
toggleFilter(e.target.value);
}
return (
<div className="filter">
<input type="text" placeholder={messages.filterPlaceholder} onChange={(e) => onFilterChange(e)} />
</div>
);
};
export default FilterInput;

12
src/components/footer.js Normal file
View File

@ -0,0 +1,12 @@
import { footerLinkUrl, footerLinkText } from "../utils/conf";
const Footer = () => {
return (
<footer>
<p>&copy; {new Date().getFullYear()}</p>
<a href={footerLinkUrl()} target="_blank" rel="noopener noreferrer">{footerLinkText()}</a>
</footer>
);
};
export default Footer;

12
src/components/header.js Normal file
View File

@ -0,0 +1,12 @@
import { Link } from "react-router";
const Header = () => {
return (
<div className="nav-box">
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</div>
);
}
export default Header;

View File

@ -0,0 +1,27 @@
import { useLimitStore } from "../store/controls";
import { fetchCoins, getCoinsLength } from "../store/coins-market";
const LimitSelector = () => {
const limit = useLimitStore(state => state.limit);
const toggleLimit = useLimitStore(state => state.toggleLimit);
const onSelectChange = (e) => {
toggleLimit(parseInt(e.target.value));
fetchCoins( parseInt( e.target.value ) );
}
return (
<div className="controls">
<label htmlFor="limit">Show:</label>
<select value={limit} id="limit" onChange={(e) => onSelectChange(e)}>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
);
}
export default LimitSelector;

View File

@ -0,0 +1,24 @@
import { fetchCoins, getCoinsLength } from "../store/coins-market";
import { getTimestampOfLastPersist } from "../store/coins-market";
import toast, { Toaster } from "react-hot-toast";
import { messages } from "../utils/constants";
const notify = (text) => toast.error(text, { duration: 3000, id: "reloadRapidDisallowed", style: { textAlign: 'center' } });
const RefreshButton = () => {
const onButonClick = () => {
if (Date.now() > getTimestampOfLastPersist() + 3000) fetchCoins(getCoinsLength() || 10);
else notify(messages.reloadRapidDisallowed);
}
return (
<>
<button className="refresh-btn" onClick={(e) => onButonClick()} > Reload</button>
<Toaster />
</>
);
}
export default RefreshButton;

View File

@ -0,0 +1,27 @@
import { SortBy } from "../utils/enum";
import { useSortByStore } from "../store/controls";
const SortSelector = () => {
const sortBy = useSortByStore(state => state.sortBy);
const toggleSortBy = useSortByStore(state => state.toggleSortBy);
const onSelectChange = (e) => {
toggleSortBy(e.target.value);
}
return (
<div className="controls">
<label htmlFor="sort">Sort By:</label>
<select id="sort" value={sortBy} onChange={(e) => onSelectChange(e)}>
<option value={SortBy.MARKET_CAP_DESC}>Market Cap (High To Low)</option>
<option value={SortBy.MARKET_CAP_ASC}>Market Cap (Low To High)</option>
<option value={SortBy.PRICE_DESC}>Price (High To Low)</option>
<option value={SortBy.PRICE_ASC}>Price (Low To High)</option>
<option value={SortBy.CHANGE_DESC}>24h Change (High To Low)</option>
<option value={SortBy.CHANGE_ASC}>24h Change (Low To High)</option>
</select>
</div>
);
}
export default SortSelector;

16
src/components/spinner.js Normal file
View File

@ -0,0 +1,16 @@
import { BarLoader } from "react-spinners";
const override = {
display: "block",
margin: "0 auto 50px auto",
};
const Spinner = ({ color = "blue", width = 200 }) => {
return (
<div>
<BarLoader color={color} width={width} cssOverride={override} aria-label="Loading..." />
</div>
);
}
export default Spinner;

13
src/index.css Normal file
View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

14
src/index.js Normal file
View File

@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from "react-router";
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
//<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
//</React.StrictMode>
);

1
src/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

12
src/pages/about.js Normal file
View File

@ -0,0 +1,12 @@
import { messages } from "../utils/constants";
const About = () => {
return (
<div className="about">
<h1>📈 Crypto Viewer</h1>
<p>{messages.aboutDescription}</p>
</div>
);
}
export default About;

46
src/pages/coin-detail.js Normal file
View File

@ -0,0 +1,46 @@
import { Link, useParams } from "react-router";
import CoinInfo from "../components/coin-info";
import { useCoinInfoStore, reset, fetchCoinInfos } from "../store/coin-info";
import { useEffect } from "react";
import CoinChart from "../components/coin-chart";
import Spinner from "../components/spinner";
const CoinDetail = () => {
const { id } = useParams();
const coin = useCoinInfoStore(state => state.coinInfo);
const loading = useCoinInfoStore(state => state.loading);
const error = useCoinInfoStore(state => state.error);
useEffect(() => {
reset();
}, [])
useEffect(() => {
const fetch = async () => {
fetchCoinInfos(id);
}
fetch();
}, [id]);
return (
<div className="coin-details-container">
<Link to="/"> Back to Home</Link>
{loading && <Spinner color="#3b88c3" />}
{error && <div>Server overloaded, try again in few seconds</div>}
{!loading && coin && (
<>
<CoinInfo coin={coin} />
<CoinChart coinId={coin.id} />
</>
)}
</div>
);
};
export default CoinDetail;

71
src/pages/home.js Normal file
View File

@ -0,0 +1,71 @@
import { useEffect, useRef } from "react";
import FilterInput from "../components/filter-input";
import LimitSelector from "../components/limit-selector";
import SortSelector from "../components/sort-selector";
import CoinCard from "../components/coin-card";
import { useCoinsMarketStore, fetchCoins, getTimestampOfLastPersist } from "../store/coins-market";
import { useFilterStore, useSortByStore, clearFilter } from "../store/controls";
import { coinsFilter } from "../utils/coins-filter";
import Spinner from "../components/spinner";
import RefreshButton from "../components/refresh-button";
import ErrorCard from "../components/error-card";
import { initialCoinsCoint } from "../utils/conf";
const Home = () => {
const coins = useCoinsMarketStore(state => state.coins);
const loading = useCoinsMarketStore(state => state.loading);
const error = useCoinsMarketStore(state => state.error);
const filter = useFilterStore(state => state.filter);
const sortBy = useSortByStore(state => state.sortBy);
useEffect(() => {
clearFilter();
if (getTimestampOfLastPersist() === 0) {
fetchCoins(initialCoinsCoint());
}
}, []);
let filteredCoins = [];
if (coins) {
filteredCoins = coinsFilter(coins, filter, sortBy);
}
return (
<div>
{/* HEADER ROW*/}
<h1>📈 Crypto Viewer</h1>
{/* CONTROLS ROW */}
<div className="control-box">
<FilterInput />
<LimitSelector />
<SortSelector />
<RefreshButton />
</div>
{/* LOADER */}
{loading && <Spinner color="white" />}
{/* ERROR */}
{error && <ErrorCard message={error} />}
{/* GRID */}
{!loading && !error && (
<main className="grid">
{
filteredCoins.length > 0
?
filteredCoins.map(coin => <CoinCard key={coin.id} coin={coin} />)
:
<p>No matching coins</p>
}
</main>
)}
</div>
);
}
export default Home;

13
src/pages/not-found.js Normal file
View File

@ -0,0 +1,13 @@
import { Link } from "react-router";
const NotFound = () => {
return (
<div className="not-found-container">
<h1 className="title">404</h1>
<p className="message">The page does not exist</p>
<Link to="/" className="title">Go Back Home</Link>
</div>
);
}
export default NotFound;

13
src/reportWebVitals.js Normal file
View File

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

5
src/setupTests.js Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

86
src/store/coin-info.js Normal file
View File

@ -0,0 +1,86 @@
import { create } from "zustand";
import axios from "axios";
import { join } from "../utils/path";
import { messages } from "../utils/constants";
import { apiBaseUrl, apiKey } from "../utils/conf";
import sleep from "../utils/sleep";
const init = {
coinId: null,
coinInfo: null,
loading: false,
error: null,
ts: 0,
}
export const useCoinInfoStore = create((set, get) => ({
...init,
clear: () => {
set(state => ({
coinId: null,
coinInfo: null,
}))
},
persistCoinInfo: (coinId, coinInfo) => {
set(state => ({
coinId: coinId,
coinInfo: coinInfo,
ts: Date.now(),
}))
},
setError: (message) => {
set(state => ({
error: message,
}))
},
fetch: async (id) => {
try {
set({ loading: true });
if (get().ts) await sleep(get().ts);
const keyParam = (apiKey()) ? `?x_cg_demo_api_key=${apiKey()}` : "";
const url = join(
apiBaseUrl(),
`coins/${id}${keyParam}`
);
const res = await axios.get(url);
get().persistCoinInfo(id, res.data);
} catch (err) {
switch (err.code) {
case 'ERR_NETWORK':
get().setError(messages.serverOverload);
break;
default:
get().setError(err.message);
}
} finally {
set({ loading: false });
}
}
}));
export const reset = () => {
useCoinInfoStore.getState().clear();
}
export const persist = (coinId, coinInfo) => {
useCoinInfoStore.getState().persistCoinPrices(coinId, coinInfo);
};
export const toggleLoading = (isLoading) => {
useCoinInfoStore.setState({ loading: isLoading })
};
export const errorLoading = (message) => {
useCoinInfoStore.setState()({ error: message });
};
export const fetchCoinInfos = (coinId) => {
useCoinInfoStore.getState().fetch(coinId);
};
export const getTimestampOfLastPersist = () => {
return useCoinInfoStore.getState().ts;
}

108
src/store/coins-market.js Normal file
View File

@ -0,0 +1,108 @@
import { create } from "zustand";
import axios from "axios";
import { join } from "../utils/path";
import { messages } from "../utils/constants";
import { apiBaseUrl, apiKey } from "../utils/conf";
import sleep from "../utils/sleep";
const init = {
coins: null,
size: 0,
loading: false,
error: null,
ts: 0,
}
export const useCoinsMarketStore = create((set, get) => {
return {
...init,
reset: () => {
set(state => ({
...init,
}))
},
addCoins: (coins) => {
set(state => ({
coins: [...state.coins, ...coins]
}))
},
removeCoin: (coinId) => {
set(state => ({
coins: state.coins.filter(coin => coin.id !== coinId)
}))
},
persistCoins: (coins) => {
set(state => ({
size: coins.length,
coins: coins,
error: null,
ts: Date.now(),
}))
},
setError: (message) => {
set(state => ({
error: message,
}))
},
fetch: async (limit) => {
try {
set({ loading: true });
if (get().ts) await sleep(get().ts);
const keyParam = (apiKey()) ? `&x_cg_demo_api_key=${apiKey()}` : "";
const url = join(
apiBaseUrl(),
`coins/markets?vs_currency=usd&order=market_cap_desc&per_page=${limit}&page=1&sparkline=false${keyParam}`,
);
const res = await axios.get(url);
get().persistCoins(res.data);
} catch (err) {
switch (err.code) {
case 'ERR_NETWORK':
get().setError(messages.serverOverload);
break;
default:
get().setError(err.message);
}
} finally {
set({ loading: false });
}
}
}
});
export const reset = () => {
useCoinsMarketStore.getState().reset();
}
export const tsLastFetch = () => {
return useCoinsMarketStore.getState().ts;
}
export const getTimestampOfLastPersist = () => {
return useCoinsMarketStore.getState().ts;
}
export const persist = (coins) => {
useCoinsMarketStore.getState().persistCoins(coins);
};
export const toggleLoading = (isLoading) => {
useCoinsMarketStore.setState({ loading: isLoading })
};
export const errorLoading = (message) => {
useCoinsMarketStore.getState().setError(message);
};
export const fetchCoins = (limit = 10) => {
useCoinsMarketStore.getState().fetch(limit);
};
export const getCoinsLength = () => {
const coins = useCoinsMarketStore.getState().coins;
return (coins) ? coins.length : null;
}

75
src/store/coins-prices.js Normal file
View File

@ -0,0 +1,75 @@
import { create } from "zustand";
import axios from "axios";
import { join } from "../utils/path";
import { messages } from "../utils/constants";
import { apiBaseUrl, apiKey } from "../utils/conf";
import sleep from "../utils/sleep";
const init = {
coinId: null,
coinPrices: null,
loading: false,
error: null,
}
export const useCoinPricesStore = create((set, get) => {
return {
...init,
reset: () => {
set(state => ({
...init,
}))
},
persistCoinPrices: (coinId, coinPrices) => {
set(state => ({
coinId: coinId,
coinPrices: coinPrices,
}))
},
setError: (message) => {
set(state => ({
error: message,
}))
},
fetch: async (coinId, limit) => {
try {
set({ loading: true });
if (get().ts) await sleep(get().ts);
const keyParam = (apiKey()) ? `&x_cg_demo_api_key=${apiKey()}` : "";
const url = join(apiBaseUrl(), `coins/${coinId}/market_chart?vs_currency=usd&days=${limit}${keyParam}`);
const res = await axios.get(url);
get().persistCoinPrices(coinId, res.data);
} catch (err) {
switch (err.code) {
case 'ERR_NETWORK':
get().setError(messages.serverOverload);
break;
default:
get().setError(err.message);
}
} finally {
set({ loading: false });
}
}
}
});
export const reset = () => {
useCoinPricesStore.getState().reset();
}
export const persist = (coinId, prices) => {
useCoinPricesStore.getState().persistCoinPrices(coinId, prices);
};
export const toggleLoading = (isLoading) => {
useCoinPricesStore.setState({ loading: isLoading })
};
export const errorLoading = (message) => {
useCoinPricesStore.setState({ error: message });
};
export const fetchCoinPrices = (coinId, limit = 10) => {
useCoinPricesStore.getState().fetch(coinId, limit);
};

53
src/store/controls.js vendored Normal file
View File

@ -0,0 +1,53 @@
import { create } from "zustand";
import { SortBy } from "../utils/enum";
export const useRefreshStore = create((set, get) => {
return {
previousRefreshDatetime: null,
refreshDatetime: null,
refresh: () => {
set(state => ({
previousRefreshDatetime: state.refreshDatetime,
refreshDatetime: Date.now(),
}))
}
}
});
export const useFilterStore = create((set, get) => {
return {
filter: "",
toggleFilter: (filter) => {
set(state => ({
filter: filter
}))
},
}
});
export const clearFilter = () => {
useFilterStore.setState({filter: ""});
}
export const useSortByStore = create((set, get) => {
return {
sortBy: SortBy.MARKET_CAP_DESC,
toggleSortBy: (sortBy) => {
set(state => ({
sortBy: sortBy,
}))
},
}
});
export const useLimitStore = create((set, get) => {
return {
limit: 10,
toggleLimit: (limit) => {
set(state => ({
limit: limit,
}))
},
}
})

18
src/styles/_about.scss Normal file
View File

@ -0,0 +1,18 @@
/* About Page */
.about {
max-width: 600px;
margin: 2rem auto;
background-color: #161b22;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
h1 {
margin-bottom: 1rem;
}
p {
margin-bottom: 1rem;
line-height: 1.6;
}
}

View File

@ -0,0 +1,55 @@
.coin-card {
display: flex;
flex-flow: column nowrap;
justify-content: center;
background-color: #161b22;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
transition: transform 0.4s ease;
min-height: 260px;
&:hover {
transform: translateY(-5px);
}
p {
font-weight: bold;
margin: 0.1rem 0;
}
span {
font-weight: normal;
}
.coin-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.coin-image {
width: 40px;
height: 40px;
}
.symbol {
font-size: 0.8rem;
color: #aaa;
}
.positive {
color: #4caf50;
}
.negative {
color: #f44336;
}
}
@media screen and (max-width: 300px) {
.coin-card {
padding: 1rem;
}
}

View File

@ -0,0 +1,75 @@
.coin-details-container {
max-width: 700px;
margin: 40px auto;
text-align: center;
padding: 20px;
font-family: Arial, sans-serif;
color: #333;
background-color: #f9f9f9;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
a {
display: inline-block;
margin-bottom: 10px;
text-decoration: none;
color: #3b88c3;
font-weight: bold;
transition: color 0.3s;
&:hover {
color: #0056b3;
}
}
.coin-details-title {
font-size: 36px;
margin-bottom: 10px;
}
.coin-details-image {
width: 80px;
margin-bottom: 10px;
}
.coin-details-description {
font-size: 16px;
margin-bottom: 10px;
color: #555;
}
.coin-details-info {
display: flex;
flex-flow: column nowrap;
h3,
h4 {
margin: 5px 0;
color: #222;
}
h3 {
font-size: 22px;
}
h4 {
font-size: 16px;
}
span {
font-weight: normal;
}
}
.coin-details-links {
margin-top: 30px;
a {
color: #007bff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}

27
src/styles/_common.scss Normal file
View File

@ -0,0 +1,27 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: $background-color;
color: $main-text-color;
line-height: 1.6;
padding: 2rem;
}
h1 {
margin-bottom: 2rem;
font-size: 2rem;
}
h2 {
font-size: 1.2rem;
}
a {
color: #fff;
text-decoration: none;
}

68
src/styles/_controls.scss Normal file
View File

@ -0,0 +1,68 @@
.control-box {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3rem;
gap: 1rem;
flex-wrap: wrap;
.filter {
flex: 1;
}
/* Filter Input */
.filter {
_margin-bottom: 2rem;
input {
width: 100%;
padding: 0.75rem;
border-radius: 8px;
border: none;
background: $controls-bg-color;
color: #fff;
font-size: 1rem;
}
}
.controls {
flex-shrink: 0;
}
/* Limit Select */
.controls {
_margin-bottom: 2rem;
display: flex;
align-items: center;
justify-content: end;
gap: 0.75rem;
label {
font-weight: bold;
}
select {
padding: 0.75rem 0.5rem;
border-radius: 8px;
background: $controls-bg-color;
color: white;
border: none;
}
}
}
button {
&.refresh-btn {
padding: 0.75rem 1rem;
border-radius: 8px;
border: none;
background: $controls-bg-color;
color: #fff;
font-weight: 600;
cursor: pointer;
&:hover {
background: #2b303a;
}
}
}

12
src/styles/_error.scss Normal file
View File

@ -0,0 +1,12 @@
.error {
margin: 0 auto 50px auto;
text-align: center;
background-color: #161b22;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
p {
line-height: 2rem;
}
}

10
src/styles/_footer.scss Normal file
View File

@ -0,0 +1,10 @@
footer {
display: flex;
justify-content: center;
gap: 0.25rem;
padding: 1rem 0;
a:hover {
text-decoration: underline;
}
}

18
src/styles/_grid.scss Normal file
View File

@ -0,0 +1,18 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.grid-table {
padding: 0 3rem;
display: grid;
justify-items: start;
grid-template-columns: 30% 70%;
row-gap: 0.5rem;
.left {
font-weight: bold;
}
}

17
src/styles/_nav.scss Normal file
View File

@ -0,0 +1,17 @@
.nav-box {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-bottom: 1rem;
a {
color: $nav-text-color;
text-decoration: none;
font-weight: bold;
transition: color 0.2s ease;
&:hover {
color: $nav-text-hover-color;
}
}
}

View File

@ -0,0 +1,22 @@
.not-found-container {
text-align: center;
padding: 80px 20px;
color: #fff;
.title {
font-size: 72px;
margin-bottom: 20px;
}
.message {
font-size: 18px;
margin-bottom: 30px;
}
link {
text-decoration: none;
color: #007bff;
font-weight: bold;
}
}

View File

@ -0,0 +1,5 @@
$background-color: #0e1117;
$main-text-color: #f0f0f0;
$nav-text-color: #58a6ff;
$nav-text-hover-color: #4090db;
$controls-bg-color: #1c1f26;

11
src/styles/main.scss Normal file
View File

@ -0,0 +1,11 @@
@import 'variables';
@import 'common';
@import 'controls';
@import 'nav';
@import 'about';
@import 'grid';
@import 'coin-card';
@import 'coin-details';
@import 'not-found';
@import 'footer';
@import 'error';

32
src/utils/coins-filter.js Normal file
View File

@ -0,0 +1,32 @@
import { SortBy } from "./enum";
export const coinsFilter = (coins, filter, sortBy) => {
let filteredCoins = coins
.filter(coin => {
return (
coin?.name.toLowerCase().includes(filter.toLowerCase())
||
coin?.symbol.toLowerCase().includes(filter.toLowerCase()));
})
.slice()
.sort((a, b) => {
switch (sortBy) {
case SortBy.MARKET_CAP_DESC:
return b.market_cap - a.market_cap;
case SortBy.MARKET_CAP_ASC:
return a.market_cap - b.market_cap;
case SortBy.PRICE_DESC:
return b.current_price - a.current_price;
case SortBy.PRICE_ASC:
return a.current_price - b.current_price;
case SortBy.CHANGE_DESC:
return b.price_change_percentage_24h - a.price_change_percentage_24h;
case SortBy.CHANGE_ASC:
return a.price_change_percentage_24h - b.price_change_percentage_24h;
default:
return 0;
}
});
return filteredCoins;
};

10
src/utils/conf.js Normal file
View File

@ -0,0 +1,10 @@
const env = process.env;
export const apiBaseUrl = () => env?.REACT_APP_CRYPTO_API || "";
export const apiReloadWaitTime = () => env?.REACT_APP_API_RELOAD_WAIT_TIME || 3000;
export const isNetworkOnline = () => env?.REACT_APP_NETWORK_ONLINE || true;
export const rateLimitInterval = () => env?.REACT_APP_NETWORK_RATE_LIMIT_INTERVAL || 5000;
export const apiKey = () => env?.REACT_APP_CRYPTO_API_KEY || "";
export const footerLinkUrl = () => env?.REACT_APP_FOOTER_LINK_URL || "";
export const footerLinkText = () => env?.REACT_APP_FOOTER_LINK_TEXT || "";
export const initialCoinsCoint = () => env?.REACT_APP_INITIAL_COINS_COUNT || 10;

7
src/utils/constants.js Normal file
View File

@ -0,0 +1,7 @@
export const messages = {
filterPlaceholder: "Filter coins by name",
serverOverload: "Failed to handle request, server currently overloaded",
reloadRapidDisallowed: "Rapid reloading not allowed, wait few seconds.",
aboutDescription: "Find an display cryptocurrency data."
}

12
src/utils/enum.js Normal file
View File

@ -0,0 +1,12 @@
const sortBy = {
MARKET_CAP_DESC: 'market_cap_desc',
MARKET_CAP_ASC: 'market_cap_asc',
PRICE_DESC: 'price_desc',
PRICE_ASC: 'price_asc',
CHANGE_DESC: 'change_desc',
CHANGE_ASC: 'change_asc',
};
export const SortBy = Object.freeze({
...sortBy,
});

28
src/utils/path.js Normal file
View File

@ -0,0 +1,28 @@
/**
* An analog of Node.js's `path.join`
* @param {...string} segments
* @return {string}
*/
export const join = (...segments) => {
const parts = segments.reduce((parts, segment) => {
// Remove leading slashes from non-first part
if (parts.length > 0) {
segment = segment.replace(/^\//, '')
}
// Remove trailing slashes
segment = segment.replace(/\/$/, '')
return parts.concat(segment.split('/'))
}, [])
const resultParts = []
for (const part of parts) {
if (part === '.') {
continue
}
if (part === '..') {
resultParts.pop()
continue
}
resultParts.push(part)
}
return resultParts.join('/')
}

15
src/utils/sleep.js Normal file
View File

@ -0,0 +1,15 @@
import { rateLimitInterval } from "./conf";
const sleep = (lastTimestamp) => {
let p = new Promise(resolve => resolve());
const currentTimestamp = Date.now();
if (currentTimestamp < lastTimestamp + rateLimitInterval()) {
const iv = currentTimestamp - lastTimestamp;
let ms = Math.ceil(rateLimitInterval() - iv);
p = new Promise(resolve => setTimeout(resolve, ms));
}
return p;
}
export default sleep;

View File

@ -0,0 +1,14 @@
const shorten = (text, maxLength) => {
return text.slice(0, maxLength - 1);
}
const nameShorten = (text) => {
let parts = text.split(" ");
return (parts.length >= 2) ? `${parts[0]} ${parts[1]}` : parts[0];
}
export {
shorten,
nameShorten,
}