Merge pull request #9 from ravixalgorithm/main

implemented search
This commit is contained in:
Mr. Algorithm 2025-10-04 19:50:01 +05:30 committed by GitHub
commit 7adf8deab5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 893 additions and 118 deletions

View File

@ -14,8 +14,7 @@ const Layout = async ({ children }: { children : React.ReactNode }) => {
<main className="auth-layout">
<section className="auth-left-section scrollbar-hide-default">
<Link href="/" className="auth-logo flex items-center gap-2">
<Image src="/assets/images/logo.png" alt="" width={30} height={30}/>
<h2 className="text-3xl font-bold text-white">OpenStock</h2>
<Image src="/assets/images/openstock-logo.png" alt="Openstock" width={200} height={50}/>
</Link>
<div className="pb-6 lg:pb-8 flex-1">

View File

@ -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 (
<div className="flex min-h-screen home-wrapper">
<section className="grid w-full gap-8 home-section">
@ -15,38 +21,35 @@ const Home = () => {
className="custom-chart"
height={600}
/>
</div>
<div className="md:col-span-1 xl:col-span-2">
<div className="md-col-span xl:col-span-2">
<TradingViewWidget
title="Stock Heatmap"
scriptUrl={`${scriptUrl}stock-heatmap.js`}
config={MARKET_OVERVIEW_WIDGET_CONFIG}
className="custom-chart"
config={HEATMAP_WIDGET_CONFIG}
height={600}
/>
</div>
</section>
<section className="grid w-full gap-8 home-section">
<div className="md:col-span-1 xl:col-span-2">
<div className="h-full md:col-span-1 xl:col-span-2">
<TradingViewWidget
title="Market Quotes"
scriptUrl={`${scriptUrl}market-quotes.js`}
config={MARKET_DATA_WIDGET_CONFIG}
height={600}
/>
</div>
<div className="md:col-span-1 xl:col-span-1">
<div className="h-full md:col-span-1 xl:col-span-1">
<TradingViewWidget
title="Top Stories"
scriptUrl={`${scriptUrl}timeline.js`}
config={TOP_STORIES_WIDGET_CONFIG}
height={600}
/>
</div>
</section>
</div>
)
}
export default Home
export default Home;

View File

@ -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 (
<div className="flex min-h-screen p-4 md:p-6 lg:p-8">
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
{/* Left column */}
<div className="flex flex-col gap-6">
<TradingViewWidget
scriptUrl={`${scriptUrl}symbol-info.js`}
config={SYMBOL_INFO_WIDGET_CONFIG(symbol)}
height={170}
/>
<TradingViewWidget
scriptUrl={`${scriptUrl}advanced-chart.js`}
config={CANDLE_CHART_WIDGET_CONFIG(symbol)}
className="custom-chart"
height={600}
/>
<TradingViewWidget
scriptUrl={`${scriptUrl}advanced-chart.js`}
config={BASELINE_WIDGET_CONFIG(symbol)}
className="custom-chart"
height={600}
/>
</div>
{/* Right column */}
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<WatchlistButton symbol={symbol.toUpperCase()} company={symbol.toUpperCase()} isInWatchlist={false} />
</div>
<TradingViewWidget
scriptUrl={`${scriptUrl}technical-analysis.js`}
config={TECHNICAL_ANALYSIS_WIDGET_CONFIG(symbol)}
height={400}
/>
<TradingViewWidget
scriptUrl={`${scriptUrl}company-profile.js`}
config={COMPANY_PROFILE_WIDGET_CONFIG(symbol)}
height={440}
/>
<TradingViewWidget
scriptUrl={`${scriptUrl}financials.js`}
config={COMPANY_FINANCIALS_WIDGET_CONFIG(symbol)}
height={800}
/>
</div>
</section>
</div>
);
}

View File

@ -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],
})

View File

@ -2,23 +2,28 @@ 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 (
<header className="sticky top-0 header">
<div className="container header-wrapper">
<Link href="/" className="flex items-center justify-center gap-2">
<Image src="/assets/images/logo.png" alt="" width={30} height={30}/>
<h2 className="text-3xl font-bold text-white">OpenStock</h2>
<Image
src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png"
alt="OpenStock"
width={200}
height={50}
/>
</Link>
<nav className="hidden sm:block">
<NavItems/>
<NavItems initialStocks={initialStocks}/>
</nav>
{/* UserDropDown */}
<UserDropdown user={user}/>
<UserDropdown user={user} initialStocks={initialStocks} />
</div>
</header>
)
}

View File

