diff --git a/frontend/package.json b/frontend/package.json index fadd90d..0405070 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,7 @@ }, "devDependencies": { "@types/node": "^22.5.4", - "@types/react": "^18.0.28", + "@types/react": "latest", "@types/react-dom": "^18.0.11", "@types/vite-plugin-react-svg": "^0.2.2", "@vitejs/plugin-react": "^3.1.0", diff --git a/frontend/src/App.css b/frontend/src/App.css index c608d44..040acba 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -76,12 +76,15 @@ select { #root { height: 100vh; width: 100%; + display: grid; } .App { padding: 12px 24px 0; background-color: rgba(14, 17, 35, 0.02); min-height: 100%; + display: grid; + align-content: start; } @media (max-width: 767px) { diff --git a/frontend/src/components/ListItem/ListItem.tsx b/frontend/src/components/ListItem/ListItem.tsx index f6c3b7a..c996969 100644 --- a/frontend/src/components/ListItem/ListItem.tsx +++ b/frontend/src/components/ListItem/ListItem.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import './listItem.css'; type ListItemProps = { Icon: React.ReactNode; @@ -23,4 +23,4 @@ const ListItem = ({ Icon, label, spacing }: ListItemProps) => { ); }; -export default ListItem; +export default memo(ListItem); diff --git a/frontend/src/components/PerformanceCard/src/VolumeLabel.tsx b/frontend/src/components/PerformanceCard/src/VolumeLabel.tsx index d20f3f2..75175a4 100644 --- a/frontend/src/components/PerformanceCard/src/VolumeLabel.tsx +++ b/frontend/src/components/PerformanceCard/src/VolumeLabel.tsx @@ -3,6 +3,7 @@ import './performanceInfo.css'; import { ReactComponent as UpArrow } from '@/assets/up-arrow.svg'; import { ReactComponent as DownArrow } from '@/assets/down-arrow.svg'; import ListItem from '@/components/ListItem'; +import { showShortenedAmount } from '@/helpers/currency'; type TrendLabelProps = { volume: number; @@ -11,6 +12,6 @@ type TrendLabelProps = { const VolumeLabel = ({ volume, change }: TrendLabelProps) => { const arrow = change > 1 ? : ; - return ; + return ; }; export default memo(VolumeLabel); diff --git a/frontend/src/components/PriceChart/PriceChart.tsx b/frontend/src/components/PriceChart/PriceChart.tsx index 3846e8d..3e78a09 100644 --- a/frontend/src/components/PriceChart/PriceChart.tsx +++ b/frontend/src/components/PriceChart/PriceChart.tsx @@ -10,9 +10,11 @@ type PriceChartProps = { const PriceChart = ({ symbolId }: PriceChartProps) => { const dispatch = useAppDispatch(); + useEffect(() => { if (symbolId) { - dispatch(fetchPriceHistory(symbolId)); + const promise = dispatch(fetchPriceHistory(symbolId)); + return () => promise?.abort(); } }, [dispatch, symbolId]); @@ -20,18 +22,19 @@ const PriceChart = ({ symbolId }: PriceChartProps) => { const data = useAppSelector(selectors.selectPriceHistory); const symbolInfo = useAppSelector(selectors.selectSymbolInfo); - if (apiState.loading && symbolId !== null) + if (apiState.loading && symbolId !== null) { return (
); + } if (apiState.error) return
Failed to get price history!
; if (!symbolId) return
Select stock
; return (
{symbolInfo}
- + ({ ...e, time: new Date(e.time).toLocaleTimeString() }))}> diff --git a/frontend/src/components/PriceChart/priceChart.css b/frontend/src/components/PriceChart/priceChart.css index 6d8cb45..18636a6 100644 --- a/frontend/src/components/PriceChart/priceChart.css +++ b/frontend/src/components/PriceChart/priceChart.css @@ -1,7 +1,8 @@ .priceChart { + display: flex; + flex-direction: column; width: 400px; height: 300px; - display: flex; } @media (max-width: 1023px) { diff --git a/frontend/src/components/SymbolCard/SymbolCard.tsx b/frontend/src/components/SymbolCard/SymbolCard.tsx index 248de34..46a05b9 100644 --- a/frontend/src/components/SymbolCard/SymbolCard.tsx +++ b/frontend/src/components/SymbolCard/SymbolCard.tsx @@ -1,28 +1,84 @@ import './symbolCard.css'; -import { ReactComponent as CompanyIcon } from '@/assets/company.svg'; import { useAppSelector } from '@/hooks/redux'; -import ListItem from '@/components/ListItem'; +import { memo, useEffect, useRef } from 'react'; +import { selectShowCardInfo } from '@/store/dashboardOptionsSlice'; +import useAddBoxShadow from '@/hooks/useAddBoxShadow'; +import useAddEffect from '@/hooks/useAddEffect'; +import SymbolCardBody from '@/components/SymbolCard/src/SymbolCardBody'; +import SymbolCardHeader from '@/components/SymbolCard/src/SymbolCardHeader'; type SymbolCardProps = { id: string; onClick: (symbolId: string) => void; price: number; + activeSymbol: string | null; }; -const SymbolCard = ({ id, onClick, price }: SymbolCardProps) => { - const { trend, companyName } = useAppSelector((state) => state.stocks.entities[id]); +const SymbolCard = memo(({ id, onClick, price, activeSymbol }: SymbolCardProps) => { + const ref = useRef(null); + const priceRef = useRef(price); + const { shadow, addShadow, setShadow } = useAddBoxShadow(); + const { effect, addEffect, setEffect } = useAddEffect(); + const { trend, companyName, industry, marketCap } = useAppSelector( + (state) => state.stocks.entities[id] + ); + const showCardInfo = useAppSelector(selectShowCardInfo); + const handleOnClick = () => { onClick(id); }; + + useEffect(() => { + addShadow(price, priceRef.current); + }, [price]); + + useEffect(() => { + addEffect(price, priceRef.current); + priceRef.current = price; + }, [activeSymbol, price]); + + const getClassName = () => { + let className = 'symbolCard'; + + if (activeSymbol) { + if (activeSymbol === id) { + className = className + ` symbolCard--active`; + } else className = className + ` symbolCard--nonactive`; + } + + if (effect) className = className + ` symbolCard--${effect}`; + if (shadow) className = className + ` symbolCard--${shadow}`; + + return className; + }; + + const onAnimationEnd = () => { + if (ref.current) { + setShadow(''); + setEffect(''); + } + }; + return ( -
-
- {id} - {trend} -
-
Price:
-
{price || '--'}
- } label={companyName} /> +
+ +
); -}; +}); + export default SymbolCard; + +SymbolCard.displayName = 'SymbolCard'; diff --git a/frontend/src/components/SymbolCard/src/SymbolCardBody.tsx b/frontend/src/components/SymbolCard/src/SymbolCardBody.tsx new file mode 100644 index 0000000..c1cef36 --- /dev/null +++ b/frontend/src/components/SymbolCard/src/SymbolCardBody.tsx @@ -0,0 +1,21 @@ +import SymbolCardList from '@/components/SymbolCard/src/SymbolCardList'; +import SymbolCardPrice from '@/components/SymbolCard/src/SymbolCardPrice'; +import './symbolCardBody.css'; +import { Stock } from '@/store/stocksSlice'; +import { memo } from 'react'; + +export type SymbolCardBodyProps = { + price: number; + showCardInfo: boolean; +} & Pick; + +const SymbolCardBody = ({ price, showCardInfo, ...rest }: SymbolCardBodyProps) => { + return ( +
+ + {showCardInfo && } +
+ ); +}; + +export default memo(SymbolCardBody); diff --git a/frontend/src/components/SymbolCard/src/SymbolCardHeader.tsx b/frontend/src/components/SymbolCard/src/SymbolCardHeader.tsx new file mode 100644 index 0000000..e1a55b2 --- /dev/null +++ b/frontend/src/components/SymbolCard/src/SymbolCardHeader.tsx @@ -0,0 +1,25 @@ +import './symbolCardHeader.css'; +import arrowDown from '@/assets/down.png'; +import arrowUp from '@/assets/up.png'; +import { memo } from 'react'; + +type SymbolCardHeaderProps = { + id: string; + trend: 'UP' | 'DOWN' | null; +}; + +const SymbolCardHeader = memo(({ id, trend }: SymbolCardHeaderProps) => { + return ( +
+ {trend && ( + + )} + {id} +
+ ); +}); + +export default SymbolCardHeader; diff --git a/frontend/src/components/SymbolCard/src/SymbolCardList.tsx b/frontend/src/components/SymbolCard/src/SymbolCardList.tsx new file mode 100644 index 0000000..e30a949 --- /dev/null +++ b/frontend/src/components/SymbolCard/src/SymbolCardList.tsx @@ -0,0 +1,36 @@ +import ListItem from '@/components/ListItem'; +import { SymbolCardBodyProps } from '@/components/SymbolCard/src/SymbolCardBody'; +import { showShortenedAmount } from '@/helpers/currency'; +import { ReactComponent as CompanyIcon } from '@/assets/company.svg'; +import { ReactComponent as IndustryIcon } from '@/assets/industry.svg'; +import { ReactComponent as MarketCapIcon } from '@/assets/market_cap.svg'; +import { memo } from 'react'; + +type SymbolCardListProps = Omit; + +const SymbolCardList = ({ companyName, industry, marketCap }: SymbolCardListProps) => { + const listItems = [ + { + id: 'companyName', + label: companyName, + icon: CompanyIcon + }, + { + id: 'industry', + label: industry, + icon: IndustryIcon + }, + { + id: 'marketCap', + label: showShortenedAmount(marketCap), + icon: MarketCapIcon + } + ]; + + return listItems.map(({ id, label, icon }) => { + const Icon = icon; + return } label={label} spacing="space-between" />; + }); +}; + +export default memo(SymbolCardList); diff --git a/frontend/src/components/SymbolCard/src/SymbolCardPrice.tsx b/frontend/src/components/SymbolCard/src/SymbolCardPrice.tsx new file mode 100644 index 0000000..596c7a5 --- /dev/null +++ b/frontend/src/components/SymbolCard/src/SymbolCardPrice.tsx @@ -0,0 +1,17 @@ +import { addCurrency, getInteger } from '@/helpers/currency'; +import './symbolCardPrice.css'; + +type SymbolCardPriceProps = { + price: number; +}; + +const SymbolCardPrice = ({ price }: SymbolCardPriceProps) => { + return ( +
+ Price: + {price ? addCurrency(getInteger(price)) : '--'} +
+ ); +}; + +export default SymbolCardPrice; diff --git a/frontend/src/components/SymbolCard/src/symbolCardBody.css b/frontend/src/components/SymbolCard/src/symbolCardBody.css new file mode 100644 index 0000000..dca8d7c --- /dev/null +++ b/frontend/src/components/SymbolCard/src/symbolCardBody.css @@ -0,0 +1,4 @@ +.symbolCard__body { + background-color: var(--colorWhite); + padding: 8px; +} diff --git a/frontend/src/components/SymbolCard/src/symbolCardHeader.css b/frontend/src/components/SymbolCard/src/symbolCardHeader.css new file mode 100644 index 0000000..c50445d --- /dev/null +++ b/frontend/src/components/SymbolCard/src/symbolCardHeader.css @@ -0,0 +1,15 @@ +.symbolCard__header { + position: relative; + background-color: var(--colorDark); + color: var(--colorWhite); + font-size: 18px; + font-weight: 500; + padding: 0 8px; +} + +.symbolCard__arrowIcon { + position: absolute; + top: -16px; + right: -16px; + width: 40px; +} diff --git a/frontend/src/components/SymbolCard/src/symbolCardPrice.css b/frontend/src/components/SymbolCard/src/symbolCardPrice.css new file mode 100644 index 0000000..8567361 --- /dev/null +++ b/frontend/src/components/SymbolCard/src/symbolCardPrice.css @@ -0,0 +1,14 @@ +.symbolCard__price { + display: flex; + align-items: center; + justify-content: space-between; +} + +.symbolCard__price span { + font-size: 12px; + text-transform: uppercase; +} + +.symbolCard__price b { + font-size: 24px; +} diff --git a/frontend/src/components/SymbolCard/symbolCard.css b/frontend/src/components/SymbolCard/symbolCard.css index 08f2b47..fb35da6 100644 --- a/frontend/src/components/SymbolCard/symbolCard.css +++ b/frontend/src/components/SymbolCard/symbolCard.css @@ -1,11 +1,27 @@ .symbolCard { - border: 2px solid black; /* just for visibility, feel free to remove it */ - margin: 10px; /* just for visibility, feel free to remove it */ - padding: 10px; /* just for visibility, feel free to remove it */ + cursor: pointer; + transition: box-shadow 0.3s, scale 0.3s; } -.symbolCard__shake { - animation: shake 0.62s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; +.symbolCard--active { + box-shadow: 0 0 15px 0 var(--colorDark); + scale: 1.02; +} + +.symbolCard--nonactive { + scale: 0.98; +} + +.symbolCard--green { + animation: greenShadow 1s forwards; +} + +.symbolCard--red { + animation: redShadow 1s forwards; +} + +.symbolCard--shake { + animation: greenShadow 1s forwards, shake 0.62s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; } @keyframes shake { @@ -36,3 +52,15 @@ transform: translateX(6px); } } + +@keyframes greenShadow { + 20% { + box-shadow: 0 0 15px 0 green; + } +} + +@keyframes redShadow { + 20% { + box-shadow: 0 0 15px 0 red; + } +} diff --git a/frontend/src/components/SymbolsGrid/SymbolsGrid.tsx b/frontend/src/components/SymbolsGrid/SymbolsGrid.tsx index 10b1ae7..612d670 100644 --- a/frontend/src/components/SymbolsGrid/SymbolsGrid.tsx +++ b/frontend/src/components/SymbolsGrid/SymbolsGrid.tsx @@ -1,23 +1,33 @@ +import './symbolsGrid.css'; import { useEffect } from 'react'; import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import SymbolCard from '../SymbolCard'; import { fetchAllStocks, selectors } from '@/store/stocksSlice'; + type SymbolsGridProps = { onSymbolClick: (symbolId: string) => void; + activeSymbol: string | null; }; -const SymbolsGrid = ({ onSymbolClick }: SymbolsGridProps) => { +const SymbolsGrid = ({ onSymbolClick, activeSymbol }: SymbolsGridProps) => { const stockSymbols = useAppSelector(selectors.selectStockIds); const prices = useAppSelector((state) => state.prices); const dispatch = useAppDispatch(); + useEffect(() => { dispatch(fetchAllStocks()); }, [dispatch]); return ( -
+
{stockSymbols.map((id, i) => ( - + ))}
); diff --git a/frontend/src/components/SymbolsGrid/symbolsGrid.css b/frontend/src/components/SymbolsGrid/symbolsGrid.css new file mode 100644 index 0000000..8197da1 --- /dev/null +++ b/frontend/src/components/SymbolsGrid/symbolsGrid.css @@ -0,0 +1,5 @@ +.symbolsGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-gap: 24px; +} diff --git a/frontend/src/components/SymbolsView/SymbolsView.tsx b/frontend/src/components/SymbolsView/SymbolsView.tsx index 129d46f..0f1b20c 100644 --- a/frontend/src/components/SymbolsView/SymbolsView.tsx +++ b/frontend/src/components/SymbolsView/SymbolsView.tsx @@ -3,25 +3,35 @@ import PriceChart from '@/components/PriceChart'; import DesktopInfo from './src/DesktopInfo'; import { useState } from 'react'; +import './symbolsView.css'; + const SymbolsView = () => { - const [activeSymbol, setActiveSymbol] = useState(null); + const savedSymbol = localStorage.getItem('activeSymbol'); + + const [activeSymbol, setActiveSymbol] = useState(savedSymbol || null); + const handleSymbolClick = (symbolId: string) => { - setActiveSymbol((s) => (s === symbolId ? null : symbolId)); + setActiveSymbol((s) => { + const newActiveSymbol = s === symbolId ? null : symbolId; + localStorage.setItem('activeSymbol', newActiveSymbol || ''); + + return newActiveSymbol; + }); }; return ( -
- +
+ +

PRICE HISTORY

+
-
- -
- -
+
+
+
); }; diff --git a/frontend/src/components/SymbolsView/src/desktopInfo.css b/frontend/src/components/SymbolsView/src/desktopInfo.css index efe9d55..157aaca 100644 --- a/frontend/src/components/SymbolsView/src/desktopInfo.css +++ b/frontend/src/components/SymbolsView/src/desktopInfo.css @@ -1,3 +1,7 @@ +.desktopInfo { + overflow: hidden; +} + @media (max-width: 1023px) { .desktopInfo { display: none; diff --git a/frontend/src/components/SymbolsView/symbolsView.css b/frontend/src/components/SymbolsView/symbolsView.css new file mode 100644 index 0000000..563b135 --- /dev/null +++ b/frontend/src/components/SymbolsView/symbolsView.css @@ -0,0 +1,27 @@ +.symbolsView, +.symbolsView__content { + display: grid; + overflow: hidden; +} + +.symbolsView__chart { + padding: 12px 12px 24px; +} + +.symbolsView__cards { + padding: 16px; + background-color: #dbdbdb; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: none; +} + +@media (min-width: 1024px) { + .symbolsView__content { + grid-template-columns: 1fr 400px; + } + + .symbolsView__chart { + order: 1; + } +} diff --git a/frontend/src/helpers/currency.ts b/frontend/src/helpers/currency.ts new file mode 100644 index 0000000..5b30af9 --- /dev/null +++ b/frontend/src/helpers/currency.ts @@ -0,0 +1,11 @@ +const addCurrency = (value: number | string) => { + return `$${value}`; +}; + +const getInteger = (amount: number) => Math.ceil(amount); + +const showShortenedAmount = (amount: number) => { + return addCurrency(Intl.NumberFormat('en', { notation: 'compact' }).format(amount)); +}; + +export { addCurrency, getInteger, showShortenedAmount }; diff --git a/frontend/src/hooks/useAddBoxShadow.ts b/frontend/src/hooks/useAddBoxShadow.ts new file mode 100644 index 0000000..b993d2e --- /dev/null +++ b/frontend/src/hooks/useAddBoxShadow.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; + +export default () => { + const [shadow, setShadow] = useState(''); + + const addShadow = (price: number, prevPrice = 0) => { + if (price > prevPrice) { + setShadow('green'); + return; + } + + if (price < prevPrice) { + setShadow('red'); + return; + } + }; + + return { shadow, addShadow, setShadow }; +}; diff --git a/frontend/src/hooks/useAddEffect.ts b/frontend/src/hooks/useAddEffect.ts new file mode 100644 index 0000000..91c43df --- /dev/null +++ b/frontend/src/hooks/useAddEffect.ts @@ -0,0 +1,17 @@ +import { useState } from 'react'; + +export default () => { + const [effect, setEffect] = useState(''); + + const addEffect = (price: number, prevPrice: number) => { + if (price > prevPrice) { + const increasePercent = (100 * (price - prevPrice)) / prevPrice; + + if (increasePercent >= 25) { + setEffect('shake'); + } + } + }; + + return { effect, setEffect, addEffect }; +}; diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 105644d..f6a76c9 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -1,16 +1,28 @@ -import SymbolsView from '@/components/SymbolsView'; -import { Route, Routes, Navigate } from 'react-router-dom'; -import StatementsView from "@/components/StatementsView"; -import ProfileView from "@/components/ProfileView"; +import { Route, Routes, Navigate, Outlet } from 'react-router-dom'; +import { lazy, Suspense } from 'react'; + +const SymbolsView = lazy(() => import('@/components/SymbolsView')); +const StatementsView = lazy(() => import('@/components/StatementsView')); +const ProfileView = lazy(() => import('@/components/ProfileView')); + +function Layout() { + return ( + + + + ); +} const Router = () => { return ( - + + }> } /> } /> } /> } /> - + + ); }; diff --git a/frontend/src/store/stocksSlice.ts b/frontend/src/store/stocksSlice.ts index e7eb911..25f09b0 100644 --- a/frontend/src/store/stocksSlice.ts +++ b/frontend/src/store/stocksSlice.ts @@ -1,7 +1,7 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import { RootState } from '@/store/index'; -type Stock = { +export type Stock = { symbol: string; companyName: string; industry: string; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index a24c0b9..872606f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -850,7 +850,7 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:^18.0.28": +"@types/react@npm:*": version: 18.0.31 resolution: "@types/react@npm:18.0.31" dependencies: @@ -861,6 +861,16 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:latest": + version: 18.3.12 + resolution: "@types/react@npm:18.3.12" + dependencies: + "@types/prop-types": "*" + csstype: ^3.0.2 + checksum: 4ab1577a8c2105a5e316536f724117c90eee5f4bd5c137fc82a2253d8c1fd299dedaa07e8dfc95d6e2f04a4be3cb8b0e1b06098c6233ebd55c508d88099395b7 + languageName: node + linkType: hard + "@types/scheduler@npm:*": version: 0.16.3 resolution: "@types/scheduler@npm:0.16.3" @@ -1767,7 +1777,7 @@ __metadata: dependencies: "@reduxjs/toolkit": ^1.9.3 "@types/node": ^22.5.4 - "@types/react": ^18.0.28 + "@types/react": latest "@types/react-dom": ^18.0.11 "@types/vite-plugin-react-svg": ^0.2.2 "@vitejs/plugin-react": ^3.1.0 diff --git a/yarn.lock b/yarn.lock index 0141185..eeef3c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -824,7 +824,7 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:^18.0.28": +"@types/react@npm:*": version: 18.3.5 resolution: "@types/react@npm:18.3.5" dependencies: @@ -834,6 +834,16 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:^18.3.12": + version: 18.3.12 + resolution: "@types/react@npm:18.3.12" + dependencies: + "@types/prop-types": "*" + csstype: ^3.0.2 + checksum: 4ab1577a8c2105a5e316536f724117c90eee5f4bd5c137fc82a2253d8c1fd299dedaa07e8dfc95d6e2f04a4be3cb8b0e1b06098c6233ebd55c508d88099395b7 + languageName: node + linkType: hard + "@types/strip-bom@npm:^3.0.0": version: 3.0.0 resolution: "@types/strip-bom@npm:3.0.0" @@ -2303,7 +2313,7 @@ __metadata: dependencies: "@reduxjs/toolkit": ^1.9.3 "@types/node": ^22.5.4 - "@types/react": ^18.0.28 + "@types/react": ^18.3.12 "@types/react-dom": ^18.0.11 "@types/vite-plugin-react-svg": ^0.2.2 "@vitejs/plugin-react": ^3.1.0