diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index 669877e..91e002a 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -14,8 +14,7 @@ const Layout = async ({ children }: { children : React.ReactNode }) => {
- -

OpenStock

+Openstock
diff --git a/app/(root)/page.tsx b/app/(root)/page.tsx index fc71f30..18d490c 100644 --- a/app/(root)/page.tsx +++ b/app/(root)/page.tsx @@ -1,9 +1,15 @@ -import React from 'react' import TradingViewWidget from "@/components/TradingViewWidget"; -import {MARKET_DATA_WIDGET_CONFIG, MARKET_OVERVIEW_WIDGET_CONFIG, TOP_STORIES_WIDGET_CONFIG} from "@/lib/constants"; +import { + HEATMAP_WIDGET_CONFIG, + MARKET_DATA_WIDGET_CONFIG, + MARKET_OVERVIEW_WIDGET_CONFIG, + TOP_STORIES_WIDGET_CONFIG +} from "@/lib/constants"; +import {sendDailyNewsSummary} from "@/lib/inngest/functions"; const Home = () => { const scriptUrl = `https://s3.tradingview.com/external-embedding/embed-widget-`; + return (
@@ -15,38 +21,35 @@ const Home = () => { className="custom-chart" height={600} /> -
-
+
-
+
-
+
+
- ) } -export default Home + +export default Home; \ No newline at end of file diff --git a/app/(root)/stocks/[symbol]/page.tsx b/app/(root)/stocks/[symbol]/page.tsx new file mode 100644 index 0000000..a3e038c --- /dev/null +++ b/app/(root)/stocks/[symbol]/page.tsx @@ -0,0 +1,69 @@ +import TradingViewWidget from "@/components/TradingViewWidget"; +import WatchlistButton from "@/components/WatchlistButton"; +import { + SYMBOL_INFO_WIDGET_CONFIG, + CANDLE_CHART_WIDGET_CONFIG, + BASELINE_WIDGET_CONFIG, + TECHNICAL_ANALYSIS_WIDGET_CONFIG, + COMPANY_PROFILE_WIDGET_CONFIG, + COMPANY_FINANCIALS_WIDGET_CONFIG, +} from "@/lib/constants"; + +export default async function StockDetails({ params }: StockDetailsPageProps) { + const { symbol } = await params; + const scriptUrl = `https://s3.tradingview.com/external-embedding/embed-widget-`; + + return ( +
+
+ {/* Left column */} +
+ + + + + +
+ + {/* Right column */} +
+
+ +
+ + + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/app/api/inngest/route.ts b/app/api/inngest/route.ts index 466beec..55532b9 100644 --- a/app/api/inngest/route.ts +++ b/app/api/inngest/route.ts @@ -1,8 +1,8 @@ import {serve} from "inngest/next"; import {inngest} from "@/lib/inngest/client"; -import {sendSignUpEmail} from "@/lib/inngest/functions"; +import {sendDailyNewsSummary, sendSignUpEmail} from "@/lib/inngest/functions"; export const {GET, POST, PUT } = serve({ client: inngest, - functions: [sendSignUpEmail], + functions: [sendSignUpEmail, sendDailyNewsSummary], }) \ No newline at end of file diff --git a/components/Header.tsx b/components/Header.tsx index 6255d25..1d5effe 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -2,24 +2,29 @@ import Link from "next/link"; import Image from "next/image"; import NavItems from "@/components/NavItems"; import UserDropdown from "@/components/UserDropdown"; +import {searchStocks} from "@/lib/actions/finnhub.actions"; const Header = async ({ user }: { user: User }) => { + const initialStocks = await searchStocks(); + return (
- -

OpenStock

+ OpenStock - {/* UserDropDown */} - +
-
) } -export default Header +export default Header \ No newline at end of file diff --git a/components/NavItems.tsx b/components/NavItems.tsx index ed9020d..53434dd 100644 --- a/components/NavItems.tsx +++ b/components/NavItems.tsx @@ -5,8 +5,9 @@ import React from 'react' import {NAV_ITEMS} from "@/lib/constants"; import Link from "next/link"; import {usePathname} from "next/navigation"; +import SearchCommand from "@/components/SearchCommand"; -const NavItems = () => { +const NavItems = ({initialStocks}: { initialStocks: StockWithWatchlistStatus[]}) => { const pathname = usePathname() const isActive = (path: string) => { @@ -16,13 +17,22 @@ const NavItems = () => { } return ( ) } diff --git a/components/SearchCommand.tsx b/components/SearchCommand.tsx new file mode 100644 index 0000000..f7bc62f --- /dev/null +++ b/components/SearchCommand.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useEffect, useState } from "react" +import { CommandDialog, CommandEmpty, CommandInput, CommandList } from "@/components/ui/command" +import {Button} from "@/components/ui/button"; +import {Loader2, TrendingUp} from "lucide-react"; +import Link from "next/link"; +import {searchStocks} from "@/lib/actions/finnhub.actions"; +import {useDebounce} from "@/hooks/useDebounce"; + +export default function SearchCommand({ renderAs = 'button', label = 'Add stock', initialStocks }: SearchCommandProps) { + const [open, setOpen] = useState(false) + const [searchTerm, setSearchTerm] = useState("") + const [loading, setLoading] = useState(false) + const [stocks, setStocks] = useState(initialStocks); + + const isSearchMode = !!searchTerm.trim(); + const displayStocks = isSearchMode ? stocks : stocks?.slice(0, 10); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault() + setOpen(v => !v) + } + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, []) + + const handleSearch = async () => { + if(!isSearchMode) return setStocks(initialStocks); + + setLoading(true) + try { + const results = await searchStocks(searchTerm.trim()); + setStocks(results); + } catch { + setStocks([]) + } finally { + setLoading(false) + } + } + + const debouncedSearch = useDebounce(handleSearch, 300); + + useEffect(() => { + debouncedSearch(); + }, [searchTerm]); + + const handleSelectStock = () => { + setOpen(false); + setSearchTerm(""); + setStocks(initialStocks); + } + + return ( + <> + {renderAs === 'text' ? ( + + ): ( + + )} + +
+ + {loading && } +
+ + {loading ? ( + Loading stocks... + ) : displayStocks?.length === 0 ? ( +
+ {isSearchMode ? 'No results found' : 'No stocks available'} +
+ ) : ( +
    +
    + {isSearchMode ? 'Search results' : 'Popular stocks'} + {` `}({displayStocks?.length || 0}) +
    + {displayStocks?.map((stock, i) => ( +
  • + + +
    +
    + {stock.name} +
    +
    + {stock.symbol} | {stock.exchange } | {stock.type} +
    +
    + + +
  • + ))} +
+ ) + } +
+
+ + ) +} \ No newline at end of file diff --git a/components/TradingViewWidget.tsx b/components/TradingViewWidget.tsx index 64359aa..3461891 100644 --- a/components/TradingViewWidget.tsx +++ b/components/TradingViewWidget.tsx @@ -4,27 +4,25 @@ import React, { memo } from 'react'; import useTradingViewWidget from "@/hooks/useTradingViewWidget"; import {cn} from "@/lib/utils"; -interface TradingViewWidgetProps{ - title: string; +interface TradingViewWidgetProps { + title?: string; scriptUrl: string; config: Record; height?: number; className?: string; } -const TradingViewWidget = ({title, scriptUrl, config, height = 600, className}: TradingViewWidgetProps) => { +const TradingViewWidget = ({ title, scriptUrl, config, height = 600, className }: TradingViewWidgetProps) => { const containerRef = useTradingViewWidget(scriptUrl, config, height); - return (
{title &&

{title}

}
-
+
- ); } -export default memo(TradingViewWidget); +export default memo(TradingViewWidget); \ No newline at end of file diff --git a/components/UserDropdown.tsx b/components/UserDropdown.tsx index acc9091..7542821 100644 --- a/components/UserDropdown.tsx +++ b/components/UserDropdown.tsx @@ -1,8 +1,5 @@ 'use client'; - -import {Button} from "@/components/ui/button"; -import React from 'react' import { DropdownMenu, DropdownMenuContent, @@ -13,14 +10,15 @@ import { } from "@/components/ui/dropdown-menu" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import {useRouter} from "next/navigation"; +import {Button} from "@/components/ui/button"; import {LogOut} from "lucide-react"; import NavItems from "@/components/NavItems"; import {signOut} from "@/lib/actions/auth.actions"; -const UserDropdown = ({user} : {user: User}) => { +const UserDropdown = ({ user, initialStocks }: {user: User, initialStocks: StockWithWatchlistStatus[]}) => { const router = useRouter(); - const handleSignOut = async() => { + const handleSignOut = async () => { await signOut(); router.push("/sign-in"); } @@ -28,21 +26,21 @@ const UserDropdown = ({user} : {user: User}) => { return ( - - +
@@ -52,23 +50,21 @@ const UserDropdown = ({user} : {user: User}) => {
- - {user.name} - - - {user.email} - + + {user.name} + + {user.email}
- + Logout - +
diff --git a/components/WatchlistButton.tsx b/components/WatchlistButton.tsx new file mode 100644 index 0000000..1e53e55 --- /dev/null +++ b/components/WatchlistButton.tsx @@ -0,0 +1,71 @@ +"use client"; +import React, { useMemo, useState } from "react"; + + +const WatchlistButton = ({ + symbol, + company, + isInWatchlist, + showTrashIcon = false, + type = "button", + onWatchlistChange, + }: WatchlistButtonProps) => { + const [added, setAdded] = useState(!!isInWatchlist); + + const label = useMemo(() => { + if (type === "icon") return added ? "" : ""; + return added ? "Remove from Watchlist" : "Add to Watchlist"; + }, [added, type]); + + const handleClick = () => { + const next = !added; + setAdded(next); + onWatchlistChange?.(symbol, next); + }; + + if (type === "icon") { + return ( + + ); + } + + return ( + + ); +}; + +export default WatchlistButton; \ No newline at end of file diff --git a/database/models/watchlist.model.ts b/database/models/watchlist.model.ts new file mode 100644 index 0000000..6f811f1 --- /dev/null +++ b/database/models/watchlist.model.ts @@ -0,0 +1,24 @@ +import { Schema, model, models, type Document, type Model } from 'mongoose'; + +export interface WatchlistItem extends Document { + userId: string; + symbol: string; + company: string; + addedAt: Date; +} + +const WatchlistSchema = new Schema( + { + userId: { type: String, required: true, index: true }, + symbol: { type: String, required: true, uppercase: true, trim: true }, + company: { type: String, required: true, trim: true }, + addedAt: { type: Date, default: Date.now }, + }, + { timestamps: false } +); + +// Prevent duplicate symbols per user +WatchlistSchema.index({ userId: 1, symbol: 1 }, { unique: true }); + +export const Watchlist: Model = + (models?.Watchlist as Model) || model('Watchlist', WatchlistSchema); \ No newline at end of file diff --git a/hooks/useDebounce.ts b/hooks/useDebounce.ts new file mode 100644 index 0000000..2ffbe2f --- /dev/null +++ b/hooks/useDebounce.ts @@ -0,0 +1,19 @@ +'use client'; + +import { useCallback, useRef } from 'react'; + +export function useDebounce(callback: () => void, delay: number) { + const timeoutRef = useRef(null); + const callbackRef = useRef(callback); + + // Keep callback ref up to date + callbackRef.current = callback; + + return useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => callbackRef.current(), delay); + }, [delay]) +} \ No newline at end of file diff --git a/lib/actions/auth.actions.ts b/lib/actions/auth.actions.ts index 1794300..22b9bac 100644 --- a/lib/actions/auth.actions.ts +++ b/lib/actions/auth.actions.ts @@ -40,4 +40,5 @@ export const signOut = async () => { console.log('Sign out failed', e) return { success: false, error: 'Sign out failed' } } -} \ No newline at end of file +} + diff --git a/lib/actions/finnhub.actions.ts b/lib/actions/finnhub.actions.ts new file mode 100644 index 0000000..ae71d76 --- /dev/null +++ b/lib/actions/finnhub.actions.ts @@ -0,0 +1,180 @@ +'use server'; + +import { getDateRange, validateArticle, formatArticle } from '@/lib/utils'; +import { POPULAR_STOCK_SYMBOLS } from '@/lib/constants'; +import { cache } from 'react'; + +const FINNHUB_BASE_URL = 'https://finnhub.io/api/v1'; +const NEXT_PUBLIC_FINNHUB_API_KEY = process.env.NEXT_PUBLIC_FINNHUB_API_KEY ?? ''; + +async function fetchJSON(url: string, revalidateSeconds?: number): Promise { + const options: RequestInit & { next?: { revalidate?: number } } = revalidateSeconds + ? { cache: 'force-cache', next: { revalidate: revalidateSeconds } } + : { cache: 'no-store' }; + + const res = await fetch(url, options); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Fetch failed ${res.status}: ${text}`); + } + return (await res.json()) as T; +} + +export { fetchJSON }; + +export async function getNews(symbols?: string[]): Promise { + try { + const range = getDateRange(5); + const token = process.env.FINNHUB_API_KEY ?? NEXT_PUBLIC_FINNHUB_API_KEY; + if (!token) { + throw new Error('FINNHUB API key is not configured'); + } + const cleanSymbols = (symbols || []) + .map((s) => s?.trim().toUpperCase()) + .filter((s): s is string => Boolean(s)); + + const maxArticles = 6; + + // If we have symbols, try to fetch company news per symbol and round-robin select + if (cleanSymbols.length > 0) { + const perSymbolArticles: Record = {}; + + await Promise.all( + cleanSymbols.map(async (sym) => { + try { + const url = `${FINNHUB_BASE_URL}/company-news?symbol=${encodeURIComponent(sym)}&from=${range.from}&to=${range.to}&token=${token}`; + const articles = await fetchJSON(url, 300); + perSymbolArticles[sym] = (articles || []).filter(validateArticle); + } catch (e) { + console.error('Error fetching company news for', sym, e); + perSymbolArticles[sym] = []; + } + }) + ); + + const collected: MarketNewsArticle[] = []; + // Round-robin up to 6 picks + for (let round = 0; round < maxArticles; round++) { + for (let i = 0; i < cleanSymbols.length; i++) { + const sym = cleanSymbols[i]; + const list = perSymbolArticles[sym] || []; + if (list.length === 0) continue; + const article = list.shift(); + if (!article || !validateArticle(article)) continue; + collected.push(formatArticle(article, true, sym, round)); + if (collected.length >= maxArticles) break; + } + if (collected.length >= maxArticles) break; + } + + if (collected.length > 0) { + // Sort by datetime desc + collected.sort((a, b) => (b.datetime || 0) - (a.datetime || 0)); + return collected.slice(0, maxArticles); + } + // If none collected, fall through to general news + } + + // General market news fallback or when no symbols provided + const generalUrl = `${FINNHUB_BASE_URL}/news?category=general&token=${token}`; + const general = await fetchJSON(generalUrl, 300); + + const seen = new Set(); + const unique: RawNewsArticle[] = []; + for (const art of general || []) { + if (!validateArticle(art)) continue; + const key = `${art.id}-${art.url}-${art.headline}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(art); + if (unique.length >= 20) break; // cap early before final slicing + } + + const formatted = unique.slice(0, maxArticles).map((a, idx) => formatArticle(a, false, undefined, idx)); + return formatted; + } catch (err) { + console.error('getNews error:', err); + throw new Error('Failed to fetch news'); + } +} + +export const searchStocks = cache(async (query?: string): Promise => { + try { + const token = process.env.FINNHUB_API_KEY ?? NEXT_PUBLIC_FINNHUB_API_KEY; + if (!token) { + // If no token, log and return empty to avoid throwing per requirements + console.error('Error in stock search:', new Error('FINNHUB API key is not configured')); + return []; + } + + const trimmed = typeof query === 'string' ? query.trim() : ''; + + let results: FinnhubSearchResult[] = []; + + if (!trimmed) { + // Fetch top 10 popular symbols' profiles + const top = POPULAR_STOCK_SYMBOLS.slice(0, 10); + const profiles = await Promise.all( + top.map(async (sym) => { + try { + const url = `${FINNHUB_BASE_URL}/stock/profile2?symbol=${encodeURIComponent(sym)}&token=${token}`; + // Revalidate every hour + const profile = await fetchJSON(url, 3600); + return { sym, profile } as { sym: string; profile: any }; + } catch (e) { + console.error('Error fetching profile2 for', sym, e); + return { sym, profile: null } as { sym: string; profile: any }; + } + }) + ); + + results = profiles + .map(({ sym, profile }) => { + const symbol = sym.toUpperCase(); + const name: string | undefined = profile?.name || profile?.ticker || undefined; + const exchange: string | undefined = profile?.exchange || undefined; + if (!name) return undefined; + const r: FinnhubSearchResult = { + symbol, + description: name, + displaySymbol: symbol, + type: 'Common Stock', + }; + // We don't include exchange in FinnhubSearchResult type, so carry via mapping later using profile + // To keep pipeline simple, attach exchange via closure map stage + // We'll reconstruct exchange when mapping to final type + (r as any).__exchange = exchange; // internal only + return r; + }) + .filter((x): x is FinnhubSearchResult => Boolean(x)); + } else { + const url = `${FINNHUB_BASE_URL}/search?q=${encodeURIComponent(trimmed)}&token=${token}`; + const data = await fetchJSON(url, 1800); + results = Array.isArray(data?.result) ? data.result : []; + } + + const mapped: StockWithWatchlistStatus[] = results + .map((r) => { + const upper = (r.symbol || '').toUpperCase(); + const name = r.description || upper; + const exchangeFromDisplay = (r.displaySymbol as string | undefined) || undefined; + const exchangeFromProfile = (r as any).__exchange as string | undefined; + const exchange = exchangeFromDisplay || exchangeFromProfile || 'US'; + const type = r.type || 'Stock'; + const item: StockWithWatchlistStatus = { + symbol: upper, + name, + exchange, + type, + isInWatchlist: false, + }; + return item; + }) + .slice(0, 15); + + return mapped; + } catch (err) { + console.error('Error in stock search:', err); + return []; + } +}); diff --git a/lib/actions/user.actions.ts b/lib/actions/user.actions.ts new file mode 100644 index 0000000..1630e5b --- /dev/null +++ b/lib/actions/user.actions.ts @@ -0,0 +1,25 @@ +'use server'; + +import {connectToDatabase} from "@/database/mongoose"; + +export const getAllUsersForNewsEmail = async () => { + try { + const mongoose = await connectToDatabase(); + const db = mongoose.connection.db; + if(!db) throw new Error('Mongoose connection not connected'); + + const users = await db.collection('user').find( + { email: { $exists: true, $ne: null }}, + { projection: { _id: 1, id: 1, email: 1, name: 1, country:1 }} + ).toArray(); + + return users.filter((user) => user.email && user.name).map((user) => ({ + id: user.id || user._id?.toString() || '', + email: user.email, + name: user.name + })) + } catch (e) { + console.error('Error fetching users for news email:', e) + return [] + } +} \ No newline at end of file diff --git a/lib/actions/watchlist.actions.ts b/lib/actions/watchlist.actions.ts new file mode 100644 index 0000000..7e4c542 --- /dev/null +++ b/lib/actions/watchlist.actions.ts @@ -0,0 +1,28 @@ +'use server'; + +import { connectToDatabase } from '@/database/mongoose'; +import { Watchlist } from '@/database/models/watchlist.model'; + +export async function getWatchlistSymbolsByEmail(email: string): Promise { + if (!email) return []; + + try { + const mongoose = await connectToDatabase(); + const db = mongoose.connection.db; + if (!db) throw new Error('MongoDB connection not found'); + + // Better Auth stores users in the "user" collection + const user = await db.collection('user').findOne<{ _id?: unknown; id?: string; email?: string }>({ email }); + + if (!user) return []; + + const userId = (user.id as string) || String(user._id || ''); + if (!userId) return []; + + const items = await Watchlist.find({ userId }, { symbol: 1 }).lean(); + return items.map((i) => String(i.symbol)); + } catch (err) { + console.error('getWatchlistSymbolsByEmail error:', err); + return []; + } +} \ No newline at end of file diff --git a/lib/constants.ts b/lib/constants.ts index 06ff8d4..aad9698 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -1,7 +1,7 @@ export const NAV_ITEMS = [ { href: '/', label: 'Dashboard' }, { href: '/search', label: 'Search' }, - { href: '/watchlist', label: 'Watchlist' }, + // { href: '/watchlist', label: 'Watchlist' }, ]; // Sign-up form select options diff --git a/lib/inngest/functions.ts b/lib/inngest/functions.ts index 446916f..eed9200 100644 --- a/lib/inngest/functions.ts +++ b/lib/inngest/functions.ts @@ -1,11 +1,15 @@ -import {inngest} from '@/lib/inngest/client'; -import {PERSONALIZED_WELCOME_EMAIL_PROMPT} from "@/lib/inngest/prompts"; -import {sendWelcomeEmail} from "@/lib/nodemailer"; +import {inngest} from "@/lib/inngest/client"; +import {NEWS_SUMMARY_EMAIL_PROMPT, PERSONALIZED_WELCOME_EMAIL_PROMPT} from "@/lib/inngest/prompts"; +import {sendNewsSummaryEmail, sendWelcomeEmail} from "@/lib/nodemailer"; +import {getAllUsersForNewsEmail} from "@/lib/actions/user.actions"; +import { getWatchlistSymbolsByEmail } from "@/lib/actions/watchlist.actions"; +import { getNews } from "@/lib/actions/finnhub.actions"; +import { getFormattedTodayDate } from "@/lib/utils"; export const sendSignUpEmail = inngest.createFunction( - {id: 'sign-up-email'}, - {event: 'app/user.created'}, - async ({event, step}) => { + { id: 'sign-up-email' }, + { event: 'app/user.created'}, + async ({ event, step }) => { const userProfile = ` - Country: ${event.data.country} - Investment goals: ${event.data.investmentGoals} @@ -13,36 +17,104 @@ export const sendSignUpEmail = inngest.createFunction( - Preferred industry: ${event.data.preferredIndustry} ` - const prompt = PERSONALIZED_WELCOME_EMAIL_PROMPT.replace('{{userProfile}}', userProfile); + const prompt = PERSONALIZED_WELCOME_EMAIL_PROMPT.replace('{{userProfile}}', userProfile) const response = await step.ai.infer('generate-welcome-intro', { - model: step.ai.models.gemini({model: 'gemini-2.5-flash-lite'}), + model: step.ai.models.gemini({ model: 'gemini-2.5-flash-lite' }), body: { contents: [ { role: 'user', parts: [ - {text: prompt} + { text: prompt } ] - } - ] + }] } - }) - await step.run('send-welcome-email', async() => { + await step.run('send-welcome-email', async () => { const part = response.candidates?.[0]?.content?.parts?.[0]; - const introText = (part && 'text' in part ? part.text : null) || 'Thanks for joining OpenStock and be part of this initiative by Open Dev Society'; - - const {data: {email, name}} = event; - return await sendWelcomeEmail({email, name, intro: introText }); + const introText = (part && 'text' in part ? part.text : null) ||'Thanks for joining Openstock. You now have the tools to track markets and make smarter moves.' + const { data: { email, name } } = event; + return await sendWelcomeEmail({ email, name, intro: introText }); }) return { success: true, - message: 'Welcome email sent successfully!', + message: 'Welcome email sent successfully' } } +) + +export const sendDailyNewsSummary = inngest.createFunction( + { id: 'daily-news-summary' }, + [ { event: 'app/send.daily.news' }, { cron: '0 12 * * *' } ], + async ({ step }) => { + // Step #1: Get all users for news delivery + const users = await step.run('get-all-users', getAllUsersForNewsEmail) + + if(!users || users.length === 0) return { success: false, message: 'No users found for news email' }; + + // Step #2: For each user, get watchlist symbols -> fetch news (fallback to general) + const results = await step.run('fetch-user-news', async () => { + const perUser: Array<{ user: User; articles: MarketNewsArticle[] }> = []; + for (const user of users as User[]) { + try { + const symbols = await getWatchlistSymbolsByEmail(user.email); + let articles = await getNews(symbols); + // Enforce max 6 articles per user + articles = (articles || []).slice(0, 6); + // If still empty, fallback to general + if (!articles || articles.length === 0) { + articles = await getNews(); + articles = (articles || []).slice(0, 6); + } + perUser.push({ user, articles }); + } catch (e) { + console.error('daily-news: error preparing user news', user.email, e); + perUser.push({ user, articles: [] }); + } + } + return perUser; + }); + + // Step #3: (placeholder) Summarize news via AI + const userNewsSummaries: { user: User; newsContent: string | null }[] = []; + + for (const { user, articles } of results) { + try { + const prompt = NEWS_SUMMARY_EMAIL_PROMPT.replace('{{newsData}}', JSON.stringify(articles, null, 2)); + + const response = await step.ai.infer(`summarize-news-${user.email}`, { + model: step.ai.models.gemini({ model: 'gemini-2.5-flash-lite' }), + body: { + contents: [{ role: 'user', parts: [{ text:prompt }]}] + } + }); + + const part = response.candidates?.[0]?.content?.parts?.[0]; + const newsContent = (part && 'text' in part ? part.text : null) || 'No market news.' + + userNewsSummaries.push({ user, newsContent }); + } catch (e) { + console.error('Failed to summarize news for : ', user.email); + userNewsSummaries.push({ user, newsContent: null }); + } + } + + // Step #4: (placeholder) Send the emails + await step.run('send-news-emails', async () => { + await Promise.all( + userNewsSummaries.map(async ({ user, newsContent}) => { + if(!newsContent) return false; + + return await sendNewsSummaryEmail({ email: user.email, date: getFormattedTodayDate(), newsContent }) + }) + ) + }) + + return { success: true, message: 'Daily news summary emails sent successfully' } + } ) \ No newline at end of file diff --git a/lib/inngest/prompts.ts b/lib/inngest/prompts.ts index 04e9d3e..f6890f6 100644 --- a/lib/inngest/prompts.ts +++ b/lib/inngest/prompts.ts @@ -41,7 +41,7 @@ CRITICAL FORMATTING REQUIREMENTS: - Second sentence should add helpful context or reinforce the personalization Example personalized outputs (showing obvious customization with TWO sentences): -

Thanks for joining Signalist! As someone focused on technology growth stocks, you'll love our real-time alerts for companies like the ones you're tracking. We'll help you spot opportunities before they become mainstream news.

+

Thanks for joining Openstock! As someone focused on technology growth stocks, you'll love our real-time alerts for companies like the ones you're tracking. We'll help you spot opportunities before they become mainstream news.

Great to have you aboard! Perfect for your conservative retirement strategy — we'll help you monitor dividend stocks without overwhelming you with noise. You can finally track your portfolio progress with confidence and clarity.

diff --git a/lib/nodemailer/index.ts b/lib/nodemailer/index.ts index 52bac00..fd7cd08 100644 --- a/lib/nodemailer/index.ts +++ b/lib/nodemailer/index.ts @@ -1,28 +1,44 @@ -import nodemailer from 'nodemailer' -import {WELCOME_EMAIL_TEMPLATE} from "@/lib/nodemailer/templates"; - +import nodemailer from 'nodemailer'; +import {WELCOME_EMAIL_TEMPLATE, NEWS_SUMMARY_EMAIL_TEMPLATE} from "@/lib/nodemailer/templates"; export const transporter = nodemailer.createTransport({ service: 'gmail', auth: { - user: process.env.NODEMAILER_EMAIL, - pass: process.env.NODEMAILER_PASSWORD, + user: process.env.NODEMAILER_EMAIL!, + pass: process.env.NODEMAILER_PASSWORD!, } }) -export const sendWelcomeEmail = async ({email, name, intro} : WelcomeEmailData) => { +export const sendWelcomeEmail = async ({ email, name, intro }: WelcomeEmailData) => { const htmlTemplate = WELCOME_EMAIL_TEMPLATE .replace('{{name}}', name) .replace('{{intro}}', intro); - const mailOptions = { from: `"Openstock" `, to: email, - subject: 'Welcome to OpenStock - your open-source stock market toolkit', - text: 'Thanks for joining Openstock and believing in this initiative by Open Dev Society', + subject: `Welcome to Openstock - your open-source stock market toolkit!`, + text: 'Thanks for joining Openstock, an initiative by open dev society', html: htmlTemplate, } await transporter.sendMail(mailOptions); -} \ No newline at end of file +} + +export const sendNewsSummaryEmail = async ( + { email, date, newsContent }: { email: string; date: string; newsContent: string } +): Promise => { + const htmlTemplate = NEWS_SUMMARY_EMAIL_TEMPLATE + .replace('{{date}}', date) + .replace('{{newsContent}}', newsContent); + + const mailOptions = { + from: `"Openstock" `, + to: email, + subject: `📈 Market News Summary Today - ${date}`, + text: `Today's market news summary from Openstock`, + html: htmlTemplate, + }; + + await transporter.sendMail(mailOptions); +}; \ No newline at end of file diff --git a/lib/nodemailer/templates.ts b/lib/nodemailer/templates.ts index 3524bfa..f115584 100644 --- a/lib/nodemailer/templates.ts +++ b/lib/nodemailer/templates.ts @@ -97,14 +97,14 @@ export const WELCOME_EMAIL_TEMPLATE = ` - OpenStock Logo + OpenStock Logo - OpenStock Dashboard Preview + OpenStock Dashboard Preview @@ -260,7 +260,7 @@ export const NEWS_SUMMARY_EMAIL_TEMPLATE = ` - Signalist Logo + Openstock Logo @@ -284,14 +284,14 @@ export const NEWS_SUMMARY_EMAIL_TEMPLATE = `

- You're receiving this because you subscribed to Signalist news updates. + You're receiving this because you subscribed to Openstock news updates.

Unsubscribe | - Visit Signalist + Visit Openstock

- © 2025 Signalist + © 2025 Open Dev Society

@@ -410,7 +410,7 @@ export const STOCK_ALERT_UPPER_EMAIL_TEMPLATE = ` - Signalist Logo + Openstock Logo @@ -483,7 +483,7 @@ export const STOCK_ALERT_UPPER_EMAIL_TEMPLATE = ` @@ -493,15 +493,14 @@ export const STOCK_ALERT_UPPER_EMAIL_TEMPLATE = `

- You're receiving this because you subscribed to Signalist news updates. + You're receiving this because you subscribed to Openstock news updates.

Unsubscribe | - Visit Signalist + Visit Openstock

- © 2025 Signalist -

+ © 2025 Open Dev Society
@@ -619,7 +618,7 @@ export const STOCK_ALERT_LOWER_EMAIL_TEMPLATE = ` @@ -692,7 +691,7 @@ export const STOCK_ALERT_LOWER_EMAIL_TEMPLATE = `
- + View Dashboard
- Signalist Logo + Openstock Logo
@@ -702,14 +701,14 @@ export const STOCK_ALERT_LOWER_EMAIL_TEMPLATE = `

- You're receiving this because you subscribed to Signalist news updates. + You're receiving this because you subscribed to Openstock news updates.

Unsubscribe | - Visit Signalist + Visit Openstock

- © 2025 Signalist + © 2025 Open Dev Society

@@ -819,7 +818,7 @@ export const VOLUME_ALERT_EMAIL_TEMPLATE = ` @@ -905,7 +904,7 @@ export const VOLUME_ALERT_EMAIL_TEMPLATE = `
- + View Dashboard
- Signalist Logo + Openstock Logo
@@ -922,14 +921,14 @@ export const VOLUME_ALERT_EMAIL_TEMPLATE = `

- You're receiving this because you subscribed to Signalist news updates. + You're receiving this because you subscribed to Openstock news updates.

Unsubscribe | - Visit Signalist + Visit Openstock

- © 2025 Signalist + © 2025 Open Dev Society

@@ -1042,7 +1041,7 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = ` @@ -1057,7 +1056,7 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = `

- We noticed you haven't visited Signalist in a while. The markets have been moving, and there might be some opportunities you don't want to miss! + We noticed you haven't visited Openstock in a while. The markets have been moving, and there might be some opportunities you don't want to miss!

@@ -1079,7 +1078,7 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = `
- + View Dashboard
- Signalist Logo + Openstock Logo
@@ -1092,11 +1091,11 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = ` Questions? Reply to this email or contact our support team.

- Unsubscribe | - Visit Signalist + Unsubscribe | + Visit Openstock

- © 2025 Signalist + © 2025 Openstock

diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..4031a4c 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,139 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } + +export const formatTimeAgo = (timestamp: number) => { + const now = Date.now(); + const diffInMs = now - timestamp * 1000; // Convert to milliseconds + const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60)); + const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); + + if (diffInHours > 24) { + const days = Math.floor(diffInHours / 24); + return `${days} day${days > 1 ? 's' : ''} ago`; + } else if (diffInHours >= 1) { + return `${diffInHours} hour${diffInHours > 1 ? 's' : ''} ago`; + } else { + return `${diffInMinutes} minute${diffInMinutes > 1 ? 's' : ''} ago`; + } +}; + +export function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Formatted string like "$3.10T", "$900.00B", "$25.00M" or "$999,999.99" +export function formatMarketCapValue(marketCapUsd: number): string { + if (!Number.isFinite(marketCapUsd) || marketCapUsd <= 0) return 'N/A'; + + if (marketCapUsd >= 1e12) return `$${(marketCapUsd / 1e12).toFixed(2)}T`; // Trillions + if (marketCapUsd >= 1e9) return `$${(marketCapUsd / 1e9).toFixed(2)}B`; // Billions + if (marketCapUsd >= 1e6) return `$${(marketCapUsd / 1e6).toFixed(2)}M`; // Millions + return `$${marketCapUsd.toFixed(2)}`; // Below one million, show full USD amount +} + +export const getDateRange = (days: number) => { + const toDate = new Date(); + const fromDate = new Date(); + fromDate.setDate(toDate.getDate() - days); + return { + to: toDate.toISOString().split('T')[0], + from: fromDate.toISOString().split('T')[0], + }; +}; + +// Get today's date range (from today to today) +export const getTodayDateRange = () => { + const today = new Date(); + const todayString = today.toISOString().split('T')[0]; + return { + to: todayString, + from: todayString, + }; +}; + +// Calculate news per symbol based on watchlist size +export const calculateNewsDistribution = (symbolsCount: number) => { + let itemsPerSymbol: number; + let targetNewsCount = 6; + + if (symbolsCount < 3) { + itemsPerSymbol = 3; // Fewer symbols, more news each + } else if (symbolsCount === 3) { + itemsPerSymbol = 2; // Exactly 3 symbols, 2 news each = 6 total + } else { + itemsPerSymbol = 1; // Many symbols, 1 news each + targetNewsCount = 6; // Don't exceed 6 total + } + + return { itemsPerSymbol, targetNewsCount }; +}; + +// Check for required article fields +export const validateArticle = (article: RawNewsArticle) => + article.headline && article.summary && article.url && article.datetime; + +// Get today's date string in YYYY-MM-DD format +export const getTodayString = () => new Date().toISOString().split('T')[0]; + +export const formatArticle = ( + article: RawNewsArticle, + isCompanyNews: boolean, + symbol?: string, + index: number = 0 +) => ({ + id: isCompanyNews ? Date.now() + Math.random() : article.id + index, + headline: article.headline!.trim(), + summary: + article.summary!.trim().substring(0, isCompanyNews ? 200 : 150) + '...', + source: article.source || (isCompanyNews ? 'Company News' : 'Market News'), + url: article.url!, + datetime: article.datetime!, + image: article.image || '', + category: isCompanyNews ? 'company' : article.category || 'general', + related: isCompanyNews ? symbol! : article.related || '', +}); + +export const formatChangePercent = (changePercent?: number) => { + if (changePercent === undefined || changePercent === null) return ''; + const sign = changePercent > 0 ? '+' : ''; + return `${sign}${changePercent.toFixed(2)}%`; +}; + +export const getChangeColorClass = (changePercent?: number) => { + if (!changePercent) return 'text-gray-400'; + return changePercent > 0 ? 'text-green-500' : 'text-red-500'; +}; + +export const formatPrice = (price: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + }).format(price); +}; + +export const formatDateToday = new Date().toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', +}); + + +export const getAlertText = (alert: Alert) => { + const condition = alert.alertType === 'upper' ? '>' : '<'; + return `Price ${condition} ${formatPrice(alert.threshold)}`; +}; + +export const getFormattedTodayDate = () => new Date().toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', +}); \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index e9ffa30..c9bb3cc 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,16 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'i.ibb.co', + port: '', + pathname: '/**', + }, + ], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index e5afa91..8a7dc30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "signalist", + "name": "Openstock", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "signalist", + "name": "Openstock", "version": "0.1.0", "dependencies": { "@radix-ui/react-avatar": "^1.1.10", diff --git a/package.json b/package.json index 89d2bc0..34cba7b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "signalist", + "name": "Openstock", "version": "0.1.0", "private": true, "scripts": { diff --git a/public/assets/images/dashboard-preview.png b/public/assets/images/dashboard-preview.png deleted file mode 100644 index a6fc9db..0000000 Binary files a/public/assets/images/dashboard-preview.png and /dev/null differ diff --git a/public/assets/images/dashboard.png b/public/assets/images/dashboard.png index ebe44a8..b022de7 100644 Binary files a/public/assets/images/dashboard.png and b/public/assets/images/dashboard.png differ diff --git a/public/assets/images/logo.png b/public/assets/images/logo.png index a1917fa..a026f12 100644 Binary files a/public/assets/images/logo.png and b/public/assets/images/logo.png differ
- + Return to Dashboard