diff --git a/backend/middleware/authMiddleware.js b/backend/middleware/authMiddleware.js new file mode 100644 index 0000000000..2c58ad1a81 --- /dev/null +++ b/backend/middleware/authMiddleware.js @@ -0,0 +1,22 @@ +import { User } from "../models/User.js" + + export const authenticateUser = async (req, res, next) => { + try { + const accessToken = req.header("Authorization") + const user = await User.findOne({ accessToken: accessToken }) + if (user) { + req.user = user + next() + } else { + res.status(401).json({ + message: "Authentication missing or invalid.", + loggedOut: true + }) + } + } catch (error) { + res.status(500).json({ + message: "Internal server error", + error: error.message + }); + } + } \ No newline at end of file diff --git a/backend/models/Income.js b/backend/models/Income.js new file mode 100644 index 0000000000..bb7a19def2 --- /dev/null +++ b/backend/models/Income.js @@ -0,0 +1,19 @@ +import mongoose from 'mongoose' + +const incomeSchema = new mongoose.Schema({ + amount: { + type: Number, + required: true, + default: 0, + }, + month: { + type: String, + required: true, + }, + createdAt: { + type: Date, + default: Date.now, + }, +}); + +export const Income = mongoose.model("Income", incomeSchema); \ No newline at end of file diff --git a/backend/models/Spendings.js b/backend/models/Spendings.js new file mode 100644 index 0000000000..71cb6658dd --- /dev/null +++ b/backend/models/Spendings.js @@ -0,0 +1,21 @@ +import mongoose from 'mongoose' + +const spendingsSchema = new mongoose.Schema({ + category: { + type: String, + required: true, + enum: ["Rent", "Parking", "Insurance", "Broadband", "Phone", "Grocery", "Shopping", "Entertainment", "Restaurants", "Cafe", "Travel", "Others"] +}, + amount: { + type: Number, + required: true, + default: 0, + }, + createdAt: { + type: Date, + default: Date.now, + }, +}); + + +export const Spendings = mongoose.model("Spendings", spendingsSchema); \ No newline at end of file diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 0000000000..92c7c31db9 --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,26 @@ +import mongoose from 'mongoose' +import crypto from 'crypto' + + +const userSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + }, + email: { + type: String, + required: true, + unique: true + }, + password: { + type: String, + required: true, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString('hex') + } +}); + + +export const User = mongoose.model("User", userSchema); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 08f29f2448..585ccb1439 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,6 +2,7 @@ "name": "project-final-backend", "version": "1.0.0", "description": "Server part of final project", + "type": "module", "scripts": { "start": "babel-node server.js", "dev": "nodemon server.js --exec babel-node" @@ -12,9 +13,13 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcryptjs": "^3.0.2", "cors": "^2.8.5", + "dotenv": "^17.2.1", "express": "^4.17.3", + "express-list-endpoints": "^7.1.1", + "jsonwebtoken": "^9.0.2", "mongoose": "^8.4.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/routes/incomeRoutes.js b/backend/routes/incomeRoutes.js new file mode 100644 index 0000000000..d4c6d8359b --- /dev/null +++ b/backend/routes/incomeRoutes.js @@ -0,0 +1,71 @@ +import express from "express"; +import { Income } from "../models/Income.js"; +import mongoose from "mongoose"; + +const router = express.Router(); + +// GET /income - Fetch the current income +router.get("/", async (req, res) => { + try { + let income = await Income.findOne(); + if (!income) { + income = await Income.create({ amount: 0 }); + } + res.status(200).json({ income: income.amount }); + } catch (error) { + console.error("Error fetching income:", error); + res.status(500).json({ error: "Server error" }); + } +}); + +// GET /income/months +router.get("/months", async (req, res) => { + try { + const months = await Income.find({}, '_id month amount createdAt').sort({ createdAt: -1 }); + res.json(months); + } catch (error) { + console.error("Error fetching months:", error); + res.status(500).json({ message: "Failed to fetch months"}); + } +}); + +// POST /income - Add a new income +router.post("/", async (req, res) => { + const { amount, month } = req.body; + + if (typeof amount !== "number" || amount < 0) { + return res.status(400).json({ error: "Invalid income value" }); + } + + if (!month || typeof month !== "string") { + return res.status(400).json({ error: "Invalid month value" }); + } + + try { + const newIncome = await Income.create({month, amount}); + res.status(201).json(newIncome); + } catch (error) { + res.status(400).json({ message: "Failed to add income", error: error.message }); + } +}); + +// DELETE /income - Delete the income +router.delete("/:id", async (req, res) => { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: "Invalid income ID" }); + } + try { + const deletedIncome = await Income.findByIdAndDelete(id); + if (!deletedIncome) { + return res.status(404).json({ message: "Income not found" }); + } + res.status(200).json({ message: "Income deleted successfully" }); + } catch (error) { + console.error("Error deleting income:", error.message, error.stack); + return res.status(500).json({ error: "Server error" }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/routes/spendingsRoutes.js b/backend/routes/spendingsRoutes.js new file mode 100644 index 0000000000..13311e39ac --- /dev/null +++ b/backend/routes/spendingsRoutes.js @@ -0,0 +1,57 @@ +import express from "express"; +import { Spendings } from "../models/Spendings.js"; + +const router = express.Router(); + +// POST - Add income +router.post("/", async (req, res) => { + const { category, amount } = req.body; + + try { + let newSpending = await Spendings.create({ category, amount}); + res.status(201).json(newSpending); + } catch (error) { + res.status(400).json({ message: "Failed to add spending", error: error.message }); + } +}); + +// GET - Get all spendings +router.get("/", async (req, res) => { + try { + const spendings = await Spendings.find(); + res.status(200).json(spendings); + } catch (error) { + res.status(500).json({ message: "Failed to fetch spendings", error: error.message }); + } +}); + +// GET - Get total spendings +router.get("/total", async (req, res) => { + try { + const total = await Spendings.aggregate([ + { $group: {_id: null, totalAmount: { $sum: "$amount" } } }, + ]); + console.log("Total Spendings Calculation:", total); + res.status(200).json({ total: total[0]?.totalAmount || 0 }); + } catch (error) { + console.error("Error Fetching Total Spendings:", error.message); + res.status(500).json({ message: "Failed to fetch total spendings", error: error.message }); + } +}); + +router.delete("/:id", async (req, res) => { + const { id } = req.params; + + try { + const deletedSpending = await Spendings.findByIdAndDelete(id); + if (!deletedSpending) { + return res.status(404).json({ message: "Spending not found" }); + } + res.status(200).json({ message: "Spending deleted successfully" }); + } catch (error) { + console.error("Error deleting spending:", error.message, error.stack); + return res.status(500).json({ error: "Server error" }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 0000000000..cad1547ad1 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,131 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { User } from '../models/User.js'; +import { authenticateUser } from '../middleware/authMiddleware.js'; + +const router = express.Router() +const JWT_SECRET = process.env.JWT_SECRET || 'your_secure_secret_key'; // Use environment variable for the secret key + +// POST - Register a new user +router.post("/signup", async (req, res) => { + try { + const { name, email, password } = req.body + + if (!name || !email || !password) { + return res.status(400).json({ + success: false, + message: "Name, email, and password are required.", + }); + } + + const salt = bcrypt.genSaltSync() + const hashedPassword = bcrypt.hashSync(password, salt); + const user = new User({ name, email, password: hashedPassword + }) + + //await to not send response before database finished saving + await user.save() + + // Generate a JWT token + const token = jwt.sign({ id: user._id, email: user.email }, JWT_SECRET, { + expiresIn: '1h', // Token expires in 1 hour + }); + + res.status(201).json({ + success: true, + message: "User created successfully.", + response: { + id: user._id, + accessToken: token, + } + }) + + } catch (error) { + console.error("Signup error:", error) + res.status(400).json({ + success: false, + message: "Failed to create user.", + response: error.message + }) + } + }) + + + // POST - Login route +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body + + if (!email || !password) { + return res.status(400).json({ + success: false, + message: "Email and password are required.", + }); + } + + // Find user by email + const user = await User.findOne({ email }) + if (!user) { + return res.status(404).json({ + success: false, + message: "User not found with provided email.", + }) + } + + // Compare the provided password with the stored hashed password + const isPasswordValid = bcrypt.compareSync(password, user.password) + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + message: "Incorrect password. Please try again.", + }) + } + // Generate a JWT token + const token = jwt.sign({ id: user._id, email: user.email }, JWT_SECRET, { + expiresIn: '1h', // Token expires in 1 hour + }) + + res.status(200).json({ + success: true, + message: "Login successfull.", + response: { + id: user._id, + accessToken: token, + } + }) + + } catch (error) { + console.error("Login error:", error) + res.status(500).json({ + success: false, + message: "Failed to login. Please try again.", + response: error.message + }) + } + }) + + // GET - Protected dashboard route + router.get("/dashboard", authenticateUser, async (req, res) => { + try { + res.status(200).json({ + success: true, + message: "User authenticated successfully.", + response: { + id: req.user._id, + name: req.user.name, + email: req.user.email, + } + }) + } catch (error) { + console.error("Dashboard error:", error) + res.status(500).json({ + success: false, + message: "Failed to access dashboard.", + response: error.message + }) + } + }) + + export default router + \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 070c875189..6fe0cc021c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,14 @@ import express from "express"; import cors from "cors"; import mongoose from "mongoose"; +import dotenv from "dotenv"; +import userRoutes from "./routes/userRoutes.js"; +import incomeRoutes from "./routes/incomeRoutes.js"; +import listEndpoints from "express-list-endpoints"; +import spendingsRoutes from "./routes/spendingsRoutes.js"; + +dotenv.config(); +console.log('JWT_SECRET:', process.env.JWT_SECRET); const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; mongoose.connect(mongoUrl); @@ -12,9 +20,19 @@ const app = express(); app.use(cors()); app.use(express.json()); +// endpoint for documentation of the API app.get("/", (req, res) => { - res.send("Hello Technigo!"); -}); + const endpoints = listEndpoints(app) + res.json({ + message: "Welcome to StoryBudget API", + endpoints: endpoints + }) +}) + +//endpoint routes +app.use('/users', userRoutes); +app.use('/income', incomeRoutes); +app.use('/spendings', spendingsRoutes); // Start the server app.listen(port, () => { diff --git a/frontend/index.html b/frontend/index.html index 664410b5b9..52c5c7a1aa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,8 @@ - Technigo React Vite Boiler Plate + + StoryBudget
diff --git a/frontend/package.json b/frontend/package.json index 7b2747e949..ae37bdda4f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "axios": "^1.11.0", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.8.0", + "recharts": "^3.1.2", + "styled-components": "^6.1.19", + "zustand": "^5.0.7" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb1b2..0000000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6e..b8116809fc 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,30 @@ +import { ThemeProvider } from 'styled-components'; +import { theme } from './styles/theme'; +import { GlobalStyles } from './styles/globalStyles'; +import { Routes, Route, Router } from 'react-router-dom'; +import Landing from './components/pages/Landing'; +import Signup from './components/pages/Signup'; +import Login from './components/pages/Login'; +import Dashboard from './components/pages/Dashboard'; +import IncomeManagement from './components/pages/IncomeManagement'; +import SpendingsManagement from './components/pages/SpendingsManagement'; +import ProtectedRoute from './components/pages/ProtectedRoute'; + export const App = () => { return ( - <> -

Welcome to Final Project!

- + + + + } /> + } /> + } /> + } /> + }/> + }/> + + ); }; + +export default App; diff --git a/frontend/src/assets/boiler-plate.svg b/frontend/src/assets/boiler-plate.svg deleted file mode 100644 index c9252833b4..0000000000 --- a/frontend/src/assets/boiler-plate.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/images/Mascot.svg b/frontend/src/assets/images/Mascot.svg new file mode 100644 index 0000000000..66ac1aeb35 --- /dev/null +++ b/frontend/src/assets/images/Mascot.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9bb3..0000000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/technigo-logo.svg b/frontend/src/assets/technigo-logo.svg deleted file mode 100644 index 3f0da3e572..0000000000 --- a/frontend/src/assets/technigo-logo.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/components/pages/Dashboard.jsx b/frontend/src/components/pages/Dashboard.jsx new file mode 100644 index 0000000000..5745a66cfd --- /dev/null +++ b/frontend/src/components/pages/Dashboard.jsx @@ -0,0 +1,155 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import VariableExpenses from './VariableExpenses'; +import FixedExpenses from './FixedExpenses'; +import DashboardCards from './DashboardCards'; +import DashboardChart from './DashboardChart'; +import LeftPanel from './LeftPanel'; +import Navbar from './Navbar'; + +const DashboardContainer = styled.div` + display: flex; + height: calc(100vh - ${({ theme }) => theme.spacing(8)}); // Full height minus navbar height +`; + +const RightContent = styled.div` + flex: 1; + padding: ${({ theme }) => theme.spacing(4)}; + display: grid; + gap: ${({ theme }) => theme.spacing(2)}; + grid-template-columns: 40% 60%; // Default to one column + + @media (max-width: 1023px) { + grid-template-columns: 1fr; // Two columns on tablet + } +`; + +const Section = styled.div` + background-color: ${({ theme }) => theme.colors.white}; + border: 1px solid ${({ theme }) => theme.colors.greyLight}; + border-radius: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(4)}; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + h3 { + margin-bottom: ${({ theme }) => theme.spacing(2)}; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + } + + &.chart-section { + background-color: ${({ theme }) => theme.colors.primary}; + color: ${({ theme }) => theme.colors.white}; + } + + &.chart-section h3 { + margin-bottom: ${({ theme }) => theme.spacing(2)}; + } +`; + +const Dashboard = () => { + const [income, setIncome] = useState(0); + const [totalSpendings, setTotalSpendings] = useState(0); + const [loading, setLoading] = useState(true); + const [chartData, setChartData] = useState([]); + const navigate = useNavigate(); + + const balance = income - totalSpendings; + + const handleLogout = () => { + localStorage.removeItem('accessToken'); + navigate('/'); + }; + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + await fetchIncome(); + await fetchTotalSpendings(); + setLoading(false); + }; + + fetchData(); + }, []); + + + const fetchIncome = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/income`); + if (!response.ok) { + throw new Error('Failed to fetch income'); + } + const data = await response.json(); + setIncome(data.income); + } catch (error) { + console.error('Error fetching income:', error); + } + }; + + const fetchTotalSpendings = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/spendings/total`); + if (!response.ok) { + throw new Error('Failed to fetch total spendings'); + } + const data = await response.json(); + setTotalSpendings(data.total); + } catch (error) { + console.error('Error fetching total spendings:', error); + } + }; + + useEffect(() => { + const fetchChartData = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/income/months`); + if (!response.ok) { + throw new Error('Failed to fetch months data'); + } + const monthsData = await response.json(); + const data = monthsData.map((monthData) => ({ + name: monthData.month, + Income: monthData.amount || 0, + Spendings: totalSpendings, // Use totalSpendings state + })); + setChartData(data); + } catch (error) { + console.error('Error fetching months data:', error); + } + }; + fetchChartData(); + }, [totalSpendings]); // Re-fetch chart data when totalSpendings changes + + return ( + <> + + + + +
+ +
+
+

Balance

+ +
+
+

Variable Expenses

+ +
+
+

Fixed Expenses

+ +
+
+
+ + ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/components/pages/DashboardCards.jsx b/frontend/src/components/pages/DashboardCards.jsx new file mode 100644 index 0000000000..d7e516d9ad --- /dev/null +++ b/frontend/src/components/pages/DashboardCards.jsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { FiEdit } from 'react-icons/fi'; +import styled from 'styled-components'; +import { theme } from '../../styles/theme'; + +const CardContainer = styled.div` + display: grid; + gap: ${({ theme }) => theme.spacing(4)}; + grid-template-columns: repeat(1, 1fr); // Default to one column + + @media (min-width: 768px) { + grid-template-columns: repeat(2, 1fr); // Two columns on tablet + } + + @media (min-width: 1024px) { + grid-template-columns: repeat(2, 1fr); // Two columns on desktop + } +`; + +const Card = styled.div` + background-color: ${({ $bgColor, theme }) => $bgColor || theme.colors.greyLight}; + border: 1px solid ${({ theme }) => theme.colors.grey}; + border-radius: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(4)}; + position: relative; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + h4 { + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + margin-bottom: ${({ theme }) => theme.spacing(1)}; + } + + p { + font-size: 1.25rem; + font-weight: ${({ theme }) => theme.typography.fontWeightSemiBold}; + margin: 0; + } + + span { + font-size: 0.9rem; + color: ${({ theme }) => theme.colors.primaryDark}; + } + + .edit-icon { + position: absolute; + top: ${({ theme }) => theme.spacing(2)}; + right: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + font-size: 1rem; // Adjust the size of the emoji + } +`; + +const DashboardCards = ({ balance, totalSpendings, income, loading }) => { + const navigate = useNavigate(); + + return ( + + + + + +

Balance

+ {loading ? null :

SEK {balance}

} + Additional Info +
+ + navigate('/spendings-management')} > + + +

Spendings

+ {loading ? null :

SEK {totalSpendings}

} + Additional Info +
+ + + + +

Savings

+

SEK 0

+ Additional Info +
+ + navigate('/income-management')} > + + +

