From 2bf8d1625076dfe81717fd4c50b016daec5616f3 Mon Sep 17 00:00:00 2001 From: Ryana May Que Date: Fri, 6 Sep 2024 16:37:27 +0800 Subject: [PATCH] improve: nav ui --- package-lock.json | 11 ++ package.json | 1 + src/components/FadeLoader.tsx | 18 +++ src/components/SearchBar.tsx | 296 +++++++++++++++++++++------------- src/pages/api/search.ts | 28 ++-- src/utils/sparkle.ts | 42 ++--- 6 files changed, 252 insertions(+), 144 deletions(-) create mode 100644 src/components/FadeLoader.tsx diff --git a/package-lock.json b/package-lock.json index fab5287..82f38c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "react": "^18", "react-dom": "^18", "react-icons": "^5.3.0", + "react-spinners": "^0.14.1", "recharts": "^2.12.7" }, "devDependencies": { @@ -4483,6 +4484,16 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-spinners": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/react-spinners/-/react-spinners-0.14.1.tgz", + "integrity": "sha512-2Izq+qgQ08HTofCVEdcAQCXFEYfqTDdfeDQJeo/HHQiQJD4imOicNLhkfN2eh1NYEWVOX4D9ok2lhuDB0z3Aag==", + "license": "MIT", + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", diff --git a/package.json b/package.json index b33cf02..13112f7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "react": "^18", "react-dom": "^18", "react-icons": "^5.3.0", + "react-spinners": "^0.14.1", "recharts": "^2.12.7" }, "devDependencies": { diff --git a/src/components/FadeLoader.tsx b/src/components/FadeLoader.tsx new file mode 100644 index 0000000..95075d0 --- /dev/null +++ b/src/components/FadeLoader.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { FadeLoader as Spinner } from 'react-spinners'; + +interface FadeLoaderProps { + loading: boolean; + color?: string; +} + +const FadeLoader: React.FC = ({ loading, color }) => { + const themeColor = color || getComputedStyle(document.documentElement).getPropertyValue('--color-text').trim(); + return ( +
+ +
+ ); +}; + +export default FadeLoader; \ No newline at end of file diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 4743f74..a808757 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,126 +1,200 @@ import { useState, useEffect, useCallback, useRef } from "react"; import debounce from "lodash.debounce"; import { FaSearch, FaTimes } from "react-icons/fa"; +import FadeLoader from "./FadeLoader"; interface SearchBarProps { - onSelectSymbol: (symbol: string) => void; + onSelectSymbol: (symbol: string) => void; } const SearchBar = ({ onSelectSymbol }: SearchBarProps) => { - const [query, setQuery] = useState(""); - const [selectedQuery, setSelectedQuery] = useState(""); - const [suggestions, setSuggestions] = useState<{ symbol: string; description: string }[]>([]); - const [loading, setLoading] = useState(false); - const [notFound, setNotFound] = useState(false); - const [isPickerVisible, setIsPickerVisible] = useState(false); - const pickerRef = useRef(null); + const [query, setQuery] = useState(""); + const [selectedQuery, setSelectedQuery] = useState(""); + 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(null); - const fetchSuggestions = async (query: string) => { - setLoading(true); - setNotFound(false); - try { - const res = await fetch(`/api/search?query=${query}`); - const data = await res.json(); - if (data.result && data.result.length > 0) { - setSuggestions(data.result.slice(0, 5)); - } else { - setNotFound(true); + const fetchSuggestions = (() => { + let currentController: AbortController | null = null; + + return async (query: string) => { + if (currentController) { + currentController.abort(); + } + + currentController = new AbortController(); + const { signal } = currentController; + + setLoading(true); + setQueryTotal(queryTotal + 1); + setNotFound(false); + + 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; + } + }; + })(); + + const debouncedFetchSuggestions = useCallback( + debounce((query: string) => { + fetchSuggestions(query); + }, 5000), + [] + ); + + useEffect(() => { + if (query.length > 0) { + debouncedFetchSuggestions(query); + setIsPickerVisible(true); + } else { + setSuggestions([]); + setNotFound(false); + setIsPickerVisible(false); + } + }, [query, debouncedFetchSuggestions]); + + const handleSelect = (symbol: string) => { + setQuery(""); + setSelectedQuery(symbol); setSuggestions([]); - } - } catch (error) { - console.error("Error fetching suggestions:", error); - setNotFound(true); - setSuggestions([]); - } finally { - setLoading(false); - } - }; - - const debouncedFetchSuggestions = useCallback(debounce((query: string) => { - fetchSuggestions(query); - }, 300), []); - - useEffect(() => { - if (query.length > 1) { - debouncedFetchSuggestions(query); - setIsPickerVisible(true); - } else { - setSuggestions([]); - setNotFound(false); - setIsPickerVisible(false); - } - }, [query, debouncedFetchSuggestions]); - - const handleSelect = (symbol: string) => { - setQuery(""); - setSelectedQuery(symbol); - setSuggestions([]); - setIsPickerVisible(false); - onSelectSymbol(symbol); - }; - - const handleClickOutside = (event: MouseEvent) => { - if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) { - setIsPickerVisible(false); - } - }; - - useEffect(() => { - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); + setIsPickerVisible(false); + onSelectSymbol(symbol); }; - }, []); - return ( -
-
- setQuery(e.target.value)} - className="pl-8 w-full bg-transparent outline-none text-text" - onFocus={() => setIsPickerVisible(true)} - /> - - - - {query && ( - setQuery('')}> - - - )} -
- {isPickerVisible && ( - <> - {loading && ( -
- Loading... + const handleClickOutside = (event: MouseEvent) => { + if ( + pickerRef.current && + !pickerRef.current.contains(event.target as Node) + ) { + setIsPickerVisible(false); + } + }; + + useEffect(() => { + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + return ( +
+
+ { + setLoading(true); + setQuery(e.target.value); + }} + className="pl-8 w-full bg-transparent outline-none text-text" + onFocus={() => setIsPickerVisible(true)} + /> + + + + {query && ( + setQuery("")} + > + + + )}
- )} - {!loading && notFound && ( -
- No results found -
- )} - {suggestions.length > 0 && ( -
    - {suggestions.map((item) => ( -
  • handleSelect(item.symbol)} - className="p-2 hover:bg-crust cursor-pointer" - > - {item.description} ({item.symbol}) -
  • - ))} -
- )} - - )} -
- ); + {isPickerVisible && ( + <> + { +
    + + {suggestions.map((item, index) => ( +
  • handleSelect(item.symbol)} + className={`p-2 hover:bg-crust cursor-pointer transition-all border-b-2 dark:border-base border-overlay0 `} + > +
    + +
    + {item.symbol}{" "} +
    +
    + {index === 0 && ( + + Top Result + + )} + {item.description} +
    +
    +
  • + ))} +
  • +
    + + + {loading + ? notFound + ? query + ? "Searching..." + : "Start searching" + : `Searching...` + : `Showing top hits for '${query}'. (Matched ${totalCount} result${totalCount > 1 ? "s" : ""})`} + + {loading && ( + + + + )} +
    +
  • +
+ } + + )} +
+ ); }; -export default SearchBar; \ No newline at end of file +export default SearchBar; diff --git a/src/pages/api/search.ts b/src/pages/api/search.ts index e063e82..3e16534 100644 --- a/src/pages/api/search.ts +++ b/src/pages/api/search.ts @@ -2,19 +2,19 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { fetchSymbols } from "../../utils/sparkle"; export default async function handler( - req: NextApiRequest, - res: NextApiResponse + req: NextApiRequest, + res: NextApiResponse ) { - const { query } = req.query; - if (!query || typeof query !== "string") { - res.status(400).json({ error: "Invalid symbol" }); - return; - } + const { query } = req.query; + if (!query || typeof query !== "string") { + res.status(400).json({ error: "Invalid symbol" }); + return; + } - try { - const quote = await fetchSymbols(query); - res.status(200).json(quote); - } catch (error) { - res.status(500).json({ error: "Error fetching quote" }); - } -} + 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" }); + } +} \ No newline at end of file diff --git a/src/utils/sparkle.ts b/src/utils/sparkle.ts index fc446a5..e176d2b 100644 --- a/src/utils/sparkle.ts +++ b/src/utils/sparkle.ts @@ -1,28 +1,32 @@ const SPARKLE_BASE_URL = process.env.NEXT_PUBLIC_SPARKLE_BASE_URL; export const fetchQuote = async (symbol: string) => { - const url = `${SPARKLE_BASE_URL}/api/v1/quote?symbol=${symbol}` - const res = await fetch(url) + const url = `${SPARKLE_BASE_URL}/api/v1/quote?symbol=${symbol}`; + const res = await fetch(url); if (!res.ok) { - throw new Error('Error fetching quote') + throw new Error('Error fetching quote'); } - return res.json() -} + return res.json(); +}; export const fetchSymbols = async (symbol: string) => { - 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() + const url = `${SPARKLE_BASE_URL}/api/v1/search?query=${symbol}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error('Error fetching symbols'); } + const data = await res.json(); + return { + symbols: data.result, + totalCount: data.totalCount, + }; +}; - 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() - } \ No newline at end of file +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(); +}; \ No newline at end of file