{feature.title}
+{feature.description}
+diff --git a/backend/index.js b/backend/index.js deleted file mode 100644 index ff6a6a3b..00000000 --- a/backend/index.js +++ /dev/null @@ -1,13 +0,0 @@ -const express = require("express"); -const app = express(); -const PORT = process.env.PORT || 4000; - -// Basic route -app.get("/", (req, res) => { - res.send("Hello from Express!"); -}); - -// Start server -app.listen(PORT, () => { - console.log(`Backend is running on http://localhost:${PORT}`); -}); diff --git a/backend/package.json b/backend/package.json index c85981fa..796cf9ba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,10 +1,20 @@ { - "name": "backend", + "name": "codebility-backend-assessment", "version": "1.0.0", + "description": "Simple Todo API for Codebility backend assessment", + "main": "src/server.js", "scripts": { - "start": "node index.js" + "dev": "nodemon src/server.js", + "start": "node src/server.js" }, + "keywords": [], + "author": "John Matthew Tizon", + "license": "ISC", "dependencies": { - "express": "^4.18.2" + "cors": "^2.8.5", + "express": "^5.1.0" + }, + "devDependencies": { + "nodemon": "^3.1.10" } -} +} \ No newline at end of file diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 00000000..d5c37ffa --- /dev/null +++ b/backend/src/app.js @@ -0,0 +1,30 @@ +const express = require("express"); +const cors = require("cors"); + +const todoRoutes = require("./routes/todoRoutes"); +const { notFoundHandler, errorHandler } = require("./middleware/errorHandler"); + +const app = express(); + +app.use(cors()); +app.use(express.json()); + +app.get("/", (req, res) => { + res.status(200).json({ + message: "Todo API is running", + endpoints: { + listTodos: "GET /api/todos", + getTodo: "GET /api/todos/:id", + createTodo: "POST /api/todos", + updateTodo: "PUT /api/todos/:id", + deleteTodo: "DELETE /api/todos/:id" + } + }); +}); + +app.use("/api/todos", todoRoutes); + +app.use(notFoundHandler); +app.use(errorHandler); + +module.exports = app; \ No newline at end of file diff --git a/backend/src/controllers/todoController.js b/backend/src/controllers/todoController.js new file mode 100644 index 00000000..5146f8ff --- /dev/null +++ b/backend/src/controllers/todoController.js @@ -0,0 +1,118 @@ +const todoService = require("../services/todoService"); +const { isValidTitle, isValidCompleted } = require("../utils/validators"); + +function getAllTodos(req, res) { + const todos = todoService.getAllTodos(); + + res.status(200).json({ + success: true, + count: todos.length, + data: todos + }); +} + +function getTodoById(req, res) { + const todo = todoService.getTodoById(req.params.id); + + if (!todo) { + return res.status(404).json({ + success: false, + message: "Todo not found" + }); + } + + res.status(200).json({ + success: true, + data: todo + }); +} + +function createTodo(req, res) { + const { title, completed } = req.body; + + if (!isValidTitle(title)) { + return res.status(400).json({ + success: false, + message: "Title is required and must be a non-empty string" + }); + } + + if (completed !== undefined && !isValidCompleted(completed)) { + return res.status(400).json({ + success: false, + message: "Completed must be a boolean" + }); + } + + const todo = todoService.createTodo({ + title, + completed + }); + + res.status(201).json({ + success: true, + message: "Todo created successfully", + data: todo + }); +} + +function updateTodo(req, res) { + const { title, completed } = req.body; + + if (title !== undefined && !isValidTitle(title)) { + return res.status(400).json({ + success: false, + message: "Title must be a non-empty string" + }); + } + + if (completed !== undefined && !isValidCompleted(completed)) { + return res.status(400).json({ + success: false, + message: "Completed must be a boolean" + }); + } + + const updatedTodo = todoService.updateTodo(req.params.id, { + title, + completed + }); + + if (!updatedTodo) { + return res.status(404).json({ + success: false, + message: "Todo not found" + }); + } + + res.status(200).json({ + success: true, + message: "Todo updated successfully", + data: updatedTodo + }); +} + +function deleteTodo(req, res) { + const deletedTodo = todoService.deleteTodo(req.params.id); + + if (!deletedTodo) { + return res.status(404).json({ + success: false, + message: "Todo not found" + }); + } + + res.status(200).json({ + success: true, + message: "Todo deleted successfully", + data: deletedTodo + }); +} + +module.exports = { + getAllTodos, + getTodoById, + createTodo, + updateTodo, + deleteTodo +}; \ No newline at end of file diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 00000000..ff69b9a3 --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -0,0 +1,20 @@ +function notFoundHandler(req, res) { + res.status(404).json({ + success: false, + message: "Route not found" + }); +} + +function errorHandler(err, req, res, next) { + console.error(err); + + res.status(500).json({ + success: false, + message: "Internal server error" + }); +} + +module.exports = { + notFoundHandler, + errorHandler +}; \ No newline at end of file diff --git a/backend/src/routes/todoRoutes.js b/backend/src/routes/todoRoutes.js new file mode 100644 index 00000000..f8a25d40 --- /dev/null +++ b/backend/src/routes/todoRoutes.js @@ -0,0 +1,19 @@ +const express = require("express"); + +const { + getAllTodos, + getTodoById, + createTodo, + updateTodo, + deleteTodo +} = require("../controllers/todoController"); + +const router = express.Router(); + +router.get("/", getAllTodos); +router.get("/:id", getTodoById); +router.post("/", createTodo); +router.put("/:id", updateTodo); +router.delete("/:id", deleteTodo); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 00000000..8fa43703 --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,7 @@ +const app = require("./app"); + +const PORT = process.env.PORT || 3000; + +app.listen(PORT, () => { + console.log(`Server is running on http://localhost:${PORT}`); +}); \ No newline at end of file diff --git a/backend/src/services/todoService.js b/backend/src/services/todoService.js new file mode 100644 index 00000000..84004b7c --- /dev/null +++ b/backend/src/services/todoService.js @@ -0,0 +1,61 @@ +let todos = []; +let nextId = 1; + +function getAllTodos() { + return todos; +} + +function getTodoById(id) { + return todos.find((todo) => todo.id === Number(id)); +} + +function createTodo(data) { + const newTodo = { + id: nextId++, + title: data.title.trim(), + completed: data.completed ?? false, + createdAt: new Date().toISOString() + }; + + todos.push(newTodo); + + return newTodo; +} + +function updateTodo(id, data) { + const todo = getTodoById(id); + + if (!todo) { + return null; + } + + if (data.title !== undefined) { + todo.title = data.title.trim(); + } + + if (data.completed !== undefined) { + todo.completed = data.completed; + } + + return todo; +} + +function deleteTodo(id) { + const todoIndex = todos.findIndex((todo) => todo.id === Number(id)); + + if (todoIndex === -1) { + return null; + } + + const deletedTodo = todos.splice(todoIndex, 1)[0]; + + return deletedTodo; +} + +module.exports = { + getAllTodos, + getTodoById, + createTodo, + updateTodo, + deleteTodo +}; \ No newline at end of file diff --git a/backend/src/utils/validators.js b/backend/src/utils/validators.js new file mode 100644 index 00000000..e7a7f654 --- /dev/null +++ b/backend/src/utils/validators.js @@ -0,0 +1,12 @@ +function isValidTitle(title) { + return typeof title === "string" && title.trim().length > 0; +} + +function isValidCompleted(completed) { + return typeof completed === "boolean"; +} + +module.exports = { + isValidTitle, + isValidCompleted +}; \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a2dc41ec..35260e1b 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1,26 +1,335 @@ -@import "tailwindcss"; +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} -:root { - --background: #ffffff; - --foreground: #171717; +html { + scroll-behavior: smooth; } -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); +body { + font-family: Arial, Helvetica, sans-serif; + background: #f7f8f3; + color: #132018; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } +a { + color: inherit; + text-decoration: none; } -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; +.navbar { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 24px 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.logo { + font-size: 24px; + font-weight: 800; + letter-spacing: -0.04em; +} + +.logo span { + color: #73c23a; +} + +.nav-links { + display: flex; + gap: 32px; + color: #5d6b61; + font-size: 15px; +} + +.nav-button, +.primary-button { + background: #132018; + color: white; + padding: 14px 22px; + border-radius: 999px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.secondary-button { + background: white; + color: #132018; + padding: 14px 22px; + border-radius: 999px; + font-weight: 700; + border: 1px solid #dce5d5; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.hero { + width: min(1180px, calc(100% - 32px)); + margin: 40px auto 0; + min-height: calc(100vh - 110px); + display: grid; + grid-template-columns: 1.1fr 0.9fr; + align-items: center; + gap: 56px; +} + +.eyebrow { + color: #5da82f; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 13px; + margin-bottom: 18px; +} + +.hero-content h1 { + font-size: clamp(46px, 7vw, 88px); + line-height: 0.95; + letter-spacing: -0.07em; + max-width: 760px; +} + +.hero-description { + margin-top: 24px; + max-width: 590px; + color: #5d6b61; + font-size: 20px; + line-height: 1.7; +} + +.hero-actions { + margin-top: 34px; + display: flex; + gap: 14px; + flex-wrap: wrap; } + +.hero-stats { + margin-top: 44px; + display: flex; + gap: 34px; + flex-wrap: wrap; +} + +.hero-stats div { + display: flex; + flex-direction: column; + gap: 4px; +} + +.hero-stats strong { + font-size: 26px; +} + +.hero-stats span { + color: #6d7a70; +} + +.hero-card { + background: #142018; + color: white; + border-radius: 36px; + padding: 28px; + box-shadow: 0 30px 80px rgba(19, 32, 24, 0.22); + position: relative; + overflow: hidden; +} + +.hero-card::before { + content: ""; + position: absolute; + width: 220px; + height: 220px; + background: #78d34c; + border-radius: 50%; + top: -70px; + right: -70px; + opacity: 0.6; +} + +.card-header, +.balance-card, +.transaction-list { + position: relative; + z-index: 1; +} + +.card-header { + display: flex; + justify-content: space-between; + margin-bottom: 28px; +} + +.card-header span { + color: #cbd8ce; +} + +.card-header strong { + background: rgba(255, 255, 255, 0.12); + padding: 8px 12px; + border-radius: 999px; + font-size: 13px; +} + +.balance-card { + background: linear-gradient(135deg, #88e355, #d7ff83); + color: #132018; + padding: 28px; + border-radius: 28px; + margin-bottom: 24px; +} + +.balance-card p { + color: #304233; + margin-bottom: 8px; +} + +.balance-card h2 { + font-size: 36px; + letter-spacing: -0.04em; + margin-bottom: 8px; +} + +.balance-card span { + font-weight: 700; +} + +.transaction-list { + display: grid; + gap: 14px; +} + +.transaction-item { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + padding: 18px; + border-radius: 22px; + display: flex; + justify-content: space-between; + gap: 20px; +} + +.transaction-item p { + color: #b8c7bd; + margin-top: 4px; + font-size: 14px; +} + +.transaction-item span { + font-weight: 800; + white-space: nowrap; +} + +.features { + width: min(1180px, calc(100% - 32px)); + margin: 60px auto 100px; + padding: 80px 0 20px; +} + +.section-heading { + max-width: 720px; + margin-bottom: 36px; +} + +.section-heading h2 { + font-size: clamp(34px, 5vw, 56px); + line-height: 1; + letter-spacing: -0.06em; + margin-bottom: 18px; +} + +.section-heading p:last-child { + color: #5d6b61; + font-size: 18px; + line-height: 1.7; +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 18px; +} + +.feature-card { + background: white; + padding: 30px; + border-radius: 28px; + border: 1px solid #e4eadf; + box-shadow: 0 14px 40px rgba(19, 32, 24, 0.06); +} + +.feature-icon { + width: 44px; + height: 44px; + background: #dfff8f; + color: #132018; + border-radius: 14px; + display: grid; + place-items: center; + font-weight: 900; + margin-bottom: 24px; +} + +.feature-card h3 { + font-size: 22px; + margin-bottom: 12px; +} + +.feature-card p { + color: #5d6b61; + line-height: 1.7; +} + +@media (max-width: 900px) { + .nav-links { + display: none; + } + + .hero { + grid-template-columns: 1fr; + min-height: auto; + margin-top: 20px; + gap: 36px; + } + + .hero-content h1 { + font-size: 52px; + } + + .hero-description { + font-size: 17px; + } + + .feature-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .nav-button { + display: none; + } + + .hero-content h1 { + font-size: 42px; + } + + .hero-actions { + flex-direction: column; + } + + .primary-button, + .secondary-button { + width: 100%; + } + + .transaction-item { + flex-direction: column; + } +} \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f7fa87eb..ee75176f 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,20 +1,9 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "MoolaLite Landing Page", + description: "A responsive landing page inspired by Moola.com", }; export default function RootLayout({ @@ -24,11 +13,7 @@ export default function RootLayout({ }>) { return ( -
- {children} - + {children} ); -} +} \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e68abe6b..d009b93b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,103 +1,13 @@ -import Image from "next/image"; +import Navbar from "@/components/Navbar"; +import Hero from "@/components/Hero"; +import FeatureSection from "@/components/FeatureSection"; export default function Home() { return ( -
- src/app/page.tsx
-
- .
- Why choose us
++ A simplified finance experience designed for startups, growing teams, + and modern businesses. +
+{feature.description}
+Business finance made simple
+ ++ Manage payments, cards, invoices, and business spending in one clean + platform built for modern teams. +
+ + + +Total Balance
+Client payment received
+Software subscription
+Bank transfer completed
+