implemented search
This commit is contained in:
parent
bde774ad4b
commit
30aa5d48e8
|
|
@ -14,8 +14,7 @@ const Layout = async ({ children }: { children : React.ReactNode }) => {
|
||||||
<main className="auth-layout">
|
<main className="auth-layout">
|
||||||
<section className="auth-left-section scrollbar-hide-default">
|
<section className="auth-left-section scrollbar-hide-default">
|
||||||
<Link href="/" className="auth-logo flex items-center gap-2">
|
<Link href="/" className="auth-logo flex items-center gap-2">
|
||||||
<Image src="/assets/images/logo.png" alt="" width={30} height={30}/>
|
<Image src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png" alt="" width={200} height={50}/>
|
||||||
<h2 className="text-3xl font-bold text-white">OpenStock</h2>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="pb-6 lg:pb-8 flex-1">
|
<div className="pb-6 lg:pb-8 flex-1">
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,15 @@
|
||||||
import React from 'react'
|
|
||||||
import TradingViewWidget from "@/components/TradingViewWidget";
|
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 Home = () => {
|
||||||
const scriptUrl = `https://s3.tradingview.com/external-embedding/embed-widget-`;
|
const scriptUrl = `https://s3.tradingview.com/external-embedding/embed-widget-`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen home-wrapper">
|
<div className="flex min-h-screen home-wrapper">
|
||||||
<section className="grid w-full gap-8 home-section">
|
<section className="grid w-full gap-8 home-section">
|
||||||
|
|
@ -15,38 +21,35 @@ const Home = () => {
|
||||||
className="custom-chart"
|
className="custom-chart"
|
||||||
height={600}
|
height={600}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-1 xl:col-span-2">
|
<div className="md-col-span xl:col-span-2">
|
||||||
<TradingViewWidget
|
<TradingViewWidget
|
||||||
title="Stock Heatmap"
|
title="Stock Heatmap"
|
||||||
scriptUrl={`${scriptUrl}stock-heatmap.js`}
|
scriptUrl={`${scriptUrl}stock-heatmap.js`}
|
||||||
config={MARKET_OVERVIEW_WIDGET_CONFIG}
|
config={HEATMAP_WIDGET_CONFIG}
|
||||||
className="custom-chart"
|
|
||||||
height={600}
|
height={600}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid w-full gap-8 home-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
|
<TradingViewWidget
|
||||||
title="Market Quotes"
|
|
||||||
scriptUrl={`${scriptUrl}market-quotes.js`}
|
scriptUrl={`${scriptUrl}market-quotes.js`}
|
||||||
config={MARKET_DATA_WIDGET_CONFIG}
|
config={MARKET_DATA_WIDGET_CONFIG}
|
||||||
height={600}
|
height={600}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-1 xl:col-span-1">
|
<div className="h-full md:col-span-1 xl:col-span-1">
|
||||||
<TradingViewWidget
|
<TradingViewWidget
|
||||||
title="Top Stories"
|
|
||||||
scriptUrl={`${scriptUrl}timeline.js`}
|
scriptUrl={`${scriptUrl}timeline.js`}
|
||||||
config={TOP_STORIES_WIDGET_CONFIG}
|
config={TOP_STORIES_WIDGET_CONFIG}
|
||||||
height={600}
|
height={600}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export default Home
|
|
||||||
|
export default Home;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import {serve} from "inngest/next";
|
import {serve} from "inngest/next";
|
||||||
import {inngest} from "@/lib/inngest/client";
|
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({
|
export const {GET, POST, PUT } = serve({
|
||||||
client: inngest,
|
client: inngest,
|
||||||
functions: [sendSignUpEmail],
|
functions: [sendSignUpEmail, sendDailyNewsSummary],
|
||||||
})
|
})
|
||||||
|
|
@ -2,23 +2,23 @@ import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import NavItems from "@/components/NavItems";
|
import NavItems from "@/components/NavItems";
|
||||||
import UserDropdown from "@/components/UserDropdown";
|
import UserDropdown from "@/components/UserDropdown";
|
||||||
|
import {searchStocks} from "@/lib/actions/finnhub.actions";
|
||||||
|
|
||||||
const Header = async ({ user }: { user: User }) => {
|
const Header = async ({ user }: { user: User }) => {
|
||||||
|
const initialStocks = await searchStocks();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 header">
|
<header className="sticky top-0 header">
|
||||||
<div className="container header-wrapper">
|
<div className="container header-wrapper">
|
||||||
<Link href="/" className="flex items-center justify-center gap-2">
|
<Link href="/" className="flex items-center justify-center gap-2">
|
||||||
<Image src="/assets/images/logo.png" alt="" width={30} height={30}/>
|
<Image src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png" alt="" width={200} height={50}/>
|
||||||
<h2 className="text-3xl font-bold text-white">OpenStock</h2>
|
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="hidden sm:block">
|
<nav className="hidden sm:block">
|
||||||
<NavItems/>
|
<NavItems initialStocks={initialStocks}/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* UserDropDown */}
|
<UserDropdown user={user} initialStocks={initialStocks} />
|
||||||
<UserDropdown user={user}/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@ import React from 'react'
|
||||||
import {NAV_ITEMS} from "@/lib/constants";
|
import {NAV_ITEMS} from "@/lib/constants";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {usePathname} from "next/navigation";
|
import {usePathname} from "next/navigation";
|
||||||
|
import SearchCommand from "@/components/SearchCommand";
|
||||||
|
|
||||||
const NavItems = () => {
|
const NavItems = ({initialStocks}: { initialStocks: StockWithWatchlistStatus[]}) => {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
|
|
@ -16,13 +17,22 @@ const NavItems = () => {
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col sm:flex-row p-2 gap-3 sm:gap-10 font-medium">
|
<ul className="flex flex-col sm:flex-row p-2 gap-3 sm:gap-10 font-medium">
|
||||||
{NAV_ITEMS.map(({href, label}) => (
|
{NAV_ITEMS.map(({href, label}) => {
|
||||||
<li key={href}>
|
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' : ''}`}>
|
<Link href={href} className={`hover:text-teal-500 transition-colors ${isActive(href) ? 'text-gray-100' : ''}`}>
|
||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
"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' ? (
|
||||||
|
<span onClick={() => setOpen(true)} className="search-text">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
): (
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import useTradingViewWidget from "@/hooks/useTradingViewWidget";
|
||||||
import {cn} from "@/lib/utils";
|
import {cn} from "@/lib/utils";
|
||||||
|
|
||||||
interface TradingViewWidgetProps {
|
interface TradingViewWidgetProps {
|
||||||
title: string;
|
title?: string;
|
||||||
scriptUrl: string;
|
scriptUrl: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
|
@ -15,7 +15,6 @@ interface TradingViewWidgetProps{
|
||||||
const TradingViewWidget = ({ title, scriptUrl, config, height = 600, className }: TradingViewWidgetProps) => {
|
const TradingViewWidget = ({ title, scriptUrl, config, height = 600, className }: TradingViewWidgetProps) => {
|
||||||
const containerRef = useTradingViewWidget(scriptUrl, config, height);
|
const containerRef = useTradingViewWidget(scriptUrl, config, height);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{title && <h3 className="font-semibold text-2xl text-gray-100 mb-5">{title}</h3>}
|
{title && <h3 className="font-semibold text-2xl text-gray-100 mb-5">{title}</h3>}
|
||||||
|
|
@ -23,7 +22,6 @@ const TradingViewWidget = ({title, scriptUrl, config, height = 600, className}:
|
||||||
<div className="tradingview-widget-container__widget" style={{ height, width: "100%" }} />
|
<div className="tradingview-widget-container__widget" style={{ height, width: "100%" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
import {Button} from "@/components/ui/button";
|
|
||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -13,11 +10,12 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
import {LogOut} from "lucide-react";
|
import {LogOut} from "lucide-react";
|
||||||
import NavItems from "@/components/NavItems";
|
import NavItems from "@/components/NavItems";
|
||||||
import {signOut} from "@/lib/actions/auth.actions";
|
import {signOut} from "@/lib/actions/auth.actions";
|
||||||
|
|
||||||
const UserDropdown = ({user} : {user: User}) => {
|
const UserDropdown = ({ user, initialStocks }: {user: User, initialStocks: StockWithWatchlistStatus[]}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
|
|
@ -28,21 +26,21 @@ const UserDropdown = ({user} : {user: User}) => {
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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">
|
<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" />
|
<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]}
|
{user.name[0]}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="hidden md:flex flex-col items-start ">
|
<div className="hidden md:flex flex-col items-start ">
|
||||||
<span className="text-base font-medium text-gray-400 hover:text-teal-500">
|
<span className='text-base font-medium text-gray-400 hover:text-teal-500 '>
|
||||||
{user.name}
|
{user.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="text-gray-400 bg-gray-800 relative right-10">
|
<DropdownMenuContent className="text-gray-400 bg-gray-800 relative right-5">
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
<div className="flex relative items-center gap-3 py-2">
|
<div className="flex relative items-center gap-3 py-2">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
|
|
@ -52,23 +50,21 @@ const UserDropdown = ({user} : {user: User}) => {
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col">
|
<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}
|
{user.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">{user.email}</span>
|
||||||
{user.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator className="bg-gray-600"/>
|
<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">
|
<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
|
Logout
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className="block sm:hidden bg-gray-600"/>
|
<DropdownMenuSeparator className="block sm:hidden bg-gray-600"/>
|
||||||
<nav className="sm:hidden">
|
<nav className="sm:hidden">
|
||||||
<NavItems/>
|
<NavItems initialStocks={initialStocks} />
|
||||||
</nav>
|
</nav>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce(callback: () => void, delay: number) {
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
return useCallback(() => {
|
||||||
|
if(timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(callback, delay);
|
||||||
|
}, [callback, delay])
|
||||||
|
}
|
||||||
|
|
@ -41,3 +41,4 @@ export const signOut = async () => {
|
||||||
return { success: false, error: 'Sign out failed' }
|
return { success: false, error: 'Sign out failed' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export const NAV_ITEMS = [
|
export const NAV_ITEMS = [
|
||||||
{ href: '/', label: 'Dashboard' },
|
{ href: '/', label: 'Dashboard' },
|
||||||
{ href: '/search', label: 'Search' },
|
{ href: '/search', label: 'Search' },
|
||||||
{ href: '/watchlist', label: 'Watchlist' },
|
// { href: '/watchlist', label: 'Watchlist' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sign-up form select options
|
// Sign-up form select options
|
||||||
|
|
@ -71,7 +71,7 @@ export const MARKET_OVERVIEW_WIDGET_CONFIG = {
|
||||||
{ s: 'NASDAQ:AAPL', d: 'Apple' },
|
{ s: 'NASDAQ:AAPL', d: 'Apple' },
|
||||||
{ s: 'NASDAQ:GOOGL', d: 'Alphabet' },
|
{ s: 'NASDAQ:GOOGL', d: 'Alphabet' },
|
||||||
{ s: 'NASDAQ:MSFT', d: 'Microsoft' },
|
{ s: 'NASDAQ:MSFT', d: 'Microsoft' },
|
||||||
{ s: 'NASDAQ:META', d: 'Meta Platforms' },
|
{ s: 'NASDAQ:FB', d: 'Meta Platforms' },
|
||||||
{ s: 'NYSE:ORCL', d: 'Oracle Corp' },
|
{ s: 'NYSE:ORCL', d: 'Oracle Corp' },
|
||||||
{ s: 'NASDAQ:INTC', d: 'Intel Corp' },
|
{ s: 'NASDAQ:INTC', d: 'Intel Corp' },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import {inngest} from '@/lib/inngest/client';
|
import {inngest} from "@/lib/inngest/client";
|
||||||
import {PERSONALIZED_WELCOME_EMAIL_PROMPT} from "@/lib/inngest/prompts";
|
import {NEWS_SUMMARY_EMAIL_PROMPT, PERSONALIZED_WELCOME_EMAIL_PROMPT} from "@/lib/inngest/prompts";
|
||||||
import {sendWelcomeEmail} from "@/lib/nodemailer";
|
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(
|
export const sendSignUpEmail = inngest.createFunction(
|
||||||
{ id: 'sign-up-email' },
|
{ id: 'sign-up-email' },
|
||||||
|
|
@ -13,7 +17,7 @@ export const sendSignUpEmail = inngest.createFunction(
|
||||||
- Preferred industry: ${event.data.preferredIndustry}
|
- 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', {
|
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' }),
|
||||||
|
|
@ -24,25 +28,93 @@ export const sendSignUpEmail = inngest.createFunction(
|
||||||
parts: [
|
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 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 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;
|
const { data: { email, name } } = event;
|
||||||
|
|
||||||
return await sendWelcomeEmail({ email, name, intro: introText });
|
return await sendWelcomeEmail({ email, name, intro: introText });
|
||||||
|
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
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' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -41,7 +41,7 @@ CRITICAL FORMATTING REQUIREMENTS:
|
||||||
- Second sentence should add helpful context or reinforce the personalization
|
- Second sentence should add helpful context or reinforce the personalization
|
||||||
|
|
||||||
Example personalized outputs (showing obvious customization with TWO sentences):
|
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>
|
<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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import nodemailer from 'nodemailer'
|
import nodemailer from 'nodemailer';
|
||||||
import {WELCOME_EMAIL_TEMPLATE} from "@/lib/nodemailer/templates";
|
import {WELCOME_EMAIL_TEMPLATE, NEWS_SUMMARY_EMAIL_TEMPLATE} from "@/lib/nodemailer/templates";
|
||||||
|
|
||||||
|
|
||||||
export const transporter = nodemailer.createTransport({
|
export const transporter = nodemailer.createTransport({
|
||||||
service: 'gmail',
|
service: 'gmail',
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.NODEMAILER_EMAIL,
|
user: process.env.NODEMAILER_EMAIL!,
|
||||||
pass: process.env.NODEMAILER_PASSWORD,
|
pass: process.env.NODEMAILER_PASSWORD!,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -15,14 +14,31 @@ export const sendWelcomeEmail = async ({email, name, intro} : WelcomeEmailData)
|
||||||
.replace('{{name}}', name)
|
.replace('{{name}}', name)
|
||||||
.replace('{{intro}}', intro);
|
.replace('{{intro}}', intro);
|
||||||
|
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: `"Openstock" <opendevsociety@gmail.com>`,
|
from: `"Openstock" <opendevsociety@gmail.com>`,
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'Welcome to OpenStock - your open-source stock market toolkit',
|
subject: `Welcome to Openstock - your open-source stock market toolkit!`,
|
||||||
text: 'Thanks for joining Openstock and believing in this initiative by Open Dev Society',
|
text: 'Thanks for joining Openstock, an initiative by open dev society',
|
||||||
html: htmlTemplate,
|
html: htmlTemplate,
|
||||||
}
|
}
|
||||||
|
|
||||||
await transporter.sendMail(mailOptions);
|
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);
|
||||||
|
};
|
||||||
|
|
@ -97,14 +97,14 @@ export const WELCOME_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Header with Logo -->
|
<!-- Header with Logo -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<!-- Dashboard Preview Image -->
|
<!-- Dashboard Preview Image -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" class="dashboard-preview" style="padding: 40px 40px 0px 40px;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|
@ -260,7 +260,7 @@ export const NEWS_SUMMARY_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Header with Logo -->
|
<!-- Header with Logo -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|
@ -284,14 +284,14 @@ export const NEWS_SUMMARY_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Footer Text -->
|
<!-- Footer Text -->
|
||||||
<div style="text-align: center; margin: 40px 0 0 0;">
|
<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;">
|
<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>
|
||||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<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="#" 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>
|
||||||
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
||||||
© 2025 Signalist
|
© 2025 Open Dev Society
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -410,7 +410,7 @@ export const STOCK_ALERT_UPPER_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Header with Logo -->
|
<!-- Header with Logo -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
|
<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>
|
</td>
|
||||||
</tr>
|
</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;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<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
|
View Dashboard
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -493,15 +493,14 @@ export const STOCK_ALERT_UPPER_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Footer Text -->
|
<!-- Footer Text -->
|
||||||
<div style="text-align: center; margin: 40px 0 0 0;">
|
<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;">
|
<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>
|
||||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<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="#" 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>
|
||||||
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
||||||
© 2025 Signalist
|
© 2025 Open Dev Society
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -619,7 +618,7 @@ export const STOCK_ALERT_LOWER_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Header with Logo -->
|
<!-- Header with Logo -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
|
<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>
|
</td>
|
||||||
</tr>
|
</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;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<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
|
View Dashboard
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -702,14 +701,14 @@ export const STOCK_ALERT_LOWER_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Footer Text -->
|
<!-- Footer Text -->
|
||||||
<div style="text-align: center; margin: 40px 0 0 0;">
|
<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;">
|
<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>
|
||||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<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="#" 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>
|
||||||
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
||||||
© 2025 Signalist
|
© 2025 Open Dev Society
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -819,7 +818,7 @@ export const VOLUME_ALERT_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Header with Logo -->
|
<!-- Header with Logo -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
|
<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>
|
</td>
|
||||||
</tr>
|
</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;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center">
|
<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
|
View Dashboard
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -922,14 +921,14 @@ export const VOLUME_ALERT_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Footer Text -->
|
<!-- Footer Text -->
|
||||||
<div style="text-align: center; margin: 40px 0 0 0;">
|
<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;">
|
<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>
|
||||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<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="#" 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>
|
||||||
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
||||||
© 2025 Signalist
|
© 2025 Open Dev Society
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -1042,7 +1041,7 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
<!-- Header with Logo -->
|
<!-- Header with Logo -->
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left" class="mobile-header-padding" style="padding: 40px 40px 20px 40px;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|
@ -1057,7 +1056,7 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
|
|
||||||
<!-- Main Message -->
|
<!-- Main Message -->
|
||||||
<p class="mobile-text dark-text-secondary" style="margin: 0 0 30px 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
|
<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>
|
</p>
|
||||||
|
|
||||||
<!-- Additional Motivation -->
|
<!-- 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%;">
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 0 20px 0; width: 100%;">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" class="mobile-button">
|
<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
|
Return to Dashboard
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -1092,11 +1091,11 @@ export const INACTIVE_USER_REMINDER_EMAIL_TEMPLATE = `<!DOCTYPE html>
|
||||||
Questions? Reply to this email or contact our support team.
|
Questions? Reply to this email or contact our support team.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<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="#" style="color: #CCDADC !important; text-decoration: underline;">Unsubscribe</a> |
|
||||||
<a href="{{dashboardUrl}}" 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>
|
||||||
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #CCDADC !important;">
|
||||||
© 2025 Signalist
|
© 2025 Openstock
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
139
lib/utils.ts
139
lib/utils.ts
|
|
@ -1,6 +1,139 @@
|
||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
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) 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',
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,16 @@ import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* config options here */
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'i.ibb.co',
|
||||||
|
port: '',
|
||||||
|
pathname: '/**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"name": "signalist",
|
"name": "Openstock",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "signalist",
|
"name": "Openstock",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.10",
|
"@radix-ui/react-avatar": "^1.1.10",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "signalist",
|
"name": "Openstock",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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 |
Loading…
Reference in New Issue