@ -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 (
<ul className="flex flex-col sm:flex-row p-2 gap-3 sm:gap-10 font-medium">
{NAV_ITEMS.map(({href, label}) => (
<li key={href}>
{NAV_ITEMS.map(({href, label}) => {
if (href === '/search') return (
<li key="search-trigger">
<SearchCommand
renderAs="text"
label="Search"
initialStocks={initialStocks}
/>
</li>
)
return <li key={href}>
<Link href={href} className={`hover:text-teal-500 transition-colors ${isActive(href) ? 'text-gray-100' : ''}`}>
{label}
</Link>
</li>
))}
})}
</ul>
)
}

View File

@ -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<StockWithWatchlistStatus[]>(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' ? (
<button
type="button"
onClick={() => setOpen(true)}
className="search-text"
>
{label}
</button>
): (
<Button onClick={() => setOpen(true)} className="search-btn">
{label}
</Button>
)}
<CommandDialog open={open} onOpenChange={setOpen} className="search-dialog">
<div className="search-field">
<CommandInput value={searchTerm} onValueChange={setSearchTerm} placeholder="Search stocks..." className="search-input" />
{loading && <Loader2 className="search-loader" />}
</div>
<CommandList className="search-list">
{loading ? (
<CommandEmpty className="search-list-empty">Loading stocks...</CommandEmpty>
) : displayStocks?.length === 0 ? (
<div className="search-list-indicator">
{isSearchMode ? 'No results found' : 'No stocks available'}
</div>
) : (
<ul>
<div className="search-count">
{isSearchMode ? 'Search results' : 'Popular stocks'}
{` `}({displayStocks?.length || 0})
</div>
{displayStocks?.map((stock, i) => (
<li key={stock.symbol} className="search-item">
<Link
href={`/stocks/${stock.symbol}`}
onClick={handleSelectStock}
className="search-item-link"
>
<TrendingUp className="h-4 w-4 text-gray-500" />
<div className="flex-1">
<div className="search-item-name">
{stock.name}
</div>
<div className="text-sm text-gray-500">
{stock.symbol} | {stock.exchange } | {stock.type}
</div>
</div>
</Link>
</li>
))}
</ul>
)
}
</CommandList>
</CommandDialog>
</>
)
}

View File

@ -4,26 +4,24 @@ 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<string, unknown>;
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 (
<div className="w-full">
{title && <h3 className="font-semibold text-2xl text-gray-100 mb-5">{title}</h3>}
<div className={cn('tradingview-widget-container', className)} ref={containerRef}>
<div className="tradingview-widget-container__widget" style={{ height, width: "100%" }}/>
<div className="tradingview-widget-container__widget" style={{ height, width: "100%" }} />
</div>
</div>
);
}

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="bg-gray-800 hover:bg-gray-800 flex items-center gap-3 text-gray-400 hover:text-teal-500">
<Button className="flex items-center gap-3 text-gray-4 hover:bg-gray-800 bg-gray-800">
<Avatar className="h-8 w-8">
<AvatarImage src="https://media.licdn.com/dms/image/v2/D560BAQGHApE1Vtq6DA/company-logo_200_200/B56ZY1OFJOGcAI-/0/1744649609317/philosopai_in_logo?e=1761782400&v=beta&t=uLNK6v7h96sXybdT42cVK0cJSZaA8KVLw8JYO5fY4oQ" />
<AvatarFallback className="bg-teal-500 text-yellow-900 text-sm font-bold">
<AvatarFallback className="bg-teal-500 text-teal-900 text-sm font-bold">
{user.name[0]}
</AvatarFallback>
</Avatar>
<div className="hidden md:flex flex-col items-start">
<span className="text-base font-medium text-gray-400 hover:text-teal-500">
<div className="hidden md:flex flex-col items-start ">
<span className='text-base font-medium text-gray-400 hover:text-teal-500 '>
{user.name}
</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="text-gray-400 bg-gray-800 relative right-10">
<DropdownMenuContent className="text-gray-400 bg-gray-800 relative right-5">
<DropdownMenuLabel>
<div className="flex relative items-center gap-3 py-2">
<Avatar className="h-10 w-10">
@ -52,23 +50,21 @@ const UserDropdown = ({user} : {user: User}) => {
</AvatarFallback>
</Avatar>
<div className="flex flex-col">
<span className="text-base font-medium text-gray-400">
<span className='text-base font-medium text-gray-400'>
{user.name}
</span>
<span className="text-sm text-gray-500">
{user.email}
</span>
<span className="text-sm text-gray-500">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-gray-600"/>
<DropdownMenuItem onClick={handleSignOut} className="text-gray-100 text-md font-medium focus:bg-transparent focus:text-teal-500 transition-colors cursor-pointer">
<LogOut className="h-4 w-4 mr-2 hidden sm:block hover:text-teal-500"/>
<LogOut className="h-4 w-4 mr-2 hidden sm:block" />
Logout
</DropdownMenuItem>
<DropdownMenuSeparator className=" block sm:hidden bg-gray-600"/>
<DropdownMenuSeparator className="block sm:hidden bg-gray-600"/>
<nav className="sm:hidden">
<NavItems/>
<NavItems initialStocks={initialStocks} />
</nav>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -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<boolean>(!!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 (
<button
title={added ? `Remove ${symbol} from watchlist` : `Add ${symbol} to watchlist`}
aria-label={added ? `Remove ${symbol} from watchlist` : `Add ${symbol} to watchlist`}
className={`watchlist-icon-btn ${added ? "watchlist-icon-added" : ""}`}
onClick={handleClick}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill={added ? "#FACC15" : "none"}
stroke="#FACC15"
strokeWidth="1.5"
className="watchlist-star"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.385a.563.563 0 00-.182-.557L3.04 10.385a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345l2.125-5.111z"
/>
</svg>
</button>
);
}
return (
<button className={`watchlist-btn ${added ? "watchlist-remove" : ""}`} onClick={handleClick}>
{showTrashIcon && added ? (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-5 h-5 mr-2"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 4v6m4-6v6m4-6v6" />
</svg>
) : null}
<span>{label}</span>
</button>
);
};
export default WatchlistButton;

