added donation

This commit is contained in:
Mr. Algorithm
2025-12-13 01:35:52 +05:30
parent 8cc53a1152
commit ff6cfc42f3
14 changed files with 1092 additions and 681 deletions

View File

@@ -242,13 +242,15 @@ BETTER_AUTH_SECRET=your_better_auth_secret
BETTER_AUTH_URL=http://localhost:3000
# Finnhub
FINNHUB_API_KEY=your_finnhub_key
# Optional client-exposed variant if needed by client code:
NEXT_PUBLIC_FINNHUB_API_KEY=
# Note: NEXT_PUBLIC_FINNHUB_API_KEY is required for Vercel deployment
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
FINNHUB_BASE_URL=https://finnhub.io/api/v1
# Inngest AI (Gemini)
GEMINI_API_KEY=your_gemini_api_key
# Inngest Signing Key (required for Vercel deployment)
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
INNGEST_SIGNING_KEY=your_inngest_signing_key
# Email (Nodemailer via Gmail; consider App Passwords if 2FA)
NODEMAILER_EMAIL=youraddress@gmail.com
@@ -268,12 +270,15 @@ BETTER_AUTH_SECRET=your_better_auth_secret
BETTER_AUTH_URL=http://localhost:3000
# Finnhub
FINNHUB_API_KEY=your_finnhub_key
NEXT_PUBLIC_FINNHUB_API_KEY=
# Note: NEXT_PUBLIC_FINNHUB_API_KEY is required for Vercel deployment
NEXT_PUBLIC_FINNHUB_API_KEY=your_finnhub_key
FINNHUB_BASE_URL=https://finnhub.io/api/v1
# Inngest AI (Gemini)
GEMINI_API_KEY=your_gemini_api_key
# Inngest Signing Key (required for Vercel deployment)
# Get this from your Inngest dashboard: https://app.inngest.com/env/settings/keys
INNGEST_SIGNING_KEY=your_inngest_signing_key
# Email (Nodemailer via Gmail; consider App Passwords if 2FA)
NODEMAILER_EMAIL=youraddress@gmail.com
@@ -329,7 +334,7 @@ public/assets/images/ # logos and screenshots
- Finnhub
- Stock search, company profiles, and market news.
- Set `FINNHUB_API_KEY` and `FINNHUB_BASE_URL` (default: https://finnhub.io/api/v1).
- Set `NEXT_PUBLIC_FINNHUB_API_KEY` and `FINNHUB_BASE_URL` (default: https://finnhub.io/api/v1).
- Free tiers may return delayed quotes; respect rate limits and terms.
- TradingView

View File

@@ -3,6 +3,7 @@ import {auth} from "@/lib/better-auth/auth";
import {headers} from "next/headers";
import {redirect} from "next/navigation";
import Footer from "@/components/Footer";
import DonatePopup from "@/components/DonatePopup";
const Layout = async ({ children }: { children : React.ReactNode }) => {
const session = await auth.api.getSession({ headers: await headers() });
@@ -24,6 +25,7 @@ const Layout = async ({ children }: { children : React.ReactNode }) => {
</div>
<Footer />
<DonatePopup />
</main>
)
}

106
components/DonatePopup.tsx Normal file
View File

@@ -0,0 +1,106 @@
'use client';
import React, { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Heart, Github } from 'lucide-react';
const DONATE_POPUP_KEY = 'opendevsociety-donate-popup-dismissed';
const DONATE_POPUP_DELAY = 3000; // Show after 3 seconds
const DONATE_POPUP_COOLDOWN = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
const GITHUB_SPONSOR_URL = 'https://github.com/sponsors/ravixalgorithm';
export default function DonatePopup() {
const [open, setOpen] = useState(false);
useEffect(() => {
// Check if user has dismissed popup
const dismissed = localStorage.getItem(DONATE_POPUP_KEY);
if (dismissed) {
const dismissedTime = parseInt(dismissed, 10);
const now = Date.now();
// Show again after cooldown period
if (now - dismissedTime < DONATE_POPUP_COOLDOWN) {
return;
}
}
// Show popup after delay
const timer = setTimeout(() => {
setOpen(true);
}, DONATE_POPUP_DELAY);
return () => clearTimeout(timer);
}, []);
// Listen for custom event from donate button
useEffect(() => {
const handleOpenPopup = () => setOpen(true);
window.addEventListener('open-donate-popup', handleOpenPopup);
return () => window.removeEventListener('open-donate-popup', handleOpenPopup);
}, []);
const handleDismiss = () => {
setOpen(false);
// Store dismissal time
localStorage.setItem(DONATE_POPUP_KEY, Date.now().toString());
};
const handleDonate = () => {
window.open(GITHUB_SPONSOR_URL, '_blank', 'noopener,noreferrer');
handleDismiss();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="!bg-gray-800 !border-teal-600/50 text-gray-100 max-w-md mx-4 sm:mx-auto sm:w-full sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-teal-500/20 rounded-lg">
<Heart className="h-6 w-6 text-teal-400 fill-teal-400" />
</div>
<DialogTitle className="text-2xl font-bold text-gray-100">
Keep OpenStock Free
</DialogTitle>
</div>
<DialogDescription className="text-gray-400 text-base leading-relaxed pt-2">
Your overwhelming love for OpenStock and Open Dev Society has helped us grow,
but we're hitting Vercel's free tier limits.
<br /><br />
Help us keep OpenStock free and accessible for everyone by supporting us on GitHub Sponsors.
Every contribution, no matter how small, makes a difference! 💙
</DialogDescription>
</DialogHeader>
<div className="flex flex-col sm:flex-row gap-3 mt-6">
<Button
onClick={handleDonate}
className="flex-1 bg-gradient-to-r from-teal-500 to-cyan-500 hover:from-teal-600 hover:to-cyan-600 text-white font-semibold h-11 transition-all duration-200 transform hover:scale-105"
>
<Github className="h-4 w-4 mr-2" />
Sponsor on GitHub
</Button>
<Button
onClick={handleDismiss}
variant="outline"
className="flex-1 border-teal-600/50 text-teal-400 hover:bg-teal-600/10 hover:text-teal-300 h-11 transition-all duration-200"
>
Maybe Later
</Button>
</div>
<p className="text-xs text-gray-500 text-center mt-4">
This popup won't appear again for 24 hours after dismissing
</p>
</DialogContent>
</Dialog>
);
}

View File

@@ -11,7 +11,7 @@ const Footer = () => {
<div className="col-span-1 md:col-span-2">
<Link href="/" className="flex items-center gap-2 mb-4">
<Image
src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png"
src="/assets/images/logo.png"
alt="OpenStock"
width={150}
height={38}

View File

@@ -12,7 +12,7 @@ const Header = async ({ user }: { user: User }) => {
<div className="container header-wrapper">
<Link href="/" className="flex items-center justify-center gap-2">
<Image
src="https://i.ibb.co/r28VWPjS/Screenshot-2025-10-04-123317-Picsart-Ai-Image-Enhancer-removebg-preview.png"
src="/assets/images/logo.png"
alt="OpenStock"
width={200}
height={50}

View File

@@ -1,11 +1,22 @@
'use client'
import React from 'react'
import React, { createContext, useContext } from 'react'
import {NAV_ITEMS} from "@/lib/constants";
import Link from "next/link";
import {usePathname} from "next/navigation";
import SearchCommand from "@/components/SearchCommand";
import { Heart } from 'lucide-react';
import { Button } from '@/components/ui/button';
// Create context for popup state
const DonatePopupContext = createContext<{
openDonatePopup: () => void;
}>({
openDonatePopup: () => {}
});
export const useDonatePopup = () => useContext(DonatePopupContext);
const NavItems = ({initialStocks}: { initialStocks: StockWithWatchlistStatus[]}) => {
const pathname = usePathname()
@@ -15,8 +26,15 @@ const NavItems = ({initialStocks}: { initialStocks: StockWithWatchlistStatus[]})
return pathname.startsWith(path);
}
const openDonatePopup = () => {
// Trigger the popup by dispatching a custom event
window.dispatchEvent(new CustomEvent('open-donate-popup'));
}
return (
<ul className="flex flex-col sm:flex-row p-2 gap-3 sm:gap-10 font-medium">
<DonatePopupContext.Provider value={{ openDonatePopup }}>
<ul className="flex flex-col sm:flex-row p-2 gap-3 sm:gap-10 font-medium">
{NAV_ITEMS.map(({href, label}) => {
if (href === '/search') return (
<li key="search-trigger">
@@ -33,7 +51,18 @@ const NavItems = ({initialStocks}: { initialStocks: StockWithWatchlistStatus[]})
</Link>
</li>
})}
<li key="donate">
<Button
onClick={openDonatePopup}
className="bg-gradient-to-r from-teal-500 to-cyan-500 hover:from-teal-600 hover:to-cyan-600 text-white font-semibold px-4 py-2 rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105 flex items-center gap-2 animate-pulse"
size="sm"
>
<Heart className="h-4 w-4 fill-current" />
Donate
</Button>
</li>
</ul>
</DonatePopupContext.Provider>
)
}
export default NavItems

View File

@@ -9,10 +9,17 @@ export const signUpWithEmail = async ({ email, password, fullName, country, inve
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 }
})
try {
console.log('📤 Sending Inngest event: app/user.created for', email);
await inngest.send({
name: 'app/user.created',
data: { email, name: fullName, country, investmentGoals, riskTolerance, preferredIndustry }
});
console.log('✅ Inngest event sent successfully');
} catch (error) {
console.error('❌ Failed to send Inngest event:', error);
// Don't fail signup if email fails
}
}
return { success: true, data: response }

View File

@@ -25,7 +25,7 @@ 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;
const token = NEXT_PUBLIC_FINNHUB_API_KEY;
if (!token) {
throw new Error('FINNHUB API key is not configured');
}
@@ -100,7 +100,7 @@ export async function getNews(symbols?: string[]): Promise<MarketNewsArticle[]>
export const searchStocks = cache(async (query?: string): Promise<StockWithWatchlistStatus[]> => {
try {
const token = process.env.FINNHUB_API_KEY ?? NEXT_PUBLIC_FINNHUB_API_KEY;
const token = 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'));

View File

@@ -2,5 +2,7 @@ import {Inngest} from "inngest"
export const inngest = new Inngest({
id: "openStock",
ai: {gemini: {apiKey: process.env.GEMINI_API_KEY}}
ai: {gemini: {apiKey: process.env.GEMINI_API_KEY}},
// Add signing key for Vercel deployment
signingKey: process.env.INNGEST_SIGNING_KEY,
})

View File

@@ -33,12 +33,20 @@ export const sendSignUpEmail = inngest.createFunction(
})
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. You now have the tools to track markets and make smarter moves.'
try {
const part = response.candidates?.[0]?.content?.parts?.[0];
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 });
console.log(`📧 Attempting to send welcome email to: ${email}`);
const result = await sendWelcomeEmail({ email, name, intro: introText });
console.log(`✅ Welcome email sent successfully to: ${email}`);
return result;
} catch (error) {
console.error('❌ Error sending welcome email:', error);
throw error;
}
})
return {
@@ -106,13 +114,28 @@ export const sendDailyNewsSummary = inngest.createFunction(
// Step #4: (placeholder) Send the emails
await step.run('send-news-emails', async () => {
await Promise.all(
const results = await Promise.allSettled(
userNewsSummaries.map(async ({ user, newsContent}) => {
if(!newsContent) return false;
if(!newsContent) {
console.log(`⏭️ Skipping email for ${user.email} - no news content`);
return false;
}
return await sendNewsSummaryEmail({ email: user.email, date: getFormattedTodayDate(), newsContent })
try {
console.log(`📧 Attempting to send news summary email to: ${user.email}`);
const result = await sendNewsSummaryEmail({ email: user.email, date: getFormattedTodayDate(), newsContent });
console.log(`✅ News summary email sent successfully to: ${user.email}`);
return result;
} catch (error) {
console.error(`❌ Failed to send news summary email to ${user.email}:`, error);
throw error;
}
})
)
);
const successful = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
console.log(`📊 Email sending summary: ${successful} successful, ${failed} failed`);
})
return { success: true, message: 'Daily news summary emails sent successfully' }

View File

@@ -1,44 +1,84 @@
import nodemailer from 'nodemailer';
import {WELCOME_EMAIL_TEMPLATE, NEWS_SUMMARY_EMAIL_TEMPLATE} from "@/lib/nodemailer/templates";
// Verify transporter configuration
if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) {
console.warn('⚠️ NODEMAILER_EMAIL or NODEMAILER_PASSWORD is not set. Email functionality will not work.');
}
export const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.NODEMAILER_EMAIL!,
pass: process.env.NODEMAILER_PASSWORD!,
}
},
// Add connection timeout and retry options
pool: true,
maxConnections: 1,
maxMessages: 3,
})
export const sendWelcomeEmail = async ({ email, name, intro }: WelcomeEmailData) => {
const htmlTemplate = WELCOME_EMAIL_TEMPLATE
.replace('{{name}}', name)
.replace('{{intro}}', intro);
const mailOptions = {
from: `"Openstock" <opendevsociety@gmail.com>`,
to: email,
subject: `Welcome to Openstock - your open-source stock market toolkit!`,
text: 'Thanks for joining Openstock, an initiative by open dev society',
html: htmlTemplate,
// Verify connection on startup
transporter.verify((error, success) => {
if (error) {
console.error('❌ Nodemailer transporter verification failed:', error);
} else {
console.log('✅ Nodemailer transporter is ready to send emails');
}
});
await transporter.sendMail(mailOptions);
export const sendWelcomeEmail = async ({ email, name, intro }: WelcomeEmailData) => {
try {
if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) {
throw new Error('Email credentials not configured');
}
const htmlTemplate = WELCOME_EMAIL_TEMPLATE
.replace('{{name}}', name)
.replace('{{intro}}', intro);
const mailOptions = {
from: `"Openstock" <${process.env.NODEMAILER_EMAIL}>`,
to: email,
subject: `Welcome to Openstock - your open-source stock market toolkit!`,
text: 'Thanks for joining Openstock, an initiative by open dev society',
html: htmlTemplate,
}
const info = await transporter.sendMail(mailOptions);
console.log('✅ Welcome email sent successfully:', info.messageId);
return info;
} catch (error) {
console.error('❌ Failed to send welcome email:', error);
throw error;
}
}
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);
) => {
try {
if (!process.env.NODEMAILER_EMAIL || !process.env.NODEMAILER_PASSWORD) {
throw new Error('Email credentials not configured');
}
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,
};
const htmlTemplate = NEWS_SUMMARY_EMAIL_TEMPLATE
.replace('{{date}}', date)
.replace('{{newsContent}}', newsContent);
await transporter.sendMail(mailOptions);
const mailOptions = {
from: `"Openstock" <${process.env.NODEMAILER_EMAIL}>`,
to: email,
subject: `📈 Market News Summary Today - ${date}`,
text: `Today's market news summary from Openstock`,
html: htmlTemplate,
};
const info = await transporter.sendMail(mailOptions);
console.log('✅ News summary email sent successfully:', info.messageId);
return info;
} catch (error) {
console.error('❌ Failed to send news summary email:', error);
throw error;
}
};