Income

+ {loading ? null :

SEK {income}

} + Additional Info +
+
+ ); +} + +export default DashboardCards; diff --git a/frontend/src/components/pages/DashboardChart.jsx b/frontend/src/components/pages/DashboardChart.jsx new file mode 100644 index 0000000000..edd474a9e8 --- /dev/null +++ b/frontend/src/components/pages/DashboardChart.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { theme } from '../../styles/theme'; + +const DashboardChart = ({ chartData }) => { + return ( + + + + + { + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}K`; // Format numbers as "K" + } + return value; + }} + tick={{ + fill: theme.colors.greyLight, // Lighter gray color for the text + }} + /> + { + if (value >= 1000) { + return `${(value / 1000).toFixed(1)}K`; // Format tooltip numbers as "K" + } + return value; + }} + /> + + + + + + ); +} + +export default DashboardChart; \ No newline at end of file diff --git a/frontend/src/components/pages/FixedExpenses.jsx b/frontend/src/components/pages/FixedExpenses.jsx new file mode 100644 index 0000000000..7c3258b402 --- /dev/null +++ b/frontend/src/components/pages/FixedExpenses.jsx @@ -0,0 +1,111 @@ +import React from 'react'; +import styled from 'styled-components'; +import { theme } from '../../styles/theme'; +import { FiHome, FiWifi } from 'react-icons/fi'; +import { AiOutlineSafetyCertificate } from 'react-icons/ai'; +import { BiMobileAlt } from 'react-icons/bi'; +import MascotImageFile from '../../assets/images/Mascot.svg'; + +const CardContainer = styled.div` + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: ${({ theme }) => theme.spacing(1)}; + `; + +const Card = styled.div` + background-color: ${({ $bgColor, theme }) => $bgColor || theme.colors.greyLight}; + border: 1px solid ${({ theme }) => theme.colors.grey}; + border-radius: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(2)}; + text-align: left; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + flex: 1 1 calc(25% - ${({ theme }) => theme.spacing(2)}); + min-width: 100px; + + height: auto; + + h4 { + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + margin: ${({ theme }) => theme.spacing(1)} 0; + } + + p { + font-size: 1rem; + margin: 0; + } +`; + +const SummaryContainer = styled.div` + background-color: ${({ theme }) => theme.colors.white}; + border: 1px solid ${({ theme }) => theme.colors.greyLight}; + border-radius: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(4)}; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-top: ${({ theme }) => theme.spacing(4)}; + color: ${({ theme }) => theme.colors.black}; + display: flex; + align-items: center; + justify-content: space-between; + + h3 { + margin-bottom: ${({ theme }) => theme.spacing(2)}; + font-size: 1.25rem; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + } + + p { + font-size: 1rem; + line-height: 1.5; + margin: 0; +`; + +const MascotImage = styled.img` + width: 100px; + height: auto; + margin-top: ${({ theme }) => theme.spacing(4)}; + transform: scaleX(-1); +`; + +const FixedExpenses = () => { + return ( +
+ + + +

Rent

+

SEK 9600

+
+ + +

Parking

+

SEK 1000

+
+ + +

Insurance

+

SEK 400

+
+ + +

Broadband

+

SEK 500

+
+
+ +
+

Monthly Summary

+

+ You have saved 7000 SEK towards your “Trip to Quebec”, that is already 20% of your goal! This month, you spent a + bit more on food than last, but your variable expenses were still lower than average. Great job overall, you + ended up with a positive balance of 7 000 SEK this month! +

+
+ +
+
+ ); +} + +export default FixedExpenses; \ No newline at end of file diff --git a/frontend/src/components/pages/IncomeManagement.jsx b/frontend/src/components/pages/IncomeManagement.jsx new file mode 100644 index 0000000000..944f7d04a1 --- /dev/null +++ b/frontend/src/components/pages/IncomeManagement.jsx @@ -0,0 +1,247 @@ + +import React, { useState } from 'react'; +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import { FiX } from 'react-icons/fi'; + +const Navbar = styled.nav` + display: flex; + justify-content: space-between; + align-items: center; + padding: ${({ theme }) => theme.spacing(4)}; + border-bottom: 1px solid ${({ theme }) => theme.colors.greyLight}; + background-color: ${({ theme }) => theme.colors.primary}; + width: 100%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +`; + +const Logo = styled.h1` + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + color: ${({ theme }) => theme.colors.white}; + margin: 0; +`; + +const NavButtons = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + + @media (max-width: 767px) { + display: none; + } +`; + +const Button = styled.button` + background-color: ${({ theme }) => theme.colors.pink}; + color: ${({ theme }) => theme.colors.black}; + padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(4)}; + font-size: ${({ theme }) => theme.typography.fontSize}; + border: none; + border-radius: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + + &:hover { + background-color: ${({ theme }) => theme.colors.limeGreenLight}; + } +`; + +const IncomeManagementContainer = styled.div` + max-width: 400px; /* Set a max-width for the content */ + margin: 40px auto; /* Center the container */ + padding: ${({ theme }) => theme.spacing(4)}; + background-color: ${({ theme }) => theme.colors.white}; + border: 1px solid ${({ theme }) => theme.colors.greyLight}; + border-radius: ${({ theme }) => theme.spacing(2)}; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +`; + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; +`; + +const Input = styled.input` + padding: ${({ theme }) => theme.spacing(2)}; + font-size: ${({ theme }) => theme.typography.fontSize}; + border: 1px solid ${({ theme }) => theme.colors.grey}; + border-radius: ${({ theme }) => theme.spacing(2)}; +`; + +const SaveButton = styled(Button)` + background-color: ${({ theme }) => theme.colors.primary}; + color: ${({ theme }) => theme.colors.white}; + + &:hover { + background-color: ${({ theme }) => theme.colors.primaryDark}; + } +`; + +const CardContainer = styled.div` + display: grid; + gap: ${({ theme }) => theme.spacing(4)}; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +const Card = styled.div` + background-color: ${({ theme }) => theme.colors.greyLight}; + border: 1px solid ${({ theme }) => theme.colors.grey}; + border-radius: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(4)}; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + position: relative; + + h4 { + margin-bottom: ${({ theme }) => theme.spacing(2)}; + } + + p { + margin: 0; + + .actions { + margin-top: ${({ theme }) => theme.spacing(2)}; + display: flex; + justify-content: space-between; +} +`; + +const DeleteIcon = styled.button` + position: absolute; + top: ${({ theme }) => theme.spacing(2)}; + right: ${({ theme }) => theme.spacing(2)}; + background: none; + border: none; + color: ${({ theme }) => theme.colors.black}; + font-size: 1rem; + cursor: pointer; +`; + +const IncomeManagement = () => { + const navigate = useNavigate(); + const [income, setIncome] = useState(''); + const [months, setMonths] = useState([]); + const [month, setMonth] = useState(''); + + useEffect(() => { + const fetchMonths = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/income/months`); + if (!response.ok) { + throw new Error('Failed to fetch months'); + } + const data = await response.json(); + setMonths(data); + } catch (error) { + console.error('Error fetching months:', error); + } + }; + + fetchMonths(); + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (income <= 0) { + alert('Please enter a valid income greater than 0.'); + return; + } + + if (!month) { + alert('Please enter a valid month.'); + return; + } + + try { + await saveIncomeToAPI(Number(income), month); + console.log('Income submitted:', income, month); + navigate('/dashboard'); + } catch (error) { + console.error('Error submitting income:', error); + alert('Failed to save income. Please try again.'); + } + }; + + const saveIncomeToAPI = async (income, month) => { + const response = await fetch(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/income`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ amount: income, month }), + }); + + if (!response.ok) { + throw new Error('Failed to save income'); + } + + const newIncome = await response.json(); // Parse the response to get the new spending + return newIncome; + }; + + const handleDelete = async (id) => { + const confirmDelete = window.confirm('Are you sure you want to delete this month?'); + if (!confirmDelete) return; + + try { + await fetch(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/income/${id}`, { + method: 'DELETE', + }); + setMonths((prevMonths) => prevMonths.filter((month) => month._id !== id)); + } catch (error) { + console.error('Error deleting month:', error); + alert('Failed to delete month. Please try again.'); + } + }; + + return ( + <> + + StoryBudget + + + + + +

