Compare commits

...

2 commits

Author SHA1 Message Date
43a99125c3 add: react-icons 2024-09-06 13:16:03 +08:00
2e41c46a1d feat: ticker, catpuccin theme, base layout 2024-09-06 13:13:21 +08:00
14 changed files with 437 additions and 34 deletions

10
package-lock.json generated
View file

@ -12,6 +12,7 @@
"next": "14.2.8",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.3.0",
"recharts": "^2.12.7"
},
"devDependencies": {
@ -4452,6 +4453,15 @@
"react": "^18.3.1"
}
},
"node_modules/react-icons": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz",
"integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View file

@ -13,6 +13,7 @@
"next": "14.2.8",
"react": "^18",
"react-dom": "^18",
"react-icons": "^5.3.0",
"recharts": "^2.12.7"
},
"devDependencies": {

View file

@ -1,9 +0,0 @@
const Header = () => (
<header className="py-4">
<h1 className="text-3xl font-bold">
Stock Price Application
</h1>
</header>
)
export default Header

View file

@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import SearchBar from './SearchBar'
import Link from 'next/link'
import ThemeSwitcher from './ThemeSwitcher'
interface NavigationBarProps {
onSelectSymbol: (symbol: string) => void
}
const NavigationBar = ({ onSelectSymbol }: NavigationBarProps) => {
const [currency, setCurrency] = useState('USD')
const [watchlistView, setWatchlistView] = useState('priceChange')
const [dropdownOpen, setDropdownOpen] = useState(false)
const handleCurrencyChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setCurrency(event.target.value)
}
const handleWatchlistViewChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
setWatchlistView(event.target.value)
}
return (
<nav className="bg-base text-text">
<div className="container mx-auto max-w-8xl flex items-center justify-between py-4 px-4 lg:px-8">
<Link className="text-lg font-bold" href="/">TWL
</Link>
<div className="flex-grow max-w-md mx-auto lg:max-w-lg w-full">
<SearchBar onSelectSymbol={onSelectSymbol} />
</div>
<div className="relative flex items-center space-x-4">
<ThemeSwitcher />
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
className="bg-mantle px-4 py-2 rounded focus:outline-none focus:bg-overlay0"
>
X
</button>
{dropdownOpen && (
<div className="absolute right-0 mt-2 w-48 bg-surface0 rounded-md shadow-lg z-20">
<div className="py-2 px-4">
<label
htmlFor="currency"
className="block text-sm font-medium text-text"
>
Currency:
</label>
<select
id="currency"
value={currency}
onChange={handleCurrencyChange}
className="mt-1 block w-full p-1 rounded border border-overlay2"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="JPY">JPY</option>
<option value="GBP">GBP</option>
</select>
</div>
<div className="py-2 px-4">
<label
htmlFor="watchlistView"
className="block text-sm font-medium text-text"
>
View:
</label>
<select
id="watchlistView"
value={watchlistView}
onChange={handleWatchlistViewChange}
className="mt-1 block w-full p-1 rounded border border-overlay2"
>
<option value="priceChange">Price Change</option>
<option value="percentageChange">Percentage Change</option>
<option value="marketCap">Market Cap</option>
</select>
</div>
</div>
)}
</div>
</div>
</nav>
)
}
export default NavigationBar

View file

