clean: refactor code

This commit is contained in:
ryana mittens 2024-09-06 22:34:40 +08:00
parent 4d8f7f72e5
commit bc080f39a3
28 changed files with 789 additions and 567 deletions

View file

@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
// Props for the CompanyNewsWidget component, including the stock symbol
interface CompanyNewsWidgetProps {
symbol: string;
}
// Interface for a NewsArticle object to ensure type safety
interface NewsArticle {
category: string;
datetime: number;
@ -16,55 +18,75 @@ interface NewsArticle {
url: string;
}
// SkeletonLoader component to display a loading state with shimmering effect
const SkeletonLoader = () => (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="rounded-lg bg-gray-300 dark:bg-gray-700 p-4 gap-3 animate-pulse">
<div className="h-6 bg-gray-400 dark:bg-gray-600 rounded mb-2"></div>
<div className="h-4 bg-gray-400 dark:bg-gray-600 rounded mb-2"></div>
<div className="h-4 bg-gray-400 dark:bg-gray-600 rounded w-3/4"></div>
</div>
))}
</div>
);
// Main component to load and display company news based on a stock symbol
const CompanyNewsWidget = ({ symbol }: CompanyNewsWidgetProps) => {
// State to hold the news articles
const [news, setNews] = useState<NewsArticle[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// State to manage the loading state
const [loading, setLoading] = useState<boolean>(true);
// State to handle any errors that might occur during fetch
const [error, setError] = useState<string | null>(null);
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);
// Function to fetch the company news from the API
const fetchNews = useCallback(async () => {
// Set loading to true at the start of the fetch operation
setLoading(true);
// Clear any previous errors
setError(null);
try {
// Fetch data from the API using the provided symbol
const res = await fetch(`/api/company-news?symbol=${symbol}`);
const data = await res.json();
// Handle response: check if request was successful
if (res.ok) {
// Set the news articles in state
setNews(data);
} else {
// If not successful, set the error message from response
throw new Error(data.error || 'Failed to fetch data');
}
};
if (symbol) {
fetchNews();
} catch (err) {
// If an error occurs, set the error message in state
setError(err instanceof Error ? err.message : 'Failed to fetch data');
} finally {
// Set loading to false after the fetch operation is complete
setLoading(false);
}
}, [symbol]);
const SkeletonLoader = () => (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="rounded-lg bg-gray-300 dark:bg-gray-700 p-4 gap-3 animate-pulse">
<div className="h-6 bg-gray-400 dark:bg-gray-600 rounded mb-2"></div>
<div className="h-4 bg-gray-400 dark:bg-gray-600 rounded mb-2"></div>
<div className="h-4 bg-gray-400 dark:bg-gray-600 rounded w-3/4"></div>
</div>
))}
</div>
);
// Effect that runs when the component mounts or when symbol changes
useEffect(() => {
if (symbol) {
fetchNews(); // Fetch news when the symbol is available
}
}, [symbol, fetchNews]);
// If loading, display SkeletonLoader component
if (loading) {
return <SkeletonLoader />;
}
// If error occurred, display the error message
if (error) {
return <p className="text-red-500">{error}</p>;
}
// Render the list of news articles
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>
@ -73,7 +95,9 @@ const CompanyNewsWidget = ({ symbol }: CompanyNewsWidgetProps) => {
<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-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>

View file

@ -1,10 +1,12 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import PeersWidget from './PeersWidget';
// Props interface for the CompanyProfileCard component, including the stock ticker symbol
interface CompanyProfileProps {
ticker: string;
}
// Interface for the company profile data fetched from the API
interface CompanyProfileData {
country: string;
currency: string;
@ -20,94 +22,110 @@ interface CompanyProfileData {
finnhubIndustry: string;
}
const CompanyProfileCard = ({ ticker }: CompanyProfileProps) => {
const [profile, setProfile] = useState<CompanyProfileData | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const fetchCompanyProfile = async (ticker: string) => {
setError('');
try {
setLoading(true);
const res = await fetch(`/api/profile?symbol=${ticker}`);
const data = await res.json();
if (data.error) {
setError(data.error);
setProfile(null);
} else {
setProfile(data);
}
} catch (err) {
setError('Failed to fetch company profile');
setProfile(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (ticker) {
fetchCompanyProfile(ticker);
}
}, [ticker]);
const SkeletonLoader = () => (
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg gap-4 shadow-md animate-pulse">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 bg-gray-300 dark:bg-gray-700 rounded-lg"></div>
<div className="flex-1">
<div className="h-5 bg-gray-300 dark:bg-gray-700 rounded mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
</div>
<div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
</div>
// Component to display a loading skeleton while data is being fetched
const SkeletonLoader = () => (
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg gap-4 shadow-md animate-pulse">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-1/3 mb-4"></div>
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 bg-gray-300 dark:bg-gray-700 rounded-lg"></div>
<div className="flex-1">
<div className="h-5 bg-gray-300 dark:bg-gray-700 rounded mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-1/2"></div>
</div>
</div>
);
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
</div>
<div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
</div>
</div>
</div>
);
// Main component to load and display company profile information based on a stock ticker symbol
const CompanyProfileCard = ({ ticker }: CompanyProfileProps) => {
// State to hold the company profile data
const [profile, setProfile] = useState<CompanyProfileData | null>(null);
// State to manage loading state
const [loading, setLoading] = useState<boolean>(true);
// State to handle any errors that might occur during fetch
const [error, setError] = useState<string | null>(null);
// Function to fetch the company profile from the API
const fetchCompanyProfile = useCallback(async (ticker: string) => {
setError(null); // Clear any previous errors
setLoading(true); // Set loading to true at the start of the fetch operation
try {
// Fetch data from the API using the provided ticker symbol
const res = await fetch(`/api/profile?symbol=${ticker}`);
const data = await res.json();
// Handle response: check if the request was successful
if (res.ok) {
setProfile(data); // Set the profile data in state
} else {
// If not successful, set the error message from response
throw new Error(data.error || 'Failed to fetch company profile data');
}
} catch (err) {
// If an error occurs, set the error message in state
setError(err instanceof Error ? err.message : 'Failed to fetch company profile');
setProfile(null); // Reset profile data
} finally {
// Set loading to false after the fetch operation is complete
setLoading(false);
}
}, []);
// Effect that runs when the component mounts or when the ticker changes
useEffect(() => {
if (ticker) {
fetchCompanyProfile(ticker); // Fetch profile when the ticker is available
}
}, [ticker, fetchCompanyProfile]);
// If loading, display SkeletonLoader component
if (loading) {
return <SkeletonLoader />;
}
// If an error occurred, display the error message
if (error) {
return <p className="text-red-500">{error}</p>;
}
// Render the company profile information
return (
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md">
<h1 className="text-xl font-bold text-text dark:text-text">About this company</h1>
<div className="flex items-center gap-4 mt-4">
<img src={profile.logo} alt={`${profile.name} logo`} className="w-16 h-16 object-cover rounded-lg" />
<img src={profile?.logo} alt={`${profile?.name} logo`} className="w-16 h-16 object-cover rounded-lg" />
<div>
<h2 className="text-2xl font-bold text-text dark:text-text">{profile.name}</h2>
<p className="text-sm text-text dark:text-subtext1">{profile.ticker} | {profile.finnhubIndustry}</p>
<h2 className="text-2xl font-bold text-text dark:text-text">{profile?.name}</h2>
<p className="text-sm text-text dark:text-subtext1">Ticker: {profile?.ticker} | Industry: {profile?.finnhubIndustry}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div>
<p className="text-text dark:text-subtext1"><span className="font-semibold text-text dark:text-subtext1">Country:</span> {profile.country}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold text-text dark:text-subtext1">Currency:</span> {profile.currency}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold text-text dark:text-subtext1">Exchange:</span> {profile.exchange}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold text-text dark:text-subtext1">Market Cap:</span> ${profile.marketCapitalization.toFixed(2)}B</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold">Country:</span> {profile?.country}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold">Currency:</span> {profile?.currency}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold">Exchange:</span> {profile?.exchange}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold">Market Cap:</span> ${profile?.marketCapitalization.toFixed(2)}B</p>
</div>
<div>
<p className="text-text dark:text-subtext1"><span className="font-semibold text-text dark:text-subtext1">IPO Date:</span> {new Date(profile.ipo).toLocaleDateString()}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold text-text dark:text-subtext1">Outstanding Shares:</span> {profile.shareOutstanding.toFixed(2)}M</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold text-text dark:text-subtext1">Phone:</span> {profile.phone}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold text-text dark:text-subtext1">Website:</span> <a href={profile.weburl} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{profile.weburl}</a></p>
<p className="text-text dark:text-subtext1"><span className="font-semibold">IPO Date:</span> {new Date(profile?.ipo).toLocaleDateString()}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold">Outstanding Shares:</span> {profile?.shareOutstanding.toFixed(2)}M</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold">Phone:</span> {profile?.phone}</p>
<p className="text-text dark:text-subtext1"><span className="font-semibold">Website:</span> <a href={profile?.weburl} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">{profile?.weburl}</a></p>
</div>
</div>
<PeersWidget symbol={ticker} />

View file

@ -1,16 +1,32 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { FadeLoader as Spinner } from 'react-spinners';
// Props interface for the FadeLoader component
interface FadeLoaderProps {
loading: boolean;
color?: string;
loading: boolean; // Indicates if the loader should be shown
color?: string; // Optional color for the loader
}
// FadeLoader component to display a loading spinner with fade animation
const FadeLoader: React.FC<FadeLoaderProps> = ({ loading, color }) => {
const themeColor = color || getComputedStyle(document.documentElement).getPropertyValue('--color-text').trim();
// State to hold the theme color
const [themeColor, setThemeColor] = useState<string>(color || '');
// Effect to set default theme color if color prop is not provided
useEffect(() => {
if (!color) {
// Get the CSS property value for --color-text from the document's root element
const defaultColor = getComputedStyle(document.documentElement).getPropertyValue('--color-text').trim();
setThemeColor(defaultColor);
} else {
setThemeColor(color);
}
}, [color]);
// Render the Spinner component from react-spinners library
return (
<div className="flex justify-center items-center translate-x-3 translate-y-3 px-2">
<Spinner color={themeColor} loading={loading} height={6} padding={0} margin={-12} width={2}/>
<Spinner color={themeColor} loading={loading} height={6} padding={0} margin={-12} width={2} />
</div>
);
};

View file

@ -1,14 +1,17 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
// Props interface for the FinancialsWidget component
interface FinancialsWidgetProps {
symbol: string;
}
// Interface for a formatted financial metric
interface FinancialMetric {
name: string;
value: string | number;
}
// Interface for the financial data fetched from the API
interface FinancialData {
metric: Record<string, number | string>;
series: {
@ -16,71 +19,87 @@ interface FinancialData {
};
}
// Component to display a loading skeleton while data is being fetched
const SkeletonLoader = () => (
<div className="hidden md:block p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4 animate-pulse">
<h2 className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-1/3 mb-4"></h2>
<div className="flex flex-wrap gap-4">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-gray-300 dark:bg-gray-700 p-4 rounded-lg flex-1 min-w-[200px] h-16"></div>
))}
</div>
</div>
);
// Main component to display financial data for a given stock symbol
const FinancialsWidget = ({ symbol }: FinancialsWidgetProps) => {
// State to hold the fetched financial data
const [financialData, setFinancialData] = useState<FinancialData | null>(null);
// State to hold the formatted financial data
const [formattedData, setFormattedData] = useState<FinancialMetric[]>([]);
// State to manage the loading state
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// State to handle any errors that might occur during the fetch
const [error, setError] = useState<string | null>(null);
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) {
// Function to format the fetched financial data into a readable format
const formatData = useCallback((data: FinancialData) => {
const metrics: FinancialMetric[] = [];
for (const key in data.metric) {
if (data.metric.hasOwnProperty(key)) {
metrics.push({
name: key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase()),
value: data.metric[key],
});
}
setFormattedData(metrics);
};
if (symbol) {
fetchFinancialData();
}
}, [symbol]);
setFormattedData(metrics);
}, []);
const SkeletonLoader = () => (
<div className="hidden md:block p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4 animate-pulse">
<h2 className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-1/3 mb-4"></h2>
<div className="flex flex-wrap gap-4">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="bg-gray-300 dark:bg-gray-700 p-4 rounded-lg flex-1 min-w-[200px] h-16"></div>
))}
</div>
</div>
);
// Function to fetch the financial data from the API
const fetchFinancialData = useCallback(async () => {
setLoading(true);
setError(null); // Clear previous errors
try {
// Fetch data from the API using the provided symbol
const res = await fetch(`/api/basic-financials?symbol=${symbol}`);
const data = await res.json();
if (res.ok) {
setFinancialData(data); // Set the fetched data in state
formatData(data); // Format and set the data in formattedData state
} else {
setError(data.error || 'Failed to fetch data');
}
} catch (err) {
setError('Failed to fetch data'); // Handle any errors during fetch
} finally {
setLoading(false); // Set loading to false after the fetch operation is complete
}
}, [symbol, formatData]);
// Effect to fetch financial data when the component mounts or when the symbol changes
useEffect(() => {
if (symbol) {
fetchFinancialData(); // Fetch financial data when the symbol is available
}
}, [symbol, fetchFinancialData]);
// If loading, display the SkeletonLoader component
if (loading) {
return <SkeletonLoader />;
}
// If an error occurred, display the error message
if (error) {
return <p className="text-red-500">{error}</p>;
}
// If no financial data is available, display a relevant message
if (!financialData) {
return <p>No financial data available.</p>;
}
// Render the formatted financial data
return (
<div className="hidden md:block 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>

View file

@ -1,12 +1,27 @@
// components/Footer.tsx
const Footer = () => {
return (
<footer className="w-full bg-crust text-text py-4 border-t-2 border-surface0">
<div className="container mx-auto max-w-7xl flex flex-col text-center items-center justify-center space-y-2">
<p className="text-sm">this project is open-source and is available at github.com/ryanamay or code.lgbt/ryanamay</p>
</div>
</footer>
);
};
import React from 'react';
export default Footer;
// Footer functional component
const Footer: React.FC = () => {
return (
// The footer element with full width, background color, and text styling
<footer className="w-full bg-crust text-text py-4 border-t-2 border-surface0">
{/* Container to center the content and limit its max width */}
<div className="container mx-auto max-w-7xl flex flex-col text-center items-center justify-center space-y-2">
{/* Paragraph containing the footer text */}
<p className="text-sm">
this project is open-source and is available at{" "}
<a href="https://github.com/ryanamay" className="text-blue-500 hover:underline">
github.com/ryanamay
</a>{" "}
or{" "}
<a href="https://code.lgbt/ryanamay" className="text-blue-500 hover:underline">
code.lgbt/ryanamay
</a>
</p>
</div>
</footer>
);
};
export default Footer;

View file

@ -1,14 +1,27 @@
// components/HeadTemplate.tsx
import Head from 'next/head';
import React from 'react';
const HeadTemplate = () => {
// HeadTemplate functional component for setting up the document head
const HeadTemplate: React.FC = () => {
return (
// The Head component from Next.js to modify the head section of the document
<Head>
{/* Title of the document */}
<title>Twinkle - Track Your Stocks</title>
<meta name="description" content="Twinkle helps you track your favorite stocks in a delightful and user-friendly way." />
{/* Meta description for SEO and providing information about the content */}
<meta
name="description"
content="Twinkle helps you track your favorite stocks in a delightful and user-friendly way."
/>
{/* Meta viewport tag to control layout on mobile browsers */}
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/* Link tag for the favicon */}
<link rel="icon" href="/favicon.svg" />
</Head>
</Head>
);
};

View file

@ -1,23 +1,40 @@
import React from 'react';
import { FaArrowUp } from 'react-icons/fa';
const HeroSection = () => {
// HeroSection functional component representing the main hero section of the webpage
const HeroSection: React.FC = () => {
return (
// Main container with flex properties for centering and styling
<div className="flex flex-col items-center justify-center min-h-[70vh] mt-8 text-center bg-base text-text px-4">
{/* Arrow icon with bouncing animation and absolute positioned text */}
<div className="relative mb-6">
<FaArrowUp className="text-4xl text-overlay0 animate-bounce" />
<p className="absolute top-[-2rem] left-1/2 transform -translate-x-1/2 text-xs w-[400px] text-text">Search your first stock</p>
<p className="absolute top-[-2rem] left-1/2 transform -translate-x-1/2 text-xs w-[400px] text-text">
Search your first stock
</p>
</div>
{/* Main title of the hero section */}
<h1 className="text-6xl font-bold text-lavender mb-3">twinkle</h1>
<p className="text-3xl font-extrabold mb-8 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-500 to-red-500 ">
{/* Sub-title with gradient text */}
<p className="text-3xl font-extrabold mb-8 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 via-pink-500 to-red-500">
your symbol twinkling at the distance
</p>
{/* Introduction text */}
<p className="text-lg text-overlay1-dark mb-4 max-w-2xl mx-auto px-4">
Welcome to <span className="font-bold text-lavender">twinkle</span>, your charming companion for keeping an eye on your favorite stocks.
Welcome to <span className="font-bold text-lavender">twinkle</span>, your charming companion for keeping an eye on your favorite
stocks.
</p>
{/* Description text */}
<p className="text-lg text-overlay1-dark mb-8 max-w-2xl mx-auto px-4">
Powered by our magical <span className="font-bold text-lavender">sparkle aggregator API</span>, twinkle makes it a breeze to stay updated with the latest stock prices in a delightful and user-friendly way.
Powered by our magical <span className="font-bold text-lavender">sparkle aggregator API</span>, twinkle makes it a breeze to stay
updated with the latest stock prices in a delightful and user-friendly way.
</p>
{/* Links to source code with gradient buttons and hover effects */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-6">
<a
href="https://code.lgbt/ryanamay/sparkle"
@ -27,7 +44,6 @@ const HeroSection = () => {
>
View Sparkle Source Code on Code.lgbt
</a>
<a
href="https://code.lgbt/ryanamay/twinkle"
target="_blank"

View file

@ -1,26 +1,34 @@
import { useState } from 'react';
import React, { useState } from 'react';
import SearchBar from './SearchBar';
import Link from 'next/link';
import ThemeSwitcher from './ThemeSwitcher';
import { IoSparkles } from 'react-icons/io5';
// Props interface for the NavigationBar component
interface NavigationBarProps {
onSelectSymbol: (symbol: string) => void;
}
const NavigationBar = ({ onSelectSymbol }: NavigationBarProps) => {
// NavigationBar functional component
const NavigationBar: React.FC<NavigationBarProps> = ({ onSelectSymbol }) => {
// State to manage the selected currency
const [currency, setCurrency] = useState('USD');
// State to manage the watchlist view option
const [watchlistView, setWatchlistView] = useState('priceChange');
// State to manage the dropdown open/close state
const [dropdownOpen, setDropdownOpen] = useState(false);
// Handle currency change event
const handleCurrencyChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setCurrency(event.target.value);
};
// Handle watchlist view change event
const handleWatchlistViewChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setWatchlistView(event.target.value);
};
// Function to reset the selected symbol
const resetSymbol = () => {
onSelectSymbol('');
};
@ -28,13 +36,18 @@ const NavigationBar = ({ onSelectSymbol }: NavigationBarProps) => {
return (
<nav className="w-full bg-crust transition-all text-text border-b-2 border-surface0 px-4">
<div className="container mx-auto max-w-7xl w-full flex items-center justify-between py-1">
{/* Logo and title section */}
<div onClick={resetSymbol} className="cursor-pointer text-lg font-bold flex flex-row align-middle items-center gap-2">
<IoSparkles />
<span className="md:block hidden">twinkle</span>
</div>
{/* SearchBar section */}
<div className="flex-grow max-w-md mx-auto lg:max-w-lg w-full">
<SearchBar onSelectSymbol={onSelectSymbol} />
</div>
{/* Theme switcher section */}
<div className="relative flex items-center space-x-4">
<ThemeSwitcher />
</div>

View file

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
// Define the structure of a news article
interface NewsArticle {
category: string;
datetime: number;
@ -12,28 +13,36 @@ interface NewsArticle {
url: string;
}
// Skeleton components for loading state
// Skeleton component for the loading state
const Skeleton = ({ className }: { className: string }) => (
<div className={`animate-pulse bg-overlay1 ${className}`}></div>
);
// Main component to display news articles
const NewsColumn = () => {
// State to hold the list of news articles
const [news, setNews] = useState<NewsArticle[]>([]);
// State to manage the loading state
const [loading, setLoading] = useState(true);
// State to handle any errors that might occur during the fetch
const [error, setError] = useState('');
// Effect to fetch news articles when the component mounts
useEffect(() => {
const fetchNews = async () => {
try {
// Fetch news data from the API
const res = await fetch('/api/news');
const data = await res.json();
const filteredNews = data.result.filter(
(article: any) => article.source !== 'MarketWatch'
);
// Filter out news articles from "MarketWatch"
const filteredNews = data.result.filter((article: any) => article.source !== 'MarketWatch');
// Update the news state with the filtered articles
setNews(filteredNews);
} catch (err) {
// Update the error state if the fetch fails
setError('Failed to fetch news');
} finally {
// Set loading to false after the fetch operation is complete
setLoading(false);
}
};
@ -41,6 +50,7 @@ const NewsColumn = () => {
fetchNews();
}, []);
// Render the loading skeleton
if (loading) {
return (
<div className="lg:flex lg:space-x-4 w-full">
@ -60,10 +70,7 @@ const NewsColumn = () => {
{/* Other articles skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="block overflow-hidden rounded-lg shadow-lg"
>
<div key={index} className="block overflow-hidden rounded-lg shadow-lg">
<Skeleton className="w-full h-32 rounded-t-lg" />
<div className="p-2 bg-surface0 dark:bg-surface0 h-full">
<Skeleton className="h-4 mb-1" />
@ -76,9 +83,7 @@ const NewsColumn = () => {
{/* Latest news sidebar skeleton */}
<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>
<h2 className="text-xl font-bold text-text dark:text-text mb-4">Latest</h2>
<ul>
{Array.from({ length: 5 }).map((_, index) => (
<li key={index} className="mb-4">
@ -92,10 +97,12 @@ const NewsColumn = () => {
);
}
// Render the error message if any
if (error) {
return <p className="text-red-500">{error}</p>;
}
// Prepare the news articles for display
const featuredArticle = news[0];
const otherArticles = news.slice(1, 7);
const latestArticles = news.slice(7);
@ -122,8 +129,7 @@ const NewsColumn = () => {
{featuredArticle.headline}
</h3>
<p className="text-sm text-subtext1 dark:text-subtext1">
{new Date(featuredArticle.datetime * 1000).toLocaleString()} |{' '}
{featuredArticle.source}
{new Date(featuredArticle.datetime * 1000).toLocaleString()} | {featuredArticle.source}
</p>
<p className="text-sm mt-2 text-text dark:text-subtext1">
{featuredArticle.summary}
@ -153,8 +159,7 @@ const NewsColumn = () => {
{article.headline}
</h3>
<p className="text-xs text-subtext1 dark:text-subtext1">
{new Date(article.datetime * 1000).toLocaleString()} |{' '}
{article.source}
{new Date(article.datetime * 1000).toLocaleString()} | {article.source}
</p>
</div>
</a>
@ -163,10 +168,8 @@ const NewsColumn = () => {
</div>
{/* Latest news sidebar */}
<div className="mt-4 lg:mt-0 lg:w-1/4 lg:order-last border- dark:border-crust border p-4 lg:overflow-y-auto lg:h-screen bg-surface0 dark:bg-surface0 rounded-lg shadow-md">
<h2 className="text-xl font-bold text-text dark:text-text mb-4">
Latest
</h2>
<div className="mt-4 lg:mt-0 lg:w-1/4 lg:order-last border- dark:border-crust border p-4 lg:overflow-y-auto lg:h-screen bg-surface0 dark:bg-surface0 rounded-lg shadow-md">
<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">

View file

@ -4,11 +4,16 @@ interface PeersWidgetProps {
symbol: string;
}
/**
* PeersWidget component fetches and displays peer companies for a given symbol.
*/
const PeersWidget = ({ symbol }: PeersWidgetProps) => {
// State variables to manage peers data, loading state, and errors
const [peers, setPeers] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// useEffect hook to fetch peers whenever the symbol changes
useEffect(() => {
const fetchPeers = async () => {
setLoading(true);
@ -33,14 +38,17 @@ const PeersWidget = ({ symbol }: PeersWidgetProps) => {
}
}, [symbol]);
// If the data is still loading, show a loading message
if (loading) {
return <p>Loading...</p>;
}
// If there was an error fetching the data, show an error message
if (error) {
return <p className="text-red-500">{error}</p>;
}
// Render the list of peers
return (
<div className="p-4 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4">
<h2 className="text-xl font-bold text-text dark:text-text mb-2">Peers for {symbol}</h2>

View file

@ -1,52 +1,63 @@
import {
LineChart,
Line,
CartesianGrid,
ResponsiveContainer,
defs,
linearGradient,
stop,
YAxis,
} from 'recharts';
import { formatPriceData } from '../utils/formatPriceData';
LineChart,
Line,
CartesianGrid,
ResponsiveContainer,
YAxis,
defs,
linearGradient,
stop,
} 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; }[];
}
// Define the interface for the component's props
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);
/**
* PriceGraph component renders a responsive line chart with provided price data.
* @param data - An array of price data objects.
*/
const PriceGraph = ({ data }: PriceGraphProps) => {
// Format the input data using the custom formatPriceData function
const formattedData = formatPriceData(data);
const prices = formattedData.map((d) => d.price);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
// Extract prices from the formatted data to determine the min and max prices
const prices = formattedData.map((d) => d.price);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
return (
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>
<LineChart data={formattedData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<defs>
{/* Defining gradient for the line fill */}
<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>
{/* Adding a cartesian grid */}
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-overlay0)" />
{/* Y-axis configuration, hidden in this case */}
<YAxis
type="number"
domain={[minPrice - 1, maxPrice + 1]}
hide
/>
{/* Line chart configuration */}
<Line
type="monotone"
dataKey="price"
stroke="var(--color-text)"
strokeWidth={2}
dot={false}
fill="url(#colorUv)"
/>
</LineChart>
</ResponsiveContainer>
);
};
);
};
export default PriceGraph;
export default PriceGraph;

View file

@ -8,6 +8,9 @@ import {
Tooltip,
ResponsiveContainer,
Legend,
defs,
linearGradient,
stop,
} from 'recharts';
interface RecommendationTrendsWidgetProps {
@ -23,11 +26,16 @@ interface RecommendationTrend {
strongSell: number;
}
/**
* RecommendationTrendsWidget component fetches and displays recommendation trends for a given stock symbol.
* @param symbol - The stock symbol to fetch recommendation trends for.
*/
const RecommendationTrendsWidget = ({ symbol }: RecommendationTrendsWidgetProps) => {
const [recommendationTrends, setRecommendationTrends] = useState<RecommendationTrend[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Fetch recommendation trends whenever the symbol changes
useEffect(() => {
const fetchRecommendationTrends = async () => {
setLoading(true);
@ -52,6 +60,7 @@ const RecommendationTrendsWidget = ({ symbol }: RecommendationTrendsWidgetProps)
}
}, [symbol]);
// SkeletonLoader component for loading state
const SkeletonLoader = () => (
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4 animate-pulse">
<div className="h-6 bg-gray-300 dark:bg-gray-700 rounded w-1/3 mb-4"></div>

View file

@ -10,72 +10,68 @@ interface SearchBarProps {
const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
const [query, setQuery] = useState("");
const [selectedQuery, setSelectedQuery] = useState("");
const [suggestions, setSuggestions] = useState<
{ symbol: string; description: string }[]
>([]);
const [suggestions, setSuggestions] = useState<{ symbol: string; description: string }[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const [notFound, setNotFound] = useState(false);
const [isPickerVisible, setIsPickerVisible] = useState(false);
const [queryTotal, setQueryTotal] = useState(0);
const pickerRef = useRef<HTMLDivElement>(null);
/**
* fetchSuggestions is a function to fetch stock suggestions based on user query.
* It uses the AbortController to cancel any ongoing fetch request if a new query comes in.
*/
const fetchSuggestions = (() => {
let currentController: AbortController | null = null;
let currentController: AbortController | null = null;
return async (query: string) => {
if (currentController) {
currentController.abort();
}
return async (query: string) => {
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
const { signal } = currentController;
currentController = new AbortController();
const { signal } = currentController;
setLoading(true);
setQueryTotal(queryTotal + 1);
setNotFound(false);
setLoading(true);
setNotFound(false);
try {
const res = await fetch(`/api/search?query=${query}`, { signal });
const data = await res.json();
try {
const res = await fetch(`/api/search?query=${query}`, { signal });
const data = await res.json();
if (data.result && data.result.length > 0) {
setTotalCount(data.result.length);
setSuggestions(data.result.slice(0, 5));
setLoading(false);
} else {
setNotFound(true);
setSuggestions([]);
setTotalCount(0);
setLoading(false);
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error("Error fetching suggestions:", error);
setNotFound(true);
setSuggestions([]);
setTotalCount(0);
setLoading(false);
}
} finally {
setQueryTotal(queryTotal - 1);
if (queryTotal === 0) {
setLoading(false);
}
currentController = null;
}
};
if (res.ok && data.result && data.result.length > 0) {
setTotalCount(data.result.length);
setSuggestions(data.result.slice(0, 5));
} else {
setNotFound(true);
setSuggestions([]);
setTotalCount(0);
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error("Error fetching suggestions:", error);
setNotFound(true);
setSuggestions([]);
setTotalCount(0);
}
} finally {
setLoading(false);
currentController = null;
}
};
})();
// Debounce the fetchSuggestions function to avoid excessive API calls
const debouncedFetchSuggestions = useCallback(
debounce((query: string) => {
fetchSuggestions(query);
}, 5000),
}, 500),
[]
);
// Effect handler to run debounced fetchSuggestions when query changes
useEffect(() => {
if (query.length > 0) {
debouncedFetchSuggestions(query);
@ -87,6 +83,7 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
}
}, [query, debouncedFetchSuggestions]);
// Handle the selection of a suggestion
const handleSelect = (symbol: string) => {
setQuery("");
setSelectedQuery(symbol);
@ -95,15 +92,14 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
onSelectSymbol(symbol);
};
// Handle clicks outside the suggestion picker to close it
const handleClickOutside = (event: MouseEvent) => {
if (
pickerRef.current &&
!pickerRef.current.contains(event.target as Node)
) {
if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {
setIsPickerVisible(false);
}
};
// Effect handler to add and remove event listener for clicks outside picker
useEffect(() => {
document.addEventListener("mousedown", handleClickOutside);
return () => {
@ -119,7 +115,6 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
placeholder={selectedQuery || "Search stocks..."}
value={query}
onChange={(e) => {
setLoading(true);
setQuery(e.target.value);
}}
className="pl-8 w-full bg-transparent outline-none text-text"
@ -138,60 +133,44 @@ const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
)}
</div>
{isPickerVisible && (
<>
{
<ul className="z-50 absolute border bg-mantle w-full mt-2 rounded-md border-surface0 overflow-hidden">
{suggestions.map((item, index) => (
<li
key={item.symbol}
onClick={() => handleSelect(item.symbol)}
className={`p-2 hover:bg-crust cursor-pointer transition-all border-b-2 dark:border-base border-overlay0 `}
>
<div className="flex flex-col">
<div className="font-bold">
{item.symbol}{" "}
</div>
<div className="text-xs flex flex-row gap-2">
{index === 0 && (
<span className=" rounded-md text-xs px-1 dark:text-crust bg-text text-overlay0">
Top Result
</span>
)}
{item.description}
</div>
</div>
</li>
))}
<li
key="count"
className="bottom p-2 text-xs text-right bg-overlay0 dark:bg-crust "
>
<div className="flex flex-row items-center align-middle justify-between">
<span className="p-1 text-left">
{loading
? notFound
? query
? "Searching..."
: "Start searching"
: `Searching...`
: `Showing top hits for '${query}'. (Matched ${totalCount} result${totalCount > 1 ? "s" : ""}).`}
</span>
{loading && (
<span
key="loading"
className=" text-xs text-center"
>
<FadeLoader loading={true} />
<ul className="z-50 absolute border bg-mantle w-full mt-2 rounded-md border-surface0 overflow-hidden">
{suggestions.map((item, index) => (
<li
key={item.symbol}
onClick={() => handleSelect(item.symbol)}
className={`p-2 hover:bg-crust cursor-pointer transition-all border-b-2 dark:border-base border-overlay0 `}
>
<div className="flex flex-col">
<div className="font-bold">{item.symbol}</div>
<div className="text-xs flex flex-row gap-2">
{index === 0 && (
<span className="rounded-md text-xs px-1 dark:text-crust bg-text text-overlay0">
Top Result
</span>
)}
{item.description}
</div>
</li>
</ul>
}
</>
</div>
</li>
))}
<li
key="count"
className="bottom p-2 text-xs text-right bg-overlay0 dark:bg-crust"
>
<div className="flex flex-row items-center align-middle justify-between">
<span className="p-1 text-left">
{loading
? "Searching..."
: `Showing top hits for '${query}'. (Matched ${totalCount} result${totalCount > 1 ? "s" : ""}).`}
</span>
{loading && (
<span className="text-xs text-center">
<FadeLoader loading={true} />
</span>
)}
</div>
</li>
</ul>
)}
</div>
);

View file

@ -6,9 +6,14 @@ interface StockGraphProps {
symbol: string;
}
/**
* StockGraph component renders a line chart showing the historical trend for a given stock symbol.
* @param symbol - The stock symbol to display the trend for.
*/
const StockGraph = ({ symbol }: StockGraphProps) => {
const [historicalData, setHistoricalData] = useState([]);
// useEffect hook to fetch and set the mock historical data whenever the symbol changes
useEffect(() => {
if (symbol) {
const data = generateMockHistoricalData(symbol);
@ -16,6 +21,7 @@ const StockGraph = ({ symbol }: StockGraphProps) => {
}
}, [symbol]);
// Show a loading message if data is not yet available
if (historicalData.length === 0) {
return <p>Loading data...</p>;
}
@ -25,10 +31,15 @@ const StockGraph = ({ symbol }: StockGraphProps) => {
<h2 className="text-xl font-bold mb-4">Stock Trend for {symbol}</h2>
<ResponsiveContainer width="100%" height={400}>
<LineChart data={historicalData}>
{/* Adds a Cartesian grid to the chart for better readability */}
<CartesianGrid strokeDasharray="3 3" />
{/* XAxis displays dates from the data */}
<XAxis dataKey="date" />
{/* YAxis displays the stock prices */}
<YAxis />
{/* Tooltip shows detailed information when hovering over the data points */}
<Tooltip />
{/* Line component to draw the line chart with dataKey `close` */}
<Line type="monotone" dataKey="close" stroke="#8884d8" activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>

View file

@ -16,23 +16,28 @@ interface StockData {
pc: number; // Previous close price
}
/**
* StockPrice component fetches and displays stock price details and trends for a given symbol.
* @param symbol - The stock symbol to display the information for.
*/
const StockPrice = ({ symbol }: StockPriceProps) => {
const [stockData, setStockData] = useState<StockData | null>(null);
const [stockDescription, setStockDescription] = useState<string>('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
// Fetch stock price data from the API
const fetchStockPrice = async (selectedSymbol: string) => {
setError('');
setLoading(true);
try {
const res = await fetch(`/api/quote?symbol=${selectedSymbol}`);
const data = await res.json();
if (data.error) {
setError(data.error);
setStockData(null);
} else {
if (res.ok) {
setStockData(data);
} else {
setError(data.error || 'An error occurred');
setStockData(null);
}
} catch (err) {
setError('Failed to fetch stock price');
@ -42,13 +47,14 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
}
};
// Fetch stock description from the API
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
if (res.ok && data.result && data.result.length > 0) {
setStockDescription(data.result[0]?.description || 'No description available');
} else {
setError('Description not found');
setStockDescription('');
@ -69,18 +75,21 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
return () => clearInterval(intervalId);
}, [symbol]);
// Badge component to show price changes
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>
);
const Skeleton = ({ width, height }: { width: string, height: string }) => (
<div className={`bg-gray-300 dark:bg-gray-700 rounded-md`} style={{ width, height }}></div>
// Skeleton loader component for loading states
const Skeleton = ({ width, height, className = '' }: { width: string, height: string, className?: string }) => (
<div className={`bg-gray-300 dark:bg-gray-700 rounded-md ${className}`} style={{ width, height }}></div>
);
return (
<div className="relative bg-surface0 dark:bg-surface0 p-6 rounded-t-lg shadow-md">
{/* Background StockPriceGraph for visual effect */}
<div className="absolute inset-0 opacity-20">
<StockPriceGraph symbol={symbol} />
</div>
@ -100,7 +109,7 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
) : stockData ? (
<div className="flex space-x-2">
<PriceBadge label="Change:" value={stockData.d} isPositive={stockData.d >= 0} />
<PriceBadge label="Percent Change:" value={`${stockData.dp}%`} isPositive={stockData.dp >= 0} />
<PriceBadge label="Percent Change:" value={`${stockData.dp.toFixed(2)}%`} isPositive={stockData.dp >= 0} />
</div>
) : null}
</div>
@ -118,23 +127,23 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
</div>
) : stockData && (
<div className="mt-4">
<div className="text-5xl font-bold mb-2 text-subtext0 dark:text-subtext0">${stockData.c}</div>
<div className="text-5xl font-bold mb-2 text-subtext0 dark:text-subtext0">${stockData.c.toFixed(2)}</div>
<div className="grid grid-cols-4 gap-4 mt-4">
<div className="bg-overlay0 dark:bg-overlay0 p-4 rounded-lg shadow-md">
<p className="text-sm font-semibold text-text dark:text-subtext1">High</p>
<p className="text-xl text-text dark:text-text">${stockData.h}</p>
<p className="text-xl text-text dark:text-text">${stockData.h.toFixed(2)}</p>
</div>
<div className="bg-overlay0 dark:bg-overlay0 p-4 rounded-lg shadow-md">
<p className="text-sm font-semibold text-text dark:text-subtext1">Low</p>
<p className="text-xl text-text dark:text-text">${stockData.l}</p>
<p className="text-xl text-text dark:text-text">${stockData.l.toFixed(2)}</p>
</div>
<div className="bg-overlay0 dark:bg-overlay0 p-4 rounded-lg shadow-md">
<p className="text-sm font-semibold text-text dark:text-subtext1">Open</p>
<p className="text-xl text-text dark:text-text">${stockData.o}</p>
<p className="text-xl text-text dark:text-text">${stockData.o.toFixed(2)}</p>
</div>
<div className="bg-overlay0 dark:bg-overlay0 p-4 rounded-lg shadow-md">
<p className="text-sm font-semibold text-text dark:text-subtext1">Previous Close</p>
<p className="text-xl text-text dark:text-text">${stockData.pc}</p>
<p className="text-xl text-text dark:text-text">${stockData.pc.toFixed(2)}</p>
</div>
</div>
</div>

View file

@ -16,21 +16,26 @@ interface StockData {
t: number; // Timestamp
}
/**
* StockPriceGraph component fetches and displays the historical stock price trend for a given symbol.
* @param symbol - The stock symbol to display the trend for.
*/
const StockPriceGraph = ({ symbol }: StockPriceProps) => {
const [stockData, setStockData] = useState<StockData[] | null>(null);
const [stockDescription, setStockDescription] = useState<string>('');
const [error, setError] = useState('');
// Function to fetch stock price data
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 {
if (res.ok) {
setStockData(data);
} else {
setError(data.error || 'Failed to fetch stock price');
setStockData(null);
}
} catch (err) {
setError('Failed to fetch stock price');
@ -38,13 +43,14 @@ const StockPriceGraph = ({ symbol }: StockPriceProps) => {
}
};
// Function to fetch stock description
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
if (res.ok && data.result && data.result.length > 0) {
setStockDescription(data.result[0].description || 'No description available');
} else {
setError('Description not found');
setStockDescription('');
@ -55,6 +61,7 @@ const StockPriceGraph = ({ symbol }: StockPriceProps) => {
}
};
// Effect hook to fetch data and set an interval for updates
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (symbol) {
@ -65,6 +72,12 @@ const StockPriceGraph = ({ symbol }: StockPriceProps) => {
return () => clearInterval(intervalId);
}, [symbol]);
/**
* PriceBadge component renders the label and value with conditional styling.
* @param label - The label for the badge.
* @param value - The value to display.
* @param isPositive - Boolean indicating if the value is positive or negative.
*/
const PriceBadge = ({
label,
value,
@ -76,9 +89,7 @@ const StockPriceGraph = ({ symbol }: StockPriceProps) => {
}) => (
<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'
isPositive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
} rounded-md`}
>
{label} {value}
@ -87,6 +98,11 @@ const StockPriceGraph = ({ symbol }: StockPriceProps) => {
return (
<div className="relative bg-surface0 dark:bg-surface0 p-6 rounded-t-lg shadow-md overflow-hidden h-full">
{error && (
<div className="p-4 bg-red-100 dark:bg-red-700 text-red-800 dark:text-red-100 rounded-lg">
<p>{error}</p>
</div>
)}
<div className="absolute inset-0 z-10">
{stockData && <PriceGraph data={stockData} />}
</div>

View file

@ -1,28 +1,35 @@
import { useEffect, useState } from 'react';
import { FaMoon, FaSun } from 'react-icons/fa';
/**
* ThemeSwitcher component toggles between light and dark themes.
*/
const ThemeSwitcher = () => {
const [theme, setTheme] = useState('light');
// State variable to hold the current theme, defaulting to 'light'
const [theme, setTheme] = useState<'light' | 'dark'>('light');
// useEffect hook to apply the theme when it changes
useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
document.documentElement.classList.add('dark'); // Adds the 'dark' class for dark theme
} else {
document.documentElement.classList.remove('dark');
document.documentElement.classList.remove('dark'); // Removes the 'dark' class for light theme
}
}, [theme]);
// Function to handle theme toggle
const handleThemeToggle = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<button
onClick={handleThemeToggle}
onClick={handleThemeToggle} // Toggles the theme on button click
className="px-4 py-2 bg-text text-crust rounded-md transition-all hover:bg-overlay1 focus:outline-none"
aria-label="Toggle Theme"
>
{
theme === 'light' ? <FaSun /> : <FaMoon />
theme === 'light' ? <FaSun /> : <FaMoon /> // Renders Sun icon for light theme and Moon icon for dark theme
}
</button>
);

View file

@ -10,13 +10,18 @@ interface Trade {
c?: number[]; // Conditions (DOES NOT EXIST FOR ALL TRADES)
}
const Ticker = ({ symbol }: { symbol: string }) => {
interface TickerProps {
symbol: string;
}
const Ticker = ({ symbol }: TickerProps) => {
const [latestTrade, setLatestTrade] = useState<Trade | null>(null);
const [bid, setBid] = useState<number | null>(null);
const [ask, setAsk] = useState<number | null>(null);
const [webSocketInitialized, setWebSocketInitialized] = useState(false);
const [loading, setLoading] = useState(true);
// Fetch initial bid and ask prices
useEffect(() => {
const initializePrices = async () => {
try {
@ -30,6 +35,7 @@ const Ticker = ({ symbol }: { symbol: string }) => {
initializePrices();
}, [symbol]);
// Initialize WebSocket connection
useEffect(() => {
const initializeWebSocket = async () => {
try {
@ -45,15 +51,18 @@ const Ticker = ({ symbol }: { symbol: string }) => {
initializeWebSocket();
}, []);
// Manage WebSocket for live trade updates
useEffect(() => {
if (!webSocketInitialized) return;
setLoading(true); // Set loading to true when the symbol changes
const socket = new WebSocket(`${process.env.NEXT_PUBLIC_SPARKLE_BASE_URL!.replace('http', 'ws')}/ws/trades`);
// Define a function to handle incoming WebSocket messages
const handleWebSocketMessage = (event: MessageEvent) => {
socket.onopen = () => {
console.log('WebSocket connection established');
socket.send(JSON.stringify({ type: 'subscribe', symbol }));
};
socket.onmessage = (event: MessageEvent) => {
const data = JSON.parse(event.data);
if (data.type === 'trade' && data.data.some((trade: Trade) => trade.s === symbol)) {
const tradeForSymbol = data.data.find((trade: Trade) => trade.s === symbol);
@ -62,20 +71,10 @@ const Ticker = ({ symbol }: { symbol: string }) => {
}
};
// Subscribe to the symbol when the socket opens
socket.onopen = () => {
console.log('WebSocket connection established');
socket.send(JSON.stringify({ type: 'subscribe', symbol }));
};
// Handle incoming messages
socket.onmessage = handleWebSocketMessage;
socket.onclose = () => {
console.log('WebSocket connection closed');
};
// Clean up by unsubscribing from the symbol and closing the socket on component unmount or symbol change
return () => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'unsubscribe', symbol }));
@ -84,6 +83,7 @@ const Ticker = ({ symbol }: { symbol: string }) => {
};
}, [symbol, webSocketInitialized]);
// Identify the trade type based on the latest trade price
const identifyTradeType = (trade: Trade) => {
if (bid !== null && ask !== null) {
if (trade.p >= ask) {
@ -95,6 +95,7 @@ const Ticker = ({ symbol }: { symbol: string }) => {
return 'Unknown';
};
// Get a string representation of the trade conditions
const getTradeConditions = (trade: Trade) => {
if (trade.c && trade.c.length > 0) {
return trade.c.map(code => tradeConditions[code] || `Unknown Condition: ${code}`).join(', ');
@ -102,29 +103,28 @@ const Ticker = ({ symbol }: { symbol: string }) => {
return 'No conditions';
};
// Skeleton placeholder to show when loading
const SkeletonPlaceholder = () => (
<div className="flex flex-row space-x-4 ed-lg text-xs">
<strong>LIVE: </strong>
<div>Waiting for a trade to happen...</div>
</div>
<div className="flex flex-row space-x-4 text-xs p-2">
<strong>LIVE: </strong>
<div>Waiting for a trade to happen...</div>
</div>
);
return (
<div className="">
<div className="border-b dark:bg-overlay0 bg-overlay0 p-2 px-4 rounded-b-lg">
{symbol && (
<div className="border-b dark:bg-overlay0 bg-overlay0 p-2 px-4 rounded-b-lg">
{loading ? (
<SkeletonPlaceholder />
) : (
latestTrade && (
<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>{getTradeConditions(latestTrade)}</div>
</div>
)
)}
</div>
loading ? (
<SkeletonPlaceholder />
) : (
latestTrade && (
<div className="flex flex-row space-x-4 text-xs">
<strong>LIVE ${latestTrade.s}</strong>
<div>Traded {latestTrade.v} @ ${latestTrade.p.toFixed(2)} on {new Date(latestTrade.t).toLocaleTimeString()}</div>
<div>{getTradeConditions(latestTrade)}</div>
</div>
)
)
)}
</div>
);

View file

@ -1,18 +0,0 @@
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

@ -1,18 +0,0 @@
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 });
}
}

141
src/pages/api/index.ts Normal file
View file

@ -0,0 +1,141 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { fetchNews, fetchPeers, fetchCompanyNews, fetchSymbols, fetchQuote, fetchBasicFinancials, fetchProfile, fetchRecommendationTrends } from "../../utils/sparkle";
type RouteHandler = (req: NextApiRequest, res: NextApiResponse) => Promise<void>;
const routeHandlers: Record<string, RouteHandler> = {
'/api/news': async (req, res) => {
if (req.method !== "GET") {
res.setHeader("Allow", ["GET"]);
res.status(405).json({ error: `Method ${req.method} Not Allowed` });
return;
}
try {
const result = await fetchNews();
res.status(200).json({ result });
} catch (error) {
res.status(500).json({ error: "Error fetching news" });
}
},
'/api/peers': async (req, res) => {
const { symbol } = req.query;
if (!symbol || typeof symbol !== 'string') {
res.status(400).json({ error: 'Invalid symbol' });
return;
}
try {
const data = await fetchPeers(symbol);
res.status(200).json(data);
} catch (error) {
res.status(500).json({ error: "Error fetching peers" });
}
},
'/api/company-news': async (req, res) => {
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 instanceof Error ? error.message : "Unknown error" });
}
},
'/api/search': async (req, res) => {
const { query } = req.query;
if (!query || typeof query !== "string") {
res.status(400).json({ error: "Invalid query parameter" });
return;
}
try {
const result = await fetchSymbols(query);
res.status(200).json({ symbols: result.symbols, totalCount: result.totalCount });
} catch (error) {
res.status(500).json({ error: "Error fetching symbols" });
}
},
'/api/quote': async (req, res) => {
const { symbol } = req.query;
if (!symbol || typeof symbol !== "string") {
res.status(400).json({ error: "Invalid symbol" });
return;
}
try {
const quote = await fetchQuote(symbol);
res.status(200).json(quote);
} catch (error) {
res.status(500).json({ error: "Error fetching quote" });
}
},
'/api/basic-financials': async (req, res) => {
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 instanceof Error ? error.message : "Unknown error" });
}
},
'/api/profile': async (req, res) => {
const { symbol } = req.query;
if (!symbol || typeof symbol !== "string") {
res.status(400).json({ error: "Invalid symbol" });
return;
}
try {
const profile = await fetchProfile(symbol);
res.status(200).json(profile);
} catch (error) {
res.status(500).json({ error: "Error fetching profile" });
}
},
'/api/recommendation-trends': async (req, res) => {
const { symbol } = req.query;
if (!symbol || typeof symbol !== 'string') {
res.status(400).json({ error: 'Invalid symbol' });
return;
}
try {
const data = await fetchRecommendationTrends(symbol);
res.status(200).json(data);
} catch (error) {
res.status(500).json({ error: error instanceof Error ? error.message : "Unknown error" });
}
}
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const route = req.url?.split('?')[0] || '';
if (route in routeHandlers) {
return routeHandlers[route](req, res);
}
res.status(404).json({ error: 'Not found' });
};
export default handler;

View file

@ -1,20 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { fetchNews } from "../../utils/sparkle";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "GET") {
res.setHeader("Allow", ["GET"]);
res.status(405).json({ error: `Method ${req.method} Not Allowed` });
return;
}
try {
const result = await fetchNews();
res.status(200).json({ result: result });
} catch (error) {
res.status(500).json({ error: "Error fetching symbols" });
}
}

View file

@ -1,18 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { fetchPeers } 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 fetchPeers(symbol);
res.status(200).json(data);
} catch (error) {
res.status(500).json({ error: "Error fetching peers" });
}
}

View file

@ -1,21 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { fetchProfile } 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 quote = await fetchProfile(symbol as string);
res.status(200).json(quote);
} catch (error) {
res.status(500).json({ error: "Error fetching quote" });
}
}

View file

@ -1,21 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { fetchQuote } 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 quote = await fetchQuote(symbol as string);
res.status(200).json(quote);
} catch (error) {
res.status(500).json({ error: "Error fetching quote" });
}
}

View file

@ -1,18 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { fetchRecommendationTrends } 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 fetchRecommendationTrends(symbol);
res.status(200).json(data);
} catch (error) {
res.status(500).json({ error: error.message });
}
}

View file

@ -1,20 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { fetchSymbols } from "../../utils/sparkle";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { query } = req.query;
if (!query || typeof query !== "string") {
res.status(400).json({ error: "Invalid symbol" });
return;
}
try {
const result = await fetchSymbols(query);
res.status(200).json({ result: result.symbols, totalCount: result.totalCount });
} catch (error) {
res.status(500).json({ error: "Error fetching symbols" });
}
}

View file

@ -1,5 +1,11 @@
const SPARKLE_BASE_URL = process.env.NEXT_PUBLIC_SPARKLE_BASE_URL;
/**
* Handles the API response. Throws an error if the response is not ok.
* @param res - The API response
* @returns The parsed JSON response if the response is ok.
* @throws An error with a message from the response or a generic error message.
*/
const handleResponse = async (res: Response) => {
if (!res.ok) {
const error = await res.json();
@ -8,14 +14,24 @@ const handleResponse = async (res: Response) => {
return res.json();
};
/**
* Fetches quote data for a given symbol.
* @param symbol - The stock symbol to fetch the quote for.
* @returns The quote data.
*/
export const fetchQuote = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/quote?symbol=${symbol}`;
const res = await fetch(url);
return handleResponse(res);
};
export const fetchSymbols = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/search?query=${symbol}`;
/**
* Fetches symbols matching the provided query.
* @param symbol - The query to search for symbols.
* @returns An object containing an array of symbols and the total count.
*/
export const fetchSymbols = async (query: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/search?query=${query}`;
const res = await fetch(url);
const data = await handleResponse(res);
return {
@ -24,45 +40,77 @@ export const fetchSymbols = async (symbol: string) => {
};
};
/**
* Starts the WebSocket connection by hitting the start endpoint.
* @returns The response data confirming the WebSocket is running.
*/
export const startWebSocket = async () => {
const url = `${SPARKLE_BASE_URL}/ws/start-websocket`;
const res = await fetch(url);
return handleResponse(res);
};
/**
* Fetches market news.
* @returns The market news data.
*/
export const fetchNews = async () => {
const url = `${SPARKLE_BASE_URL}/api/v1/marketnews`;
const res = await fetch(url);
return handleResponse(res);
};
/**
* Fetches the profile data for a given symbol.
* @param symbol - The stock symbol to fetch the profile for.
* @returns The profile data.
*/
export const fetchProfile = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/profile?symbol=${symbol}`;
const res = await fetch(url);
return handleResponse(res);
};
/**
* Fetches peers for a given symbol.
* @param symbol - The stock symbol to fetch peers for.
* @returns The peers data.
*/
export const fetchPeers = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/api/v1/peers?symbol=${symbol}`;
const res = await fetch(url);
return handleResponse(res);
};
/**
* Fetches recommendation trends for a given symbol.
* @param symbol - The stock symbol to fetch recommendation trends for.
* @returns The recommendation trends data.
*/
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);
};
/**
* Fetches basic financials for a given symbol.
* @param symbol - The stock symbol to fetch basic financials for.
* @returns The basic financials data.
*/
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);
}
};
/**
* Fetches company news for a given symbol.
* @param symbol - The stock symbol to fetch company news for.
* @returns The company news data.
*/
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);
}
};