Manage Income

+
+ + setMonth(e.target.value)} + placeholder="e.g. January" + required + /> + + setIncome(e.target.value)} + placeholder="e.g. 26000" + required + /> + Save Income +
+ + {months.map((month) => ( + + handleDelete(month._id)}> + + +

Month: {month.month}

+

Amount: SEK {month.amount}

+

Date: {month.createdAt ? new Date(month.createdAt).toLocaleDateString() : "N/A"}

+
+ ))} +
+
+ + ); +}; + +export default IncomeManagement; \ No newline at end of file diff --git a/frontend/src/components/pages/Landing.jsx b/frontend/src/components/pages/Landing.jsx new file mode 100644 index 0000000000..880c9eea52 --- /dev/null +++ b/frontend/src/components/pages/Landing.jsx @@ -0,0 +1,267 @@ +import styled from "styled-components"; +import { useNavigate } from "react-router-dom"; +import Mascot from '../../assets/images/Mascot.svg'; + +const LandingContainer = styled.div` + background-color: ${({ theme }) => theme.colors.primary}; + color: ${({ theme }) => theme.colors.white}; + font-family: ${({ theme }) => theme.typography.fontFamily}; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + height: 100vh; + position: relative; + overflow: hidden; /* Ensure stars don't overflow */ +`; + +const Navbar = styled.nav` + display: flex; + justify-content: space-between; + align-items: center; + padding: ${({ theme }) => theme.spacing(4)}; + border-bottom: 1px solid ${({ theme }) => theme.colors.greyLight}; + background-color: ${({ theme }) => theme.colors.primary}; + width: 100%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 3; +`; + +const Logo = styled.h1` + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + color: ${({ theme }) => theme.colors.white}; + margin: 0; +`; + +const NavButtons = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + + @media (max-width: 767px) { + display: none; // Hide buttons on small screens + } +`; + +const Button = styled.button` + background-color: ${({ theme }) => theme.colors.pink}; + color: ${({ theme }) => theme.colors.black}; + padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(4)}; + font-size: ${({ theme }) => theme.typography.fontSize}; + border: none; + border-radius: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + + &.transparent { + background-color: inherit; + color: ${({ theme }) => theme.colors.white}; + } + + &:hover { + background-color: ${({ theme }) => theme.colors.limeGreenLight}; + } +`; + +const StarsContainer = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; // Prevent interaction with stars +`; + +const Star = styled(({ top, left, size, duration, ...rest }) =>
)` + position: absolute; + width: ${({ size }) => size}px; + height: ${({ size }) => size}px; + background-color: transparent; + clip-path: polygon( + 50% 0%, + 61% 35%, + 98% 35%, + 68% 57%, + 79% 91%, + 50% 70%, + 21% 91%, + 32% 57%, + 2% 35%, + 39% 35% + ); // Creates a star shape + background-color: white; + animation: twinkle ${props => props.duration}s infinite ease-in-out; ease-in-out; + top: ${props => props.top}%; + left: ${props => props.left}%; + + @keyframes twinkle { + 0%, 100% { + opacity: 0.2; + } + 50% { + opacity: 1; + } + } +`; + +const FallingStar = styled.div` + position: absolute; + width: 4px; + height: 4px; + background-color: white; + border-radius: 50%; + box-shadow: 0 0 8px white, 0 0 16px white, 0 0 24px white; // Glow effect + animation: fall 14s infinite ease-in-out; + top: -10%; + left: -10%; + z-index: 0; // Ensure falling star is behind the content + + &::after { + content: ''; + position: absolute; + height: 2px; // Thickness of the tail + background: linear-gradient(90deg, rgba(151, 151, 151, 0.8), rgba(255, 255, 255, 0)); // Fading tail effect + top: 50%; + left: -60px; // Position the tail behind the star + transform: translateY(-50%); + border-radius: 50px; // Curve the tail + } + + @keyframes fall { + 0% { + transform: translate(0vw, -10vh) rotate(45deg); + opacity: 1; + } + 70% { + opacity: 1; + } + 100% { + transform: translate(90vw, 120vh) rotate(45deg); + opacity: 0; + } + } +`; + +const Title = styled.h2` + font-size: 2rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + margin-bottom: ${({ theme }) => theme.spacing(4)}; + + @media (min-width: 768px) { + font-size: 2.5rem; + } +`; + +const Text = styled.p` + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightRegular}; + margin-bottom: ${({ theme }) => theme.spacing(4)}; +`; + +const LimeButton = styled(Button)` + margin-top: ${({ theme }) => theme.spacing(4)}; + padding: ${({ theme }) => theme.spacing(3)} ${({ theme }) => theme.spacing(6)}; + font-size: 1rem; + background-color: ${({ theme }) => theme.colors.limeGreen}; + color: ${({ theme }) => theme.colors.black}; + + &:hover { + background-color: ${({ theme }) => theme.colors.limeGreenLight}; + } +`; + +const ContentContainer = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; + width: 100%; + position: relative; + z-index: 2; // Ensure content is above stars + `; + +const Section = styled.section` + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + text-align: center; + padding: ${({ theme }) => theme.spacing(8)}; + + @media (min-width: 768px) { + padding: ${({ theme }) => theme.spacing(12)}; + } +`; + +const MascotSection = styled(Section)` + flex: 1; + display: flex; + justify-content: flex-start; + align-items: center; +`; + +const MascotImage = styled.img` + width: 180px; // Adjust mascot size + height: auto; + animation: float 12s ease-in-out infinite; + + @keyframes float { + 0%, 50%, 100%{ + transform: translateY(0); // Start position + } + 25%, 75% { + transform: translateY(-10px); // Slight upward movement and rotation + } + } +`; + + +const Landing = () => { + const navigate = useNavigate(); + + // Generate random stars + const stars = Array.from({ length: 50 }, (_, i) => ({ + id: i, + size: Math.random() * 3 + 2, // Random size between 2px and 5px + duration: Math.random() * 3 + 2, // Random animation duration between 2s and 5s + top: Math.random() * 100, // Random position (top) + left: Math.random() * 100, // Random position (left) + })); + + return ( + + + StoryBudget + + + + + + + {stars.map((star) => ( + + ))} + {/* Single falling star */} + + +
+ Welcome To StoryBudget + Your Finances, Your Story, Your Control. + navigate('/signup')}>Get Started +
+ + + +
+
+ ) +}; + +export default Landing; diff --git a/frontend/src/components/pages/LeftPanel.jsx b/frontend/src/components/pages/LeftPanel.jsx new file mode 100644 index 0000000000..48a9c3eee4 --- /dev/null +++ b/frontend/src/components/pages/LeftPanel.jsx @@ -0,0 +1,63 @@ +import React from "react"; +import styled from "styled-components"; +import { FiBarChart2, FiCreditCard, FiTrendingUp } from 'react-icons/fi'; +import { FaCoins } from 'react-icons/fa'; + +const LeftPanelContainer = styled.div` + width: 190px; + background-color: ${({ theme }) => theme.colors.white}; + padding: ${({ theme }) => theme.spacing(4)}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; + + h2 { + margin-top: ${({ theme }) => theme.spacing(4)}; + margin-bottom: ${({ theme }) => theme.spacing(2)}; + } + + @media (max-width: 767px) { + display: none; // Hide left panel on mobile +`; + +const TabButton = styled.button` + background-color: ${({ theme }) => theme.colors.greyLight}; + color: ${({ theme }) => theme.colors.black}; + padding: ${({ theme }) => theme.spacing(3)}; + font-size: 1rem; + border: none; + border-radius: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + text-align: left; + width: 100%; + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; + + &:hover { + background-color: ${({ theme }) => theme.colors.primaryLight}; + color: ${({ theme }) => theme.colors.white}; + } +`; + +const LeftPanel = () => { + return ( + +

Dashboard

+ + Balance + + + Spendings + + + Savings + + + Income + +
+ ); +}; + +export default LeftPanel; \ No newline at end of file diff --git a/frontend/src/components/pages/Login.jsx b/frontend/src/components/pages/Login.jsx new file mode 100644 index 0000000000..41769ddbc1 --- /dev/null +++ b/frontend/src/components/pages/Login.jsx @@ -0,0 +1,340 @@ +import { useState } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import Mascot from '../../assets/images/Mascot.svg'; + +const LoginContainer = styled.div` + background-color: ${({ theme }) => theme.colors.primary}; + color: ${({ theme }) => theme.colors.white}; + font-family: ${({ theme }) => theme.typography.fontFamily}; + height: 100vh; + display: flex; + flex-direction: column; + position: relative; +`; + +const Navbar = styled.nav` + display: flex; + justify-content: space-between; + align-items: center; + padding: ${({ theme }) => theme.spacing(4)}; + border-bottom: 1px solid ${({ theme }) => theme.colors.greyLight}; + background-color: ${({ theme }) => theme.colors.primary}; + width: 100%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +`; + +const Logo = styled.h1` + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + color: ${({ theme }) => theme.colors.white}; + margin: 0; +`; + +const NavButtons = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + + @media (max-width: 767px) { + display: none; + } +`; + +const Button = styled.button` + background-color: ${({ theme }) => theme.colors.pink}; + color: ${({ theme }) => theme.colors.black}; + padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(4)}; + font-size: ${({ theme }) => theme.typography.fontSize}; + border: none; + border-radius: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + + &:hover { + background-color: ${({ theme }) => theme.colors.limeGreenLight}; + } +`; + +const Content = styled.div` + display: flex; + flex: 1; + height: 100%; +`; + +const LeftSection = styled.div` + flex: 1; + padding: ${({ theme }) => theme.spacing(8)}; + display: flex; + flex-direction: column; + justify-content: center; + background-color: ${({ theme }) => theme.colors.white}; + color: ${({ theme }) => theme.colors.black}; + + h1 { + font-size: 1.5rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + margin-bottom: ${({ theme }) => theme.spacing(4)}; + max-width: 400px; + text-align: left; + margin-left : auto; + margin-right: auto; + + @media (min-width: 768px) { + font-size: 2rem; + } + } + + form { + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; + max-width: 400px; + width: 100%; + margin: 0 auto; + } + + label { + font-size: 1rem; + margin-bottom: ${({ theme }) => theme.spacing(1)}; + } + + input { + padding: ${({ theme }) => theme.spacing(2)}; + font-size: 1rem; + border: 1px solid ${({ theme }) => theme.colors.greyLight}; + border-radius: ${({ theme }) => theme.spacing(1)}; + width: 100%; + box-sizing: border-box; + } + + button { + padding: ${({ theme }) => theme.spacing(2)}; + font-size: 1rem; + background-color: ${({ theme }) => theme.colors.primary}; + color: ${({ theme }) => theme.colors.white}; + border: none; + border-radius: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + margin-top: ${({ theme }) => theme.spacing(4)}; + + &:hover { + background-color: ${({ theme }) => theme.colors.primaryDark}; + } + + &:disabled { + background-color: ${({ theme }) => theme.colors.greyLight}; + cursor: not-allowed; + } + } + + .error-message { + color: ${({ theme }) => theme.colors.error}; + font-size: 0.9rem; + margin-top: ${({ theme }) => theme.spacing(2)}; + } + + p { + margin-top: ${({ theme }) => theme.spacing(4)}; + font-size: 0.9rem; + max-width: 400px; + text-align: left; + margin-left: auto; + margin-right: auto; + + a { + color: ${({ theme }) => theme.colors.primary}; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +`; + +const RightSection = styled.div` + flex: 1; + padding: ${({ theme }) => theme.spacing(8)}; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + background-color: ${({ theme }) => theme.colors.primary}; + position: relative; + overflow: hidden; + + @media (max-width: 768px) { + display: none; /* Hide the RightSection on smaller screens */ + } +`; + + +const StarsContainer = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + pointer-events: none; // Prevent interaction with stars +`; + +const Star = styled(({ top, left, size, duration, ...rest }) =>
)` +position: absolute; +width: ${({ size }) => size}px; +height: ${({ size }) => size}px; +background-color: transparent; +clip-path: polygon( + 50% 0%, + 61% 35%, + 98% 35%, + 68% 57%, + 79% 91%, + 50% 70%, + 21% 91%, + 32% 57%, + 2% 35%, + 39% 35% +); // Creates a star shape +background-color: white; +animation: twinkle ${props => props.duration}s infinite ease-in-out; ease-in-out; +top: ${props => props.top}%; +left: ${props => props.left}%; + +@keyframes twinkle { + 0%, 100% { + opacity: 0.2; + } + 50% { + opacity: 1; + } +} +`; + +const Heading = styled.h2` + font-size: 2rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + margin-bottom: ${({ theme }) => theme.spacing(4)}; +`; + +const Paragraph = styled.p` + font-size: 1rem; + margin-bottom: ${({ theme }) => theme.spacing(4)}; +`; + +const MascotImage = styled.img` + width: 200px; + height: auto; + margin-top: ${({ theme }) => theme.spacing(6)}; +`; + +const Login = () => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + + try { + // Call backend API to create user + const response = await axios.post(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/users/login`, { + email, + password + }); + console.log("Login successful"); + + // Store the accessToken in localStorage + localStorage.setItem('accessToken', response.data.response.accessToken); + + // Redirect to login page after successful login + navigate('/dashboard'); + } catch (err) { + console.error(err); + setError(err.response?.data?.message || 'Invalid email or password. Please try again.'); + } finally { + setLoading(false); + } + } + + // Generate random stars + const stars = Array.from({ length: 50 }, (_, i) => ({ + id: i, + size: Math.random() * 3 + 2, // Random size between 2px and 5px + duration: Math.random() * 3 + 2, // Random animation duration between 2s and 5s + top: Math.random() * 100, // Random position (top) + left: Math.random() * 100, // Random position (left) + })); + + return ( + + + StoryBudget + + + + + + +
+

Login

+
+
+ + setEmail(e.target.value)} + required + autoComplete='email' + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete='current-password' + /> +
+ + {error &&

{error}

} +
+

Don't have an account? Sign up

+
+
+ + + {stars.map((star) => ( + + ))} + + Welcome to StoryBudget + Manage your budgets effortlessly and take control of your finances today! + + +
+
+ ); +}; + + +export default Login; \ No newline at end of file diff --git a/frontend/src/components/pages/Navbar.jsx b/frontend/src/components/pages/Navbar.jsx new file mode 100644 index 0000000000..6d9a5dd5f7 --- /dev/null +++ b/frontend/src/components/pages/Navbar.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import styled from 'styled-components'; + +const NavbarContainer = styled.nav` + display: flex; + justify-content: space-between; + align-items: center; + padding: ${({ theme }) => theme.spacing(4)}; + border-bottom: 1px solid ${({ theme }) => theme.colors.greyLight}; + background-color: ${({ theme }) => theme.colors.primary}; + width: 100%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +`; + +const Logo = styled.h1` + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + color: ${({ theme }) => theme.colors.white}; + margin: 0; +`; + +const NavButtons = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + + @media (max-width: 767px) { + display: none; + } +`; + +const Button = styled.button` + background-color: ${({ theme }) => theme.colors.pink}; + color: ${({ theme }) => theme.colors.black}; + padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(4)}; + font-size: ${({ theme }) => theme.typography.fontSize}; + border: none; + border-radius: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + + &:hover { + background-color: ${({ theme }) => theme.colors.limeGreenLight}; + } +`; + +const Navbar = ({onLogout}) => { + return ( + + StoryBudget + + + + + ); +}; + +export default Navbar; \ No newline at end of file diff --git a/frontend/src/components/pages/ProtectedRoute.jsx b/frontend/src/components/pages/ProtectedRoute.jsx new file mode 100644 index 0000000000..6b6963e3f9 --- /dev/null +++ b/frontend/src/components/pages/ProtectedRoute.jsx @@ -0,0 +1,11 @@ +import React from "react"; +import { Navigate } from "react-router-dom"; + +const ProtectedRoute = ({ children }) => { + const accessToken = localStorage.getItem("accessToken"); + if (!accessToken) { + return ; + } + return children; +} +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/src/components/pages/Signup.jsx b/frontend/src/components/pages/Signup.jsx new file mode 100644 index 0000000000..79a52aed63 --- /dev/null +++ b/frontend/src/components/pages/Signup.jsx @@ -0,0 +1,355 @@ +import { useState } from 'react'; +import axios from 'axios'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import Mascot from '../../assets/images/Mascot.svg'; + +const SignupContainer = styled.div` + background-color: ${({ theme }) => theme.colors.primary}; + color: ${({ theme }) => theme.colors.white}; + font-family: ${({ theme }) => theme.typography.fontFamily}; + height: 100vh; + display: flex; + flex-direction: column; + position: relative; +`; + +const Navbar = styled.nav` + display: flex; + justify-content: space-between; + align-items: center; + padding: ${({ theme }) => theme.spacing(4)}; + border-bottom: 1px solid ${({ theme }) => theme.colors.greyLight}; + background-color: ${({ theme }) => theme.colors.primary}; + width: 100%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +`; + +const Logo = styled.h1` + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + color: ${({ theme }) => theme.colors.white}; + margin: 0; +`; + +const NavButtons = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + + @media (max-width: 767px) { + display: none; + } +`; + +const Button = styled.button` + background-color: ${({ theme }) => theme.colors.pink}; + color: ${({ theme }) => theme.colors.black}; + padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(4)}; + font-size: ${({ theme }) => theme.typography.fontSize}; + border: none; + border-radius: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + + &:hover { + background-color: ${({ theme }) => theme.colors.limeGreenLight}; + } +`; + +const Content = styled.div` + display: flex; + flex: 1; + height: 100%; +`; + +const LeftSection = styled.div` + flex: 1; + padding: ${({ theme }) => theme.spacing(8)}; + display: flex; + flex-direction: column; + justify-content: center; + background-color: ${({ theme }) => theme.colors.white}; + color: ${({ theme }) => theme.colors.black}; + + h1 { + font-size: 1.5rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + margin-bottom: ${({ theme }) => theme.spacing(4)}; + max-width: 400px; + text-align: left; + margin-left : auto; + margin-right: auto; + + @media (min-width: 768px) { + font-size: 2rem; + } + } + + form { + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; + max-width: 400px; + width: 100%; + margin: 0 auto; + } + + label { + font-size: 1rem; + margin-bottom: ${({ theme }) => theme.spacing(1)}; + } + + input { + padding: ${({ theme }) => theme.spacing(2)}; + font-size: 1rem; + border: 1px solid ${({ theme }) => theme.colors.greyLight}; + border-radius: ${({ theme }) => theme.spacing(1)}; + width: 100%; + box-sizing: border-box; + } + + button { + padding: ${({ theme }) => theme.spacing(2)}; + font-size: 1rem; + background-color: ${({ theme }) => theme.colors.primary}; + color: ${({ theme }) => theme.colors.white}; + border: none; + border-radius: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + margin-top: ${({ theme }) => theme.spacing(4)}; + + &:hover { + background-color: ${({ theme }) => theme.colors.primaryDark}; + } + + &:disabled { + background-color: ${({ theme }) => theme.colors.greyLight}; + cursor: not-allowed; + } + } + + .error-message { + color: ${({ theme }) => theme.colors.error}; + font-size: 0.9rem; + margin-top: ${({ theme }) => theme.spacing(2)}; + } + + p { + margin-top: ${({ theme }) => theme.spacing(4)}; + font-size: 0.9rem; + max-width: 400px; + text-align: left; + margin-left: auto; + margin-right: auto; + + a { + color: ${({ theme }) => theme.colors.primary}; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +`; + +const RightSection = styled.div` + flex: 1; + padding: ${({ theme }) => theme.spacing(8)}; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + background-color: ${({ theme }) => theme.colors.primary}; + position: relative; + overflow: hidden; + + @media (max-width: 768px) { + display: none; /* Hide the RightSection on smaller screens */ + } +`; + + +const StarsContainer = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + pointer-events: none; // Prevent interaction with stars +`; + +const Star = styled(({ top, left, size, duration, ...rest }) =>
)` +position: absolute; +width: ${({ size }) => size}px; +height: ${({ size }) => size}px; +background-color: transparent; +clip-path: polygon( + 50% 0%, + 61% 35%, + 98% 35%, + 68% 57%, + 79% 91%, + 50% 70%, + 21% 91%, + 32% 57%, + 2% 35%, + 39% 35% +); // Creates a star shape +background-color: white; +animation: twinkle ${props => props.duration}s infinite ease-in-out; ease-in-out; +top: ${props => props.top}%; +left: ${props => props.left}%; + +@keyframes twinkle { + 0%, 100% { + opacity: 0.2; + } + 50% { + opacity: 1; + } +} +`; + +const Heading = styled.h2` + font-size: 2rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + margin-bottom: ${({ theme }) => theme.spacing(4)}; +`; + +const Paragraph = styled.p` + font-size: 1rem; + margin-bottom: ${({ theme }) => theme.spacing(4)}; +`; + +const MascotImage = styled.img` + width: 200px; + height: auto; + margin-top: ${({ theme }) => theme.spacing(6)}; +`; + + +const Signup = () => { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + + try { + // Call backend API to create user + const response = await axios.post(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/users/signup`, { + name, + email, + password + }); + console.log("Signup successful"); + + // Store the accessToken in localStorage + localStorage.setItem('accessToken', response.data.response.accessToken); + + // Redirect to login page after successful signup + navigate('/dashboard'); + } catch (err) { + console.error(err); + setError(err.response?.data?.message || 'Something went wrong. Please try again.'); + } finally { + setLoading(false); + } + } + + // Generate random stars + const stars = Array.from({ length: 50 }, (_, i) => ({ + id: i, + size: Math.random() * 3 + 2, // Random size between 2px and 5px + duration: Math.random() * 3 + 2, // Random animation duration between 2s and 5s + top: Math.random() * 100, // Random position (top) + left: Math.random() * 100, // Random position (left) + })); + + return ( + + + StoryBudget + + + + + + +
+

Create Your Account

+
+
+ + setName(e.target.value)} + required + autoComplete='name' + /> +
+
+ + setEmail(e.target.value)} + required + autoComplete='email' + /> +
+
+ + setPassword(e.target.value)} + required + autoComplete='new-password' + /> +
+ + {error &&

{error}

} +
+

