init commit
This commit is contained in:
commit
730590bdc9
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal 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
17898
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
public/index.html
Normal file
43
public/index.html
Normal 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
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal 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
3
public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
27
src/App.js
Normal file
27
src/App.js
Normal 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
8
src/App.test.js
Normal 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
BIN
src/assets/bitcoin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
34
src/components/coin-card.js
Normal file
34
src/components/coin-card.js
Normal 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;
|
||||
108
src/components/coin-chart.js
Normal file
108
src/components/coin-chart.js
Normal 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;
|
||||
84
src/components/coin-info.js
Normal file
84
src/components/coin-info.js
Normal 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;
|
||||
19
src/components/error-card.js
Normal file
19
src/components/error-card.js
Normal 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;
|
||||
18
src/components/filter-input.js
Normal file
18
src/components/filter-input.js
Normal 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
12
src/components/footer.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { footerLinkUrl, footerLinkText } from "../utils/conf";
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer>
|
||||
<p>© {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
12
src/components/header.js
Normal 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;
|
||||
27
src/components/limit-selector.js
Normal file
27
src/components/limit-selector.js
Normal 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;
|
||||
24
src/components/refresh-button.js
Normal file
24
src/components/refresh-button.js
Normal 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;
|
||||
27
src/components/sort-selector.js
Normal file
27
src/components/sort-selector.js
Normal 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
16
src/components/spinner.js
Normal 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
13
src/index.css
Normal 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
14
src/index.js
Normal 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
1
src/logo.svg
Normal 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
12
src/pages/about.js
Normal 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
46
src/pages/coin-detail.js
Normal 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
71
src/pages/home.js
Normal 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
13
src/pages/not-found.js
Normal 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
13
src/reportWebVitals.js
Normal 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
5
src/setupTests.js
Normal 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
86
src/store/coin-info.js
Normal 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
108
src/store/coins-market.js
Normal 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
75
src/store/coins-prices.js
Normal 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
53
src/store/controls.js
vendored
Normal 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
18
src/styles/_about.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
55
src/styles/_coin-card.scss
Normal file
55
src/styles/_coin-card.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
75
src/styles/_coin-details.scss
Normal file
75
src/styles/_coin-details.scss
Normal 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
27
src/styles/_common.scss
Normal 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
68
src/styles/_controls.scss
Normal 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
12
src/styles/_error.scss
Normal 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
10
src/styles/_footer.scss
Normal 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
18
src/styles/_grid.scss
Normal 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
17
src/styles/_nav.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/styles/_not-found.scss
Normal file
22
src/styles/_not-found.scss
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
5
src/styles/_variables.scss
Normal file
5
src/styles/_variables.scss
Normal 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
11
src/styles/main.scss
Normal 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
32
src/utils/coins-filter.js
Normal 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
10
src/utils/conf.js
Normal 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
7
src/utils/constants.js
Normal 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
12
src/utils/enum.js
Normal 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
28
src/utils/path.js
Normal 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
15
src/utils/sleep.js
Normal 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;
|
||||
|
||||
14
src/utils/string-helper.js
Normal file
14
src/utils/string-helper.js
Normal 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,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user