mirror of
https://github.com/ryanamay/twinkle.git
synced 2024-09-20 03:40:35 +00:00
feat: ticker, catpuccin theme, base layout
This commit is contained in:
parent
1f64c881b9
commit
2e41c46a1d
12 changed files with 426 additions and 34 deletions
|
@ -1,9 +0,0 @@
|
|||
const Header = () => (
|
||||
<header className="py-4">
|
||||
<h1 className="text-3xl font-bold">
|
||||
Stock Price Application
|
||||
</h1>
|
||||
</header>
|
||||
)
|
||||
|
||||
export default Header
|
86
src/components/NavigationBar.tsx
Normal file
86
src/components/NavigationBar.tsx
Normal 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
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
28
src/components/ThemeSwitcher.tsx
Normal file
28
src/components/ThemeSwitcher.tsx
Normal 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
112
src/components/Ticker.tsx
Normal 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
13
src/pages/api/ws/start.ts
Normal 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' })
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -1,3 +1,42 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@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);
|
||||
}
|
|
@ -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()
|
||||
}
|
43
src/utils/tradeConditions.ts
Normal file
43
src/utils/tradeConditions.ts
Normal 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)",
|
||||
};
|
|
@ -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
|
Loading…
Reference in a new issue