feat: massive ui improvements, proper fetching

This commit is contained in:
ryana mittens 2024-09-06 20:38:10 +08:00
parent 57ea40937d
commit d099694583
16 changed files with 426 additions and 89 deletions

View file

@ -0,0 +1,74 @@
import { useState, useEffect } from 'react';
interface CompanyNewsWidgetProps {
symbol: string;
}
interface NewsArticle {
category: string;
datetime: number;
headline: string;
id: number;
image: string;
related: string;
source: string;
summary: string;
url: string;
}
const CompanyNewsWidget = ({ symbol }: CompanyNewsWidgetProps) => {
const [news, setNews] = useState<NewsArticle[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchNews = async () => {
setLoading(true);
setError('');
try {
const res = await fetch(`/api/company-news?symbol=${symbol}`);
const data = await res.json();
if (res.ok) {
setNews(data);
} else {
setError(data.error || 'Failed to fetch data');
}
} catch (err) {
setError('Failed to fetch data');
} finally {
setLoading(false);
}
};
if (symbol) {
fetchNews();
}
}, [symbol]);
if (loading) {
return <p>Loading...</p>;
}
if (error) {
return <p className="text-red-500">{error}</p>;
}
return (
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4">
<h2 className="text-xl font-bold text-text dark:text-text mb-4">Company News for {symbol}</h2>
<div className="space-y-4">
{news.map((article) => (
<div key={article.id} className="rounded-lg bg-overlay0 dark:bg-mantle p-4">
<a href={article.url} target="_blank" rel="noopener noreferrer" className="text-text dark:text-text hover:underline">
<h3 className="text-lg font-semibold mb-2">{article.headline}</h3>
<p className="text-sm text-subtext0 dark:text-subtext1 mb-2">{article.source} | {new Date(article.datetime * 1000).toLocaleDateString()}</p>
<p className="text-sm text-text dark:text-text">{article.summary}</p>
</a>
</div>
))}
</div>
</div>
);
};
export default CompanyNewsWidget;

View file

@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
interface FinancialsWidgetProps {
symbol: string;
}
interface FinancialMetric {
name: string;
value: string | number;
}
interface FinancialData {
metric: Record<string, number | string>;
series: {
annual: Record<string, { period: string; v: number }[]>;
};
}
const FinancialsWidget = ({ symbol }: FinancialsWidgetProps) => {
const [financialData, setFinancialData] = useState<FinancialData | null>(null);
const [formattedData, setFormattedData] = useState<FinancialMetric[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const fetchFinancialData = async () => {
setLoading(true);
setError('');
try {
const res = await fetch(`/api/basic-financials?symbol=${symbol}`);
const data = await res.json();
if (res.ok) {
setFinancialData(data);
formatData(data);
} else {
setError(data.error || 'Failed to fetch data');
}
} catch (err) {
setError('Failed to fetch data');
} finally {
setLoading(false);
}
};
const formatData = (data: FinancialData) => {
const metrics = [];
for (const key in data.metric) {
metrics.push({
name: key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()),
value: data.metric[key],
});
}
setFormattedData(metrics);
};
if (symbol) {
fetchFinancialData();
}
}, [symbol]);
if (loading) {
return <p>Loading...</p>;
}
if (error) {
return <p className="text-red-500">{error}</p>;
}
if (!financialData) {
return <p>No financial data available.</p>;
}
return (
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4">
<h2 className="text-xl font-bold text-text dark:text-text mb-4">Basic Financials for {symbol}</h2>
<div className="flex flex-wrap gap-4">
{formattedData.map((data, index) => (
<div key={index} className="bg-overlay0 dark:bg-mantle p-4 rounded-lg flex-1 min-w-[200px]">
<p className="text-sm font-semibold text-subtext0 dark:text-subtext1">{data.name}</p>
<p className="text-lg text-text dark:text-text">{data.value}</p>
</div>
))}
</div>
</div>
);
};
export default FinancialsWidget;

View file