1345
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@
"cmdk": "^1.1.1",
"country-data-list": "^1.5.5",
"dotenv": "^17.2.3",
"inngest": "^3.44.0",
"inngest": "^3.47.0",
"lucide-react": "^0.544.0",
"mongodb": "^6.20.0",
"mongoose": "^8.19.0",

112
scripts/check-env.mjs Normal file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* Environment Variables Checker
* Run: node scripts/check-env.mjs
*/
const requiredVars = {
// Core
'NODE_ENV': 'development or production',
// Database
'MONGODB_URI': 'MongoDB connection string',
// Better Auth
'BETTER_AUTH_SECRET': 'Secret key for Better Auth',
'BETTER_AUTH_URL': 'Auth URL (e.g., http://localhost:3000)',
// Finnhub
'NEXT_PUBLIC_FINNHUB_API_KEY': 'Finnhub API key (public)',
'FINNHUB_BASE_URL': 'Finnhub API base URL',
// Inngest
'GEMINI_API_KEY': 'Google Gemini API key',
'INNGEST_SIGNING_KEY': 'Inngest signing key (for Vercel)',
// Email
'NODEMAILER_EMAIL': 'Gmail address for sending emails',
'NODEMAILER_PASSWORD': 'Gmail app password (not regular password)',
};
const optionalVars = {
'FINNHUB_API_KEY': 'Legacy Finnhub key (deprecated, use NEXT_PUBLIC_FINNHUB_API_KEY)',
};
console.log('🔍 Checking Environment Variables...\n');
console.log('='.repeat(60));
let missing = [];
let present = [];
let warnings = [];
// Check required variables
for (const [key, description] of Object.entries(requiredVars)) {
const value = process.env[key];
if (!value || value.trim() === '') {
missing.push({ key, description });
} else {
present.push({ key, description, value: maskValue(value) });
}
}
// Check optional variables
for (const [key, description] of Object.entries(optionalVars)) {
const value = process.env[key];
if (value) {
warnings.push({ key, description, message: 'This variable is deprecated or optional' });
}
}
// Display results
console.log('\n✅ Present Variables:');
console.log('-'.repeat(60));
if (present.length === 0) {
console.log(' None found');
} else {
present.forEach(({ key, description, value }) => {
console.log(`${key}`);
console.log(` ${description}`);
console.log(` Value: ${value}\n`);
});
}
if (missing.length > 0) {
console.log('\n❌ Missing Variables:');
console.log('-'.repeat(60));
missing.forEach(({ key, description }) => {
console.log(`${key}`);
console.log(` ${description}\n`);
});
}
if (warnings.length > 0) {
console.log('\n⚠ Warnings:');
console.log('-'.repeat(60));
warnings.forEach(({ key, message }) => {
console.log(`${key}: ${message}\n`);
});
}
// Summary
console.log('\n' + '='.repeat(60));
console.log(`Summary: ${present.length}/${Object.keys(requiredVars).length} required variables present`);
if (missing.length > 0) {
console.log(`\n⚠️ Missing ${missing.length} required variable(s).`);
console.log('\nTo fix:');
console.log('1. Create a .env file in the project root');
console.log('2. Add the missing variables');
console.log('3. For Vercel: Add these in Project Settings > Environment Variables');
process.exit(1);
} else {
console.log('\n✅ All required environment variables are set!');
}
// Helper function to mask sensitive values
function maskValue(value) {
if (value.length <= 8) {
return '***';
}
return value.substring(0, 4) + '***' + value.substring(value.length - 4);
}