View File

@ -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<WatchlistItem>(
{
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<WatchlistItem> =
(models?.Watchlist as Model<WatchlistItem>) || model<WatchlistItem>('Watchlist', WatchlistSchema);

19
hooks/useDebounce.ts Normal file
View File

@ -0,0 +1,19 @@
'use client';
import { useCallback, useRef } from 'react';
export function useDebounce(callback: () => void, delay: number) {
const timeoutRef = useRef<NodeJS.Timeout | null>(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])
}

View File

@ -41,3 +41,4 @@ export const signOut = async () => {
return { success: false, error: 'Sign out failed' }
}
}

View File

@ -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<T>(url: string, revalidateSeconds?: number): Promise<T> {
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<MarketNewsArticle[]> {
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<string, RawNewsArticle[]> = {};
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<RawNewsArticle[]>(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<RawNewsArticle[]>(generalUrl, 300);
const seen = new Set<string>();
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<StockWithWatchlistStatus[]> => {
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<any>(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<FinnhubSearchResponse>(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 [];
}
});

View File

@ -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 []
}
}

View File

@ -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<string[]> {
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 [];
}
}

View File

@ -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

View File

@ -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' }
}
)

View File

@ -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):
<p class="mobile-text" style="margin: 0 0 30px 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">Thanks for joining Signalist! As someone focused on <strong>technology growth stocks</strong>, 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.</p>
<p class="mobile-text" style="margin: 0 0 30px 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">Thanks for joining Openstock! As someone focused on <strong>technology growth stocks</strong>, 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.</p>
<p class="mobile-text" style="margin: 0 0 30px 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">Great to have you aboard! Perfect for your <strong>conservative retirement strategy</strong> we'll help you monitor dividend stocks without overwhelming you with noise. You can finally track your portfolio progress with confidence and clarity.</p>

View File

@ -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" <opendevsociety@gmail.com>`,
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);
}
export const sendNewsSummaryEmail = async (
{ email, date, newsContent }: { email: string; date: string; newsContent: string }
): Promise<void> => {
const htmlTemplate = NEWS_SUMMARY_EMAIL_TEMPLATE
.replace('{{date}}', date)
.replace('{{newsContent}}', newsContent);
const mailOptions = {
from: `"Openstock" <opendevsociety@gmail.com>`,
to: email,
subject: `📈 Market News Summary Today - ${date}`,
text: `Today's market news summary from Openstock`,
html: htmlTemplate,
};
await transporter.sendMail(mailOptions);
};

View File