@ -33,53 +33,7 @@ const NavigationBar = ({ onSelectSymbol }: NavigationBarProps) => {
</div>
<div className="relative flex items-center space-x-4">
<ThemeSwitcher />
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="bg-mantle px-4 py-2 rounded focus:outline-none focus:bg-overlay0"
>
<GiHamburgerMenu />
</button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-48 bg-surface0 rounded-md shadow-lg z-20">
<div className="py-2 px-4">
<label
htmlFor="currency"
className="block text-sm font-medium text-text"
>
Currency:
</label>
<select
id="currency"
value={currency}
onChange={handleCurrencyChange}
className="mt-1 block w-full p-1 rounded border border-overlay2"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="JPY">JPY</option>
<option value="GBP">GBP</option>
</select>
</div>
<div className="py-2 px-4">
<label
htmlFor="watchlistView"
className="block text-sm font-medium text-text"
>
View:
</label>
<select
id="watchlistView"
value={watchlistView}
onChange={handleWatchlistViewChange}
className="mt-1 block w-full p-1 rounded border border-overlay2"
>
<option value="priceChange">Price Change</option>
<option value="percentageChange">Percentage Change</option>
<option value="marketCap">Market Cap</option>
</select>
</div>
</div>
)}
</div>
</div>
</nav>

View file

