version 1
the reminder not showing content on the card at dashboard view and the quizes are hardcoded must move to the db make api's for it
This commit is contained in:
418
frontend/src/App.jsx
Normal file
418
frontend/src/App.jsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { Bell, DollarSign, Home, Calendar, ChevronRight, Building, CreditCard, MessageSquare, LogOut, FileText } from 'lucide-react';
|
||||
import { supabase } from './supabaseClient';
|
||||
import { Auth } from '@supabase/auth-ui-react';
|
||||
import { ThemeSupa } from '@supabase/auth-ui-shared';
|
||||
import ReminderWidget from './components/ReminderWidget';
|
||||
import ReminderPage from './pages/ReminderPage'
|
||||
import QuizPage from './pages/QuizPage';
|
||||
import ExpenseTracker from './pages/ExpenceTracker';
|
||||
import { PlaidProvider } from './PlaidProvider';
|
||||
import { usePlaid } from './PlaidProvider'; // Import the usePlaid hook
|
||||
|
||||
|
||||
|
||||
|
||||
// Error Boundary Component
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-4 text-red-500">
|
||||
<h2>Something went wrong.</h2>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth hook
|
||||
const useAuth = () => {
|
||||
const [session, setSession] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
session,
|
||||
loading,
|
||||
isAuthenticated: !!session,
|
||||
};
|
||||
};
|
||||
|
||||
// Login Component
|
||||
const Login = () => {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md w-96">
|
||||
<h2 className="text-2xl font-bold mb-6 text-center">Welcome</h2>
|
||||
<Auth
|
||||
supabaseClient={supabase}
|
||||
appearance={{ theme: ThemeSupa }}
|
||||
providers={[]}
|
||||
theme="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardContent = () => {
|
||||
const navigate = useNavigate();
|
||||
const { accounts, transactions, loading } = usePlaid(); // Use the Plaid context
|
||||
const [reminders, setReminders] = useState([]);
|
||||
|
||||
// useEffect(() => {
|
||||
// const fetchReminders = async () => {
|
||||
// const { data, error } = await supabase
|
||||
// .from('reminders')
|
||||
// .select('*')
|
||||
// .order('due_date', { ascending: true });
|
||||
|
||||
// if (data) setReminders(data);
|
||||
// };
|
||||
|
||||
// fetchReminders();
|
||||
// }, []);
|
||||
|
||||
// Process Plaid data for charts
|
||||
const processChartData = () => {
|
||||
if (!transactions?.length) return [];
|
||||
|
||||
// Group transactions by month
|
||||
const monthlyData = transactions.reduce((acc, transaction) => {
|
||||
const month = transaction.date.substring(0, 7); // Get YYYY-MM
|
||||
if (!acc[month]) {
|
||||
acc[month] = { month, income: 0, spending: 0, savings: 0 };
|
||||
}
|
||||
|
||||
// Amount > 0 is spending, < 0 is income in Plaid
|
||||
if (transaction.amount > 0) {
|
||||
acc[month].spending += transaction.amount;
|
||||
} else {
|
||||
acc[month].income += Math.abs(transaction.amount);
|
||||
}
|
||||
|
||||
// Calculate savings
|
||||
acc[month].savings = acc[month].income - acc[month].spending;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Convert to array and sort by month
|
||||
return Object.values(monthlyData).sort((a, b) => a.month.localeCompare(b.month))
|
||||
.map(data => ({
|
||||
...data,
|
||||
month: new Date(data.month + '-01').toLocaleDateString('en-US', { month: 'short' })
|
||||
}));
|
||||
};
|
||||
|
||||
const chartData = processChartData();
|
||||
|
||||
// Show loading state if Plaid data is still loading
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-full h-64 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-6">
|
||||
{/* Financial Overview Graphs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-7xl mx-auto">
|
||||
{/* Income Graph */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">Income</h3>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis tickFormatter={(value) => `$${value.toLocaleString()}`} />
|
||||
<Tooltip
|
||||
formatter={(value) => [`$${value.toLocaleString()}`, 'Income']}
|
||||
labelFormatter={(label) => `Month: ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
stroke="#4CAF50"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Spending Graph */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">Spending</h3>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis tickFormatter={(value) => `$${value.toLocaleString()}`} />
|
||||
<Tooltip
|
||||
formatter={(value) => [`$${value.toLocaleString()}`, 'Spending']}
|
||||
labelFormatter={(label) => `Month: ${label}`}
|
||||
/>
|
||||
<Bar dataKey="spending" fill="#FF5722" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Savings Graph */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-semibold">Savings</h3>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="month" />
|
||||
<YAxis tickFormatter={(value) => `$${value.toLocaleString()}`} />
|
||||
<Tooltip
|
||||
formatter={(value) => [`$${value.toLocaleString()}`, 'Savings']}
|
||||
labelFormatter={(label) => `Month: ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="savings"
|
||||
stroke="#2196F3"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-8">
|
||||
<ReminderWidget/>
|
||||
{/* Apartment Finder Card */}
|
||||
|
||||
|
||||
{/* Expense Tracker Card */}
|
||||
<div
|
||||
onClick={() => navigate('/expenses')}
|
||||
className="bg-white rounded-lg shadow transition-all hover:shadow-lg cursor-pointer p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-green-100 p-3 rounded-lg">
|
||||
<DollarSign className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Track Expenses</h3>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-600">Monitor your spending and manage your budget</p>
|
||||
</div>
|
||||
|
||||
{/* Quizzes Card */}
|
||||
<div
|
||||
onClick={() => navigate('/quizzes')}
|
||||
className="bg-white rounded-lg shadow transition-all hover:shadow-lg cursor-pointer p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-orange-100 p-3 rounded-lg">
|
||||
<FileText className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Financial Quizzes</h3>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-600">Test your financial knowledge and learn new skills</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div>
|
||||
<footer className="bg-gray-100 mt-12 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Company Info Column */}
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">FinTrack</h4>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Your personal financial management and tracking solution.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div></div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
// Layout Component with Navigation Context
|
||||
const DashboardLayout = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const [userName, setUserName] = useState('');
|
||||
|
||||
const handleLogout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getUser = async () => {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user?.email) {
|
||||
const name = user.email.split('@')[0];
|
||||
setUserName(name.charAt(0).toUpperCase() + name.slice(1));
|
||||
}
|
||||
};
|
||||
getUser();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Fixed Navbar */}
|
||||
<div className="fixed top-0 left-0 right-0 bg-white shadow z-10">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo and Brand */}
|
||||
<div className="flex items-center">
|
||||
<DollarSign className="h-8 w-8 text-green-600" />
|
||||
<span className="ml-2 text-xl font-bold text-gray-900"></span>
|
||||
</div>
|
||||
|
||||
{/* User Info and Logout */}
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-gray-600">Hi, {userName}</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 text-red-500 hover:text-red-600 flex items-center gap-2"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content with top padding for navbar */}
|
||||
<div className="max-w-7xl mx-auto px-4 pt-20">
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : <Navigate to="/login" />;
|
||||
};
|
||||
|
||||
// Main App Component
|
||||
const App = () => {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900">
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ErrorBoundary>
|
||||
<PlaidProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/dashboard" /> : <Login />}
|
||||
/>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" />} />
|
||||
<Route
|
||||
path="/reminders"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ReminderPage />
|
||||
</ProtectedRoute>
|
||||
}/>
|
||||
<Route
|
||||
path="/expenses"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ExpenseTracker />
|
||||
</ProtectedRoute>
|
||||
}/>
|
||||
<Route
|
||||
path="/quizzes"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<QuizPage />
|
||||
</ProtectedRoute>
|
||||
}/>
|
||||
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardLayout>
|
||||
<DashboardContent />
|
||||
</DashboardLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Other routes */}
|
||||
</Routes>
|
||||
</PlaidProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
151
frontend/src/PlaidProvider.jsx
Normal file
151
frontend/src/PlaidProvider.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
const PlaidContext = createContext();
|
||||
|
||||
export const PlaidProvider = ({ children }) => {
|
||||
const [linkToken, setLinkToken] = useState(null);
|
||||
const [accessToken, setAccessToken] = useState(null);
|
||||
const [accounts, setAccounts] = useState([]);
|
||||
const [transactions, setTransactions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Check for existing access token on mount
|
||||
useEffect(() => {
|
||||
checkExistingConnection();
|
||||
}, []);
|
||||
|
||||
const checkExistingConnection = async () => {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
|
||||
// Check if user has an access token in Supabase
|
||||
const { data: plaidData, error } = await supabase
|
||||
.from('plaid_connections')
|
||||
.select('access_token')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (plaidData?.access_token) {
|
||||
setAccessToken(plaidData.access_token);
|
||||
await fetchTransactions(plaidData.access_token);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('Error checking existing connection:', err);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initializePlaidLink = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Get the current user
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('No user logged in');
|
||||
|
||||
// Create link token
|
||||
const response = await fetch('http://localhost:3000/api/create_link_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId: user.id })
|
||||
});
|
||||
const data = await response.json();
|
||||
setLinkToken(data.link_token);
|
||||
|
||||
// Load Plaid script
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.plaid.com/link/v2/stable/link-initialize.js';
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
const handler = window.Plaid.create({
|
||||
token: data.link_token,
|
||||
onSuccess: async (public_token, metadata) => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/api/exchange_public_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ public_token })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Save access token to Supabase
|
||||
await supabase
|
||||
.from('plaid_connections')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
access_token: data.access_token,
|
||||
item_id: data.item_id
|
||||
});
|
||||
|
||||
setAccessToken(data.access_token);
|
||||
await fetchTransactions(data.access_token);
|
||||
resolve();
|
||||
} catch (err) {
|
||||
setError('Failed to connect to bank');
|
||||
reject(err);
|
||||
}
|
||||
},
|
||||
onExit: (err, metadata) => {
|
||||
if (err) setError('Connection process interrupted');
|
||||
setLoading(false);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
handler.open();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
} catch (err) {
|
||||
setError('Failed to initialize bank connection');
|
||||
setLoading(false);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTransactions = async (token) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('http://localhost:3000/api/transactions', {
|
||||
headers: { 'plaid-access-token': token || accessToken }
|
||||
});
|
||||
const data = await response.json();
|
||||
setTransactions(data.transactions);
|
||||
setAccounts(data.accounts || []);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch transactions');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {
|
||||
linkToken,
|
||||
accessToken,
|
||||
accounts,
|
||||
transactions,
|
||||
loading,
|
||||
error,
|
||||
initializePlaidLink,
|
||||
fetchTransactions,
|
||||
};
|
||||
|
||||
return (
|
||||
<PlaidContext.Provider value={value}>
|
||||
{children}
|
||||
</PlaidContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePlaid = () => {
|
||||
const context = useContext(PlaidContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('usePlaid must be used within a PlaidProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
81
frontend/src/components/ReminderWidget.jsx
Normal file
81
frontend/src/components/ReminderWidget.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// ReminderWidget.jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, ChevronRight } from 'lucide-react';
|
||||
import { supabase } from '../supabaseClient';
|
||||
|
||||
const ReminderWidget = () => {
|
||||
const navigate = useNavigate();
|
||||
const [reminders, setReminders] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReminders();
|
||||
}, []);
|
||||
|
||||
const fetchReminders = async () => {
|
||||
const today = new Date();
|
||||
const thirtyDaysFromNow = new Date();
|
||||
thirtyDaysFromNow.setDate(today.getDate() + 30);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('reminders')
|
||||
.select('*')
|
||||
.gte('due_date', today.toISOString().split('T')[0])
|
||||
.lte('due_date', thirtyDaysFromNow.toISOString().split('T')[0])
|
||||
.order('due_date', { ascending: true })
|
||||
.limit(5);
|
||||
|
||||
if (data) setReminders(data);
|
||||
};
|
||||
|
||||
const totalUpcoming = reminders.reduce((sum, reminder) => sum + parseFloat(reminder.amount), 0);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-purple-500" />
|
||||
<h3 className="text-lg font-semibold">Upcoming Payments</h3>
|
||||
</div>
|
||||
<div className="text-lg font-bold text-purple-600">
|
||||
${totalUpcoming.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{reminders.map((reminder) => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between p-3 bg-purple-50 rounded-lg cursor-pointer hover:bg-purple-100"
|
||||
onClick={() => navigate('/reminders')}
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{reminder.title}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Due: {new Date(reminder.due_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">${parseFloat(reminder.amount).toFixed(2)}</span>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{reminders.length === 0 && (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
No upcoming payments
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate('/reminders')}
|
||||
className="w-full mt-4 text-purple-600 hover:text-purple-700 font-medium flex items-center justify-center gap-2"
|
||||
>
|
||||
View All Reminders
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReminderWidget;
|
||||
5
frontend/src/index.css
Normal file
5
frontend/src/index.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
|
||||
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
204
frontend/src/pages/ExpenceTracker.jsx
Normal file
204
frontend/src/pages/ExpenceTracker.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { usePlaid } from '../PlaidProvider';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { DollarSign, ChevronLeft } from 'lucide-react';
|
||||
|
||||
const ExpenseTracker = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
accessToken,
|
||||
accounts,
|
||||
transactions,
|
||||
loading,
|
||||
error,
|
||||
initializePlaidLink,
|
||||
fetchTransactions
|
||||
} = usePlaid();
|
||||
|
||||
// Process transactions for chart data
|
||||
const processTransactionsForChart = (transactions) => {
|
||||
const sortedTransactions = [...transactions].sort((a, b) =>
|
||||
new Date(a.date) - new Date(b.date)
|
||||
).map(transaction => ({
|
||||
date: new Date(transaction.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}),
|
||||
amount: transaction.amount,
|
||||
name: transaction.merchant_name || transaction.name,
|
||||
category: transaction.category ? transaction.category[0] : 'Uncategorized'
|
||||
}));
|
||||
|
||||
return sortedTransactions;
|
||||
};
|
||||
|
||||
const chartData = processTransactionsForChart(transactions);
|
||||
|
||||
const CustomTooltip = ({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="bg-white p-4 shadow-lg rounded border">
|
||||
<p className="text-sm text-gray-600">{data.date}</p>
|
||||
<p className="font-bold">{data.name}</p>
|
||||
<p className={`font-medium ${data.amount > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
${Math.abs(data.amount).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{data.category}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
<p className="text-gray-600">Loading your financial data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 space-y-4">
|
||||
<h2 className="text-2xl font-bold">Connect Your Bank Account</h2>
|
||||
<button
|
||||
onClick={initializePlaidLink}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Connect Account
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation Bar */}
|
||||
<div className="fixed top-0 left-0 right-0 bg-white shadow z-10">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center h-16">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg flex items-center gap-2 text-gray-600"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div className="flex items-center ml-4">
|
||||
<DollarSign className="h-6 w-6 text-green-600" />
|
||||
<span className="ml-2 text-xl font-bold text-gray-900">Expenses</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="container mx-auto px-4 pt-20">
|
||||
{/* Account Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
{accounts.map(account => (
|
||||
<div key={account.account_id} className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold">{account.name}</h3>
|
||||
<p className="text-gray-600">{account.subtype}</p>
|
||||
<p className="text-xl mt-2">
|
||||
${account.balances.current.toFixed(2)}
|
||||
{account.balances.available && (
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
(${account.balances.available.toFixed(2)} available)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Transaction Chart */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h3 className="text-xl font-semibold mb-4">Transaction History</h3>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<LineChart data={chartData} margin={{ bottom: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ angle: -45 }}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(value) => `$${Math.abs(value).toLocaleString()}`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
stroke="#8884d8"
|
||||
dot={{
|
||||
stroke: (entry) => entry.amount > 0 ? '#ef4444' : '#22c55e',
|
||||
fill: (entry) => entry.amount > 0 ? '#ef4444' : '#22c55e',
|
||||
r: 4
|
||||
}}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Transactions Table */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden mb-6">
|
||||
<div className="flex justify-between items-center p-6 border-b">
|
||||
<h3 className="text-xl font-semibold">Recent Transactions</h3>
|
||||
<button
|
||||
onClick={fetchTransactions}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{transactions.map(transaction => (
|
||||
<tr key={transaction.transaction_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(transaction.date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{transaction.merchant_name || transaction.name}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{transaction.category ? transaction.category[0] : 'Uncategorized'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<span className={`text-sm font-medium ${
|
||||
transaction.amount > 0 ? 'text-red-600' : 'text-green-600'
|
||||
}`}>
|
||||
${Math.abs(transaction.amount).toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpenseTracker;
|
||||
379
frontend/src/pages/QuizPage.jsx
Normal file
379
frontend/src/pages/QuizPage.jsx
Normal file
@@ -0,0 +1,379 @@
|
||||
// QuizPage.jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, DollarSign, BookOpen, Award, Check, X } from 'lucide-react';
|
||||
import { supabase } from '../supabaseClient';
|
||||
|
||||
const QuizPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentQuiz, setCurrentQuiz] = useState(null);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [selectedAnswer, setSelectedAnswer] = useState(null);
|
||||
const [score, setScore] = useState(0);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const [quizStarted, setQuizStarted] = useState(false);
|
||||
|
||||
// Sample quiz data - Replace with Supabase data
|
||||
const quizzes = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Banking & Finance Essentials",
|
||||
description: "Understand the key concepts of personal finance and banking.",
|
||||
questions: [
|
||||
{
|
||||
question: "What is compound interest?",
|
||||
options: [
|
||||
"Interest earned on the initial deposit only",
|
||||
"Interest earned on both the initial deposit and previously earned interest",
|
||||
"A fee charged by banks for loans",
|
||||
"A one-time interest payment on savings"
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: "Compound interest is calculated on both the principal amount and accumulated interest over time."
|
||||
},
|
||||
{
|
||||
question: "Which of the following negatively impacts your credit score?",
|
||||
options: [
|
||||
"Paying credit card bills on time",
|
||||
"Having multiple credit cards",
|
||||
"Missing loan payments",
|
||||
"Checking your credit score"
|
||||
],
|
||||
correctAnswer: 2,
|
||||
explanation: "Missing loan or credit payments lowers your credit score as it reflects poor financial responsibility."
|
||||
},
|
||||
{
|
||||
question: "What is the primary purpose of a credit score?",
|
||||
options: [
|
||||
"To determine your annual tax rate",
|
||||
"To assess your trustworthiness as a borrower",
|
||||
"To calculate your net worth",
|
||||
"To set your bank account interest rate"
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: "A credit score helps lenders assess your creditworthiness before approving loans or credit cards."
|
||||
},
|
||||
{
|
||||
question: "Which financial tool helps you save money on unnecessary expenses?",
|
||||
options: [
|
||||
"A budgeting app",
|
||||
"A credit card",
|
||||
"A payday loan",
|
||||
"A lottery ticket"
|
||||
],
|
||||
correctAnswer: 0,
|
||||
explanation: "Budgeting apps help track expenses and reduce unnecessary spending."
|
||||
},
|
||||
{
|
||||
question: "What is an overdraft fee?",
|
||||
options: [
|
||||
"A fee for depositing money in your bank",
|
||||
"A penalty for withdrawing more money than you have in your account",
|
||||
"A charge for opening a new bank account",
|
||||
"A bonus for using your debit card frequently"
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: "An overdraft fee is charged when you spend more than what is available in your account."
|
||||
},
|
||||
{
|
||||
question: "What does APR stand for?",
|
||||
options: [
|
||||
"Annual Percentage Rate",
|
||||
"Average Payment Ratio",
|
||||
"Automated Payment Record",
|
||||
"Account Processing Report"
|
||||
],
|
||||
correctAnswer: 0,
|
||||
explanation: "APR (Annual Percentage Rate) represents the cost of borrowing, including interest and fees."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Insurance & Healthcare Knowledge",
|
||||
description: "Learn about insurance and healthcare essentials.",
|
||||
questions: [
|
||||
{
|
||||
question: "Which type of insurance helps cover the cost of medical expenses?",
|
||||
options: [
|
||||
"Home insurance",
|
||||
"Health insurance",
|
||||
"Auto insurance",
|
||||
"Travel insurance"
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: "Health insurance covers medical expenses, including doctor visits, hospital stays, and medications."
|
||||
},
|
||||
{
|
||||
question: "What is a deductible in an insurance policy?",
|
||||
options: [
|
||||
"A discount offered for early payments",
|
||||
"The amount you pay out-of-pocket before insurance kicks in",
|
||||
"The total coverage amount of an insurance policy",
|
||||
"The fine print in an insurance contract"
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: "A deductible is the amount you must pay before your insurance starts covering expenses."
|
||||
},
|
||||
{
|
||||
question: "What does ‘premium’ mean in an insurance policy?",
|
||||
options: [
|
||||
"The total amount covered by the policy",
|
||||
"The amount you pay for insurance coverage, usually monthly or yearly",
|
||||
"A special discount given to policyholders",
|
||||
"A type of investment plan"
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: "A premium is the regular payment you make to keep your insurance policy active."
|
||||
},
|
||||
{
|
||||
question: "Which of the following is NOT typically covered by standard health insurance?",
|
||||
options: [
|
||||
"Doctor visits",
|
||||
"Prescription medication",
|
||||
"Cosmetic surgery",
|
||||
"Emergency room visits"
|
||||
],
|
||||
correctAnswer: 2,
|
||||
explanation: "Cosmetic surgery is usually not covered unless it is medically necessary."
|
||||
},
|
||||
{
|
||||
question: "What does life insurance primarily provide?",
|
||||
options: [
|
||||
"Coverage for medical expenses",
|
||||
"Financial support for dependents after the policyholder’s death",
|
||||
"Investment opportunities",
|
||||
"Legal protection against lawsuits"
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: "Life insurance provides financial security to the policyholder’s beneficiaries in the event of death."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Food Safety & Consumer Awareness",
|
||||
description: "Stay informed about food safety and smart consumer choices.",
|
||||
questions: [
|
||||
{
|
||||
question: "Which of the following should you NOT do to prevent food poisoning?",
|
||||
options: [
|
||||
"Wash your hands before preparing food",
|
||||
"Leave perishable food at room temperature for hours",
|
||||
"Store raw meat separately from cooked food",
|
||||
"Cook meat to the proper internal temperature"
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: "Leaving perishable food at room temperature can lead to bacterial growth and foodborne illnesses."
|
||||
},
|
||||
{
|
||||
question: "What does the expiration date on food packaging indicate?",
|
||||
options: [
|
||||
"The last day the food should be sold",
|
||||
"The date after which the food is unsafe to eat",
|
||||
"The best quality before a certain date, but it may still be safe after",
|
||||
"A suggestion for when to buy new food"
|
||||
],
|
||||
correctAnswer: 2,
|
||||
explanation: "Expiration dates often indicate peak freshness, but many foods are still safe to consume after this date."
|
||||
},
|
||||
{
|
||||
question: "Which of the following is the safest way to thaw frozen food?",
|
||||
options: [
|
||||
"Leaving it on the kitchen counter",
|
||||
"Placing it in the refrigerator overnight",
|
||||
"Soaking it in hot water",
|
||||
"Leaving it under direct sunlight"
|
||||
],
|
||||
correctAnswer: 1,
|
||||
explanation: "Refrigerator thawing is the safest method as it prevents bacterial growth."
|
||||
},
|
||||
{
|
||||
question: "What temperature should poultry be cooked to in order to ensure food safety?",
|
||||
options: [
|
||||
"120°F (49°C)",
|
||||
"145°F (63°C)",
|
||||
"165°F (74°C)",
|
||||
"200°F (93°C)"
|
||||
],
|
||||
correctAnswer: 2,
|
||||
explanation: "Poultry should be cooked to at least 165°F (74°C) to kill harmful bacteria."
|
||||
},
|
||||
{
|
||||
question: "Which of these common kitchen habits increases the risk of cross-contamination?",
|
||||
options: [
|
||||
"Using separate cutting boards for meat and vegetables",
|
||||
"Washing hands after handling raw food",
|
||||
"Using the same knife for raw chicken and fresh vegetables without washing it",
|
||||
"Refrigerating leftovers within two hours"
|
||||
],
|
||||
correctAnswer: 2,
|
||||
explanation: "Using the same knife for raw meat and vegetables without washing it can transfer harmful bacteria."
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const handleStartQuiz = () => {
|
||||
setCurrentQuiz(quizzes[0]);
|
||||
setQuizStarted(true);
|
||||
setCurrentQuestionIndex(0);
|
||||
setScore(0);
|
||||
setShowResult(false);
|
||||
};
|
||||
|
||||
const handleAnswerSelect = (answerIndex) => {
|
||||
setSelectedAnswer(answerIndex);
|
||||
};
|
||||
|
||||
const handleNextQuestion = () => {
|
||||
// Calculate score
|
||||
if (selectedAnswer === currentQuiz.questions[currentQuestionIndex].correctAnswer) {
|
||||
setScore(score + 1);
|
||||
}
|
||||
|
||||
// Move to next question or show results
|
||||
if (currentQuestionIndex + 1 < currentQuiz.questions.length) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1);
|
||||
setSelectedAnswer(null);
|
||||
} else {
|
||||
setShowResult(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Navigation Bar */}
|
||||
<div className="fixed top-0 left-0 right-0 bg-white shadow z-10">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center h-16">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg flex items-center gap-2 text-gray-600"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div className="flex items-center ml-4">
|
||||
<BookOpen className="h-6 w-6 text-purple-600" />
|
||||
<span className="ml-2 text-xl font-bold text-gray-900">Financial Education</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-4xl mx-auto px-4 pt-20">
|
||||
{!quizStarted ? (
|
||||
// Quiz Selection Screen
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold mb-2">Financial Independence Quiz</h1>
|
||||
<p className="text-gray-600">Test your knowledge and learn about personal finance</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4 p-4 bg-purple-50 rounded-lg">
|
||||
<div className="bg-purple-100 p-3 rounded-lg">
|
||||
<Award className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Budgeting Basics</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">Learn the fundamentals of personal budgeting and financial planning</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleStartQuiz}
|
||||
className="w-full bg-purple-600 text-white rounded-lg py-3 font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Start Quiz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : showResult ? (
|
||||
// Results Screen
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
{score === currentQuiz.questions.length ? (
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Award className="h-10 w-10 text-green-600" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-purple-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<BookOpen className="h-10 w-10 text-purple-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">Quiz Completed!</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You scored {score} out of {currentQuiz.questions.length}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setQuizStarted(false)}
|
||||
className="bg-purple-600 text-white rounded-lg px-6 py-2 font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Try Another Quiz
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Quiz Questions Screen
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">{currentQuiz.title}</h2>
|
||||
<span className="text-sm text-gray-600">
|
||||
Question {currentQuestionIndex + 1} of {currentQuiz.questions.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all duration-300"
|
||||
style={{ width: `${((currentQuestionIndex + 1) / currentQuiz.questions.length) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium">
|
||||
{currentQuiz.questions[currentQuestionIndex].question}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{currentQuiz.questions[currentQuestionIndex].options.map((option, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleAnswerSelect(index)}
|
||||
className={`w-full p-4 text-left rounded-lg border-2 transition-all ${
|
||||
selectedAnswer === index
|
||||
? 'border-purple-600 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNextQuestion}
|
||||
disabled={selectedAnswer === null}
|
||||
className={`w-full py-3 rounded-lg font-medium transition-colors ${
|
||||
selectedAnswer === null
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{currentQuestionIndex + 1 === currentQuiz.questions.length ? 'Finish Quiz' : 'Next Question'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizPage;
|
||||
392
frontend/src/pages/ReminderPage.jsx
Normal file
392
frontend/src/pages/ReminderPage.jsx
Normal file
@@ -0,0 +1,392 @@
|
||||
// ReminderPage.jsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DollarSign, Plus, X, Calendar, List, Pencil, Trash2, ChevronLeft } from 'lucide-react';
|
||||
import { supabase } from '../supabaseClient';
|
||||
|
||||
const ReminderPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [reminders, setReminders] = useState([]);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('list');
|
||||
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||
const [editingReminder, setEditingReminder] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
due_date: '',
|
||||
amount: '',
|
||||
description: '',
|
||||
category: 'bill'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchReminders();
|
||||
}, []);
|
||||
|
||||
const fetchReminders = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('reminders')
|
||||
.select('*')
|
||||
.order('due_date', { ascending: true });
|
||||
|
||||
if (data) setReminders(data);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
if (editingReminder) {
|
||||
const { error } = await supabase
|
||||
.from('reminders')
|
||||
.update({
|
||||
title: formData.title,
|
||||
due_date: formData.due_date,
|
||||
amount: parseFloat(formData.amount),
|
||||
description: formData.description,
|
||||
category: formData.category
|
||||
})
|
||||
.eq('id', editingReminder.id);
|
||||
|
||||
if (error) throw error;
|
||||
setEditingReminder(null);
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('reminders')
|
||||
.insert([{
|
||||
title: formData.title,
|
||||
due_date: formData.due_date,
|
||||
amount: parseFloat(formData.amount),
|
||||
description: formData.description,
|
||||
category: formData.category
|
||||
}]);
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
await fetchReminders();
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (reminder) => {
|
||||
setEditingReminder(reminder);
|
||||
setFormData({
|
||||
title: reminder.title,
|
||||
due_date: reminder.due_date.split('T')[0],
|
||||
amount: reminder.amount,
|
||||
description: reminder.description || '',
|
||||
category: reminder.category || 'bill'
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
const { error } = await supabase
|
||||
.from('reminders')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (!error) {
|
||||
await fetchReminders();
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
due_date: '',
|
||||
amount: '',
|
||||
description: '',
|
||||
category: 'bill'
|
||||
});
|
||||
setEditingReminder(null);
|
||||
};
|
||||
|
||||
const getDaysInMonth = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const firstDayOfMonth = new Date(year, month, 1).getDay();
|
||||
return { daysInMonth, firstDayOfMonth };
|
||||
};
|
||||
|
||||
const getRemindersForDate = (date) => {
|
||||
return reminders.filter(reminder => {
|
||||
const reminderDate = new Date(reminder.due_date);
|
||||
return reminderDate.toDateString() === date.toDateString();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* Navigation Bar */}
|
||||
<div className="fixed top-0 left-0 right-0 bg-white shadow z-10">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center h-16">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg flex items-center gap-2 text-gray-600"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<div className="flex items-center ml-4">
|
||||
<DollarSign className="h-6 w-6 text-green-600" />
|
||||
<span className="ml-2 text-xl font-bold text-gray-900">Money</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 pt-20">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Payment Reminders</h1>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === 'list' ? 'calendar' : 'list')}
|
||||
className="px-4 py-2 bg-white rounded-lg shadow flex items-center gap-2"
|
||||
>
|
||||
{viewMode === 'list' ? <Calendar size={20} /> : <List size={20} />}
|
||||
{viewMode === 'list' ? 'Calendar View' : 'List View'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setShowModal(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg shadow flex items-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add Reminder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List View */}
|
||||
{viewMode === 'list' && (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{reminders.map((reminder) => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
className="flex items-center justify-between p-4 border-b last:border-b-0"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-sm ${
|
||||
reminder.category === 'bill' ? 'bg-blue-100 text-blue-700' :
|
||||
reminder.category === 'subscription' ? 'bg-green-100 text-green-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{reminder.category}
|
||||
</span>
|
||||
<h3 className="font-semibold">{reminder.title}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Due: {new Date(reminder.due_date).toLocaleDateString()}
|
||||
</p>
|
||||
{reminder.description && (
|
||||
<p className="text-sm text-gray-500 mt-1">{reminder.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-semibold">${parseFloat(reminder.amount).toFixed(2)}</span>
|
||||
<button
|
||||
onClick={() => handleEdit(reminder)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-full"
|
||||
>
|
||||
<Pencil size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(reminder.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-full"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{reminders.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No reminders yet. Add one to get started!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar View */}
|
||||
{viewMode === 'calendar' && (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<div key={day} className="text-center font-semibold py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const { daysInMonth, firstDayOfMonth } = getDaysInMonth(selectedDate);
|
||||
const days = [];
|
||||
|
||||
for (let i = 0; i < firstDayOfMonth; i++) {
|
||||
days.push(
|
||||
<div key={`empty-${i}`} className="p-2 min-h-[100px]" />
|
||||
);
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), day);
|
||||
const dayReminders = getRemindersForDate(date);
|
||||
|
||||
days.push(
|
||||
<div
|
||||
key={day}
|
||||
className={`p-2 border rounded-lg min-h-[100px] ${
|
||||
date.toDateString() === new Date().toDateString()
|
||||
? 'bg-purple-50 border-purple-200'
|
||||
: 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold mb-2">{day}</div>
|
||||
<div className="space-y-1">
|
||||
{dayReminders.map(reminder => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
className="text-sm p-1 rounded bg-purple-100 text-purple-700 cursor-pointer"
|
||||
onClick={() => handleEdit(reminder)}
|
||||
>
|
||||
{reminder.title} - ${parseFloat(reminder.amount).toFixed(2)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return days;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-bold">
|
||||
{editingReminder ? 'Edit Reminder' : 'New Reminder'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
||||
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="bill">Bill</option>
|
||||
<option value="subscription">Subscription</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.due_date}
|
||||
onChange={(e) => setFormData({ ...formData, due_date: e.target.value })}
|
||||
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.amount}
|
||||
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full p-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
{editingReminder ? 'Save Changes' : 'Add Reminder'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReminderPage;
|
||||
6
frontend/src/supabaseClient.js
Normal file
6
frontend/src/supabaseClient.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = 'https://qtjqzwqaoribjbqapybe.supabase.co';
|
||||
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InF0anF6d3Fhb3JpYmpicWFweWJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Mzk2NDc3MTEsImV4cCI6MjA1NTIyMzcxMX0.UBSC6ylMp2x8dI_-aAo6u_-uhIq5rAVwUalDD-ykYH0';
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
Reference in New Issue
Block a user