@ -1,7 +1,11 @@
import { useState, useEffect, useCallback } from 'react'
import debounce from 'lodash.debounce'
const SearchBar = ({ onSelectSymbol }: { onSelectSymbol: (symbol: string) => void }) => {
interface SearchBarProps {
onSelectSymbol: (symbol: string) => void
}
const SearchBar = ({ onSelectSymbol }: SearchBarProps) => {
const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<{ symbol: string, description: string }[]>([])
const [loading, setLoading] = useState(false)
@ -13,7 +17,6 @@ const SearchBar = ({ onSelectSymbol }: { onSelectSymbol: (symbol: string) => voi
try {
const res = await fetch(`/api/search?query=${query}`)
const data = await res.json()
if (data.result && data.result.length > 0) {
setSuggestions(data.result)
} else {

View file

@ -1,8 +1,12 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Ticker from './Ticker'
import SearchBar from './SearchBar'
const StockPrice = () => {
const [symbol, setSymbol] = useState('')
interface StockPriceProps {
symbol: string
}
const StockPrice = ({ symbol }: StockPriceProps) => {
const [price, setPrice] = useState<number | null>(null)
const [error, setError] = useState('')
@ -13,18 +17,27 @@ const StockPrice = () => {
const data = await res.json()
if (data.error) {
setError(data.error)
setPrice(null)
} else {
setPrice(data.c)
setSymbol(selectedSymbol)
}
} catch (err) {
setError('Failed to fetch stock price')
setPrice(null)
}
}
useEffect(() => {
let intervalId: NodeJS.Timeout
if (symbol) {
fetchStockPrice(symbol)
intervalId = setInterval(() => fetchStockPrice(symbol), 300000) // 300000 ms = 5 minutes
}
return () => clearInterval(intervalId)
}, [symbol])
return (
<div className="my-4">
<SearchBar onSelectSymbol={fetchStockPrice} />
{symbol && (
<div>
<h2 className="text-2xl font-bold mt-4">Symbol: {symbol}</h2>
@ -38,6 +51,7 @@ const StockPrice = () => {
<p>{error}</p>
</div>
)}
<Ticker symbol={symbol} />
</div>
)}
</div>

View file

@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
const ThemeSwitcher = () => {
const [theme, setTheme] = useState('light');
useEffect(() => {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [theme]);
const handleThemeToggle = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<button
onClick={handleThemeToggle}
className="px-4 py-2 bg-blue-600 text-white rounded-md"
>
O
</button>
);
};
export default ThemeSwitcher;

112
src/components/Ticker.tsx Normal file
View file

@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import { fetchQuote } from '../utils/sparkle'
import { tradeConditions } from '../utils/tradeConditions'
interface Trade {
p: number; // Price
s: string; // Symbol
t: number; // Timestamp
v: number; // Volume
c?: number[]; // Conditions (DOES NOT EXIST FOR ALL TRADES)
}
const Ticker = ({ symbol }: { symbol: string }) => {
const [trades, setTrades] = useState<Trade[]>([])
const [bid, setBid] = useState<number | null>(null)
const [ask, setAsk] = useState<number | null>(null)
const [webSocketInitialized, setWebSocketInitialized] = useState(false)
useEffect(() => {
// Fetch initial bid and ask prices
const initializePrices = async () => {
try {
const quote = await fetchQuote(symbol)
setBid(quote.b)
setAsk(quote.a)
} catch (error) {
console.error('Failed to fetch initial bid/ask prices:', error)
}
}
initializePrices()
}, [symbol])
useEffect(() => {
const initializeWebSocket = async () => {
try {
const response = await fetch('/api/ws/start')
const data = await response.json()
if (data.status === 'WebSocket server is running') {
setWebSocketInitialized(true)
}
} catch (error) {
console.error('Failed to initialize WebSocket:', error)
}
}
initializeWebSocket()
}, [])
useEffect(() => {
if (!webSocketInitialized) return
const socket = new WebSocket(`${process.env.NEXT_PUBLIC_SPARKLE_BASE_URL!.replace('http', 'ws')}/ws/trades`)
socket.onopen = () => {
console.log('WebSocket connection established')
socket.send(JSON.stringify({ type: 'subscribe', symbol }))
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'trade') {
setTrades((prevTrades) => [...data.data, ...prevTrades])
}
}
socket.onclose = () => {
console.log('WebSocket connection closed')
}
return () => {
socket.send(JSON.stringify({ type: 'unsubscribe', symbol }))
socket.close()
}
}, [symbol, webSocketInitialized])
const identifyTradeType = (trade: Trade) => {
if (bid !== null && ask !== null) {
if (trade.p >= ask) {
return 'Buy'
} else if (trade.p <= bid) {
return 'Sell'
}
}
return 'Unknown'
}
const getTradeConditions = (trade: Trade) => {
if (trade.c && trade.c.length > 0) {
return trade.c.map(code => tradeConditions[code] || `Unknown Condition: ${code}`).join(', ')
}
return 'No conditions'
}
return (
<div className="mt-4">
<h2 className="text-2xl font-bold">Latest Trades for {symbol}</h2>
<ul className="mt-2">
{trades.slice(0, 5).map((trade, index) => (
<li key={index} className="p-2 border-b">
<div>Price: ${trade.p}</div>
<div>Volume: {trade.v}</div>
<div>Time: {new Date(trade.t).toLocaleTimeString()}</div>
<div>Type: {identifyTradeType(trade)}</div>
<div>Conditions: {getTradeConditions(trade)}</div>
</li>
))}
</ul>
</div>
)
}
export default Ticker

13
src/pages/api/ws/start.ts Normal file
View file

@ -0,0 +1,13 @@
import { startWebSocket } from '@/utils/sparkle'
import type { NextApiRequest, NextApiResponse } from 'next'
let isWebSocketRunning = false
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (!isWebSocketRunning) {
startWebSocket();
isWebSocketRunning = true;
}
res.status(200).json({ status: 'WebSocket server is running' })
}

View file

@ -1,11 +1,20 @@
import Header from '../components/Header'
import { useState } from 'react'
import NavigationBar from '../components/NavigationBar'
import StockPrice from '../components/StockPrice'
export default function Home() {
const [symbol, setSymbol] = useState('')
const handleSelectSymbol = (selectedSymbol: string) => {
setSymbol(selectedSymbol)
}
return (
<div className="container mx-auto p-4">
<Header />
<StockPrice />
<div>
<NavigationBar onSelectSymbol={handleSelectSymbol} />
<div className="container mx-auto p-4">
<StockPrice symbol={symbol} />
</div>
</div>
)
}

View file

@ -1,3 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-base: #ebe6df; /* Latte base color */
--color-mantle: #e2ddd6;
--color-crust: #f5f4ed;
--color-surface0: #f2e7dc;
--color-surface1: #ece5df;
--color-surface2: #f4ede8;
--color-overlay0: #dad5d0;
--color-overlay1: #e3ded8;
--color-overlay2: #edebe7;
--color-text: #4c4f52;
--color-subtext1: #55595e;
--color-subtext0: #5f6368;
--background: var(--color-base);
--foreground: var(--color-text);
}
.dark {
--color-base: #1e1e2e; /* Mocha base color */
--color-mantle: #181825;
--color-crust: #13111e;
--color-surface0: #313244;
--color-surface1: #3a3c51;
--color-surface2: #4e4f68;
--color-overlay0: #6c6f85;
--color-overlay1: #898ba5;
--color-overlay2: #aabbcc;
--color-text: #cdd6f4;
--color-subtext1: #bac2de;
--color-subtext0: #a6adc8;
--background: var(--color-base);
--foreground: var(--color-text);
}
body {
background-color: var(--background);
color: var(--foreground);
}

View file

@ -1,7 +1,7 @@
const SPARKLE_BASE_URL = process.env.NEXT_PUBLIC_SPARKLE_BASE_URL;
export const fetchQuote = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/quote?symbol=${symbol}`
const url = `${SPARKLE_BASE_URL}/api/v1/quote?symbol=${symbol}`
const res = await fetch(url)
if (!res.ok) {
throw new Error('Error fetching quote')
@ -10,10 +10,19 @@ export const fetchQuote = async (symbol: string) => {
}
export const fetchSymbols = async (symbol: string) => {
const url = `${SPARKLE_BASE_URL}/search?query=${symbol}`
const url = `${SPARKLE_BASE_URL}/api/v1/search?query=${symbol}`
const res = await fetch(url)
if (!res.ok) {
throw new Error('Error fetching quote')
}
return res.json()
}
export const startWebSocket = async () => {
const url = `${SPARKLE_BASE_URL}/ws/start-websocket`
const res = await fetch(url)
if (!res.ok) {
throw new Error('Error starting WebSocket')
}
return res.json()
}

View file

@ -0,0 +1,43 @@
export const tradeConditions: Record<number, string> = {
1: "Regular",
2: "Acquisition",
3: "Average Price Trade",
4: "Bunched",
5: "Cash Sale",
6: "Distribution",
7: "Automatic Execution",
8: "Intermarket Sweep Order",
9: "Bunched Sold",
10: "Price Variation Trade",
11: "Cap Election",
12: "Odd Lot Trade",
13: "Rule 127",
14: "Rule 155",
15: "Sold last",
16: "Market Center Official Close",
17: "Next day",
18: "Market Center Opening Trade",
19: "Opening Prints",
20: "Market Center Official Open",
21: "Prior Reference Price",
22: "Seller",
23: "Split Trade",
24: "Form-T Trade",
25: "Extended Hours (Sold Out of Sequence)",
26: "Contingent Trade",
27: "Stock Option Trade",
28: "Cross Trade",
29: "Yellow Flag",
30: "Sold (Out of Sequence)",
31: "Stopped Stock",
32: "Derivatively Priced",
33: "Market Center Re-opening Trade",
34: "Re-opening Prints",
35: "Market Center Closing Trade",
36: "Closing Prints",
37: "Qualified Contingent Trade",
38: "Placeholder for 611 Exempt",
39: "Corrected Consolidated Close",
40: "Opened",
41: "Trade Through Exempt (TTE)",
};

View file

@ -1,19 +1,64 @@
import type { Config } from "tailwindcss";
import type { Config } from 'tailwindcss'
const latte = {
base: '#ebe6df',
mantle: '#e2ddd6',
crust: '#f5f4ed',
surface0: '#f2e7dc',
surface1: '#ece5df',
surface2: '#f4ede8',
overlay0: '#dad5d0',
overlay1: '#e3ded8',
overlay2: '#edebe7',
text: '#4c4f52',
subtext1: '#55595e',
subtext0: '#5f6368'
}
const mocha = {
base: '#1e1e2e',
mantle: '#181825',
crust: '#13111e',
surface0: '#313244',
surface1: '#3a3c51',
surface2: '#4e4f68',
overlay0: '#6c6f85',
overlay1: '#898ba5',
overlay2: '#aabbcc',
text: '#cdd6f4',
subtext1: '#bac2de',
subtext0: '#a6adc8'
}
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}'
],
darkMode: 'class',
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
},
background: 'var(--background)',
foreground: 'var(--foreground)',
base: 'var(--color-base)',
mantle: 'var(--color-mantle)',
crust: 'var(--color-crust)',
surface0: 'var(--color-surface0)',
surface1: 'var(--color-surface1)',
surface2: 'var(--color-surface2)',
overlay0: 'var(--color-overlay0)',
overlay1: 'var(--color-overlay1)',
overlay2: 'var(--color-overlay2)',
text: 'var(--color-text)',
subtext1: 'var(--color-subtext1)',
latte,
mocha
}
}
},
plugins: [],
};
export default config;
plugins: []
}
export default config