@ -20,7 +20,7 @@ const NewsColumn = () => {
useEffect(() => {
const fetchNews = async () => {
try {
const res = await fetch('/api/news');
const res = await fetch('/api/news');
const data = await res.json();
const filteredNews = data.result.filter((article: any) => article.source !== 'MarketWatch');
setNews(filteredNews);
@ -30,7 +30,7 @@ const NewsColumn = () => {
setLoading(false);
}
};
fetchNews();
}, []);
@ -42,10 +42,10 @@ const NewsColumn = () => {
return <p className="text-red-500">{error}</p>;
}
const featuredArticle = news[0];
const otherArticles = news.slice(1, 7);
const otherArticles = news.slice(1, 7);
const latestArticles = news.slice(7);
return (
<div className="lg:flex lg:space-x-4">
<div className="lg:flex-1 lg:space-y-4">
@ -63,12 +63,12 @@ const NewsColumn = () => {
alt={featuredArticle.headline}
className="w-full h-64 object-cover"
/>
<div className="p-4 bg-white dark:bg-gray-800">
<h3 className="text-xl font-bold">{featuredArticle.headline}</h3>
<p className="text-sm text-gray-500">
<div className="p-4 bg-surface0 dark:bg-surface0">
<h3 className="text-xl font-bold text-text dark:text-text">{featuredArticle.headline}</h3>
<p className="text-sm text-subtext1 dark:text-subtext1">
{new Date(featuredArticle.datetime * 1000).toLocaleString()} | {featuredArticle.source}
</p>
<p className="text-sm mt-2">{featuredArticle.summary}</p>
<p className="text-sm mt-2 text-text dark:text-subtext1">{featuredArticle.summary}</p>
</div>
</a>
)}
@ -89,9 +89,9 @@ const NewsColumn = () => {
alt={article.headline}
className="w-full h-32 object-cover rounded-t-lg"
/>
<div className="p-2 bg-white h-full dark:bg-gray-800">
<h3 className="text-sm font-semibold">{article.headline}</h3>
<p className="text-xs text-gray-500">
<div className="p-2 bg-surface0 dark:bg-surface0 h-full">
<h3 className="text-sm font-semibold text-text dark:text-text">{article.headline}</h3>
<p className="text-xs text-subtext1 dark:text-subtext1">
{new Date(article.datetime * 1000).toLocaleString()} | {article.source}
</p>
</div>
@ -101,8 +101,8 @@ const NewsColumn = () => {
</div>
{/* Latest news sidebar */}
<div className="mt-4 lg:mt-0 lg:w-1/4 lg:order-last border p-4 rounded-lg bg-white dark:bg-gray-800 lg:overflow-y-auto lg:h-screen">
<h2 className="text-xl font-bold mb-4">Latest</h2>
<div className="mt-4 lg:mt-0 lg:w-1/4 lg:order-last border-text dark:border-crust border p-4 rounded-lg bg-surface0 dark:bg-surface0 lg:overflow-y-auto lg:h-screen">
<h2 className="text-xl font-bold text-text dark:text-text mb-4">Latest</h2>
<ul>
{latestArticles.map(article => (
<li key={article.id} className="mb-4">
@ -112,8 +112,8 @@ const NewsColumn = () => {
rel="noopener noreferrer"
className="hover:underline block"
>
<p className="text-sm font-semibold">{article.headline}</p>
<p className="text-xs text-gray-500">
<p className="text-sm font-semibold text-text dark:text-text">{article.headline}</p>
<p className="text-xs text-subtext1 dark:text-subtext1">
{new Date(article.datetime * 1000).toLocaleString()}
</p>
</a>

View file

@ -48,7 +48,7 @@ const PeersWidget = ({ symbol }: PeersWidgetProps) => {
{peers.map((peer, index) => (
<li
key={index}
className="inline-block bg-text dark:bg-text text-white text-sm font-medium py-1 px-2 m-1 rounded-md"
className="inline-block bg-text dark:bg-crust text-white text-sm font-medium py-1 px-2 m-1 rounded-md"
>
{peer}
</li>

View file

@ -0,0 +1,52 @@
import {
LineChart,
Line,
CartesianGrid,
ResponsiveContainer,
defs,
linearGradient,
stop,
YAxis,
} from 'recharts';
import { formatPriceData } from '../utils/formatPriceData';
interface PriceGraphProps {
data: { c: number | null; h: number | null; l: number | null; o: number | null; pc: number | null; t: number | null; }[];
}
const PriceGraph = ({ data }: PriceGraphProps) => {
const formattedData = formatPriceData(data);
const prices = formattedData.map((d) => d.price);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={formattedData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-overlay1)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-crust)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-overlay0)" />
<YAxis
type="number"
domain={[minPrice - 1, maxPrice + 1]}
hide
/>
<Line
type="monotone"
dataKey="price"
stroke="var(--color-text)"
strokeWidth={2}
dot={false}
fill="url(#colorUv)"
/>
</LineChart>
</ResponsiveContainer>
);
};
export default PriceGraph;

View file

@ -140,7 +140,7 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
{isPickerVisible && (
<>
{
<ul className="absolute border bg-mantle w-full mt-2 rounded-md border-surface0 overflow-hidden">
<ul className="z-50 absolute border bg-mantle w-full mt-2 rounded-md border-surface0 overflow-hidden">
{suggestions.map((item, index) => (
<li

View file

@ -1,4 +1,5 @@
import { LineChart, Line, CartesianGrid, Tooltip, ResponsiveContainer, defs, linearGradient, stop } from 'recharts';
import { useState, useEffect } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { generateMockHistoricalData } from '../utils/mockHistoricalData';
interface StockGraphProps {
@ -6,22 +7,32 @@ interface StockGraphProps {
}
const StockGraph = ({ symbol }: StockGraphProps) => {
const data = generateMockHistoricalData(symbol);
const [historicalData, setHistoricalData] = useState([]);
useEffect(() => {
if (symbol) {
const data = generateMockHistoricalData(symbol);
setHistoricalData(data);
}
}, [symbol]);
if (historicalData.length === 0) {
return <p>Loading data...</p>;
}
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-text)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--color-text)" stopOpacity={1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<Tooltip />
<Line type="monotone" dataKey="close" stroke="var(--color-text)" strokeWidth={2} dot={false} fill="url(#fillGradient)" />
</LineChart>
</ResponsiveContainer>
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md mt-4">
<h2 className="text-xl font-bold mb-4">Stock Trend for {symbol}</h2>
<ResponsiveContainer width="100%" height={400}>
<LineChart data={historicalData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="close" stroke="#8884d8" activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
</div>
);
};

View file

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import StockGraph from './StockGraph';
import PriceGraph from './PriceGraph';
import StockPriceGraph from './StockPriceGraph';
interface StockPriceProps {
symbol: string;
@ -73,7 +74,7 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
return (
<div className="relative bg-surface0 dark:bg-surface0 p-6 rounded-t-lg shadow-md">
<div className="absolute inset-0 opacity-20">
<StockGraph symbol={symbol} />
<StockPriceGraph symbol={symbol} />
</div>
{symbol && (
<div className="relative z-10">

View file

@ -0,0 +1,97 @@
import { useState, useEffect } from 'react';
import PriceGraph from './PriceGraph';
interface StockPriceProps {
symbol: string;
}
interface StockData {
c: number; // Current price
d: number; // Change
dp: number; // Percent change
h: number; // High price of the day
l: number; // Low price of the day
o: number; // Open price of the day
pc: number; // Previous close price
t: number; // Timestamp
}
const StockPriceGraph = ({ symbol }: StockPriceProps) => {
const [stockData, setStockData] = useState<StockData[] | null>(null);
const [stockDescription, setStockDescription] = useState<string>('');
const [error, setError] = useState('');
const fetchStockPrice = async (selectedSymbol: string) => {
setError('');
try {
const res = await fetch(`/api/quote?symbol=${selectedSymbol}`);
const data = await res.json();
if (data.error) {
setError(data.error);
setStockData(null);
} else {
setStockData(data);
}
} catch (err) {
setError('Failed to fetch stock price');
setStockData(null);
}
};
const fetchStockDescription = async (selectedSymbol: string) => {
setError('');
try {
const res = await fetch(`/api/search?query=${selectedSymbol}`);
const data = await res.json();
if (data.result && data.result.length > 0) {
setStockDescription(data.result[0].description); // Assume the first result matches
} else {
setError('Description not found');
setStockDescription('');
}
} catch (err) {
setError('Failed to fetch stock description');
setStockDescription('');
}
};
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (symbol) {
fetchStockPrice(symbol);
fetchStockDescription(symbol);
intervalId = setInterval(() => fetchStockPrice(symbol), 300000); // 300000 ms = 5 minutes
}
return () => clearInterval(intervalId);
}, [symbol]);
const PriceBadge = ({
label,
value,
isPositive = true,
}: {
label: string;
value: number | string;
isPositive?: boolean;
}) => (
<span
className={`inline-block px-2 py-1 text-xs font-medium ${
isPositive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
} rounded-md`}
>
{label} {value}
</span>
);
return (
<div className="relative bg-surface0 dark:bg-surface0 p-6 rounded-t-lg shadow-md overflow-hidden h-full">
<div className="absolute inset-0 z-10">
{stockData && <PriceGraph data={stockData} />}
</div>
</div>
);
};
export default StockPriceGraph;

View file

@ -97,8 +97,7 @@ const Ticker = ({ symbol }: { symbol: string }) => {
<div className="flex flex-row space-x-4 ed-lg text-xs">
<strong>LIVE ${latestTrade.s}</strong>
<div>Traded {latestTrade.v} @ ${latestTrade.p} on {new Date(latestTrade.t).toLocaleTimeString()}</div>
<div>Type: {identifyTradeType(latestTrade)}</div>
<div>Conditions: {getTradeConditions(latestTrade)}</div>
<div>{getTradeConditions(latestTrade)}</div>
</div>
</div>
)}

View file

@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { fetchBasicFinancials } from '../../utils/sparkle';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { symbol } = req.query;
if (!symbol || typeof symbol !== 'string') {
res.status(400).json({ error: 'Invalid symbol' });
return;
}
try {
const data = await fetchBasicFinancials(symbol);
res.status(200).json(data);
} catch (error) {
res.status(500).json({ error: error.message });
}
}

View file

@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { fetchCompanyNews } from '../../utils/sparkle';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { symbol } = req.query;
if (!symbol || typeof symbol !== 'string') {
res.status(400).json({ error: 'Invalid symbol' });
return;
}
try {
const data = await fetchCompanyNews(symbol);
res.status(200).json(data);
} catch (error) {
res.status(500).json({ error: error.message });
}
}

View file

@ -6,6 +6,9 @@ import NewsColumn from '../components/NewsColumn';
import CompanyProfileCard from '../components/CompanyProfileCard';
import PeersWidget from '../components/PeersWidget';
import RecommendationTrendsWidget from '@/components/RecommendationTrends';
import FinancialsWidget from '@/components/FinancialsWidget';
import CompanyNews from './api/company-news';
import CompanyNewsWidget from '@/components/CompanyNewsWidget';
export default function Home() {
const [symbol, setSymbol] = useState('');
@ -17,20 +20,22 @@ export default function Home() {
return (
<div className="flex flex-col items-center w-full">
<NavigationBar onSelectSymbol={handleSelectSymbol} />
<div className="gap-8 flex-row flex max-w-7xl w-full p-4">
{symbol && (
<div className="gap-8 flex-wrap lg:flex-nowrap flex-row flex max-w-7xl w-full p-4">
{symbol ? (
<>
<div className="flex flex-col w-full">
<StockPrice symbol={symbol} />
<Ticker symbol={symbol} />
<RecommendationTrendsWidget symbol={symbol} /> \
<RecommendationTrendsWidget symbol={symbol} />
<FinancialsWidget symbol={symbol} />
</div>
<div className="flex flex-col max-w-md">
<CompanyProfileCard ticker={symbol} />
<CompanyNewsWidget symbol={symbol} />
</div>
</>
)}
{/* <NewsColumn /> */}
) : (<NewsColumn />)}
{/* */}
</div>
</div>
);

View file

@ -0,0 +1,7 @@
export const formatPriceData = (data) => {
return [
{ name: 'Previous Close', price: data.pc },
{ name: 'Open', price: data.o },
{ name: 'Current', price: data.c }
];
};

View file

@ -52,4 +52,17 @@ export const fetchRecommendationTrends = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/recommendation-trends?symbol=${symbol}`;
const res = await fetch(url);
return handleResponse(res);
};
};
export const fetchBasicFinancials = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/basic-financials?symbol=${symbol}`;
const res = await fetch(url);
return handleResponse(res);
}
export const fetchCompanyNews = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/company-news?symbol=${symbol}`;
const res = await fetch(url);
return handleResponse(res);
}