feat: implement skeletons, fix css, etc

This commit is contained in:
ryana mittens 2024-09-06 21:46:34 +08:00
parent d099694583
commit 4d8f7f72e5
14 changed files with 341 additions and 69 deletions

4
next copy.config.mjs Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

1
public/favicon.svg Normal file
View file

@ -0,0 +1 @@
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 512 512" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M208 512a24.84 24.84 0 0 1-23.34-16l-39.84-103.6a16.06 16.06 0 0 0-9.19-9.19L32 343.34a25 25 0 0 1 0-46.68l103.6-39.84a16.06 16.06 0 0 0 9.19-9.19L184.66 144a25 25 0 0 1 46.68 0l39.84 103.6a16.06 16.06 0 0 0 9.19 9.19l103 39.63a25.49 25.49 0 0 1 16.63 24.1 24.82 24.82 0 0 1-16 22.82l-103.6 39.84a16.06 16.06 0 0 0-9.19 9.19L231.34 496A24.84 24.84 0 0 1 208 512zm66.85-254.84zM88 176a14.67 14.67 0 0 1-13.69-9.4l-16.86-43.84a7.28 7.28 0 0 0-4.21-4.21L9.4 101.69a14.67 14.67 0 0 1 0-27.38l43.84-16.86a7.31 7.31 0 0 0 4.21-4.21L74.16 9.79A15 15 0 0 1 86.23.11a14.67 14.67 0 0 1 15.46 9.29l16.86 43.84a7.31 7.31 0 0 0 4.21 4.21l43.84 16.86a14.67 14.67 0 0 1 0 27.38l-43.84 16.86a7.28 7.28 0 0 0-4.21 4.21l-16.86 43.84A14.67 14.67 0 0 1 88 176zm312 80a16 16 0 0 1-14.93-10.26l-22.84-59.37a8 8 0 0 0-4.6-4.6l-59.37-22.84a16 16 0 0 1 0-29.86l59.37-22.84a8 8 0 0 0 4.6-4.6l22.67-58.95a16.45 16.45 0 0 1 13.17-10.57 16 16 0 0 1 16.86 10.15l22.84 59.37a8 8 0 0 0 4.6 4.6l59.37 22.84a16 16 0 0 1 0 29.86l-59.37 22.84a8 8 0 0 0-4.6 4.6l-22.84 59.37A16 16 0 0 1 400 256z"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -45,8 +45,20 @@ const CompanyNewsWidget = ({ symbol }: CompanyNewsWidgetProps) => {
} }
}, [symbol]); }, [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>
);
if (loading) { if (loading) {
return <p>Loading...</p>; return <SkeletonLoader />;
} }
if (error) { if (error) {
@ -57,7 +69,7 @@ const CompanyNewsWidget = ({ symbol }: CompanyNewsWidgetProps) => {
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4"> <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> <h2 className="text-xl font-bold text-text dark:text-text mb-4">Company News for {symbol}</h2>
<div className="space-y-4"> <div className="space-y-4">
{news.map((article) => ( {news.slice(0, 20).map((article) => (
<div key={article.id} className="rounded-lg bg-overlay0 dark:bg-mantle p-4"> <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"> <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> <h3 className="text-lg font-semibold mb-2">{article.headline}</h3>

View file

@ -23,10 +23,12 @@ interface CompanyProfileData {
const CompanyProfileCard = ({ ticker }: CompanyProfileProps) => { const CompanyProfileCard = ({ ticker }: CompanyProfileProps) => {
const [profile, setProfile] = useState<CompanyProfileData | null>(null); const [profile, setProfile] = useState<CompanyProfileData | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const fetchCompanyProfile = async (ticker: string) => { const fetchCompanyProfile = async (ticker: string) => {
setError(''); setError('');
try { try {
setLoading(true);
const res = await fetch(`/api/profile?symbol=${ticker}`); const res = await fetch(`/api/profile?symbol=${ticker}`);
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error) {
@ -38,6 +40,8 @@ const CompanyProfileCard = ({ ticker }: CompanyProfileProps) => {
} catch (err) { } catch (err) {
setError('Failed to fetch company profile'); setError('Failed to fetch company profile');
setProfile(null); setProfile(null);
} finally {
setLoading(false);
} }
}; };
@ -47,8 +51,39 @@ const CompanyProfileCard = ({ ticker }: CompanyProfileProps) => {
} }
}, [ticker]); }, [ticker]);
if (!profile) { const SkeletonLoader = () => (
return error ? <p className="text-red-500">{error}</p> : <p>Loading...</p>; <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>
);
if (loading) {
return <SkeletonLoader />;
}
if (error) {
return <p className="text-red-500">{error}</p>;
} }
return ( return (

View file

@ -58,8 +58,19 @@ const FinancialsWidget = ({ symbol }: FinancialsWidgetProps) => {
} }
}, [symbol]); }, [symbol]);
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>
);
if (loading) { if (loading) {
return <p>Loading...</p>; return <SkeletonLoader />;
} }
if (error) { if (error) {
@ -71,7 +82,7 @@ const FinancialsWidget = ({ symbol }: FinancialsWidgetProps) => {
} }
return ( return (
<div className="p-6 bg-surface0 dark:bg-surface0 rounded-lg shadow-md mt-4"> <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> <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"> <div className="flex flex-wrap gap-4">
{formattedData.map((data, index) => ( {formattedData.map((data, index) => (

12
src/components/Footer.tsx Normal file
View file

@ -0,0 +1,12 @@
// 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>
);
};
export default Footer;

View file

@ -0,0 +1,15 @@
// components/HeadTemplate.tsx
import Head from 'next/head';
const HeadTemplate = () => {
return (
<Head>
<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 name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.svg" />
</Head>
);
};
export default HeadTemplate;

View file

@ -0,0 +1,44 @@
import React from 'react';
import { FaArrowUp } from 'react-icons/fa';
const HeroSection = () => {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] mt-8 text-center bg-base text-text px-4">
<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>
</div>
<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 ">
your symbol twinkling at the distance
</p>
<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.
</p>
<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.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-6">
<a
href="https://code.lgbt/ryanamay/sparkle"
target="_blank"
rel="noopener noreferrer"
className="px-6 py-3 bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-600 hover:to-blue-600 text-white font-semibold rounded-lg shadow-md transform transition-all duration-300"
>
View Sparkle Source Code on Code.lgbt
</a>
<a
href="https://code.lgbt/ryanamay/twinkle"
target="_blank"
rel="noopener noreferrer"
className="px-6 py-3 bg-gradient-to-r from-teal-400 to-green-500 hover:from-teal-500 hover:to-green-600 text-white font-semibold rounded-lg shadow-md transform transition-all duration-300"
>
View Twinkle Source Code on Code.lgbt
</a>
</div>
</div>
);
};
export default HeroSection;

View file

@ -1,43 +1,46 @@
import { useState, useEffect } from 'react' import { useState } from 'react';
import SearchBar from './SearchBar' import SearchBar from './SearchBar';
import Link from 'next/link' import Link from 'next/link';
import ThemeSwitcher from './ThemeSwitcher' import ThemeSwitcher from './ThemeSwitcher';
import { IoSparkles } from 'react-icons/io5' import { IoSparkles } from 'react-icons/io5';
import { FaHamburger } from 'react-icons/fa'
import { GiHamburgerMenu } from 'react-icons/gi'
interface NavigationBarProps { interface NavigationBarProps {
onSelectSymbol: (symbol: string) => void onSelectSymbol: (symbol: string) => void;
} }
const NavigationBar = ({ onSelectSymbol }: NavigationBarProps) => { const NavigationBar = ({ onSelectSymbol }: NavigationBarProps) => {
const [currency, setCurrency] = useState('USD') const [currency, setCurrency] = useState('USD');
const [watchlistView, setWatchlistView] = useState('priceChange') const [watchlistView, setWatchlistView] = useState('priceChange');
const [dropdownOpen, setDropdownOpen] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false);
const handleCurrencyChange = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleCurrencyChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setCurrency(event.target.value) setCurrency(event.target.value);
} };
const handleWatchlistViewChange = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleWatchlistViewChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setWatchlistView(event.target.value) setWatchlistView(event.target.value);
} };
const resetSymbol = () => {
onSelectSymbol('');
};
return ( return (
<nav className="w-full g-crust transition-all text-text border-b-2 border-surface0 px-4"> <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"> <div className="container mx-auto max-w-7xl w-full flex items-center justify-between py-1">
<Link className="text-lg font-bold flex flex-row align-middle items-center gap-2" href="/"><IoSparkles /> <span className="md:block hidden">twinkle</span> <div onClick={resetSymbol} className="cursor-pointer text-lg font-bold flex flex-row align-middle items-center gap-2">
</Link> <IoSparkles />
<span className="md:block hidden">twinkle</span>
</div>
<div className="flex-grow max-w-md mx-auto lg:max-w-lg w-full"> <div className="flex-grow max-w-md mx-auto lg:max-w-lg w-full">
<SearchBar onSelectSymbol={onSelectSymbol} /> <SearchBar onSelectSymbol={onSelectSymbol} />
</div> </div>
<div className="relative flex items-center space-x-4"> <div className="relative flex items-center space-x-4">
<ThemeSwitcher /> <ThemeSwitcher />
</div> </div>
</div> </div>
</nav> </nav>
) );
} };
export default NavigationBar export default NavigationBar;

View file

@ -12,6 +12,11 @@ interface NewsArticle {
url: string; url: string;
} }
// Skeleton components for loading state
const Skeleton = ({ className }: { className: string }) => (
<div className={`animate-pulse bg-overlay1 ${className}`}></div>
);
const NewsColumn = () => { const NewsColumn = () => {
const [news, setNews] = useState<NewsArticle[]>([]); const [news, setNews] = useState<NewsArticle[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -22,7 +27,9 @@ const NewsColumn = () => {
try { try {
const res = await fetch('/api/news'); const res = await fetch('/api/news');
const data = await res.json(); const data = await res.json();
const filteredNews = data.result.filter((article: any) => article.source !== 'MarketWatch'); const filteredNews = data.result.filter(
(article: any) => article.source !== 'MarketWatch'
);
setNews(filteredNews); setNews(filteredNews);
} catch (err) { } catch (err) {
setError('Failed to fetch news'); setError('Failed to fetch news');
@ -35,7 +42,54 @@ const NewsColumn = () => {
}, []); }, []);
if (loading) { if (loading) {
return <p>Loading...</p>; return (
<div className="lg:flex lg:space-x-4 w-full">
<div className="lg:flex-1 lg:space-y-4">
{/* Main featured article skeleton */}
<div className="mb-4 lg:mb-0">
<div className="block overflow-hidden rounded-lg shadow-lg">
<Skeleton className="w-full h-64" />
<div className="p-4 bg-surface0 dark:bg-surface0">
<Skeleton className="h-6 mb-2" />
<Skeleton className="h-4 mb-1" />
<Skeleton className="h-4" />
</div>
</div>
</div>
{/* 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"
>
<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" />
<Skeleton className="h-4" />
</div>
</div>
))}
</div>
</div>
{/* 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>
<ul>
{Array.from({ length: 5 }).map((_, index) => (
<li key={index} className="mb-4">
<Skeleton className="h-4 mb-1" />
<Skeleton className="h-4" />
</li>
))}
</ul>
</div>
</div>
);
} }
if (error) { if (error) {
@ -64,11 +118,16 @@ const NewsColumn = () => {
className="w-full h-64 object-cover" className="w-full h-64 object-cover"
/> />
<div className="p-4 bg-surface0 dark:bg-surface0"> <div className="p-4 bg-surface0 dark:bg-surface0">
<h3 className="text-xl font-bold text-text dark:text-text">{featuredArticle.headline}</h3> <h3 className="text-xl font-bold text-text dark:text-text">
{featuredArticle.headline}
</h3>
<p className="text-sm text-subtext1 dark:text-subtext1"> <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}
</p> </p>
<p className="text-sm mt-2 text-text dark:text-subtext1">{featuredArticle.summary}</p>
</div> </div>
</a> </a>
)} )}
@ -76,7 +135,7 @@ const NewsColumn = () => {
{/* Other articles */} {/* Other articles */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{otherArticles.map(article => ( {otherArticles.map((article) => (
<a <a
key={article.id} key={article.id}
href={article.url} href={article.url}
@ -90,9 +149,12 @@ const NewsColumn = () => {
className="w-full h-32 object-cover rounded-t-lg" className="w-full h-32 object-cover rounded-t-lg"
/> />
<div className="p-2 bg-surface0 dark:bg-surface0 h-full"> <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> <h3 className="text-sm font-semibold text-text dark:text-text">
{article.headline}
</h3>
<p className="text-xs text-subtext1 dark:text-subtext1"> <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> </p>
</div> </div>
</a> </a>
@ -101,10 +163,12 @@ const NewsColumn = () => {
</div> </div>
{/* Latest news sidebar */} {/* Latest news sidebar */}
<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"> <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> <h2 className="text-xl font-bold text-text dark:text-text mb-4">
Latest
</h2>
<ul> <ul>
{latestArticles.map(article => ( {latestArticles.map((article) => (
<li key={article.id} className="mb-4"> <li key={article.id} className="mb-4">
<a <a
href={article.url} href={article.url}
@ -112,7 +176,9 @@ const NewsColumn = () => {
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:underline block" className="hover:underline block"
> >
<p className="text-sm font-semibold text-text dark:text-text">{article.headline}</p> <p className="text-sm font-semibold text-text dark:text-text">
{article.headline}
</p>
<p className="text-xs text-subtext1 dark:text-subtext1"> <p className="text-xs text-subtext1 dark:text-subtext1">
{new Date(article.datetime * 1000).toLocaleString()} {new Date(article.datetime * 1000).toLocaleString()}
</p> </p>

View file

@ -52,8 +52,15 @@ const RecommendationTrendsWidget = ({ symbol }: RecommendationTrendsWidgetProps)
} }
}, [symbol]); }, [symbol]);
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>
<div className="h-80 bg-gray-300 dark:bg-gray-700 rounded"></div>
</div>
);
if (loading) { if (loading) {
return <p>Loading...</p>; return <SkeletonLoader />;
} }
if (error) { if (error) {

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import PriceGraph from './PriceGraph'; import PriceGraph from './PriceGraph';
import StockPriceGraph from './StockPriceGraph'; import StockPriceGraph from './StockPriceGraph';
@ -20,9 +20,11 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
const [stockData, setStockData] = useState<StockData | null>(null); const [stockData, setStockData] = useState<StockData | null>(null);
const [stockDescription, setStockDescription] = useState<string>(''); const [stockDescription, setStockDescription] = useState<string>('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const fetchStockPrice = async (selectedSymbol: string) => { const fetchStockPrice = async (selectedSymbol: string) => {
setError(''); setError('');
setLoading(true);
try { try {
const res = await fetch(`/api/quote?symbol=${selectedSymbol}`); const res = await fetch(`/api/quote?symbol=${selectedSymbol}`);
const data = await res.json(); const data = await res.json();
@ -35,6 +37,8 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
} catch (err) { } catch (err) {
setError('Failed to fetch stock price'); setError('Failed to fetch stock price');
setStockData(null); setStockData(null);
} finally {
setLoading(false);
} }
}; };
@ -71,6 +75,10 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
</span> </span>
); );
const Skeleton = ({ width, height }: { width: string, height: string }) => (
<div className={`bg-gray-300 dark:bg-gray-700 rounded-md`} style={{ width, height }}></div>
);
return ( return (
<div className="relative bg-surface0 dark:bg-surface0 p-6 rounded-t-lg shadow-md"> <div className="relative bg-surface0 dark:bg-surface0 p-6 rounded-t-lg shadow-md">
<div className="absolute inset-0 opacity-20"> <div className="absolute inset-0 opacity-20">
@ -79,16 +87,36 @@ const StockPrice = ({ symbol }: StockPriceProps) => {
{symbol && ( {symbol && (
<div className="relative z-10"> <div className="relative z-10">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
{loading ? (
<Skeleton width="150px" height="2rem" />
) : (
<h1 className="text-3xl font-bold text-text dark:text-text">{symbol}</h1> <h1 className="text-3xl font-bold text-text dark:text-text">{symbol}</h1>
{stockData && ( )}
{loading ? (
<div className="flex space-x-2 my-4">
<Skeleton width="100px" height="2rem" />
<Skeleton width="100px" height="2rem" />
</div>
) : stockData ? (
<div className="flex space-x-2"> <div className="flex space-x-2">
<PriceBadge label="Change:" value={stockData.d} isPositive={stockData.d >= 0} /> <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}%`} isPositive={stockData.dp >= 0} />
</div> </div>
)} ) : null}
</div> </div>
{stockDescription && <p className="text-lg mb-4 text-text dark:text-subtext1">{stockDescription}</p>} {loading ? (
{stockData && ( <Skeleton width="100%" height="1.5rem" className="my-4" />
) : stockDescription && (
<p className="text-lg mb-4 text-text dark:text-subtext1">{stockDescription}</p>
)}
{loading ? (
<div className="grid grid-cols-4 gap-4 mt-4">
<Skeleton width="100%" height="100px" />
<Skeleton width="100%" height="100px" />
<Skeleton width="100%" height="100px" />
<Skeleton width="100%" height="100px" />
</div>
) : stockData && (
<div className="mt-4"> <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}</div>
<div className="grid grid-cols-4 gap-4 mt-4"> <div className="grid grid-cols-4 gap-4 mt-4">

View file

@ -15,6 +15,7 @@ const Ticker = ({ symbol }: { symbol: string }) => {
const [bid, setBid] = useState<number | null>(null); const [bid, setBid] = useState<number | null>(null);
const [ask, setAsk] = useState<number | null>(null); const [ask, setAsk] = useState<number | null>(null);
const [webSocketInitialized, setWebSocketInitialized] = useState(false); const [webSocketInitialized, setWebSocketInitialized] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const initializePrices = async () => { const initializePrices = async () => {
@ -47,27 +48,38 @@ const Ticker = ({ symbol }: { symbol: string }) => {
useEffect(() => { useEffect(() => {
if (!webSocketInitialized) return; 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`); 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) => {
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);
setLatestTrade(tradeForSymbol);
setLoading(false);
}
};
// Subscribe to the symbol when the socket opens
socket.onopen = () => { socket.onopen = () => {
console.log('WebSocket connection established'); console.log('WebSocket connection established');
socket.send(JSON.stringify({ type: 'subscribe', symbol })); socket.send(JSON.stringify({ type: 'subscribe', symbol }));
}; };
socket.onmessage = (event) => { // Handle incoming messages
const data = JSON.parse(event.data); socket.onmessage = handleWebSocketMessage;
if (data.type === 'trade' && data.data.some((trade: Trade) => trade.s === symbol)) {
const tradeForSymbol = data.data.find((trade: Trade) => trade.s === symbol);
setLatestTrade(tradeForSymbol);
}
};
socket.onclose = () => { socket.onclose = () => {
console.log('WebSocket connection closed'); console.log('WebSocket connection closed');
}; };
// Clean up by unsubscribing from the symbol and closing the socket on component unmount or symbol change
return () => { return () => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'unsubscribe', symbol })); socket.send(JSON.stringify({ type: 'unsubscribe', symbol }));
}
socket.close(); socket.close();
}; };
}, [symbol, webSocketInitialized]); }, [symbol, webSocketInitialized]);
@ -90,15 +102,28 @@ const Ticker = ({ symbol }: { symbol: string }) => {
return 'No conditions'; return 'No conditions';
}; };
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>
);
return ( return (
<div className=""> <div className="">
{symbol && latestTrade && ( {symbol && (
<div className="border-b dark:bg-overlay0 bg-overlay0 p-2 px-4 rounded-b-lg"> <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"> <div className="flex flex-row space-x-4 ed-lg text-xs">
<strong>LIVE ${latestTrade.s}</strong> <strong>LIVE ${latestTrade.s}</strong>
<div>Traded {latestTrade.v} @ ${latestTrade.p} on {new Date(latestTrade.t).toLocaleTimeString()}</div> <div>Traded {latestTrade.v} @ ${latestTrade.p} on {new Date(latestTrade.t).toLocaleTimeString()}</div>
<div>{getTradeConditions(latestTrade)}</div> <div>{getTradeConditions(latestTrade)}</div>
</div> </div>
)
)}
</div> </div>
)} )}
</div> </div>

View file

@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import HeadTemplate from '@/components/HeadTemplate';
import NavigationBar from '../components/NavigationBar'; import NavigationBar from '../components/NavigationBar';
import StockPrice from '../components/StockPrice'; import StockPrice from '../components/StockPrice';
import Ticker from '../components/Ticker'; import Ticker from '../components/Ticker';
@ -9,6 +10,8 @@ import RecommendationTrendsWidget from '@/components/RecommendationTrends';
import FinancialsWidget from '@/components/FinancialsWidget'; import FinancialsWidget from '@/components/FinancialsWidget';
import CompanyNews from './api/company-news'; import CompanyNews from './api/company-news';
import CompanyNewsWidget from '@/components/CompanyNewsWidget'; import CompanyNewsWidget from '@/components/CompanyNewsWidget';
import HeroSection from '@/components/HeroSection';
import Footer from '@/components/Footer';
export default function Home() { export default function Home() {
const [symbol, setSymbol] = useState(''); const [symbol, setSymbol] = useState('');
@ -18,9 +21,10 @@ export default function Home() {
}; };
return ( return (
<div className="flex flex-col items-center w-full"> <div className="flex flex-col min-h-screen items-center w-full">
<HeadTemplate />
<NavigationBar onSelectSymbol={handleSelectSymbol} /> <NavigationBar onSelectSymbol={handleSelectSymbol} />
<div className="gap-8 flex-wrap lg:flex-nowrap flex-row flex max-w-7xl w-full p-4"> <main className="gap-8 flex-wrap lg:flex-nowrap flex-row flex max-w-7xl w-full p-4 flex-1">
{symbol ? ( {symbol ? (
<> <>
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
@ -29,14 +33,19 @@ export default function Home() {
<RecommendationTrendsWidget symbol={symbol} /> <RecommendationTrendsWidget symbol={symbol} />
<FinancialsWidget symbol={symbol} /> <FinancialsWidget symbol={symbol} />
</div> </div>
<div className="flex flex-col max-w-md"> <div className="flex flex-col w-full md:max-w-md gap-2">
<CompanyProfileCard ticker={symbol} /> <CompanyProfileCard ticker={symbol} />
<CompanyNewsWidget symbol={symbol} /> <CompanyNewsWidget symbol={symbol} />
</div> </div>
</> </>
) : (<NewsColumn />)} ) : (
{/* */} <div className="w-full flex flex-col items-center">
<HeroSection />
<NewsColumn />
</div> </div>
)}
</main>
<Footer />
</div> </div>
); );
} }