Already have an account? Login

+
+
+ + + {stars.map((star) => ( + + ))} + + Welcome to StoryBudget + Manage your budgets effortlessly and take control of your finances today! + + +
+
+ ); +}; + + +export default Signup; diff --git a/frontend/src/components/pages/SpendingsManagement.jsx b/frontend/src/components/pages/SpendingsManagement.jsx new file mode 100644 index 0000000000..69012e155d --- /dev/null +++ b/frontend/src/components/pages/SpendingsManagement.jsx @@ -0,0 +1,261 @@ +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import styled from 'styled-components'; +import { FiX } from 'react-icons/fi'; + +const Navbar = styled.nav` + display: flex; + justify-content: space-between; + align-items: center; + padding: ${({ theme }) => theme.spacing(4)}; + border-bottom: 1px solid ${({ theme }) => theme.colors.greyLight}; + background-color: ${({ theme }) => theme.colors.primary}; + width: 100%; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +`; + +const Logo = styled.h1` + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + color: ${({ theme }) => theme.colors.white}; + margin: 0; +`; + +const NavButtons = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + + @media (max-width: 767px) { + display: none; + } +`; + +const Button = styled.button` + background-color: ${({ theme }) => theme.colors.pink}; + color: ${({ theme }) => theme.colors.black}; + padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(4)}; + font-size: ${({ theme }) => theme.typography.fontSize}; + border: none; + border-radius: ${({ theme }) => theme.spacing(2)}; + cursor: pointer; + + &:hover { + background-color: ${({ theme }) => theme.colors.limeGreenLight}; + } +`; + +const SpendingsManagementContainer = styled.div` + max-width: 400px; /* Set a max-width for the content */ + margin: 40px auto; /* Center the container */ + padding: ${({ theme }) => theme.spacing(4)}; + background-color: ${({ theme }) => theme.colors.white}; + border: 1px solid ${({ theme }) => theme.colors.greyLight}; + border-radius: ${({ theme }) => theme.spacing(2)}; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +`; + +const Form = styled.form` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; +`; + +const Input = styled.input` + padding: ${({ theme }) => theme.spacing(2)}; + font-size: ${({ theme }) => theme.typography.fontSize}; + border: 1px solid ${({ theme }) => theme.colors.grey}; + border-radius: ${({ theme }) => theme.spacing(2)}; +`; + +const SaveButton = styled(Button)` + background-color: ${({ theme }) => theme.colors.primary}; + color: ${({ theme }) => theme.colors.white}; + + &:hover { + background-color: ${({ theme }) => theme.colors.primaryDark}; + } +`; + +const CardContainer = styled.div` + display: grid; + gap: ${({ theme }) => theme.spacing(4)}; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + margin-top: ${({ theme }) => theme.spacing(4)}; +`; + +const Card = styled.div` + background-color: ${({ theme }) => theme.colors.greyLight}; + border: 1px solid ${({ theme }) => theme.colors.grey}; + border-radius: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(4)}; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + position: relative; + + h4 { + margin-bottom: ${({ theme }) => theme.spacing(2)}; + } + + p { + margin: 0; + + .actions { + margin-top: ${({ theme }) => theme.spacing(2)}; + display: flex; + justify-content: space-between; +} +`; + +const DeleteIcon = styled.button` + position: absolute; + top: ${({ theme }) => theme.spacing(2)}; + right: ${({ theme }) => theme.spacing(2)}; + background: none; + border: none; + color: ${({ theme }) => theme.colors.black}; + font-size: 1rem; + cursor: pointer; +`; + +// Helper function to capitalize the first letter of a string +const capitalizeFirstLetter = (string) => { + return string.charAt(0).toUpperCase() + string.slice(1); +}; + +const SpendingsManagement = () => { + const navigate = useNavigate(); + const [category, setCategory] = useState(''); + const [amount, setAmount] = useState(''); + const [spendings, setSpendings] = useState([]); + + useEffect(() => { + const fetchSpendings = async () => { + try { + const response = await fetch(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/spendings`); + if (!response.ok) { + throw new Error('Failed to fetch spendings'); + } + const data = await response.json(); + setSpendings(data); + } catch (error) { + console.error('Error fetching spendings:', error); + } + }; + + fetchSpendings(); + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (amount <= 0) { + alert('Please enter a valid amount greater than 0.'); + return; + } + + try { + const newSpending = await saveSpendingToAPI(category, Number(amount)); + console.log('Spending submitted:', newSpending); + + setSpendings(prevSpendings => [...prevSpendings, newSpending]); + setCategory(''); + setAmount(''); + } catch (error) { + console.error('Error submitting spedning:', error); + alert('Failed to save spending. Please try again.'); + } + }; + + const saveSpendingToAPI = async (category, amount) => { + const response = await fetch(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/spendings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ category, amount + }), + }); + + if (!response.ok) { + throw new Error('Failed to save spending'); + } + + const newSpending = await response.json(); // Parse the response to get the new spending + return newSpending; + }; + + const handleDelete = async (id) => { + const confirmDelete = window.confirm('Are you sure you want to delete this spending?'); + if (!confirmDelete) return; + + try { + await fetch(`${import.meta.env.VITE_REACT_APP_BACKEND_URL}/spendings/${id}`, { + method: 'DELETE', + }); + setSpendings((prevSpendings) => prevSpendings.filter((spending) => spending._id !== id)); + } catch (error) { + console.error('Error deleting spending:', error); + alert('Failed to delete spending. Please try again.'); + } + }; + + return ( + <> + + StoryBudget + + + + + +