@ -97,14 +97,14 @@ export const WELCOME_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Header with Logo -->
<tr>
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
<img src="https://jumpshare.com/share/3spAIVQ2xWKHZrVPUDKr" alt="OpenStock Logo" width="150" style="max-width: 100%; height: auto;">
<img src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png" alt="OpenStock Logo" width="150" style="max-width: 100%; height: auto;">
</td>
</tr>
<!-- Dashboard Preview Image -->
<tr>
<td align="center" class="dashboard-preview" style="padding: 40px 40px 0px 40px;">
<img src="https://jumpshare.com/share/20DJonsIbGGchzCi7sD5" alt="OpenStock Dashboard Preview" width="100%" style="max-width: 520px; width: 100%; height: auto; border-radius: 12px; border: 1px solid #30333A;">
<img src="https://i.ibb.co/BKC2HBBQ/dashboard.png" alt="OpenStock Dashboard Preview" width="100%" style="max-width: 520px; width: 100%; height: auto; border-radius: 12px; border: 1px solid #30333A;">
</td>
</tr>
@ -260,7 +260,7 @@ export const NEWS_SUMMARY_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Header with Logo -->
<tr>
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
<img src="https://ik.imagekit.io/a6fkjou7d/logo.png?updatedAt=1756378431634" alt="Signalist Logo" width="150" style="max-width: 100%; height: auto;">
<img src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png" alt="Openstock Logo" width="150" style="max-width: 100%; height: auto;">
</td>
</tr>
@ -284,14 +284,14 @@ export const NEWS_SUMMARY_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Footer Text -->
<div style="text-align: center; margin: 40px 0 0 0;">
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
You're receiving this because you subscribed to Signalist news updates.
You're receiving this because you subscribed to Openstock news updates.
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
<a href="#" style="color: #CCDADC !important; text-decoration: underline;">Unsubscribe</a> |
<a href="https://signalist.app" style="color: #CCDADC !important; text-decoration: underline;">Visit Signalist</a>
<a href="https://openstock.vercel.app" style="color: #CCDADC !important; text-decoration: underline;">Visit Openstock</a>
</p>
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
© 2025 Signalist
© 2025 Open Dev Society
</p>
</div>
</td>
@ -410,7 +410,7 @@ export const STOCK_ALERT_UPPER_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Header with Logo -->
<tr>
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
<img src="https://ik.imagekit.io/a6fkjou7d/logo.png?updatedAt=1756378431634" alt="Signalist Logo" width="150" style="max-width: 100%; height: auto;">
<img src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png" alt="Openstock Logo" width="150" style="max-width: 100%; height: auto;">
</td>
</tr>
@ -483,7 +483,7 @@ export const STOCK_ALERT_UPPER_EMAIL_TEMPLATE = `<!DOCTYPE html>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td align="center">
<a href="https://stock-market-dev.vercel.app/" style="display: block; width: 100%; max-width: 100%; box-sizing: border-box; color: #000000; background-color: #E8BA40; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 500; line-height: 1; text-align: center;">
<a href="https://openstock.vercel.app/" style="display: block; width: 100%; max-width: 100%; box-sizing: border-box; color: #000000; background-color: #E8BA40; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 500; line-height: 1; text-align: center;">
View Dashboard
</a>
</td>
@ -493,15 +493,14 @@ export const STOCK_ALERT_UPPER_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Footer Text -->
<div style="text-align: center; margin: 40px 0 0 0;">
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
You're receiving this because you subscribed to Signalist news updates.
You're receiving this because you subscribed to Openstock news updates.
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
<a href="#" style="color: #CCDADC !important; text-decoration: underline;">Unsubscribe</a> |
<a href="https://signalist.app" style="color: #CCDADC !important; text-decoration: underline;">Visit Signalist</a>
<a href="https://openstock.vercel.app/" style="color: #CCDADC !important; text-decoration: underline;">Visit Openstock</a>
</p>
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
© 2025 Signalist
</p>
© 2025 Open Dev Society
</div>
</td>
</tr>
@ -619,7 +618,7 @@ export const STOCK_ALERT_LOWER_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Header with Logo -->
<tr>
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
<img src="https://ik.imagekit.io/a6fkjou7d/logo.png?updatedAt=1756378431634" alt="Signalist Logo" width="150" style="max-width: 100%; height: auto;">
<img src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png" alt="Openstock Logo" width="150" style="max-width: 100%; height: auto;">
</td>
</tr>
@ -692,7 +691,7 @@ export const STOCK_ALERT_LOWER_EMAIL_TEMPLATE = `<!DOCTYPE html>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td align="center">
<a href="https://stock-market-dev.vercel.app/" style="display: block; width: 100%; max-width: 100%; box-sizing: border-box; background-color: #E8BA40; color: #000000; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 500; line-height: 1; text-align: center;">
<a href="https://openstock.vercel.app/" style="display: block; width: 100%; max-width: 100%; box-sizing: border-box; background-color: #E8BA40; color: #000000; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 500; line-height: 1; text-align: center;">
View Dashboard
</a>
</td>
@ -702,14 +701,14 @@ export const STOCK_ALERT_LOWER_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Footer Text -->
<div style="text-align: center; margin: 40px 0 0 0;">
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
You're receiving this because you subscribed to Signalist news updates.
You're receiving this because you subscribed to Openstock news updates.
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
<a href="#" style="color: #CCDADC !important; text-decoration: underline;">Unsubscribe</a> |
<a href="https://signalist.app" style="color: #CCDADC !important; text-decoration: underline;">Visit Signalist</a>
<a href="https://openstock.vercel.app/" style="color: #CCDADC !important; text-decoration: underline;">Visit Openstock</a>
</p>
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
© 2025 Signalist
© 2025 Open Dev Society
</p>
</div>
</td>
@ -819,7 +818,7 @@ export const VOLUME_ALERT_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Header with Logo -->
<tr>
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
<img src="https://ik.imagekit.io/a6fkjou7d/logo.png?updatedAt=1756378431634" alt="Signalist Logo" width="150" style="max-width: 100%; height: auto;">
<img src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png" alt="Openstock Logo" width="150" style="max-width: 100%; height: auto;">
</td>
</tr>
@ -905,7 +904,7 @@ export const VOLUME_ALERT_EMAIL_TEMPLATE = `<!DOCTYPE html>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td align="center">
<a href="https://stock-market-dev.vercel.app/" style="display: inline-block; background-color: #E8BA40; color: #ffffff; text-decoration: none; padding: 14px 28px; border-radius: 8px; font-size: 16px; font-weight: 500; line-height: 1;">
<a href="https://openstock.vercel.app/" style="display: inline-block; background-color: #E8BA40; color: #ffffff; text-decoration: none; padding: 14px 28px; border-radius: 8px; font-size: 16px; font-weight: 500; line-height: 1;">
View Dashboard
</a>
</td>
@ -922,14 +921,14 @@ export const VOLUME_ALERT_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Footer Text -->
<div style="text-align: center; margin: 40px 0 0 0;">
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
You're receiving this because you subscribed to Signalist news updates.
You're receiving this because you subscribed to Openstock news updates.
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
<a href="#" style="color: #CCDADC !important; text-decoration: underline;">Unsubscribe</a> |
<a href="https://signalist.app" style="color: #CCDADC !important; text-decoration: underline;">Visit Signalist</a>
<a href="https://openstock.vercel.app/" style="color: #CCDADC !important; text-decoration: underline;">Visit Openstock</a>
</p>
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
© 2025 Signalist
© 2025 Open Dev Society
</p>
</div>
</td>
@ -1042,7 +1041,7 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Header with Logo -->
<tr>
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
<img src="https://ik.imagekit.io/a6fkjou7d/logo.png?updatedAt=1756378431634" alt="Signalist Logo" width="150" style="max-width: 100%; height: auto;">
<img src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png" alt="Openstock Logo" width="150" style="max-width: 100%; height: auto;">
</td>
</tr>
@ -1057,7 +1056,7 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = `<!DOCTYPE html>
<!-- Main Message -->
<p class="mobile-text dark-text-secondary" style="margin: 0 0 30px 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
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!
</p>
<!-- Additional Motivation -->
@ -1079,7 +1078,7 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = `<!DOCTYPE html>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 0 20px 0; width: 100%;">
<tr>
<td align="center" class="mobile-button">
<a href="{{dashboardUrl}}" style="display: inline-block; background: #E8BA40; color: #000000; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 500; line-height: 1; text-align: center; width: 100%;">
<a href="https://openstock.vercel.app/" style="display: inline-block; background: #E8BA40; color: #000000; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 500; line-height: 1; text-align: center; width: 100%;">
Return to Dashboard
</a>
</td>
@ -1092,11 +1091,11 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = `<!DOCTYPE html>
Questions? Reply to this email or contact our support team.
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
<a href="{{unsubscribeUrl}}" style="color: #CCDADC !important; text-decoration: underline;">Unsubscribe</a> |
<a href="{{dashboardUrl}}" style="color: #CCDADC !important; text-decoration: underline;">Visit Signalist</a>
<a href="#" style="color: #CCDADC !important; text-decoration: underline;">Unsubscribe</a> |
<a href="https://openstock.vercel.app/" style="color: #CCDADC !important; text-decoration: underline;">Visit Openstock</a>
</p>
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
© 2025 Signalist
© 2025 Openstock
</p>
</div>
</td>

View File

@ -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',
});

View File

@ -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;

4
package-lock.json generated
View File

@ -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",

View File

@ -1,5 +1,5 @@
{
"name": "signalist",
"name": "Openstock",
"version": "0.1.0",
"private": true,
"scripts": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 32 KiB