implement auth logic

This commit is contained in:
Mr. Algorithm
2025-10-04 00:52:13 +05:30
parent bb4191b4b7
commit cf6bfbc6eb
24 changed files with 5782 additions and 116 deletions

View File

@@ -1,12 +1,20 @@
import Link from "next/link";
import React from "react";
import Image from "next/image";
import {headers} from "next/headers";
import {redirect} from "next/navigation";
import {auth} from "@/lib/better-auth/auth";
const Layout = ({ children }: { children : React.ReactNode }) => {
const Layout = async ({ children }: { children : React.ReactNode }) => {
const session = await auth.api.getSession({headers: await headers()});
if (session?.user) redirect('/')
return (
<main className="auth-layout">
<section className="auth-left-section scrollbar-hide-default">
<Link href="/" className="auth-logo">
<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>
</Link>

View File

@@ -4,68 +4,71 @@ import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import InputField from '@/components/forms/InputField';
import FooterLink from '@/components/forms/FooterLink';
import {signInWithEmail, signUpWithEmail} from "@/lib/actions/auth.actions";
import {toast} from "sonner";
import {signInEmail} from "better-auth/api";
import {useRouter} from "next/navigation";
import OpenDevSocietyBranding from "@/components/OpenDevSocietyBranding";
import React from "react";
const SignIn = () => {
const router = useRouter()
const {
register,
handleSubmit,
formState: {errors, isSubmitting},
formState: { errors, isSubmitting },
} = useForm<SignInFormData>({
defaultValues: {
email: '',
password: '',
},
mode: 'onBlur'
mode: 'onBlur',
});
const onSubmit = async(data: SignInFormData) => {
try{
console.log(data)
} catch(e){
console.log(e);
const onSubmit = async (data: SignInFormData) => {
try {
const result = await signInWithEmail(data);
if(result.success) router.push('/');
} catch (e) {
console.error(e);
toast.error('Sign in failed', {
description: e instanceof Error ? e.message : 'Failed to sign in.'
})
}
}
return (
<>
<h1 className="form-title">Sign In</h1>
<h1 className="form-title">Welcome back</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<InputField
name="email"
label="Email"
placeholder="Your email"
placeholder="contact@jsmastery.com"
register={register}
error={errors.email}
validation={{required: 'Email is required', pattern: /^\w+@\w+$/, message: 'Email is required' }}
validation={{ required: 'Email is required', pattern: /^\w+@\w+\.\w+$/ }}
/>
<InputField
name="password"
label="Password"
placeholder="Enter password"
placeholder="Enter your password"
type="password"
register={register}
error={errors.password}
validation={{required: 'Password is required', minLength: 8}}
validation={{ required: 'Password is required', minLength: 8 }}
/>
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
{isSubmitting ? 'Signing In...' : 'Sign In'}
{isSubmitting ? 'Signing In' : 'Sign In'}
</Button>
<FooterLink
text="Don't have an account?"
linkText="Sign up"
href="/sign-up"
/>
<FooterLink text="Don't have an account?" linkText="Create an account" href="/sign-up" />
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center"/>
</form>
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center"/>
</>
)
}
export default SignIn
);
};
export default SignIn;

View File

@@ -1,6 +1,5 @@
'use client';
import React from 'react'
import {useForm} from "react-hook-form";
import {Button} from "@/components/ui/button";
import InputField from "@/components/forms/InputField";
@@ -8,15 +7,19 @@ import SelectField from "@/components/forms/SelectField";
import {INVESTMENT_GOALS, PREFERRED_INDUSTRIES, RISK_TOLERANCE_OPTIONS} from "@/lib/constants";
import {CountrySelectField} from "@/components/forms/CountrySelectField";
import FooterLink from "@/components/forms/FooterLink";
import Link from "next/link";
import {signUpWithEmail} from "@/lib/actions/auth.actions";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import OpenDevSocietyBranding from "@/components/OpenDevSocietyBranding";
import React from "react";
const SignUp = () => {
const router = useRouter()
const {
register,
handleSubmit,
control,
formState: {errors, isSubmitting},
formState: { errors, isSubmitting },
} = useForm<SignUpFormData>({
defaultValues: {
fullName: '',
@@ -28,14 +31,20 @@ const SignUp = () => {
preferredIndustry: 'Technology'
},
mode: 'onBlur'
});
const onSubmit = async(data: SignUpFormData) => {
try{
console.log(data)
} catch(e){
console.log(e);
}, );
const onSubmit = async (data: SignUpFormData) => {
try {
const result = await signUpWithEmail(data);
if(result.success) router.push('/');
} catch (e) {
console.error(e);
toast.error('Sign up failed', {
description: e instanceof Error ? e.message : 'Failed to create an account.'
})
}
}
return (
<>
<h1 className="form-title">Sign Up & Personalize</h1>
@@ -44,29 +53,29 @@ const SignUp = () => {
<InputField
name="fullName"
label="Full Name"
placeholder="Your Name"
placeholder="Enter full name"
register={register}
error={errors.fullName}
validation={{required: 'Full name is required', minLength: 2 }}
validation={{ required: 'Full name is required', minLength: 2 }}
/>
<InputField
name="email"
label="Email"
placeholder="Your email"
placeholder="opendevsociety@cc.cc"
register={register}
error={errors.email}
validation={{required: 'Email is required', pattern: /^\w+@\w+$/, message: 'Email is required' }}
validation={{ required: 'Email name is required', pattern: /^\w+@\w+\.\w+$/, message: 'Email address is required' }}
/>
<InputField
name="password"
label="Password"
placeholder="Enter password"
placeholder="Enter a strong password"
type="password"
register={register}
error={errors.password}
validation={{required: 'Password is required', minLength: 8}}
validation={{ required: 'Password is required', minLength: 8 }}
/>
<CountrySelectField
@@ -84,6 +93,7 @@ const SignUp = () => {
options={INVESTMENT_GOALS}
control={control}
error={errors.investmentGoals}
required
/>
<SelectField
@@ -93,32 +103,28 @@ const SignUp = () => {
options={RISK_TOLERANCE_OPTIONS}
control={control}
error={errors.riskTolerance}
required
/>
<SelectField
name="preferredIndustry"
label="Preferred Industry"
placeholder="Select your investment goal"
placeholder="Select your preferred industry"
options={PREFERRED_INDUSTRIES}
control={control}
error={errors.preferredIndustry}
required
/>
<Button type="submit" disabled={isSubmitting} className="yellow-btn w-full mt-5">
{isSubmitting ? 'Creating account...' : 'Start Your Investing Journey'}
{isSubmitting ? 'Creating Account' : 'Start Your Investing Journey'}
</Button>
<FooterLink
text="Already have an account?"
linkText="Sign in"
href="/sign-in"
/>
<FooterLink text="Already have an account?" linkText="Sign in" href="/sign-in" />
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center"/>
</form>
<OpenDevSocietyBranding outerClassName="mt-10 flex justify-center"/>
</>
)
}
export default SignUp
export default SignUp;

View File

@@ -1,10 +1,22 @@
import React from 'react'
import Header from "../../components/Header";
import Header from "@/components/Header";
import {auth} from "@/lib/better-auth/auth";
import {headers} from "next/headers";
import {redirect} from "next/navigation";
const Layout = async ({ children }: { children : React.ReactNode }) => {
const session = await auth.api.getSession({ headers: await headers() });
if(!session?.user) redirect('/sign-in');
const user = {
id: session.user.id,
name: session.user.name,
email: session.user.email,
}
const Layout = ({ children }: { children : React.ReactNode }) => {
return (
<main className="min-h-screen text-gray-400">
<Header/>
<Header user={user} />
<div className="container py-10">
{children}
@@ -12,4 +24,4 @@ const Layout = ({ children }: { children : React.ReactNode }) => {
</main>
)
}
export default Layout
export default Layout

8
app/api/inngest/route.ts Normal file
View File

@@ -0,0 +1,8 @@
import {serve} from "inngest/next";
import {inngest} from "@/lib/inngest/client";
import {sendSignUpEmail} from "@/lib/inngest/functions";
export const {GET, POST, PUT } = serve({
client: inngest,
functions: [sendSignUpEmail],
})

View File

@@ -146,7 +146,7 @@
@apply mx-auto max-w-screen-2xl px-4 md:px-6 lg:px-8;
}
.yellow-btn {
@apply h-12 cursor-pointer bg-gradient-to-b from-yellow-400 to-yellow-500 hover:from-yellow-500 hover:to-yellow-400 text-gray-950 font-medium text-base rounded-lg shadow-lg disabled:opacity-50;
@apply h-12 cursor-pointer bg-gradient-to-b from-teal-400 to-teal-500 hover:from-teal-500 hover:to-teal-400 text-gray-800 font-medium text-base rounded-lg shadow-lg disabled:opacity-50;
}
.home-wrapper {
@apply text-gray-400 flex-col gap-4 md:gap-10 items-center sm:items-start;
@@ -188,13 +188,13 @@
@apply text-sm font-medium text-gray-400;
}
.form-input {
@apply h-12 px-3 py-3 text-white text-base placeholder:text-gray-500 border-gray-600 bg-gray-800 rounded-lg focus:!border-yellow-500 focus:ring-0;
@apply h-12 px-3 py-3 text-white text-base placeholder:text-gray-600 border-gray-600 rounded-lg focus:!border-teal-500 focus:ring-0 ;
}
.select-trigger {
@apply w-full !h-12 px-3 py-3 text-base border-gray-600 bg-gray-800 text-white rounded-lg focus:!border-yellow-500 focus:ring-0;
@apply w-full !h-12 px-3 py-3 text-base border-gray-600 bg-gray-800 text-white rounded-lg focus:!border-teal-500 focus:ring-0;
}
.country-select-trigger {
@apply h-12 px-3 py-3 text-base w-full justify-between font-normal border-gray-600 bg-gray-800 text-gray-400 rounded-lg focus:!border-yellow-500 focus:ring-0;
@apply h-12 px-3 py-3 text-base w-full justify-between font-normal border-gray-600 bg-gray-800 text-gray-400 rounded-lg focus:!border-teal-500 focus:ring-0;
}
.country-select-input {
@apply !bg-gray-800 text-gray-400 border-0 border-b border-gray-600 rounded-none focus:ring-0 placeholder:text-gray-500;
@@ -206,13 +206,13 @@
@apply text-white cursor-pointer px-3 py-2 rounded-sm bg-gray-800 hover:!bg-gray-600;
}
.footer-link {
@apply text-gray-400 font-medium hover:text-yellow-400 hover:underline transition-colors;
@apply text-gray-400 font-medium hover:text-teal-400 hover:underline transition-colors;
}
.search-text {
@apply cursor-pointer hover:text-yellow-500;
@apply cursor-pointer hover:text-teal-500;
}
.search-btn {
@apply cursor-pointer px-4 py-2 w-fit flex items-center gap-2 text-sm md:text-base bg-yellow-500 hover:bg-yellow-500 text-black font-medium rounded;
@apply cursor-pointer px-4 py-2 w-fit flex items-center gap-2 text-sm md:text-base bg-teal-500 hover:bg-teal-500 text-black font-medium rounded;
}
.search-dialog {
@apply !bg-gray-800 lg:min-w-[800px] border-gray-600 fixed top-10 left-1/2 -translate-x-1/2 translate-y-10;
@@ -254,7 +254,7 @@
@apply w-full grid-cols-1 gap-6 xl:grid-cols-3 space-y-6 sm:space-y-8;
}
.watchlist-btn {
@apply bg-yellow-500 text-base hover:bg-yellow-500 text-gray-900 w-full rounded h-11 font-semibold cursor-pointer;
@apply bg-teal-500 text-base hover:bg-teal-500 text-gray-900 w-full rounded h-11 font-semibold cursor-pointer;
}
.watchlist-remove {
@apply bg-red-500! hover:bg-red-500! text-gray-900!
@@ -284,10 +284,10 @@
@apply items-start gap-6 h-full flex-col w-full lg:col-span-1;
}
.watchlist-icon-btn {
@apply w-fit cursor-pointer hover:bg-transparent! text-gray-400 hover:text-yellow-500;
@apply w-fit cursor-pointer hover:bg-transparent! text-gray-400 hover:text-teal-500;
}
.watchlist-icon-added {
@apply !text-yellow-500 hover:!text-yellow-600;
@apply !text-teal-500 hover:!text-teal-600;
}
.watchlist-icon {
@apply w-8 h-8 rounded-full flex items-center justify-center bg-gray-700/50;
@@ -317,7 +317,7 @@
@apply font-medium text-base
}
.add-alert {
@apply flex text-sm items-center whitespace-nowrap gap-1.5 px-3 w-fit py-2 text-yellow-600 border border-yellow-600/20 rounded font-medium bg-transparent hover:bg-transparent cursor-pointer transition-colors;
@apply flex text-sm items-center whitespace-nowrap gap-1.5 px-3 w-fit py-2 text-teal-600 border border-teal-600/20 rounded font-medium bg-transparent hover:bg-transparent cursor-pointer transition-colors;
}
.watchlist-news {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4;
@@ -338,7 +338,7 @@
@apply text-gray-400 flex-1 text-base leading-relaxed mb-3 line-clamp-3;
}
.news-cta {
@apply text-sm align-bottom text-yellow-500 hover:text-gray-400;
@apply text-sm align-bottom text-teal-500 hover:text-gray-400;
}
.alert-dialog {
@apply bg-gray-800 border-gray-600 text-gray-400 max-w-md;
@@ -356,7 +356,7 @@
@apply p-4 rounded-lg bg-gray-700 border border-gray-600;
}
.alert-name {
@apply mb-2 text-lg text-yellow-500 font-semibold;
@apply mb-2 text-lg text-teal-500 font-semibold;
}
.alert-details {
@apply flex border-b pb-3 items-center justify-between gap-3 mb-2;

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import {Toaster} from "@/components/ui/sonner";
import "./globals.css";
const geistSans = Geist({
@@ -24,11 +25,12 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className="dark">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Toaster/>
</body>
</html>
);
}

View File

@@ -1,14 +1,14 @@
import React from 'react'
import Link from "next/link";
import Image from "next/image";
import NavItems from "@/components/NavItems";
import UserDropdown from "@/components/UserDropdown";
const Header = () => {
const Header = async ({ user }: { user: User }) => {
return (
<header className="sticky top-0 header">
<div className="container header-wrapper">
<Link href="/">
<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>
</Link>
<nav className="hidden sm:block">
@@ -16,7 +16,7 @@ const Header = () => {
</nav>
{/* UserDropDown */}
<UserDropdown/>
<UserDropdown user={user}/>
</div>
</header>

View File

@@ -18,7 +18,7 @@ const NavItems = () => {
<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}>
<Link href={href} className={`hover:text-yellow-500 transition-colors ${isActive(href) ? 'text-gray-100' : ''}`}>
<Link href={href} className={`hover:text-teal-500 transition-colors ${isActive(href) ? 'text-gray-100' : ''}`}>
{label}
</Link>
</li>

View File

@@ -14,44 +14,40 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {useRouter} from "next/navigation";
import {LogOut} from "lucide-react";
import {NAV_ITEMS} from "@/lib/constants";
import NavItems from "@/components/NavItems";
import {signOut} from "@/lib/actions/auth.actions";
const UserDropdown = () => {
const UserDropdown = ({user} : {user: User}) => {
const router = useRouter();
const handleSignOut = async() => {
await signOut();
router.push("/sign-in");
}
const user = {
name: "ODS",
email: "opendevsociety@cc.cc",
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-3 text-gray-400 hover:text-yellow-500">
<Button className="bg-gray-800 hover:bg-gray-800 flex items-center gap-3 text-gray-400 hover:text-teal-500">
<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-yellow-500 text-yellow-900 text-sm font-bold">
<AvatarFallback className="bg-teal-500 text-yellow-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">
<span className="text-base font-medium text-gray-400 hover:text-teal-500">
{user.name}
</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="text-gray-400">
<DropdownMenuContent className="text-gray-400 bg-gray-800 relative right-10">
<DropdownMenuLabel>
<div className="flex relative items-center gap-3 py-2">
<Avatar className="h-10 w-10">
<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-yellow-500 text-yellow-900 text-sm font-bold">
<AvatarFallback className="bg-teal-500 text-yellow-900 text-sm font-bold">
{user.name[0]}
</AvatarFallback>
</Avatar>
@@ -66,11 +62,11 @@ const UserDropdown = () => {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-gray-600"/>
<DropdownMenuItem onClick={handleSignOut} className="text-gray-100 text-md font-medium focus:bg-transparent focus:text-yellow-500 transition-colors cursor-pointer">
<LogOut className="h-4 w-4 mr-2 hidden sm:block"/>
<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
</DropdownMenuItem>
<DropdownMenuSeparator className=" hidden sm:block bg-gray-600"/>
<DropdownMenuSeparator className=" block sm:hidden bg-gray-600"/>
<nav className="sm:hidden">
<NavItems/>
</nav>

View File

@@ -97,7 +97,7 @@ const CountrySelect = ({
>
<Check
className={cn(
'mr-2 h-4 w-4 text-yellow-500',
'mr-2 h-4 w-4 text-teal-500',
value === country.value ? 'opacity-100' : 'opacity-0'
)}
/>

25
components/ui/sonner.tsx Normal file
View File

@@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -35,4 +35,5 @@ export const connectToDatabase = async () => {
}
console.log(`MongoDB Connected ${MONGODB_URI} in ${process.env.NODE_ENV}`);
return cached.conn;
}

View File

@@ -0,0 +1,43 @@
'use server';
import {auth} from "@/lib/better-auth/auth";
import {inngest} from "@/lib/inngest/client";
import {headers} from "next/headers";
export const signUpWithEmail = async ({ email, password, fullName, country, investmentGoals, riskTolerance, preferredIndustry }: SignUpFormData) => {
try {
const response = await auth.api.signUpEmail({ body: { email, password, name: fullName } })
if(response) {
await inngest.send({
name: 'app/user.created',
data: { email, name: fullName, country, investmentGoals, riskTolerance, preferredIndustry }
})
}
return { success: true, data: response }
} catch (e) {
console.log('Sign up failed', e)
return { success: false, error: 'Sign up failed' }
}
}
export const signInWithEmail = async ({ email, password }: SignInFormData) => {
try {
const response = await auth.api.signInEmail({ body: { email, password } })
return { success: true, data: response }
} catch (e) {
console.log('Sign in failed', e)
return { success: false, error: 'Sign in failed' }
}
}
export const signOut = async () => {
try {
await auth.api.signOut({ headers: await headers() });
} catch (e) {
console.log('Sign out failed', e)
return { success: false, error: 'Sign out failed' }
}
}

41
lib/better-auth/auth.ts Normal file
View File

@@ -0,0 +1,41 @@
import { betterAuth } from "better-auth";
import {mongodbAdapter} from "better-auth/adapters/mongodb";
import {connectToDatabase} from "@/database/mongoose";
import {nextCookies} from "better-auth/next-js";
let authInstance: ReturnType<typeof betterAuth> | null = null;
export const getAuth = async () => {
if(authInstance) {
return authInstance;
}
const mongoose = await connectToDatabase();
const db = mongoose.connection;
if (!db) {
throw new Error("MongoDB connection not found!");
}
authInstance = betterAuth({
database: mongodbAdapter(db as any),
secret: process.env.BETER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL,
emailAndPassword: {
enabled: true,
disableSignUp: false,
requireEmailVerification: false,
minPasswordLength: 8,
maxPasswordLength: 128,
autoSignIn: true,
},
plugins: [nextCookies()],
});
return authInstance;
}
export const auth = await getAuth();

6
lib/inngest/client.ts Normal file
View File

@@ -0,0 +1,6 @@
import {Inngest} from "inngest"
export const inngest = new Inngest({
id: "openStock",
ai: {gemini: {apiKey: process.env.GEMINI_API_KEY}}
})

48
lib/inngest/functions.ts Normal file
View File

@@ -0,0 +1,48 @@
import {inngest} from '@/lib/inngest/client';
import {PERSONALIZED_WELCOME_EMAIL_PROMPT} from "@/lib/inngest/prompts";
import {sendWelcomeEmail} from "@/lib/nodemailer";
export const sendSignUpEmail = inngest.createFunction(
{id: 'sign-up-email'},
{event: 'app/user.created'},
async ({event, step}) => {
const userProfile = `
- Country: ${event.data.country}
- Investment goals: ${event.data.investmentGoals}
- Risk tolerance: ${event.data.riskTolerance}
- Preferred industry: ${event.data.preferredIndustry}
`
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'}),
body: {
contents: [
{
role: 'user',
parts: [
{text: prompt}
]
}
]
}
})
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 });
})
return {
success: true,
message: 'Welcome email sent successfully!',
}
}
)

230
lib/inngest/prompts.ts Normal file
View File

@@ -0,0 +1,230 @@
export const PERSONALIZED_WELCOME_EMAIL_PROMPT = `Generate highly personalized HTML content that will be inserted into an email template at the {{intro}} placeholder.
User profile data:
{{userProfile}}
PERSONALIZATION REQUIREMENTS:
You MUST create content that is obviously tailored to THIS specific user by:
IMPORTANT: Do NOT start the personalized content with "Welcome" since the email header already says "Welcome aboard {{name}}". Use alternative openings like "Thanks for joining", "Great to have you", "You're all set", "Perfect timing", etc.
1. **Direct Reference to User Details**: Extract and use specific information from their profile:
- Their exact investment goals or objectives
- Their stated risk tolerance level
- Their preferred sectors/industries mentioned
- Their experience level or background
- Any specific stocks/companies they're interested in
- Their investment timeline (short-term, long-term, retirement)
2. **Contextual Messaging**: Create content that shows you understand their situation:
- New investors → Reference learning/starting their journey
- Experienced traders → Reference advanced tools/strategy enhancement
- Retirement planning → Reference building wealth over time
- Specific sectors → Reference those exact industries by name
- Conservative approach → Reference safety and informed decisions
- Aggressive approach → Reference opportunities and growth potential
3. **Personal Touch**: Make it feel like it was written specifically for them:
- Use their goals in your messaging
- Reference their interests directly
- Connect features to their specific needs
- Make them feel understood and seen
CRITICAL FORMATTING REQUIREMENTS:
- Return ONLY clean HTML content with NO markdown, NO code blocks, NO backticks
- Use SINGLE paragraph only: <p class="mobile-text" style="margin: 0 0 30px 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">content</p>
- Write exactly TWO sentences (add one more sentence than current single sentence)
- Keep total content between 35-50 words for readability
- Use <strong> for key personalized elements (their goals, sectors, etc.)
- DO NOT include "Here's what you can do right now:" as this is already in the template
- Make every word count toward personalization
- 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;">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;">You're all set! Since you're new to investing, we've designed simple tools to help you build confidence while learning the <strong>healthcare sector</strong> you're interested in. Our beginner-friendly alerts will guide you without the confusing jargon.</p>`
export const NEWS_SUMMARY_EMAIL_PROMPT = `Generate HTML content for a market news summary email that will be inserted into the NEWS_SUMMARY_EMAIL_TEMPLATE at the {{newsContent}} placeholder.
News data to summarize:
{{newsData}}
CRITICAL FORMATTING REQUIREMENTS:
- Return ONLY clean HTML content with NO markdown, NO code blocks, NO backticks
- Structure content with clear sections using proper HTML headings and paragraphs
- Use these specific CSS classes and styles to match the email template:
SECTION HEADINGS (for categories like "Market Highlights", "Top Movers", etc.):
<h3 class="mobile-news-title dark-text" style="margin: 30px 0 15px 0; font-size: 18px; font-weight: 600; color: #f8f9fa; line-height: 1.3;">Section Title</h3>
PARAGRAPHS (for news content):
<p class="mobile-text dark-text-secondary" style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">Content goes here</p>
STOCK/COMPANY MENTIONS:
<strong style="color: #FDD458;">Stock Symbol</strong> for ticker symbols
<strong style="color: #CCDADC;">Company Name</strong> for company names
PERFORMANCE INDICATORS:
Use 📈 for gains, 📉 for losses, 📊 for neutral/mixed
NEWS ARTICLE STRUCTURE:
For each individual news item within a section, use this structure:
1. Article container with visual styling and icon
2. Article title as a subheading
3. Key takeaways in bullet points (2-3 actionable insights)
4. "What this means" section for context
5. "Read more" link to the original article
6. Visual divider between articles
ARTICLE CONTAINER:
Wrap each article in a clean, simple container:
<div class="dark-info-box" style="background-color: #212328; padding: 24px; margin: 20px 0; border-radius: 8px;">
ARTICLE TITLES:
<h4 class="dark-text" style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #FFFFFF; line-height: 1.4;">
Article Title Here
</h4>
BULLET POINTS (minimum 3 concise insights):
Use this format with clear, concise explanations (no label needed):
<ul style="margin: 16px 0 20px 0; padding-left: 0; margin-left: 0; list-style: none;">
<li class="dark-text-secondary" style="margin: 0 0 16px 0; padding: 0; margin-left: 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
<span style="color: #FDD458; font-weight: bold; font-size: 20px; margin-right: 8px;">•</span>Clear, concise explanation in simple terms that's easy to understand quickly.
</li>
<li class="dark-text-secondary" style="margin: 0 0 16px 0; padding: 0; margin-left: 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
<span style="color: #FDD458; font-weight: bold; font-size: 20px; margin-right: 8px;">•</span>Brief explanation with key numbers and what they mean in everyday language.
</li>
<li class="dark-text-secondary" style="margin: 0 0 16px 0; padding: 0; margin-left: 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
<span style="color: #FDD458; font-weight: bold; font-size: 20px; margin-right: 8px;">•</span>Simple takeaway about what this means for regular people's money.
</li>
</ul>
INSIGHT SECTION:
Add simple context explanation:
<div style="background-color: #141414; border: 1px solid #374151; padding: 15px; border-radius: 6px; margin: 16px 0;">
<p class="dark-text-secondary" style="margin: 0; font-size: 14px; color: #CCDADC; line-height: 1.4;">💡 <strong style="color: #FDD458;">Bottom Line:</strong> Simple explanation of why this news matters to your money in everyday language.</p>
</div>
READ MORE BUTTON:
<div style="margin: 20px 0 0 0;">
<a href="ARTICLE_URL" style="color: #FDD458; text-decoration: none; font-weight: 500; font-size: 14px;" target="_blank" rel="noopener noreferrer">Read Full Story →</a>
</div>
ARTICLE DIVIDER:
Close each article container:
</div>
SECTION DIVIDERS:
Between major sections, use:
<div style="border-top: 1px solid #374151; margin: 32px 0 24px 0;"></div>
Content guidelines:
- Organize news into logical sections with icons (📊 Market Overview, 📈 Top Gainers, 📉 Top Losers, 🔥 Breaking News, 💼 Earnings Reports, 🏛️ Economic Data, etc.)
- NEVER repeat section headings - use each section type only once per email
- For each news article, include its actual headline/title from the news data
- Provide MINIMUM 3 CONCISE bullet points (NO "Key Takeaways" label - start directly with bullets)
- Each bullet should be SHORT and EASY TO UNDERSTAND - one clear sentence preferred
- Use PLAIN ENGLISH - avoid jargon, complex financial terms, or insider language
- Explain concepts as if talking to someone new to investing
- Include specific numbers but explain what they mean in simple terms
- Add "Bottom Line" context in everyday language anyone can understand
- Use clean, light design with yellow bullets for better readability
- Make each article easy to scan with clear spacing and structure
- Always include simple "Read Full Story" buttons with actual URLs
- Focus on PRACTICAL insights regular people can understand and use
- Explain what the news means for regular investors' money
- Keep language conversational and accessible to everyone
- Prioritize BREVITY and CLARITY over detailed explanations
Example structure:
<h3 class="mobile-news-title dark-text" style="margin: 30px 0 15px 0; font-size: 20px; font-weight: 600; color: #f8f9fa; line-height: 1.3;">📊 Market Overview</h3>
<div class="dark-info-box" style="background-color: #212328; padding: 24px; margin: 20px 0; border-radius: 8px;">
<h4 class="dark-text" style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #FDD458; line-height: 1.4;">
Stock Market Had Mixed Results Today
</h4>
<ul style="margin: 16px 0 20px 0; padding-left: 0; margin-left: 0; list-style: none;">
<li class="dark-text-secondary" style="margin: 0 0 16px 0; padding: 0; margin-left: 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
<span style="color: #FDD458; font-weight: bold; font-size: 20px; margin-right: 8px;">•</span>Tech stocks like Apple went up 1.2% today, which is good news for tech investors.
</li>
<li class="dark-text-secondary" style="margin: 0 0 16px 0; padding: 0; margin-left: 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
<span style="color: #FDD458; font-weight: bold; font-size: 20px; margin-right: 8px;">•</span>Traditional companies went down 0.3%, showing investors prefer tech right now.
</li>
<li class="dark-text-secondary" style="margin: 0 0 16px 0; padding: 0; margin-left: 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
<span style="color: #FDD458; font-weight: bold; font-size: 20px; margin-right: 8px;">•</span>High trading volume (12.4 billion shares) shows investors are confident and active.
</li>
</ul>
<div style="background-color: #141414; border: 1px solid #374151; padding: 15px; border-radius: 6px; margin: 16px 0;">
<p class="dark-text-secondary" style="margin: 0; font-size: 14px; color: #CCDADC; line-height: 1.4;">💡 <strong style="color: #FDD458;">Bottom Line:</strong> If you own tech stocks, today was good for you. If you're thinking about investing, tech companies might be a smart choice right now.</p>
</div>
<div style="margin: 20px 0 0 0;">
<a href="https://example.com/article1" style="color: #FDD458; text-decoration: none; font-weight: 500; font-size: 14px;" target="_blank" rel="noopener noreferrer">Read Full Story →</a>
</div>
</div>
<div style="border-top: 1px solid #374151; margin: 32px 0 24px 0;"></div>
<h3 class="mobile-news-title dark-text" style="margin: 30px 0 15px 0; font-size: 20px; font-weight: 600; color: #f8f9fa; line-height: 1.3;">📈 Top Gainers</h3>
<div class="dark-info-box" style="background-color: #212328; padding: 24px; margin: 20px 0; border-radius: 8px;">
<h4 class="dark-text" style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600; color: #FDD458; line-height: 1.4;">
Apple Stock Jumped After Great Earnings Report
</h4>
<ul style="margin: 16px 0 20px 0; padding-left: 0; margin-left: 0; list-style: none;">
<li class="dark-text-secondary" style="margin: 0 0 16px 0; padding: 0; margin-left: 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
<span style="color: #FDD458; font-weight: bold; font-size: 20px; margin-right: 8px;">•</span>Apple stock jumped 5.2% after beating earnings expectations.
</li>
<li class="dark-text-secondary" style="margin: 0 0 16px 0; padding: 0; margin-left: 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
<span style="color: #FDD458; font-weight: bold; font-size: 20px; margin-right: 8px;">•</span>iPhone sales expected to grow 8% next quarter despite economic uncertainty.
</li>
<li class="dark-text-secondary" style="margin: 0 0 16px 0; padding: 0; margin-left: 0; font-size: 16px; line-height: 1.6; color: #CCDADC;">
<span style="color: #FDD458; font-weight: bold; font-size: 20px; margin-right: 8px;">•</span>App store and services revenue hit $22.3 billion (up 14%), providing steady income.
</li>
</ul>
<div style="background-color: #141414; border: 1px solid #374151; padding: 15px; border-radius: 6px; margin: 16px 0;">
<p class="dark-text-secondary" style="margin: 0; font-size: 14px; color: #CCDADC; line-height: 1.4;">💡 <strong style="color: #FDD458;">Bottom Line:</strong> Apple is making money in different ways (phones AND services), so it's a pretty safe stock to own even when the economy gets shaky.</p>
</div>
<div style="margin: 20px 0 0 0;">
<a href="https://example.com/article2" style="color: #FDD458; text-decoration: none; font-weight: 500; font-size: 14px;" target="_blank" rel="noopener noreferrer">Read Full Story →</a>
</div>
</div>`
export const TRADINGVIEW_SYMBOL_MAPPING_PROMPT = `You are an expert in financial markets and trading platforms. Your task is to find the correct TradingView symbol that corresponds to a given Finnhub stock symbol.
Stock information from Finnhub:
Symbol: {{symbol}}
Company: {{company}}
Exchange: {{exchange}}
Currency: {{currency}}
Country: {{country}}
IMPORTANT RULES:
1. TradingView uses specific symbol formats that may differ from Finnhub
2. For US stocks: Usually just the symbol (e.g., AAPL for Apple)
3. For international stocks: Often includes exchange prefix (e.g., NASDAQ:AAPL, NYSE:MSFT, LSE:BARC)
4. Some symbols may have suffixes for different share classes
5. ADRs and foreign stocks may have different symbol formats
RESPONSE FORMAT:
Return ONLY a valid JSON object with this exact structure:
{
"tradingViewSymbol": "EXCHANGE:SYMBOL",
"confidence": "high|medium|low",
"reasoning": "Brief explanation of why this mapping is correct"
}
EXAMPLES:
- Apple Inc. (AAPL) from Finnhub → {"tradingViewSymbol": "NASDAQ:AAPL", "confidence": "high", "reasoning": "Apple trades on NASDAQ as AAPL"}
- Microsoft Corp (MSFT) from Finnhub → {"tradingViewSymbol": "NASDAQ:MSFT", "confidence": "high", "reasoning": "Microsoft trades on NASDAQ as MSFT"}
- Barclays PLC (BARC.L) from Finnhub → {"tradingViewSymbol": "LSE:BARC", "confidence": "high", "reasoning": "Barclays trades on London Stock Exchange as BARC"}
Your response must be valid JSON only. Do not include any other text.`

28
lib/nodemailer/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import nodemailer from 'nodemailer'
import {WELCOME_EMAIL_TEMPLATE} from "@/lib/nodemailer/templates";
export const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.NODEMAILER_EMAIL,
pass: process.env.NODEMAILER_PASSWORD,
}
})
export const sendWelcomeEmail = async ({email, name, intro} : WelcomeEmailData) => {
const htmlTemplate = WELCOME_EMAIL_TEMPLATE
.replace('{{name}}', name)
.replace('{{intro}}', intro);
const mailOptions = {
from: `"Openstock" <opendevsociety@gamil.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',
html: htmlTemplate,
}
await transporter.sendMail(mailOptions);
}

1110
lib/nodemailer/templates.ts Normal file

File diff suppressed because it is too large Load Diff

19
middleware/index.ts Normal file
View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
// Check cookie presence - prevents obviously unauthorized users
if (!sessionCookie) {
return NextResponse.redirect(new URL('/sign-in', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|sign-in|sign-up|assets).*)',
],
};

4108
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,26 +17,32 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"better-auth": "^1.3.25",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"country-data-list": "^1.5.5",
"dotenv": "^17.2.3",
"inngest": "^3.44.0",
"lucide-react": "^0.544.0",
"mongodb": "^6.20.0",
"mongoose": "^8.19.0",
"next": "15.5.4",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.6",
"react": "19.1.0",
"react-circle-flags": "^0.0.23",
"react-dom": "19.1.0",
"react-hook-form": "^7.63.0",
"react-select-country-list": "^2.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/nodemailer": "^7.0.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-select-country-list": "^2.2.3",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 41 KiB