Manage Spendings

+
+ + + + setAmount(e.target.value)} + placeholder="Enter your the amount" + required + /> + Add Spending +
+ + {spendings.map((spending) => ( + + handleDelete(spending._id)}> + + +

{capitalizeFirstLetter(spending.category)}

+

Amount: SEK {spending.amount}

+

Date: {new Date(spending.createdAt).toLocaleDateString()}

+
+ ))} +
+
+ + ); +}; + +export default SpendingsManagement; \ No newline at end of file diff --git a/frontend/src/components/pages/VariableExpenses.jsx b/frontend/src/components/pages/VariableExpenses.jsx new file mode 100644 index 0000000000..c74997a460 --- /dev/null +++ b/frontend/src/components/pages/VariableExpenses.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import styled from 'styled-components'; +import { theme } from '../../styles/theme'; +import { FiHome } from 'react-icons/fi'; +import { MdOutlineMovie, MdOutlineRestaurant, MdOutlineLocalCafe } from 'react-icons/md'; +import { AiOutlineShoppingCart, AiOutlineCar, AiOutlineSafetyCertificate } from 'react-icons/ai'; +import { BiMobileAlt } from 'react-icons/bi'; + + +const VariableExpensesContainer = styled.div` + display: flex; + flex-direction: column; + grid-template-columns: repeat(2, 1fr); + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const QuadrantCardPair = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const Quadrant = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background-color: ${({ $bgColor, theme }) => $bgColor || theme.colors.greyLight}; + border: 1px solid ${({ theme }) => theme.colors.grey}; + border-radius: ${({ theme }) => theme.spacing(2)}; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + span { + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + } +`; + +const Card = styled.div` + flex: 1; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: ${({ theme }) => theme.spacing(2)}; + background-color: ${({ $bgColor, theme }) => $bgColor || theme.colors.greyLight}; + border: 1px solid ${({ theme }) => theme.colors.grey}; + border-radius: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(4)}; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + height: 48px; + + h4 { + font-size: 1rem; + font-weight: ${({ theme }) => theme.typography.fontWeightMedium}; + } + + p { + font-size: 1rem; + } +`; + +const VariableExpenses = () => { + return ( + <> + + + + + + +

Grocery

+

SEK 2500

+
+
+ + + + + +

Shopping

+

SEK 500

+
+
+ + + + + +

Restaurants

+

SEK 800

+
+
+ + + + + +

Travel

+

SEK 500

+
+
+ + + + + +

Entertainment

+

SEK 400

+
+
+ + + + + +

Cafe

+

SEK 400

+
+
+
+ + ); +}; + +export default VariableExpenses; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f3998..052d9c7782 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { BrowserRouter } from 'react-router-dom'; import { App } from "./App.jsx"; -import "./index.css"; + ReactDOM.createRoot(document.getElementById("root")).render( - + + + ); diff --git a/frontend/src/styles/globalStyles.js b/frontend/src/styles/globalStyles.js new file mode 100644 index 0000000000..c8afc57b0d --- /dev/null +++ b/frontend/src/styles/globalStyles.js @@ -0,0 +1,37 @@ +import { createGlobalStyle } from 'styled-components'; + +export const GlobalStyles = createGlobalStyle` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: ${({ theme }) => theme.typography.fontFamily}; + background-color: ${({ theme }) => theme.colors.background}; + color: ${({ theme }) => theme.colors.text}; + line-height: 1.6; + } + + h1, h2, h3, h4, h5, h6 { + margin: 0; + font-weight: ${({ theme }) => theme.typography.fontWeightBold}; + } + + p { + margin: 0; + } + + button { + font-family: ${({ theme }) => theme.typography.fontFamily}; + cursor: pointer; + border: none; + outline: none; + } + + a { + text-decoration: none; + color: inherit; + } +`; \ No newline at end of file diff --git a/frontend/src/styles/theme.js b/frontend/src/styles/theme.js new file mode 100644 index 0000000000..ef527fd0a5 --- /dev/null +++ b/frontend/src/styles/theme.js @@ -0,0 +1,24 @@ +export const theme = { + colors: { + primary: '#181A4D', // Main blue color for background and text + primaryLight: 'rgba(24, 26, 77, 0.5)', // 50% opacity for minor parts + black: '#1E1E1E', // Black color for text, icons, and footer backgrounds + white: '#FFFFFF', // White color for background, text, and icons + grey: '#D3DDE7', // Grey color for smaller backgrounds, placeholder text, dividers + greyLight: 'rgba(211, 221, 231, 0.5)', // 50% opacity for minor grey parts + limeGreen: '#D9E73C', // Lime green for visuals + limeGreenLight: 'rgba(217, 231, 60, 0.5)', // 50% opacity for lime green backgrounds + limeGreenExtraLight: 'rgba(217, 231, 60, 0.25)', // 25% opacity for lime green visuals + pink: '#E6BFFF', // Pink for visuals + pinkLight: 'rgba(230, 191, 255, 0.5)', // 50% opacity for pink backgrounds + pinkExtraLight: 'rgba(230, 191, 255, 0.25)', // 25% opacity for pink visuals + }, + typography: { + fontFamily: '"Roboto", sans-serif', // Main font family + fontSize: '16px', // Base font size + fontWeightRegular: 400, // Regular font weight + fontWeightMedium: 500, // Medium font weight + fontWeightBold: 700, // Bold font weight + }, + spacing: (factor) => `${0.25 * factor}rem`, // Spacing utility (e.g., spacing(4) = 1rem) +}; \ No newline at end of file diff --git a/package.json b/package.json index 680d190772..ec1b8616b1 100644 --- a/package.json +++ b/package.json @@ -3,5 +3,8 @@ "version": "1.0.0", "scripts": { "postinstall": "npm install --prefix backend" + }, + "dependencies": { + "styled-components": "^6.1.19" } -} \ No newline at end of file +}